Skip to content

feat: cross-account AWS API via managed MCP Server - #20

Open
CodeOfCloud wants to merge 1 commit into
aws-samples:mainfrom
CodeOfCloud:feat/cross-account-aws-mcp
Open

feat: cross-account AWS API via managed MCP Server#20
CodeOfCloud wants to merge 1 commit into
aws-samples:mainfrom
CodeOfCloud:feat/cross-account-aws-mcp

Conversation

@CodeOfCloud

@CodeOfCloud CodeOfCloud commented Aug 3, 2026

Copy link
Copy Markdown

Summary

Adds a managed mode to lambda-proxy (enabled by aws_mcp_endpoint) that replaces the AgentCore Runtime + aws-api-mcp-server container path. The upstream container entered end-of-development on 2026-07-15 (removal 2027-07-15, awslabs/mcp#4115) and its Marketplace listing is closed to new subscriptions, so new deployments currently can't stand this target up. Managed mode provides the same any-API access with no container, subscription, or per-account runtime.

  • Proxy signs MCP requests with SigV4 (stdlib + botocore only, no new dependencies) and exposes run_script instead of the deprecated call_aws
  • Legacy Runtime path is preserved byte-for-byte when aws_mcp_endpoint is unset — existing deployments are unaffected, and rollback is a single variable
  • run_script accepts an optional account_id: the proxy assumes role/<member_role_name> in that account and signs with the member credentials, so APIs execute in the member context and CloudTrail logs land in the member account
  • New list_member_accounts tool resolves member account names to IDs via organizations:ListAccounts
  • The managed server also serves knowledge tools, so the proxy now forwards three more: get_aws_skill, search_documentation, read_documentation. These read AWS documentation rather than account resources, so they run on the proxy's own credentials and account_id is stripped — no member-account AssumeRole. get_aws_skill retrieves AWS-authored workflows such as aws-billing-and-cost-management, which encodes procedures and the mistakes models make on cost data

Per-account finops-readonly role

The proxy assumes this role for every query — including queries against the Gateway's own account (run_script with no account_id) — so its execution role becomes a pure pipe (STS + Organizations only) and payer and member queries share one permission model.

Account How the role is created
Gateway's own account examples/member-finops-readonly-role.yaml — the proxy assumes the role here too
Each member account The same template, once per account, or org-wide via StackSet

PermissionsMode selects the grant, and applies to every account since one template covers them all. It defaults to ReadOnlyAccess, whose read actions also return stored data (S3 objects, DynamoDB items, decrypted SSM parameters). InventoryOnly instead creates a scoped policy allowing inventory and cost reads while denying those that return content.

An earlier revision built the Gateway account's role in Terraform and member roles from the template. That drifted: only the template could produce the scoped policy, so the Gateway account was stuck on ReadOnlyAccess with no supported way to tighten it. terraform/finops-readonly-role.tf is gone and the template covers every account, which also avoids maintaining the same 98-action policy in both HCL and YAML.

Only the proxy's execution role can assume it, enforced with an aws:PrincipalArn condition. The condition form is deliberate: a role ARN placed in Principal resolves to a hidden unique ID, so it must exist before member accounts are provisioned and breaks if the role is recreated.

Naming read-only tools

MCP lets a server mark a tool read-only via annotations.readOnlyHint, and the managed AWS MCP Server sets it. Gateway cannot forward it: ToolDefinition accepts only name, description, inputSchema and outputSchema, so passing annotations fails parameter validation outright.

Clients then have to infer intent, and at least one infers it from the tool name. Amazon Quick classified the upstream name retrieve_skill as a write and prompted for approval on every call, despite it only reading documentation; exposing it as get_aws_skill makes Quick classify it as a read, which allows blanket approval. The upstream tool name is unchanged — the proxy already maps its own tool names to upstream ones.

docs/mcp-tools-reference.md records the convention and two limits on it: this is observed client behaviour rather than a documented contract, and a tool with genuine side effects should not be renamed to dodge a prompt. run_script keeps its name, since it executes model-authored code and is annotated destructiveHint upstream.

Docs

docs/migrate-to-managed-mode.md walks an existing deployment through the upgrade. Two steps are called out because both fail silently: a stale MCP client tool list makes the agent look healthy while all AWS API access is dead, and leaving lambda_timeout at 30s truncates multi-region sweeps into partial results.

Test plan

  • 12 offline unit tests (tests/unit/test_proxy_managed_mode.py) — SigV4 signing, tool routing, credential resolution, error propagation, and that knowledge-tool routing strips account_id
  • E2E from an MCP client through the Gateway against a live AWS Organization: list_member_accounts, same-account and cross-account run_script, all-region inventory sweep
  • Negative paths: an account without the role fails honestly instead of fabricating data; JSON-RPC errors surface instead of being swallowed into an empty result; direct Lambda invoke bypassing the Gateway returns Unknown or unresolvable tool rather than enumerating accounts
  • Regression: cost-explorer-mcp and athena-mcp unaffected; mixed Cost Explorer + run_script orchestration works in one turn
  • terraform plan against a deployed stack destroys nothing; with aws_mcp_endpoint unset it adds nothing
  • Both PermissionsMode values deployed to a member account and exercised through the Gateway: inventory and cost reads succeed under InventoryOnly while object reads, table scans, instance user data and decrypted parameters are denied; switching back to ReadOnly restores the managed policy and removes the scoped one
  • All five tools exercised end to end through the deployed Gateway: the three knowledge tools return real documentation and skill content, cross-account and same-account run_script return distinct resources, and a bare Lambda invoke still refuses to enumerate accounts
  • The migration guide walked end to end on a live deployment: rolled a managed-mode stack back to legacy, confirmed call_aws still served traffic through the Runtime, then followed the guide step by step as written. Nine defects surfaced and are fixed here — the largest being that step 1 never said the tool schema file has to move with the variable, which leaves the Gateway advertising tools the proxy no longer serves
  • aws cloudformation validate-template passes on the member template; terraform fmt/validate clean; ruff check clean on changed files

Notes for reviewers

  • The AgentCore Runtime is still created unconditionally, so migration stays reversible and the legacy path keeps working. Gating it on aws_mcp_endpoint == "" would drop the Marketplace dependency entirely — happy to do that here or as a follow-up, whichever you prefer.
  • examples/ is a new top-level directory; if you'd rather it live under terraform/ or docs/, say the word.

@CodeOfCloud
CodeOfCloud force-pushed the feat/cross-account-aws-mcp branch 9 times, most recently from bc8aa13 to 664a70f Compare August 3, 2026 11:17
@CodeOfCloud CodeOfCloud changed the title feat: cross-account AWS API access via managed AWS MCP Server feat: cross-account AWS API via managed MCP Server Aug 3, 2026
@CodeOfCloud
CodeOfCloud force-pushed the feat/cross-account-aws-mcp branch from 664a70f to a030ffa Compare August 3, 2026 11:24
Add a managed mode to lambda-proxy, enabled by aws_mcp_endpoint, that
replaces the AgentCore Runtime + aws-api-mcp-server container path. The
upstream container entered end-of-development on 2026-07-15 (removal
2027-07-15) and its Marketplace listing is closed to new subscriptions,
so a fresh deployment cannot stand that target up at all. The managed
AWS MCP Server offers the same any-API access with no container,
subscription, or per-account runtime.

The proxy signs MCP requests with SigV4 and exposes run_script instead
of the deprecated call_aws. Passing account_id assumes a role in that
member account, so the API executes in the member context and CloudTrail
logs land there. The legacy Runtime path is preserved byte-for-byte when
aws_mcp_endpoint is unset, keeping rollback to a configuration change.

Beyond the API tools, the managed server also serves knowledge tools, so
the proxy forwards three more: get_aws_skill, search_documentation and
read_documentation. These read AWS documentation rather than account
resources, so they run on the proxy's own credentials and account_id is
stripped — no member-account AssumeRole. get_aws_skill retrieves
AWS-authored workflows such as aws-billing-and-cost-management, which
encodes procedures and the mistakes models make on cost data.

Read access now comes from a per-account finops-readonly role that the
proxy assumes for every query, including against its own account, so the
proxy execution role becomes a pure pipe.
examples/member-finops-readonly-role.yaml creates that role in every
account, the Gateway's own included — one definition rather than a
Terraform copy and a CloudFormation copy that can drift. PermissionsMode
selects the grant: ReadOnlyAccess by default, or a scoped policy that
allows inventory and cost reads while denying the actions that return
stored data.

Two non-obvious choices in the role's trust policy:

- Access is restricted by aws:PrincipalArn rather than sts:ExternalId.
  An External ID addresses the confused-deputy problem when a third
  party cannot be identified by ARN; within one organization, naming the
  single permitted caller is stronger and removes a shared secret from
  the deployment path. ExternalId remains available and layers on top.
- The proxy role is matched in a condition rather than named in
  Principal, where a role ARN resolves to a hidden unique ID: it would
  have to exist before member accounts are provisioned, and would break
  if the role were recreated. A condition ARN is a plain string
  comparison.

Tool names use get_ / list_ / read_ / search_ prefixes for reads. MCP
marks read-only tools with annotations.readOnlyHint and the managed
server sets it, but Gateway's ToolDefinition accepts only name,
description, inputSchema and outputSchema, so the hint cannot be
forwarded. Clients then infer intent from the name — one classified
retrieve_skill as a write and prompted on every call despite it only
reading documentation. run_script keeps its name, since it executes
model-authored code and is annotated destructiveHint upstream.

docs/migrate-to-managed-mode.md covers upgrading an existing deployment.
It was written against a real migration, walked end to end, and revised
for what that surfaced: the tool schema file has to move together with
the variable or the Gateway advertises tools the proxy no longer serves;
the role is needed in the Gateway account too, not only in members;
PermissionsMode has to be chosen before deploying rather than after;
verification has to drive the agent before reading logs, since the
filter cannot match anything earlier.

Covered by 12 offline unit tests. Verified end to end against a live AWS
Organization: all five tools through the Gateway, cross-account and
local queries returning distinct resources, both PermissionsMode values
deployed and their denials confirmed, and a bare Lambda invoke still
refusing to enumerate accounts. Plans are non-destructive in managed
mode and additive-free in legacy mode; cfn validate-template passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CodeOfCloud
CodeOfCloud force-pushed the feat/cross-account-aws-mcp branch from 091bac0 to 59758eb Compare August 4, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant