feat: add user-configurable MCP connections
- manage_mcp_connection tool: add/remove/enable/disable/list MCP servers - mcp_loader: dynamic connection with OAuth/bearer/none auth, token caching - Secrets stored in SSM, never in DynamoDB - MCP clients loaded per-session and cleaned up in finally block
This commit is contained in:
148
agentclaw/app/agent_claw_main/tools/mcp_tools.py
Normal file
148
agentclaw/app/agent_claw_main/tools/mcp_tools.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""MCP connection management tool — add/remove/enable/disable user MCP servers."""
|
||||
import boto3
|
||||
from strands import tool
|
||||
|
||||
USERS_TABLE_NAME = 'agent-claw-users'
|
||||
|
||||
# Set per-invocation by main.py
|
||||
_current_actor_id: str = ''
|
||||
|
||||
|
||||
@tool
|
||||
def manage_mcp_connection(action: str, name: str = '', url: str = '',
|
||||
auth_type: str = 'none', cognito_token_url: str = '',
|
||||
client_id: str = '', client_secret: str = '',
|
||||
scope: str = '', token: str = '') -> str:
|
||||
"""Add, remove, enable, disable, or list MCP server connections.
|
||||
|
||||
Actions: add, remove, enable, disable, list
|
||||
|
||||
For add with auth_type=oauth_client_credentials, provide:
|
||||
- cognito_token_url: Cognito token endpoint
|
||||
- client_id: OAuth client ID
|
||||
- client_secret: Secret value (stored securely in SSM, not in database)
|
||||
- scope: Space-separated OAuth scopes
|
||||
|
||||
For add with auth_type=bearer, provide:
|
||||
- token: Bearer token value (stored securely in SSM, not in database)
|
||||
|
||||
For add with auth_type=none, only name and url are required.
|
||||
|
||||
Args:
|
||||
action: One of "add", "remove", "enable", "disable", "list".
|
||||
name: Connection name (required for add/remove/enable/disable).
|
||||
url: MCP server URL (required for add).
|
||||
auth_type: One of "none", "bearer", "oauth_client_credentials".
|
||||
cognito_token_url: Token endpoint for oauth_client_credentials.
|
||||
client_id: OAuth client ID for oauth_client_credentials.
|
||||
client_secret: OAuth client secret (will be stored in SSM).
|
||||
scope: OAuth scopes for oauth_client_credentials.
|
||||
token: Bearer token (will be stored in SSM).
|
||||
"""
|
||||
actor_id = _current_actor_id
|
||||
if not actor_id:
|
||||
return 'Cannot determine actor_id.'
|
||||
|
||||
ddb = boto3.resource('dynamodb', region_name='us-east-1')
|
||||
table = ddb.Table(USERS_TABLE_NAME)
|
||||
|
||||
if action == 'list':
|
||||
resp = table.get_item(Key={'actor_id': actor_id})
|
||||
connections = resp.get('Item', {}).get('services', {}).get('mcp_connections', [])
|
||||
if not connections:
|
||||
return 'No MCP connections configured.'
|
||||
lines = []
|
||||
for c in connections:
|
||||
status = '✓' if c.get('enabled', True) else '✗'
|
||||
lines.append(f" {status} {c['name']}: {c['url']} (auth: {c.get('auth_type', 'none')})")
|
||||
return 'MCP connections:\n' + '\n'.join(lines)
|
||||
|
||||
if not name:
|
||||
return 'name is required for this action.'
|
||||
|
||||
if action == 'add':
|
||||
if not url:
|
||||
return 'url is required for add.'
|
||||
if auth_type not in ('none', 'bearer', 'oauth_client_credentials'):
|
||||
return f'Invalid auth_type: {auth_type}. Use none, bearer, or oauth_client_credentials.'
|
||||
|
||||
ssm = boto3.client('ssm', region_name='us-east-1')
|
||||
safe_actor = actor_id.replace(':', '-')
|
||||
ssm_prefix = f'/agent-claw/mcp/{safe_actor}/{name}'
|
||||
|
||||
conn = {'name': name, 'url': url, 'auth_type': auth_type, 'enabled': True}
|
||||
|
||||
if auth_type == 'oauth_client_credentials':
|
||||
if not cognito_token_url or not client_id or not client_secret:
|
||||
return 'oauth_client_credentials requires cognito_token_url, client_id, and client_secret.'
|
||||
ssm.put_parameter(Name=f'{ssm_prefix}/client-secret', Value=client_secret,
|
||||
Type='SecureString', Overwrite=True)
|
||||
conn['cognito_token_url'] = cognito_token_url
|
||||
conn['client_id'] = client_id
|
||||
conn['client_secret_ssm'] = f'{ssm_prefix}/client-secret'
|
||||
conn['scope'] = scope
|
||||
elif auth_type == 'bearer':
|
||||
if not token:
|
||||
return 'bearer auth requires token.'
|
||||
ssm.put_parameter(Name=f'{ssm_prefix}/token', Value=token,
|
||||
Type='SecureString', Overwrite=True)
|
||||
conn['token_ssm'] = f'{ssm_prefix}/token'
|
||||
|
||||
# Upsert into mcp_connections list
|
||||
resp = table.get_item(Key={'actor_id': actor_id})
|
||||
services = resp.get('Item', {}).get('services', {})
|
||||
connections = services.get('mcp_connections', [])
|
||||
connections = [c for c in connections if c['name'] != name]
|
||||
connections.append(conn)
|
||||
|
||||
table.update_item(
|
||||
Key={'actor_id': actor_id},
|
||||
UpdateExpression='SET services = if_not_exists(services, :empty), services.mcp_connections = :conns',
|
||||
ExpressionAttributeValues={':conns': connections, ':empty': {}},
|
||||
)
|
||||
return f'MCP connection "{name}" added ({auth_type} auth). It will be available on your next message.'
|
||||
|
||||
elif action == 'remove':
|
||||
resp = table.get_item(Key={'actor_id': actor_id})
|
||||
connections = resp.get('Item', {}).get('services', {}).get('mcp_connections', [])
|
||||
found = [c for c in connections if c['name'] == name]
|
||||
if not found:
|
||||
return f'No connection named "{name}" found.'
|
||||
|
||||
# Clean up SSM secrets
|
||||
ssm = boto3.client('ssm', region_name='us-east-1')
|
||||
safe_actor = actor_id.replace(':', '-')
|
||||
for key in ('client-secret', 'token'):
|
||||
try:
|
||||
ssm.delete_parameter(Name=f'/agent-claw/mcp/{safe_actor}/{name}/{key}')
|
||||
except ssm.exceptions.ParameterNotFound:
|
||||
pass
|
||||
|
||||
connections = [c for c in connections if c['name'] != name]
|
||||
table.update_item(
|
||||
Key={'actor_id': actor_id},
|
||||
UpdateExpression='SET services.mcp_connections = :conns',
|
||||
ExpressionAttributeValues={':conns': connections},
|
||||
)
|
||||
return f'MCP connection "{name}" removed.'
|
||||
|
||||
elif action in ('enable', 'disable'):
|
||||
resp = table.get_item(Key={'actor_id': actor_id})
|
||||
connections = resp.get('Item', {}).get('services', {}).get('mcp_connections', [])
|
||||
updated = False
|
||||
for c in connections:
|
||||
if c['name'] == name:
|
||||
c['enabled'] = (action == 'enable')
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
return f'No connection named "{name}" found.'
|
||||
table.update_item(
|
||||
Key={'actor_id': actor_id},
|
||||
UpdateExpression='SET services.mcp_connections = :conns',
|
||||
ExpressionAttributeValues={':conns': connections},
|
||||
)
|
||||
return f'MCP connection "{name}" {action}d.'
|
||||
|
||||
else:
|
||||
return f'Unknown action: {action}. Use add, remove, enable, disable, or list.'
|
||||
Reference in New Issue
Block a user