refactor: move factcloud from hardcoded SSM to per-user DynamoDB oauth2_m2m connection
- Add oauth2_m2m auth type to mcp_loader.py (client_secret in record, not SSM) - Remove _get_factcloud_token(), FACTCLOUD_* config, factcloud_clients from main.py - Seed Daniel's factcloud connection into enrolled_services.mcp_connections - factcloud now loaded dynamically via mcp_loader at session start
This commit is contained in:
@@ -6,9 +6,6 @@ _DEFAULTS = {
|
|||||||
'/agent-claw/model-id': 'us.anthropic.claude-sonnet-4-6',
|
'/agent-claw/model-id': 'us.anthropic.claude-sonnet-4-6',
|
||||||
'/agent-claw/config/compaction_model_id': 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
|
'/agent-claw/config/compaction_model_id': 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
|
||||||
'/agent-claw/aws-mcp-url': 'https://aws-mcp.us-east-1.api.aws/mcp',
|
'/agent-claw/aws-mcp-url': 'https://aws-mcp.us-east-1.api.aws/mcp',
|
||||||
'/agent-claw/factcloud/client-id': '',
|
|
||||||
'/agent-claw/factcloud/client-secret': '',
|
|
||||||
'/agent-claw/factcloud/mcp-url': '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +25,3 @@ _params = _load()
|
|||||||
AGENT_MODEL_ID: str = _params['/agent-claw/model-id']
|
AGENT_MODEL_ID: str = _params['/agent-claw/model-id']
|
||||||
COMPACTION_MODEL_ID: str = _params['/agent-claw/config/compaction_model_id']
|
COMPACTION_MODEL_ID: str = _params['/agent-claw/config/compaction_model_id']
|
||||||
AWS_MCP_URL: str = _params['/agent-claw/aws-mcp-url']
|
AWS_MCP_URL: str = _params['/agent-claw/aws-mcp-url']
|
||||||
FACTCLOUD_CLIENT_ID: str = _params.get('/agent-claw/factcloud/client-id', '')
|
|
||||||
FACTCLOUD_CLIENT_SECRET: str = _params.get('/agent-claw/factcloud/client-secret', '')
|
|
||||||
FACTCLOUD_MCP_URL: str = _params.get('/agent-claw/factcloud/mcp-url', '')
|
|
||||||
|
|||||||
@@ -89,39 +89,8 @@ except Exception as _e:
|
|||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
# ── factcloud token cache ────────────────────────────────────────────────
|
|
||||||
_fc_token: str = ''
|
|
||||||
_fc_token_expiry: float = 0.0
|
|
||||||
|
|
||||||
def _get_factcloud_token() -> str:
|
|
||||||
"""Fetch/cache a Cognito M2M token for factcloud. Thread-safe for Lambda."""
|
|
||||||
global _fc_token, _fc_token_expiry
|
|
||||||
if _fc_token and time.time() < _fc_token_expiry - 60:
|
|
||||||
return _fc_token
|
|
||||||
if not config.FACTCLOUD_CLIENT_ID or not config.FACTCLOUD_CLIENT_SECRET:
|
|
||||||
raise RuntimeError('factcloud credentials not configured in SSM')
|
|
||||||
resp = httpx.post(
|
|
||||||
'https://factbase-cloud.auth.us-east-1.amazoncognito.com/oauth2/token',
|
|
||||||
data={
|
|
||||||
'grant_type': 'client_credentials',
|
|
||||||
'client_id': config.FACTCLOUD_CLIENT_ID,
|
|
||||||
'client_secret': config.FACTCLOUD_CLIENT_SECRET,
|
|
||||||
'scope': 'factbase-cloud/read factbase-cloud/write',
|
|
||||||
},
|
|
||||||
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
_fc_token = data['access_token']
|
|
||||||
_fc_token_expiry = time.time() + data.get('expires_in', 3600)
|
|
||||||
print(f'[main] factcloud token refreshed, expires in {data.get("expires_in", 3600)}s')
|
|
||||||
return _fc_token
|
|
||||||
|
|
||||||
|
|
||||||
# ── Subagent loading ──────────────────────────────────────────────────────
|
# ── Subagent loading ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
|
||||||
|
|
||||||
TOOL_PRESETS = {
|
TOOL_PRESETS = {
|
||||||
"aws": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp"))],
|
"aws": lambda: [MCPClient(lambda: aws_iam_streamablehttp_client(config.AWS_MCP_URL, aws_service="aws-mcp"))],
|
||||||
@@ -686,7 +655,7 @@ async def main(payload: dict, context):
|
|||||||
system_prompt = system_prompt + '\n\n---\n\n' + ltm_block
|
system_prompt = system_prompt + '\n\n---\n\n' + ltm_block
|
||||||
|
|
||||||
system_prompt += '\nAWS tools available: call_aws (any AWS API via AWS MCP Server), aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service. Use call_aws directly for AWS API calls — do NOT say you lack AWS access.'
|
system_prompt += '\nAWS tools available: call_aws (any AWS API via AWS MCP Server), aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service. Use call_aws directly for AWS API calls — do NOT say you lack AWS access.'
|
||||||
system_prompt += '\n\nSubagents available — use them aggressively to save cost and improve quality:\n- aws_agent: all AWS infrastructure, cost, resource, IAM, CloudWatch queries\n- coding_agent: code writing, builds, deployments, CodeBuild/AppRunner/ECR\n- document_agent: summarize URLs, extract data from documents, process long text\nYou also have direct access to factcloud MCP tools (your personal knowledge graph) — use them directly for any factbase, factcloud, or knowledge base queries. Do NOT say you lack access to factcloud.\nDefault to delegating to subagents; only answer directly for simple conversational responses or tasks that don\'t fit a subagent.'
|
system_prompt += '\n\nSubagents available — use them aggressively to save cost and improve quality:\n- aws_agent: all AWS infrastructure, cost, resource, IAM, CloudWatch queries\n- coding_agent: code writing, builds, deployments, CodeBuild/AppRunner/ECR\n- document_agent: summarize URLs, extract data from documents, process long text\nYou also have direct access to factcloud MCP tools (your personal knowledge graph) loaded from your MCP connections — use them directly for any factbase, factcloud, or knowledge base queries. Do NOT say you lack access to factcloud.\nDefault to delegating to subagents; only answer directly for simple conversational responses or tasks that don\'t fit a subagent.'
|
||||||
|
|
||||||
# Model: claude-sonnet-4-6 via cross-region inference
|
# Model: claude-sonnet-4-6 via cross-region inference
|
||||||
# NOTE: extended thinking disabled — causes retry/duplicate issues with streaming
|
# NOTE: extended thinking disabled — causes retry/duplicate issues with streaming
|
||||||
@@ -704,17 +673,6 @@ async def main(payload: dict, context):
|
|||||||
run_code, send_file, request_iam_permission, apply_iam_permission,
|
run_code, send_file, request_iam_permission, apply_iam_permission,
|
||||||
aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service]
|
aws_list_lambda_functions, aws_get_cost_and_usage, aws_describe_service]
|
||||||
|
|
||||||
# factcloud: direct MCP connection (M2M Cognito auth)
|
|
||||||
factcloud_clients = []
|
|
||||||
if config.FACTCLOUD_MCP_URL:
|
|
||||||
try:
|
|
||||||
factcloud_clients = [MCPClient(lambda: streamablehttp_client(
|
|
||||||
url=config.FACTCLOUD_MCP_URL,
|
|
||||||
headers={"Authorization": f"Bearer {_get_factcloud_token()}"},
|
|
||||||
))]
|
|
||||||
except Exception as e:
|
|
||||||
print(f'[main] factcloud connection failed: {e}')
|
|
||||||
|
|
||||||
# Load user's dynamic MCP connections
|
# Load user's dynamic MCP connections
|
||||||
mcp_connections = services.get('mcp_connections', [])
|
mcp_connections = services.get('mcp_connections', [])
|
||||||
mcp_clients, _mcp_to_close = mcp_loader.load_mcp_tools(mcp_connections, actor_id)
|
mcp_clients, _mcp_to_close = mcp_loader.load_mcp_tools(mcp_connections, actor_id)
|
||||||
@@ -723,7 +681,7 @@ async def main(payload: dict, context):
|
|||||||
ssm = boto3.client('ssm', region_name='us-east-1')
|
ssm = boto3.client('ssm', region_name='us-east-1')
|
||||||
subagent_tools = _load_subagents(ssm)
|
subagent_tools = _load_subagents(ssm)
|
||||||
|
|
||||||
all_tools = base_tools + factcloud_clients + _aws_mcp_tools + mcp_clients + subagent_tools
|
all_tools = base_tools + _aws_mcp_tools + mcp_clients + subagent_tools
|
||||||
|
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
model=model,
|
model=model,
|
||||||
|
|||||||
@@ -45,12 +45,40 @@ def _get_oauth_token(conn: dict, actor_id: str) -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _get_m2m_token(conn: dict, actor_id: str) -> str:
|
||||||
|
"""Fetch OAuth token for oauth2_m2m (secret stored directly in record)."""
|
||||||
|
cache_key = f"{actor_id}:{conn['name']}"
|
||||||
|
cached = _token_cache.get(cache_key)
|
||||||
|
if cached and cached['expires_at'] > time.time() + 60:
|
||||||
|
return cached['token']
|
||||||
|
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
'grant_type': 'client_credentials',
|
||||||
|
'client_id': conn['client_id'],
|
||||||
|
'client_secret': conn['client_secret'],
|
||||||
|
'scope': conn.get('scopes', conn.get('scope', '')),
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
req = urllib.request.Request(conn['token_url'], data=data,
|
||||||
|
headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
body = json.loads(resp.read())
|
||||||
|
|
||||||
|
token = body['access_token']
|
||||||
|
expires_in = body.get('expires_in', 3600)
|
||||||
|
_token_cache[cache_key] = {'token': token, 'expires_at': time.time() + expires_in}
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
def _resolve_auth_headers(conn: dict, actor_id: str) -> dict:
|
def _resolve_auth_headers(conn: dict, actor_id: str) -> dict:
|
||||||
"""Resolve auth headers for a connection."""
|
"""Resolve auth headers for a connection."""
|
||||||
auth_type = conn.get('auth_type', 'none')
|
auth_type = conn.get('auth_type', 'none')
|
||||||
if auth_type == 'oauth_client_credentials':
|
if auth_type == 'oauth_client_credentials':
|
||||||
token = _get_oauth_token(conn, actor_id)
|
token = _get_oauth_token(conn, actor_id)
|
||||||
return {'Authorization': f'Bearer {token}'}
|
return {'Authorization': f'Bearer {token}'}
|
||||||
|
elif auth_type == 'oauth2_m2m':
|
||||||
|
token = _get_m2m_token(conn, actor_id)
|
||||||
|
return {'Authorization': f'Bearer {token}'}
|
||||||
elif auth_type == 'bearer':
|
elif auth_type == 'bearer':
|
||||||
token = _get_ssm_value(conn['token_ssm'])
|
token = _get_ssm_value(conn['token_ssm'])
|
||||||
return {'Authorization': f'Bearer {token}'}
|
return {'Authorization': f'Bearer {token}'}
|
||||||
|
|||||||
35
scripts/seed_factcloud.py
Normal file
35
scripts/seed_factcloud.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed Daniel's factcloud MCP connection into DynamoDB."""
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
ACTOR_ID = 'telegram:8537376738'
|
||||||
|
TABLE_NAME = 'agent-claw-users'
|
||||||
|
|
||||||
|
conn = {
|
||||||
|
'name': 'factcloud',
|
||||||
|
'url': 'https://factbase-cloud-gateway-2czetaoh3u.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp',
|
||||||
|
'auth_type': 'oauth2_m2m',
|
||||||
|
'client_id': '5fo2q4fb452j3aekd55g3190i4',
|
||||||
|
'client_secret': '1e0bqs8r4jk90sbeivh96mn893mgmv96h2olvcq7m3o5gjpjc56p',
|
||||||
|
'token_url': 'https://factbase-cloud.auth.us-east-1.amazoncognito.com/oauth2/token',
|
||||||
|
'scopes': 'factbase-cloud/read factbase-cloud/write',
|
||||||
|
'enabled': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
session = boto3.Session(profile_name='ai1', region_name='us-east-1')
|
||||||
|
ddb = session.resource('dynamodb')
|
||||||
|
table = ddb.Table(TABLE_NAME)
|
||||||
|
|
||||||
|
# Get existing connections, upsert factcloud
|
||||||
|
resp = table.get_item(Key={'actor_id': ACTOR_ID})
|
||||||
|
services = resp.get('Item', {}).get('enrolled_services', {})
|
||||||
|
connections = services.get('mcp_connections', [])
|
||||||
|
connections = [c for c in connections if c['name'] != 'factcloud']
|
||||||
|
connections.append(conn)
|
||||||
|
|
||||||
|
table.update_item(
|
||||||
|
Key={'actor_id': ACTOR_ID},
|
||||||
|
UpdateExpression='SET enrolled_services.mcp_connections = :conns',
|
||||||
|
ExpressionAttributeValues={':conns': connections},
|
||||||
|
)
|
||||||
|
print(f'Seeded factcloud connection for {ACTOR_ID}')
|
||||||
Reference in New Issue
Block a user