Skip to content

Commit d7e40dd

Browse files
committed
feat(security-agent-mcp-server): Add custom User-Agent for API usage tracking
Add a custom User-Agent string to all boto3 API calls made by the security-agent-mcp-server. The User-Agent includes: - MCP server name and version (awslabs-security-agent-mcp-server/X.Y.Z) - Calling MCP client name and version (e.g. kiro/1.5.0, claude-code/1.0) This enables service-side tracking of API usage originating from the local MCP server, broken down by which IDE/tool invoked it. The client identity is extracted from the MCP protocol clientInfo field sent during the initialize handshake. Falls back to unknown when clientInfo is not available. Changes: - aws_client.py: Added botocore.config.Config with user_agent_extra to all boto3 client calls (securityagent, sts, s3, iam). Added set_mcp_client_info() for lazy injection after MCP init. - server.py: Added _ensure_client_ua(ctx) that extracts clientInfo from the MCP session context and injects it into the AWS client on the first tool invocation.
1 parent 1ea4990 commit d7e40dd

4 files changed

Lines changed: 274 additions & 8 deletions

File tree

src/security-agent-mcp-server/awslabs/security_agent_mcp_server/aws_client.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,70 @@
1515
"""AWS SecurityAgent API client using boto3 SDK."""
1616

1717
import boto3
18+
import botocore.config
1819
import json
1920
import re
2021
from typing import Any, Optional
2122

2223

24+
DEFAULT_MCP_CLIENT_NAME = 'unknown'
25+
26+
2327
class SecurityAgentClient:
2428
"""Client for AWS SecurityAgent APIs using boto3."""
2529

26-
def __init__(self, region: str = 'us-east-1'):
30+
def __init__(
31+
self,
32+
region: str = 'us-east-1',
33+
mcp_client_name: str = DEFAULT_MCP_CLIENT_NAME,
34+
mcp_client_version: str = '',
35+
):
2736
"""Initialize SecurityAgent client."""
2837
self.region = region
38+
self._mcp_client_name = mcp_client_name
39+
self._mcp_client_version = mcp_client_version
40+
self._config = self._build_config(mcp_client_name, mcp_client_version)
41+
42+
def _build_config(
43+
self, mcp_client_name: str, mcp_client_version: str
44+
) -> botocore.config.Config:
45+
"""Build a botocore Config with a custom user_agent_extra string."""
46+
try:
47+
from importlib.metadata import version as pkg_version
48+
49+
mcp_server_version = pkg_version('awslabs.security-agent-mcp-server')
50+
except Exception:
51+
mcp_server_version = 'unknown'
52+
53+
ua_extra = (
54+
f'awslabs-security-agent-mcp-server/{mcp_server_version} md/client#{mcp_client_name}'
55+
)
56+
if mcp_client_version:
57+
ua_extra += f'/{mcp_client_version}'
58+
59+
return botocore.config.Config(user_agent_extra=ua_extra)
60+
61+
def set_mcp_client_info(self, mcp_client_name: str, mcp_client_version: str = '') -> None:
62+
"""Update the MCP client identity and rebuild the botocore config.
63+
64+
Called after MCP session initialization when clientInfo becomes available.
65+
"""
66+
if (
67+
mcp_client_name == self._mcp_client_name
68+
and mcp_client_version == self._mcp_client_version
69+
):
70+
return # No change needed
71+
self._mcp_client_name = mcp_client_name
72+
self._mcp_client_version = mcp_client_version
73+
self._config = self._build_config(mcp_client_name, mcp_client_version)
2974

3075
def _get_session(self):
3176
"""Fresh session each call to pick up rotated credentials."""
3277
return boto3.Session(region_name=self.region)
3378

3479
def _client(self):
35-
"""Get a fresh securityagent boto3 client."""
36-
return self._get_session().client('securityagent')
80+
"""Get a fresh securityagent boto3 client with custom user-agent."""
81+
return self._get_session().client('securityagent', config=self._config)
3782

3883
def call(self, operation: str, params: dict) -> dict:
3984
"""Call any SecurityAgent API operation generically."""
@@ -50,7 +95,7 @@ def call(self, operation: str, params: dict) -> dict:
5095

5196
def get_caller_identity(self) -> dict:
5297
"""Get the current AWS caller identity."""
53-
return self._get_session().client('sts').get_caller_identity()
98+
return self._get_session().client('sts', config=self._config).get_caller_identity()
5499

55100
def list_agent_spaces(self) -> list[dict]:
56101
"""List all SecurityAgent agent spaces."""
@@ -224,7 +269,7 @@ def create_s3_bucket(self, bucket_name: str) -> str:
224269
225270
Public-access block, SSE-S3, TLS-only policy, and 30-day lifecycle.
226271
"""
227-
s3 = self._get_session().client('s3')
272+
s3 = self._get_session().client('s3', config=self._config)
228273
create_args: dict[str, Any] = {'Bucket': bucket_name}
229274
if self.region != 'us-east-1':
230275
create_args['CreateBucketConfiguration'] = {'LocationConstraint': self.region}
@@ -287,7 +332,7 @@ def create_s3_bucket(self, bucket_name: str) -> str:
287332

288333
def create_service_role(self, role_name: str, account_id: str, bucket_name: str) -> str:
289334
"""Create IAM service role for Security Agent with S3 + CloudWatch Logs access."""
290-
iam = self._get_session().client('iam')
335+
iam = self._get_session().client('iam', config=self._config)
291336

292337
trust_policy = json.dumps(
293338
{
@@ -347,5 +392,5 @@ def create_service_role(self, role_name: str, account_id: str, bucket_name: str)
347392

348393
def upload_to_s3(self, bucket: str, key: str, file_path: str) -> str:
349394
"""Upload a file to S3."""
350-
self._get_session().client('s3').upload_file(file_path, bucket, key)
395+
self._get_session().client('s3', config=self._config).upload_file(file_path, bucket, key)
351396
return f's3://{bucket}/{key}'

src/security-agent-mcp-server/awslabs/security_agent_mcp_server/server.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
import json
1818
import os
1919
import sys
20-
from awslabs.security_agent_mcp_server.aws_client import SecurityAgentClient
20+
from awslabs.security_agent_mcp_server.aws_client import (
21+
DEFAULT_MCP_CLIENT_NAME,
22+
SecurityAgentClient,
23+
)
2124
from awslabs.security_agent_mcp_server.consts import (
2225
DEFAULT_REGION,
2326
SERVER_INSTRUCTIONS,
@@ -140,6 +143,32 @@ def _client_prefix(ctx: Context) -> str:
140143
return 'ide'
141144

142145

146+
def _ensure_client_ua(ctx: Context) -> None:
147+
"""Inject MCP clientInfo into the SecurityAgentClient user-agent on first use.
148+
149+
Called at the start of each tool invocation. Since the module-level _client
150+
singleton is created before any MCP session connects, this defers the
151+
user-agent configuration until clientInfo is actually available.
152+
"""
153+
try:
154+
session = ctx.session
155+
if session is None:
156+
return
157+
client_params = session.client_params
158+
if client_params is None:
159+
return
160+
info = client_params.clientInfo # type: ignore[union-attr]
161+
if info is None:
162+
return
163+
name = info.name if isinstance(info.name, str) else DEFAULT_MCP_CLIENT_NAME
164+
version = (
165+
info.version if hasattr(info, 'version') and isinstance(info.version, str) else ''
166+
)
167+
_client.set_mcp_client_info(name, version)
168+
except (AttributeError, TypeError):
169+
pass
170+
171+
143172
def _ensure_s3_bucket(config: dict, kind: str = 'scans') -> None:
144173
"""Lazily create and register the per-account S3 bucket for the given kind.
145174
@@ -181,6 +210,7 @@ async def setup_check(ctx: Context) -> str:
181210
Verifies agent space and service role are available.
182211
If not ready, lists existing agent spaces to help with setup.
183212
"""
213+
_ensure_client_ua(ctx)
184214
try:
185215
config = _state.get_config()
186216
missing = []
@@ -263,6 +293,7 @@ async def setup(
263293
- Existing space + new role: setup(agent_space_id='as-xxx')
264294
- Existing space + existing role: setup(agent_space_id='as-xxx', service_role_arn='arn:...')
265295
"""
296+
_ensure_client_ua(ctx)
266297
try:
267298
identity = _client.get_caller_identity()
268299
account_id = identity['Account']
@@ -339,6 +370,7 @@ async def start_security_scan(
339370
Returns scan_id for polling with get_scan_status. The scan runs server-side.
340371
Use get_scan_status to check progress and get_scan_findings to retrieve results when complete.
341372
"""
373+
_ensure_client_ua(ctx)
342374
try:
343375
config = _state.get_config()
344376
if not config.get('agent_space_id') or not config.get('service_role'):
@@ -393,6 +425,7 @@ async def start_diff_scan(
393425
the diff patch; the agent focuses on changes while having full source for context.
394426
No prior scan required.
395427
"""
428+
_ensure_client_ua(ctx)
396429
try:
397430
config = _state.get_config()
398431
if not config.get('agent_space_id') or not config.get('service_role'):
@@ -447,6 +480,7 @@ async def start_threat_model_review(
447480
a threat model job. Returns a scan_id for polling with get_scan_status; retrieve
448481
identified threats with get_scan_findings. No prior scan required.
449482
"""
483+
_ensure_client_ua(ctx)
450484
try:
451485
config = _state.get_config()
452486
if not config.get('agent_space_id') or not config.get('service_role'):
@@ -492,6 +526,7 @@ async def get_scan_status(
492526
Useful for checking a previous scan from an earlier session, or verifying
493527
a scan completed after session recovery.
494528
"""
529+
_ensure_client_ua(ctx)
495530
try:
496531
return json.dumps(await _scanner.get_status(scan_id=scan_id), default=_json_serial)
497532
except ClientError as e:
@@ -521,6 +556,7 @@ async def get_scan_findings(
521556
522557
Returns findings with title, severity, confidence, file location, and description.
523558
"""
559+
_ensure_client_ua(ctx)
524560
try:
525561
return json.dumps(
526562
await _scanner.get_findings(scan_id=scan_id, severity=severity), default=_json_serial
@@ -539,6 +575,7 @@ async def get_scan_findings(
539575
@mcp.tool()
540576
async def list_scans(ctx: Context) -> str:
541577
"""List all recent security scans tracked locally with their status."""
578+
_ensure_client_ua(ctx)
542579
try:
543580
return json.dumps({'scans': _state.list_scans()}, default=_json_serial)
544581
except ClientError as e:
@@ -558,6 +595,7 @@ async def stop_scan(
558595
scan_id: str = Field(..., description='The scan ID to stop.'),
559596
) -> str:
560597
"""Stop a running security scan."""
598+
_ensure_client_ua(ctx)
561599
try:
562600
logger.info(f'Stopping scan: {scan_id}')
563601
return json.dumps(await _scanner.stop_scan(scan_id=scan_id), default=_json_serial)
@@ -588,6 +626,7 @@ async def call_api(
588626
589627
Use get_api_guide to discover available operations and their parameters.
590628
"""
629+
_ensure_client_ua(ctx)
591630
try:
592631
import re
593632

@@ -619,6 +658,7 @@ async def get_api_guide(ctx: Context) -> str:
619658
Returns operation names dynamically from the service model,
620659
plus a link to full API documentation with parameter details.
621660
"""
661+
_ensure_client_ua(ctx)
622662
global _cached_operations
623663
if _cached_operations is None:
624664
try:

src/security-agent-mcp-server/tests/test_aws_client.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,83 @@ def test_batch_get_threats(self, mock_boto3):
457457
mock_client.batch_get_threats.assert_called_once_with(
458458
agentSpaceId='as-1', threatIds=['t-1']
459459
)
460+
461+
462+
class TestUserAgentInjection:
463+
"""Tests for custom User-Agent injection."""
464+
465+
def test_default_user_agent_has_mcp_server_identifier(self):
466+
"""SecurityAgentClient includes MCP server identifier in config by default."""
467+
client = SecurityAgentClient(region='us-east-1')
468+
assert 'awslabs-security-agent-mcp-server' in client._config.user_agent_extra # type: ignore[attr-defined]
469+
assert 'md/client#unknown' in client._config.user_agent_extra # type: ignore[attr-defined]
470+
471+
def test_constructor_accepts_mcp_client_info(self):
472+
"""SecurityAgentClient accepts mcp_client_name and version at construction."""
473+
client = SecurityAgentClient(
474+
region='us-east-1', mcp_client_name='kiro', mcp_client_version='1.5.0'
475+
)
476+
assert 'md/client#kiro/1.5.0' in client._config.user_agent_extra # type: ignore[attr-defined]
477+
478+
def test_set_mcp_client_info_updates_user_agent(self):
479+
"""set_mcp_client_info updates the user agent with client name and version."""
480+
client = SecurityAgentClient(region='us-east-1')
481+
client.set_mcp_client_info('kiro', '1.5.0')
482+
assert 'md/client#kiro/1.5.0' in client._config.user_agent_extra # type: ignore[attr-defined]
483+
484+
def test_set_mcp_client_info_without_version(self):
485+
"""set_mcp_client_info works with just client name (no version)."""
486+
client = SecurityAgentClient(region='us-east-1')
487+
client.set_mcp_client_info('cursor', '')
488+
assert 'md/client#cursor' in client._config.user_agent_extra # type: ignore[attr-defined]
489+
assert 'md/client#cursor/' not in client._config.user_agent_extra # type: ignore[attr-defined]
490+
491+
def test_set_mcp_client_info_noop_when_same(self):
492+
"""set_mcp_client_info does not rebuild config if info is unchanged."""
493+
client = SecurityAgentClient(region='us-east-1')
494+
client.set_mcp_client_info('kiro', '1.5.0')
495+
config_after_first = client._config
496+
client.set_mcp_client_info('kiro', '1.5.0')
497+
assert client._config is config_after_first # Same object, not rebuilt
498+
499+
def test_set_mcp_client_info_rebuilds_when_different(self):
500+
"""set_mcp_client_info rebuilds config when info changes."""
501+
client = SecurityAgentClient(region='us-east-1')
502+
client.set_mcp_client_info('kiro', '1.0')
503+
config_first = client._config
504+
client.set_mcp_client_info('claude-code', '2.0')
505+
assert client._config is not config_first
506+
assert 'md/client#claude-code/2.0' in client._config.user_agent_extra # type: ignore[attr-defined]
507+
508+
@patch('awslabs.security_agent_mcp_server.aws_client.boto3')
509+
def test_client_passes_config_to_securityagent(self, mock_boto3):
510+
"""_client() passes the botocore config to boto3 securityagent client."""
511+
mock_session = MagicMock()
512+
mock_boto3.Session.return_value = mock_session
513+
client = SecurityAgentClient(region='us-east-1')
514+
client._client()
515+
mock_session.client.assert_called_once_with('securityagent', config=client._config)
516+
517+
@patch('awslabs.security_agent_mcp_server.aws_client.boto3')
518+
def test_get_caller_identity_passes_config(self, mock_boto3):
519+
"""get_caller_identity passes the botocore config to sts client."""
520+
mock_session = MagicMock()
521+
mock_boto3.Session.return_value = mock_session
522+
client = SecurityAgentClient(region='us-east-1')
523+
client.get_caller_identity()
524+
mock_session.client.assert_called_once_with('sts', config=client._config)
525+
526+
@patch('awslabs.security_agent_mcp_server.aws_client.boto3')
527+
def test_upload_to_s3_passes_config(self, mock_boto3):
528+
"""upload_to_s3 passes the botocore config to s3 client."""
529+
mock_session = MagicMock()
530+
mock_boto3.Session.return_value = mock_session
531+
client = SecurityAgentClient(region='us-east-1')
532+
client.upload_to_s3('bucket', 'key', '/path/to/file')
533+
mock_session.client.assert_called_once_with('s3', config=client._config)
534+
535+
def test_build_config_handles_missing_package_metadata(self):
536+
"""_build_config falls back to unknown when package metadata unavailable."""
537+
with patch('importlib.metadata.version', side_effect=Exception('not found')):
538+
client = SecurityAgentClient(region='us-east-1')
539+
assert 'awslabs-security-agent-mcp-server/unknown' in client._config.user_agent_extra # type: ignore[attr-defined]

0 commit comments

Comments
 (0)