From 59758ebfa26f20c895f7c27d597b1acd30276cd4 Mon Sep 17 00:00:00 2001 From: Zeng Xin Date: Tue, 4 Aug 2026 19:48:28 +0800 Subject: [PATCH] feat: cross-account AWS API via managed MCP Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 45 +- docs/architecture.md | 41 +- docs/configuration.md | 3 +- docs/mcp-tools-reference.md | 41 +- docs/migrate-to-managed-mode.md | 473 ++++++++++++++++++++ docs/quicksuite-agent-setup.md | 97 +++- examples/member-finops-readonly-role.yaml | 231 ++++++++++ scripts/invoke_lambda.py | 22 +- src/lambda/proxy/lambda_function.py | 229 +++++++++- terraform/config/terraform.tfvars.example | 19 + terraform/main.tf | 5 + terraform/modules/agentcore-gateway/main.tf | 11 +- terraform/modules/lambda-proxy/iam.tf | 28 ++ terraform/modules/lambda-proxy/main.tf | 5 +- terraform/modules/lambda-proxy/variables.tf | 23 + terraform/outputs.tf | 17 +- terraform/tool-schemas/aws_api_mcp.json | 76 +++- terraform/variables.tf | 23 + tests/unit/__init__.py | 0 tests/unit/test_proxy_managed_mode.py | 222 +++++++++ 20 files changed, 1532 insertions(+), 79 deletions(-) create mode 100644 docs/migrate-to-managed-mode.md create mode 100644 examples/member-finops-readonly-role.yaml create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_proxy_managed_mode.py diff --git a/README.md b/README.md index 4c93024..9dd4a36 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ An MCP (Model Context Protocol)-enabled agent for Cloud Financial Management (CF ![Architecture Diagram](docs/images/finops-agent-architecture.png) -All Gateway targets are **Lambda functions**. The `lambda-proxy` Lambda forwards requests to the Bedrock AgentCore Runtime which hosts the aws-api-mcp-server container. +All Gateway targets are **Lambda functions**. The `lambda-proxy` Lambda has two modes: **managed mode** (recommended; set `aws_mcp_endpoint`) signs requests with SigV4 and forwards them to the [managed AWS MCP Server](https://docs.aws.amazon.com/agent-toolkit/latest/userguide/getting-started-aws-mcp-server.html), optionally assuming a role in a member account first for cross-account queries; **legacy mode** (default when `aws_mcp_endpoint` is unset) forwards to a Bedrock AgentCore Runtime hosting the aws-api-mcp-server container. ## Deployment Modes @@ -48,9 +48,45 @@ Two distinct cross-account flows, each on its own row: A single `make deploy` creates resources in both accounts. Terraform auto-generates an [External ID](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html) to secure the assumed role (stored in Terraform state). +### Cross-account resource queries (managed mode) + +With managed mode enabled, the agent can also run **read-only AWS API queries in any member account** — e.g. "list EC2 instances and tags in account X". The `lambda-proxy` exposes two tools: + +- `list_member_accounts` — resolves member account names to IDs via `organizations:ListAccounts` (deploy in the org management account to use this) +- `run_script(code, account_id?)` — executes sandboxed Python against AWS APIs through the managed AWS MCP Server; with `account_id`, the proxy first assumes `arn:aws:iam:::role/` and signs the request with the member credentials, so the API runs in the member context and CloudTrail logs land in the member account + +Every account the agent queries — **including the Gateway's own account** — needs one IAM role (default name `finops-readonly`) with the `ReadOnlyAccess` managed policy, trusting this account and restricted to the proxy's execution role via an `aws:PrincipalArn` condition. There is no shared secret to distribute; see [how access is restricted](docs/migrate-to-managed-mode.md#how-access-is-restricted). The proxy's own execution role is a pure pipe (STS + Organizations only); all AWS read access flows through this per-account role, so local and member queries share one permission model. + +Enable managed mode in `terraform.tfvars`: + +```hcl +aws_mcp_endpoint = "https://aws-mcp.us-east-1.api.aws/mcp" +member_role_name = "finops-readonly" # optional, this is the default +lambda_timeout = 120 # multi-region inventory exceeds the 30s default +``` + +Then create the roles: + +| Account | How | +| ------- | --- | +| This account (Gateway) | Deploy [`examples/member-finops-readonly-role.yaml`](examples/member-finops-readonly-role.yaml) to it — the proxy assumes the role here too | +| Each member account | The same template, once per account, or org-wide via a CloudFormation StackSet | + +The member template takes this account's ID and the proxy's role name — the same values for every account, so one StackSet covers the organization: + +```bash +terraform -chdir=terraform output -raw proxy_role_name +``` + +Without the role in a given account, queries against it fail with a message naming the missing role — the agent reports the gap rather than returning partial data. + +The role's permissions are selectable. The default attaches `ReadOnlyAccess`, which covers any service the agent might be asked about; the template's `PermissionsMode=InventoryOnly` instead grants resource and cost metadata while denying the read actions that return stored data. See [choosing the permissions grant](docs/migrate-to-managed-mode.md#choosing-the-permissions-grant). + +Already running a Runtime-based deployment? See [Migrate to Managed Mode](docs/migrate-to-managed-mode.md). + ## Prerequisites -1. **AWS Marketplace Subscription** - [Subscribe to aws-api-mcp-server](https://aws.amazon.com/marketplace/pp/prodview-lqqkwbcraxsgw) (free, accept terms). For cross-account deployments, subscribe from the **data collection account**. +1. **AWS Marketplace Subscription** — *legacy mode only*: [aws-api-mcp-server](https://aws.amazon.com/marketplace/pp/prodview-lqqkwbcraxsgw) requires an existing subscription (the listing is closed to new subscriptions, and the upstream server is [scheduled for removal in July 2027](https://github.com/awslabs/mcp/issues/4115)). New deployments should use **managed mode** instead (`aws_mcp_endpoint = "https://aws-mcp.us-east-1.api.aws/mcp"`), which needs no subscription or container. 2. **CUR 2.0 Export** - [Create a Cost and Usage Report 2.0](https://docs.aws.amazon.com/cur/latest/userguide/cur-create.html) export to Amazon S3 with Athena integration enabled. Ensure the S3 bucket has Block Public Access enabled and server-side encryption configured. 3. **Identity Provider (IdP)** — *optional*: only needed if you switch to `gateway_auth_type = "CUSTOM_JWT"`. The default (`COGNITO`) auto-provisions a Cognito User Pool + OAuth client for service-to-service callers (QuickSuite, n8n, CI) — no external IdP required. See [Identity Provider Setup](#identity-provider-setup). 4. **AWS CLI Profiles** - [Named profiles](https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-files.html) configured for target account(s) @@ -62,7 +98,7 @@ This deploys the AWS FinOps Agent infrastructure: - AgentCore Gateway with JWT authentication - AWS Lambda functions (cost-explorer-mcp, athena-mcp, lambda-proxy) - IAM roles and policies (including the management-account role consumed by `cost-explorer-mcp`, if cross-account mode is configured) -- AgentCore Runtime (aws-api-mcp-server container) +- AgentCore Runtime (aws-api-mcp-server container) — legacy mode only; managed mode replaces it with the managed AWS MCP Server **Not included:** QuickSuite requires manual setup after deployment. See [QuickSuite Agent Setup](docs/quicksuite-agent-setup.md). @@ -223,7 +259,7 @@ After deployment, configure your MCP client (QuickSuite) to connect to the gatew | Target | Description | | ------------------------------ | ------------------------------------------------- | -| `aws-api-mcp` | AWS API MCP server (Marketplace) — `call_aws`, `suggest_aws_commands` | +| `aws-api-mcp` | Cross-account AWS API access plus AWS documentation and expert skills, via managed AWS MCP Server — `run_script`, `list_member_accounts`, `get_aws_skill`, `search_documentation`, `read_documentation` (legacy: `call_aws` via AgentCore Runtime when managed mode is disabled) | | `cost-explorer-mcp` | AWS Cost Explorer API (6 tools) | | `athena-mcp` | Athena queries (8 tools) | @@ -237,6 +273,7 @@ After deployment, configure your MCP client (QuickSuite) to connect to the gatew | [Configuration](docs/configuration.md) | tfvars, permissions, make commands | | [Troubleshooting](docs/troubleshooting.md) | Debugging, logs, common issues | | [QuickSuite Agent Setup](docs/quicksuite-agent-setup.md) | Configure CFM agent in QuickSuite | +| [Migrate to Managed Mode](docs/migrate-to-managed-mode.md) | Upgrade an existing Runtime-based deployment | ## Testing diff --git a/docs/architecture.md b/docs/architecture.md index 93b535a..74e8ae5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,36 +8,43 @@ The FinOps MCP Gateway deploys an Amazon Bedrock AgentCore Gateway that exposes ``` ┌──────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐ -│ MCP Client │ Federate JWT │ AWS Cloud │ +│ MCP Client │ Federate JWT │ AWS Cloud (payer account) │ │ │────────────────>│ │ │ QuickSuite │ │ ┌─────────────┐ ┌──────────────────────────────────────────────┐ │ │ │<────────────────│ │ AgentCore │ │ Lambda Targets │ │ │ │ │ │ Gateway │ │ │ │ └──────────────┘ │ │ │──────>│ cost-explorer-mcp ───> Cost Explorer API │ │ - │ │ │ │ athena-mcp ──────────> Athena + S3 + AWS Glue │ │ + │ │ │ │ athena-mcp ──────────> Athena + S3 + Glue │ │ │ │ │ │ │ │ - │ │ │ │ lambda-proxy ────────────────┐ │ │ - │ └─────────────┘ └───────────────────────────────┼──────────────┘ │ - │ │ │ - │ v │ - │ ┌──────────────────────────────────────────────┐ │ - │ │ AgentCore Runtime │ │ - │ │ (aws-api-mcp-server from AWS Marketplace) │ │ - │ │ │ │ - │ │ Tools: call_aws, suggest_aws_commands │ │ - │ └──────────────────────────────────────────────┘ │ - └─────────────────────────────────────────────────────────────────────────┘ + │ │ │ │ lambda-proxy (managed mode) │ │ + │ └─────────────┘ │ ├ list_member_accounts ─> Organizations │ │ + │ │ └ run_script(code, account_id?) │ │ + │ │ │ no account_id: own credentials │ │ + │ │ │ account_id: AssumeRole into member ───┼─┼──┐ + │ └──────┼───────────────────────────────────────┘ │ │ + └───────────────────────────────┼─────────────────────────────────────────┘ │ + │ SigV4 (caller's credentials) │ + v │ + ┌───────────────────────────────────────────────┐ ┌────────────────────────┴──┐ + │ AWS MCP Server (managed by AWS) │ │ Member account │ + │ aws-mcp..api.aws/mcp │ │ IAM role finops-readonly │ + │ Verifies SigV4, forwards the request with │ │ (ReadOnlyAccess, trusts │ + │ the caller's credentials — APIs execute in │ │ payer + ExternalId) │ + │ that credential's account context │ └───────────────────────────┘ + └───────────────────────────────────────────────┘ ``` -All Gateway targets are **Lambda functions**. The `lambda-proxy` Lambda forwards requests to the AgentCore Runtime which hosts the aws-api-mcp-server container from AWS Marketplace. +All Gateway targets are **Lambda functions**. In **managed mode** (recommended; `aws_mcp_endpoint` set), the `lambda-proxy` signs MCP requests with SigV4 and forwards them to the managed AWS MCP Server; passing `account_id` makes it assume the member-account role first, so the API executes in the member context. In **legacy mode** (`aws_mcp_endpoint` unset), it forwards to an AgentCore Runtime hosting the aws-api-mcp-server container (deprecated upstream, removal July 2027). ## Components | Component | Description | |-----------|-------------| | **AgentCore Gateway** | MCP endpoint with Federate JWT authentication. Routes requests to Lambda targets. | -| **AgentCore Runtime** | Hosts the aws-api-mcp-server container from AWS Marketplace. Provides `call_aws` and `suggest_aws_commands` tools. | -| **lambda-proxy** | Lambda that forwards MCP requests to AgentCore Runtime. | +| **lambda-proxy** | Lambda with two modes. Managed mode: exposes `run_script` (sandboxed Python via the managed AWS MCP Server, optional `account_id` for cross-account) and `list_member_accounts`. Legacy mode: forwards MCP requests to an AgentCore Runtime. | +| **AWS MCP Server (managed)** | AWS-hosted MCP endpoint. Authenticates SigV4, forwards each request with the caller's credentials; downstream services authorize against that credential's own IAM policies. | +| **Per-account role** | One `finops-readonly` IAM role per account — members **and the payer itself** (ReadOnlyAccess; trusts the payer with an External ID). The proxy assumes it for every query, so all read access shares one permission model; the only member-side footprint. | +| **AgentCore Runtime** | Legacy mode only. Hosts the aws-api-mcp-server container (`call_aws`, `suggest_aws_commands`). | | **cost-explorer-mcp** | Lambda implementing MCP protocol for Cost Explorer API (6 tools). | | **athena-mcp** | Lambda implementing MCP protocol for Athena queries (8 tools). | | **test-mcp** | Dummy Lambda for Gateway verification (`hello`, `echo`). | @@ -53,7 +60,7 @@ All Gateway targets are **Lambda functions**. The `lambda-proxy` Lambda forwards | Target Name | Purpose | |-------------|---------| -| `aws-api-mcp` | Forwards to AgentCore Runtime for AWS CLI execution | +| `aws-api-mcp` | Managed mode: `run_script` (cross-account via `account_id`) + `list_member_accounts`. Legacy mode: forwards to AgentCore Runtime for AWS CLI execution | | `cost-explorer-mcp` | AWS Cost Explorer API access | | `athena-mcp` | Athena query execution | | `test-mcp` | Gateway verification (dummy) | diff --git a/docs/configuration.md b/docs/configuration.md index 1b95d90..38b58a5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,7 +40,8 @@ aws_region = "us-east-1" # MCP Server version from AWS Marketplace mcp_server_image_version = "1.2.0" -# Lambda settings +# Lambda settings (with managed AWS MCP mode, use lambda_timeout >= 120 — +# multi-region run_script inventory scripts routinely exceed 30s) lambda_timeout = 30 lambda_memory_size = 256 diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index 7099447..9aae91c 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -6,12 +6,43 @@ All available MCP tools organized by Gateway target. **Target Name**: `aws-api-mcp` -The `lambda-proxy` Lambda forwards MCP requests to the **AgentCore Runtime** which hosts the `aws-api-mcp-server` container from AWS Marketplace. +The `lambda-proxy` Lambda forwards MCP requests to the **managed AWS MCP Server** for cross-account AWS API execution. | Tool | Description | |------|-------------| -| `call_aws` | Execute AWS CLI commands | -| `suggest_aws_commands` | Get AWS CLI command suggestions | +| `run_script` | Execute Python code against AWS APIs in a sandboxed environment using await call_boto3(...); supports cross-account execution via optional account_id | +| `list_member_accounts` | Resolve member account names to 12-digit account IDs; returns all accounts in the organization | +| `get_aws_skill` | Retrieve an AWS-authored expert workflow with procedures, correct API usage, and known pitfalls. `aws-billing-and-cost-management` covers cost analysis, commitment evaluation, right-sizing, budgets, and CUR queries | +| `search_documentation` | Search official AWS documentation and discover available skill names | +| `read_documentation` | Fetch a full AWS documentation page as markdown | + +`run_script` and `list_member_accounts` act on account resources. The three +knowledge tools read AWS documentation instead, so they ignore `account_id` and +run under the proxy's own credentials — no member-account role is involved. + +## Naming read-only tools + +Prefer a `get_` / `list_` / `read_` / `search_` / `describe_` prefix for any tool +that only reads. + +MCP lets a server mark a tool read-only with the `annotations.readOnlyHint` +field, and the managed AWS MCP Server does set it. Gateway cannot forward it: +[`ToolDefinition`](https://docs.aws.amazon.com/bedrock-agentcore-control/latest/APIReference/API_ToolDefinition.html) +accepts only `name`, `description`, `inputSchema`, and `outputSchema`, so +supplying `annotations` fails parameter validation outright. + +Without that hint a client has to infer intent, and at least one infers it from +the tool name. In Amazon Quick, tools classified as reads can be granted blanket +approval, while everything else prompts on every call. `get_aws_skill` is named +that way for this reason — it wraps the upstream `aws___retrieve_skill`, which is +annotated `readOnlyHint: true`, but under its original name Quick classified it +as a write and prompted on every call. + +This is observed client behaviour, not a documented contract, so treat the +convention as a soft signal that may stop mattering once Gateway forwards +annotations. Do not rename a tool that genuinely has side effects in order to +dodge an approval prompt: `run_script` executes model-authored code and is +annotated `destructiveHint: true` upstream, so prompting on each call is correct. ## Cost Explorer MCP @@ -62,9 +93,9 @@ Dummy Lambda for Gateway verification. Not intended for production use. | Target | Tools | |--------|-------| -| lambda-proxy (aws-api-mcp-server) | 2 | +| lambda-proxy (managed AWS MCP Server) | 5 | | cost-explorer-mcp | 6 | | athena-mcp | 8 | -| **Total** | **16** | +| **Total** | **19** | (Excludes test-mcp dummy tools) diff --git a/docs/migrate-to-managed-mode.md b/docs/migrate-to-managed-mode.md new file mode 100644 index 0000000..c23b771 --- /dev/null +++ b/docs/migrate-to-managed-mode.md @@ -0,0 +1,473 @@ +# Migrating an Existing Deployment to Managed Mode + +This guide upgrades a deployment that proxies to the **AgentCore Runtime** +(`aws-api-mcp-server` container) so it uses the **managed AWS MCP Server** +instead, and optionally gains cross-account resource queries. + +Why migrate: the `aws-api-mcp-server` container entered end-of-development on +2026-07-15 (removal 2027-07-15, [awslabs/mcp#4115](https://github.com/awslabs/mcp/issues/4115)) +and its Marketplace listing is closed to new subscriptions. Managed mode needs +no container, no subscription, and no per-account runtime. + +## What changes, and what doesn't + +| Unchanged | Changed | +| --------- | ------- | +| Gateway ID and MCP endpoint URL | `aws-api-mcp` target's tools | +| Cognito user pool, `client_id` / `client_secret` / `token_url` | `call_aws` → `run_script` | +| `cost-explorer-mcp` and `athena-mcp` targets and their 14 tools | `suggest_aws_commands` → `list_member_accounts` | +| CUR export, Athena table, Glue catalog | Proxy execution role becomes a pure pipe | +| The `aws-api-mcp` target name | Read access moves to a per-account `finops-readonly` role | +| | Documentation and skill tools become available | + +The Gateway endpoint and Cognito credentials are unchanged, so the connector's +settings carry over — but the connector's cached tool list **must be refreshed** +to pick up the new tools (step 4). + +The AgentCore Runtime is **not** removed, which is what keeps +[rollback](#rollback) to a single variable. It sits idle: Runtime billing is +based on active CPU and memory consumption, so an unused Runtime is not a +recurring cost. + +Note the two separate upstream timelines. The self-hosted +`aws-api-mcp-server` container is removed **2027-07-15**, which is what makes +legacy mode a dead end. Independently, the managed server's own `call_aws` tool +is removed **2026-08-31**; managed mode already uses `run_script` instead, so +that date only matters if a client is still pinned to the old tool name. + +`lambda-proxy` is not placed in a VPC in this sample, so it reaches the managed +endpoint over the public internet with TLS and SigV4. Requirements here vary too +much between organizations to pick a default. If yours needs private egress, add +`subnet_ids` / `security_group_ids` to the proxy along with a NAT path; AWS has +stated that VPC endpoint support for managed MCP servers is planned but not yet +available, so a VPC alone does not keep this traffic off the internet today. + +## How access is restricted + +The member role's trust policy names the payer account as `Principal` and then +restricts assumption to the proxy's execution role: + +```json +{ + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam:::root" }, + "Action": "sts:AssumeRole", + "Condition": { + "ArnEquals": { + "aws:PrincipalArn": "arn:aws:iam:::role/" + } + } +} +``` + +`Principal` names the account rather than the role, and the role is matched in a +condition instead. Both express the same restriction, but a role ARN in +`Principal` is resolved to a hidden unique ID when the policy is saved: the role +must already exist when the template is deployed, and the policy breaks silently +if that role is ever deleted and recreated. A condition ARN is compared as a +string, so member accounts can be provisioned before the Gateway account and +survive a proxy rebuild. + +`aws:PrincipalArn` resolves to the *role* ARN, not the session ARN, so it matches +regardless of the session name the proxy uses. + +There is no shared secret to distribute. If a third party operates the Gateway +account and an `sts:ExternalId` condition is also wanted, set +`member_role_external_id` and pass the template's `ExternalId` parameter; it +layers on top of the principal restriction. + +## Prerequisites + +- An existing deployment created by this repo, with `make output` working +- Terraform state you can apply against +- Permission to create IAM roles in the Gateway account +- Permission to administer the MCP client connector, since its tool list has to + be refreshed (see step 4) +- For cross-account queries, additionally: + - Permission to create IAM roles in each member account, and a way to deploy + CloudFormation there (StackSet, or the organization's own CI/CD) + - `list_member_accounts` calls `organizations:ListAccounts`, which AWS allows + only from the organization's management account or a delegated + administrator. Elsewhere that tool fails, but `run_script` still works when + given a 12-digit account ID directly. + +## Step 1 — Enable managed mode + +Add to `terraform/config/terraform.tfvars`: + +```hcl +aws_mcp_endpoint = "https://aws-mcp.us-east-1.api.aws/mcp" +member_role_name = "finops-readonly" # optional, this is the default +lambda_timeout = 120 # required: see note below +``` + +`lambda_timeout` matters. Multi-region inventory scripts routinely exceed the +30s default; a sweep across all enabled regions will time out mid-run and the +agent will report partial results. + +There is no shared secret to configure — see +[How access is restricted](#how-access-is-restricted). + +**Then check the tool schema.** `terraform/tool-schemas/aws_api_mcp.json` is what +the Gateway advertises, and it is a separate file from the variable above. It has +to describe the managed-mode tools: + +```bash +python3 -c "import json; print([t['name'] for t in json.load(open('terraform/tool-schemas/aws_api_mcp.json'))])" +``` + +Expect `['run_script', 'list_member_accounts', 'get_aws_skill', +'search_documentation', 'read_documentation']`. If you instead see `call_aws` and +`suggest_aws_commands`, the file is still the legacy version — check out the +managed-mode one before deploying, or the next step will re-register the legacy +tools against a proxy that no longer serves them. + +The variable and the schema file move together in both directions. This is the +same pairing that [Rollback](#rollback) reverses. + +## Step 2 — Deploy the Gateway changes + +```bash +make deploy +``` + +Use `make deploy`, not `make apply`. Only `deploy` runs +`scripts/update_tool_schemas.py` afterwards, which registers the full tool list +from the schema files. After a bare `apply` each Gateway target carries only a +single placeholder tool. + +Then confirm the Gateway actually advertises the managed tools: + +```bash +aws bedrock-agentcore-control get-gateway-target \ + --gateway-identifier $(terraform -chdir=terraform output -raw gateway_id) \ + --target-id \ + --query 'targetConfiguration.mcp.lambda.toolSchema.inlinePayload[].name' \ + --output text --region --profile +``` + +Get the target ID from `terraform output mcp_target_ids`, or from the Gateway's +Targets list in the console. + +If this still prints `call_aws suggest_aws_commands`, the schema file was the +legacy version when you deployed. Fix the file as described in step 1 and re-run +`make deploy`. The proxy will already be serving `run_script` at this point, so +until the two agree every tool call fails — and it fails in the confusing way, +with the client believing a tool exists that the Lambda will reject. + +Otherwise expect: the proxy's environment and IAM policy updated, and the +`aws-api-mcp` target's schema replaced. Nothing should be destroyed. The +`finops-readonly` role is not created here — that is step 3. + +## Step 3 — Create the finops-readonly role in every account + +The proxy assumes this role for **every** query, so the Gateway's own account +needs it too, not just member accounts. One template covers both; deploy it once +per account. + +**Decide `PermissionsMode` before deploying.** It controls what the role may +read, and choosing now is easier than changing later: + +| Mode | Grant | +| ---- | ----- | +| `ReadOnly` (default) | AWS managed `ReadOnlyAccess`. Every service works, including ones AWS adds later — but its read actions also return stored data: S3 object contents, DynamoDB items, decrypted SSM parameters, log events. | +| `InventoryOnly` | A scoped policy created in the same stack. Resource configuration, tags and cost data only; reads that return stored data are explicitly denied. Queries about services outside the policy fail until it is extended. | + +`InventoryOnly` is the tighter grant and the better fit when the agent's job is +inventory and cost analysis. Omitting the parameter gives you `ReadOnly`. Use the +same value in every account, so the agent sees one consistent permission model +instead of succeeding in one account and failing in another. Either choice can be +changed afterwards — see [Changing the grant later](#changing-the-grant-later). + +First read the proxy's role name — the only principal the role will trust: + +```bash +terraform -chdir=terraform output -raw proxy_role_name +``` + +Then deploy [`examples/member-finops-readonly-role.yaml`](../examples/member-finops-readonly-role.yaml), +starting with the Gateway account itself: + +```bash +aws cloudformation deploy \ + --template-file examples/member-finops-readonly-role.yaml \ + --stack-name finops-readonly \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides \ + PayerAccountId= \ + ProxyRoleName= \ + PermissionsMode=InventoryOnly \ + --region --profile +``` + +Then repeat for each member account you want to query, changing only the +`--profile`. Keep `--region` the same: the role is global, but CloudFormation +needs a region for the stack itself, and using one region everywhere keeps the +stacks easy to find. `PayerAccountId` stays the Gateway account in every case — +it is the account being trusted, not the account being deployed to. Because the +parameters are identical everywhere, the same template and parameter set works +as a CloudFormation StackSet across the organization or an OU. + +If a `finops-readonly` role already exists in an account — from an earlier pilot, +or from a version of this repo that created it in Terraform — **check the logical +ID of the stack that owns it** before deploying: + +```bash +aws cloudformation describe-stack-resources --stack-name \ + --query 'StackResources[].LogicalResourceId' --output text \ + --region --profile +``` + +This template uses `FinOpsReadOnlyRole`. If the existing stack used a different +logical ID, deploying this template makes CloudFormation try to create a *second* +role with the same name, which fails with `finops-readonly already exists in +stack …` and rolls back. Delete the old stack first, or update the existing +role's trust policy in place with `aws iam update-assume-role-policy`. + +If the role exists but no stack owns it — Terraform managed it in an earlier +version of this repo — drop it from Terraform state before deploying, so +Terraform stops trying to manage it and CloudFormation can take over: + +```bash +terraform -chdir=terraform state rm 'aws_iam_role.finops_readonly[0]' +terraform -chdir=terraform state rm 'aws_iam_role_policy_attachment.finops_readonly[0]' +``` + +`state rm` only stops Terraform tracking the resource; it does not delete the +role. Then delete the role itself so the template can recreate it under +CloudFormation management: + +```bash +aws iam detach-role-policy --role-name finops-readonly \ + --policy-arn arn:aws:iam::aws:policy/ReadOnlyAccess --profile +aws iam delete-role --role-name finops-readonly --profile +``` + +Accounts without the role are not silently skipped: queries against them fail +with a message naming the missing role, so the agent reports the gap instead of +returning partial data. + +### Confirm every account matches + +Once the role exists everywhere, check that the grant is identical across +accounts. It is easy to deploy to one account, adjust the parameters, and forget +to re-run the others: + +```bash +for p in ; do + printf "%-16s " "$p" + aws iam list-attached-role-policies --role-name finops-readonly \ + --profile "$p" --region \ + --query 'AttachedPolicies[].PolicyName' --output text +done +``` + +Every line must show the same policy — `ReadOnlyAccess` or +`finops-readonly-inventory`, not a mix. A mismatch means the same question +succeeds against one account and is denied against another, which reads like a +broken agent rather than a policy decision. + +### What InventoryOnly actually denies + +The scoped policy allows `Describe*` / `List*` for services whose read APIs only +return configuration, then explicitly denies the reads that return content: +`s3:GetObject*`, `dynamodb:Scan` / `Query` / `GetItem`, +`ec2:DescribeInstanceAttribute` (instance user data), `logs:GetLogEvents`, +`lambda:GetFunctionConfiguration` (environment variables), and the EC2 +console-output APIs. An explicit `Deny` cannot be overridden by the wildcard +`Allow`, so adding a broad `Describe*` later does not reopen these. + +Tags and resource metadata are unaffected — EC2 tags, for instance, arrive inside +`DescribeInstances`, not through the denied per-attribute call. + +To cover a service the policy does not mention, add its `Describe*` / `List*` to +the `InventoryAndCostMetadata` statement in the template and redeploy. + +### Changing the grant later + +Redeploy the same stack with the other value: + +```bash +aws cloudformation deploy \ + --template-file examples/member-finops-readonly-role.yaml \ + --stack-name finops-readonly \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides \ + PayerAccountId= \ + ProxyRoleName= \ + PermissionsMode=ReadOnly \ + --region --profile +``` + +CloudFormation swaps the policy attachment and, when moving back to `ReadOnly`, +deletes the scoped policy it created. The role keeps its name, ARN and trust +policy, so nothing on the client side needs reconfiguring. + +Repeat for every account, or the agent will succeed in one and fail in another +for reasons that are hard to see from the outside. + +CloudFormation reuses a stack's previous parameter values when you omit them, so +an update that does not mention `PermissionsMode` keeps whatever the stack was +last deployed with — it will not silently revert to `ReadOnly`. + +The Gateway account's own role uses the same template and the same +`PermissionsMode` value — it is not special. Deploy it there first, then to each +member account. + +## Step 4 — Refresh the MCP client's tool list + +**This step is required and easy to miss.** MCP clients cache the `tools/list` +response from the moment the connector was configured. After migrating, a stale +client keeps calling `call_aws`, the Gateway rejects it, and the model quietly +falls back to other tools — so the agent looks like it is working while all AWS +API access is dead. + +In QuickSuite, open the connector and choose **Sync**. That re-runs discovery and +picks up the new tool list in place; the connector's endpoint and credentials are +untouched. + +**Sync only discovers the tools — it does not enable them.** Every managed-mode +tool is a new name that inherits no prior approval, so all five have to be +enabled by hand: + +- `run_script` +- `list_member_accounts` +- `get_aws_skill` +- `search_documentation` +- `read_documentation` + +Then check the count, because a tool left disabled fails silently — the model +simply never calls it: + +```bash +# what the Gateway offers +aws bedrock-agentcore-control get-gateway-target \ + --gateway-identifier $(terraform -chdir=terraform output -raw gateway_id) \ + --target-id \ + --query 'length(targetConfiguration.mcp.lambda.toolSchema.inlinePayload)' \ + --output text --region --profile + +# what the connector has enabled +aws quicksight describe-action-connector \ + --aws-account-id \ + --action-connector-id \ + --query 'length(ActionConnector.EnabledActions)' \ + --output text --region --profile +``` + +The second number counts every target's tools, not just `aws-api-mcp`, so compare +it against the total across all four targets. With the stock deployment that is +5 + 6 + 8 + 2 = 21. Fewer means something is still disabled; find it in the +connector's tool list rather than guessing. + +Get the connector ID from `aws quicksight list-action-connectors`. + +Deleting and recreating the connector also works, and is the documented fallback +if Sync is unavailable in your version. Recreating means re-entering the same +endpoint and credentials, which are unchanged: + +```bash +make show-cognito-creds +``` + +Note that the QuickSuite documentation currently states that tool lists are +static after registration and that the integration must be recreated. Sync was +observed to update the tool list without recreating, so try it first. + +## Step 5 — Verify + +Drive the agent first, then read the logs — the log filter below matches nothing +until a query has actually been made. + +Ask these from the MCP client, in order. Each one exercises a different part of +what changed: + +| Ask | Exercises | If it fails | +| --- | --------- | ----------- | +| "Which accounts are in my organization?" | `list_member_accounts` | The tool is disabled in the connector (step 4), or this is not the organization's management account (see Prerequisites) | +| "List EC2 instances in this account" | `run_script` with no `account_id` | The role is missing in the Gateway account (step 3) | +| "List EC2 instances and tags in account ``" | The cross-account path | The role is missing in that member account, or its condition names the wrong proxy role (step 3) | +| Any cost question | `cost-explorer-mcp` still intact | Re-run `make deploy` (step 2) | + +The strongest check on the cross-account query is that it returns *different* +resources than the same question asked without an account ID. Matching output +usually means the account ID was dropped and the query ran locally. + +Now confirm the proxy took the managed path: + +```bash +aws logs tail /aws/lambda/finops-mcp-proxy --since 15m \ + --region --profile --filter-pattern "Managed" +``` + +Expect a line per call, such as `Managed mode tool: run_script`. If you see +`Invoking runtime:` instead, the client is still on its cached tool list — return +to step 4. If there is no output at all, no request reached the Lambda: the +connector is likely waiting on an approval prompt in the client, so check there +before assuming the deployment is broken. + +The function name above assumes the default `project_name` of `finops-mcp`; +substitute your own if it differs. + +Finally, confirm a cross-account call really executed in the member account by +checking that account's CloudTrail for the proxy's role session name, which is +always `finops-mcp-proxy` regardless of `project_name`: + +```bash +aws cloudtrail lookup-events \ + --lookup-attributes AttributeKey=Username,AttributeValue=finops-mcp-proxy \ + --max-results 5 --region --profile +``` + +CloudTrail lags by several minutes, so an empty result here right after a query +is not a failure — the successful response in the client is the stronger signal. + +## Rollback + +The Runtime is still deployed, so rolling back is a configuration change rather +than a redeployment. Two things have to move together. + +Unset the endpoint in `terraform.tfvars`: + +```hcl +aws_mcp_endpoint = "" +``` + +Then restore the legacy tool schema. `terraform/tool-schemas/aws_api_mcp.json` +describes the tools the Gateway advertises, and managed mode replaced its +contents. Unsetting the endpoint alone leaves the Gateway advertising +`run_script` while the proxy only serves `call_aws` — every call then fails. +Recover the legacy version from git: + +```bash +git show :terraform/tool-schemas/aws_api_mcp.json \ + > terraform/tool-schemas/aws_api_mcp.json +``` + +The legacy file declares `call_aws` and `suggest_aws_commands`. Confirm that +before deploying: + +```bash +make deploy +``` + +Then refresh the connector's tool list again (step 4) so the client picks up +`call_aws`. + +The `finops-readonly` roles can stay — they are inert in legacy mode, since the +proxy signs with its own execution role there. Leaving them in place makes a +second migration a configuration change only. + +## Troubleshooting + +Every failure below is silent — the error text does not identify which step was +missed. + +| Symptom | Cause | Fix | +| ------- | ----- | --- | +| Agent reports a missing read permission (e.g. `ec2:DescribeInstances`) for the Gateway's own account | The local `finops-readonly` role is missing. Read access no longer comes from the proxy's execution role. | Confirm the role exists in that account with `aws iam get-role --role-name finops-readonly`, then re-run step 3. | +| `AccessDenied` on `sts:AssumeRole` for a member account | The role is absent there, or its `aws:PrincipalArn` condition names a different role than the proxy actually uses. | Compare the member role's trust policy against `terraform output -raw proxy_role_name`; redeploy the template with the correct `ProxyRoleName`. | +| `finops-readonly already exists in stack …` when deploying the member template | A role from an earlier pilot exists under a different CloudFormation logical ID, so CloudFormation tries to create a second one. | Either `aws iam update-assume-role-policy` on the existing role, or delete the old stack first. See step 3. | +| Gateway rejects the tool, or the agent stops calling AWS APIs while still answering | Stale client tool list, or `make apply` ran without `update-schemas`. | Re-run `make deploy`, then Sync the connector (step 4). | +| Multi-region queries return partial results or time out | `lambda_timeout` is still at the 30s default. | Set `lambda_timeout = 120` and `make deploy` (step 1). | +| Cross-account query returns the Gateway account's resources instead of the member's | The model omitted `account_id`, usually because it could not resolve the account name. | Ask for the account by ID, or check that `list_member_accounts` is in the connector's approved tools. | diff --git a/docs/quicksuite-agent-setup.md b/docs/quicksuite-agent-setup.md index f1b7697..72dce8b 100644 --- a/docs/quicksuite-agent-setup.md +++ b/docs/quicksuite-agent-setup.md @@ -106,6 +106,35 @@ Enter your IdP OAuth credentials: ![MCP Integration Step 3](images/quicksuite-integ-step-3.png) +### 2.4 Naming the connector + +One MCP connector reaches exactly one AWS Organization, through that +organization's payer account. If you attach several — one per payer — every +connector exposes the **same tool names**, so the connector name is the only +thing that tells the agent which organization a call will hit: + +``` +Using aws-api-mcp___run_script in FinOps prod 111122223333 +Using aws-api-mcp___run_script in FinOps sandbox 444455556666 + ^ identical ^ the only difference +``` + +Name each connector with the payer account ID and, if useful, the environment — +for example `FinOps prod `. Keep the pattern consistent across +connectors. An agent asked about "the sandbox account" routes by matching the +user's wording against these names; vague or inconsistent names make it pick +the wrong organization, and a cost figure from the wrong payer looks perfectly +plausible. + +State in the agent's instructions which connector is the default, if one is. + +### 2.5 Refreshing tools after a redeploy + +Adding or renaming a Gateway tool does not reach an existing connector on its +own. Open the connector and choose **Sync** to re-discover the tool list, then +enable any new tools. Changes that leave the tool list untouched — Lambda code, +IAM policy, tool descriptions — need no action. + ## Step 3: Create Agent 1. Go to **Agents** and click **Create Agent** @@ -168,8 +197,8 @@ You have access to these MCP tools through AgentCore Gateway: ### AWS API MCP (Fallback for unsupported operations) | Tool | Use For | |------|---------| -| `call_aws` | Execute any AWS CLI command (read-only access) | -| `suggest_aws_commands` | Get AWS CLI command suggestions | +| `run_script` | Execute sandboxed Python against any AWS API via `await call_boto3(...)`; pass `account_id` to run in a member account | +| `list_member_accounts` | Resolve member account names to 12-digit IDs — call FIRST when the user names a member account | **Use AWS API MCP only when:** - Specialized tools don't support the required operation @@ -177,6 +206,34 @@ You have access to these MCP tools through AgentCore Gateway: - Need anomaly detection - Need non-cost AWS data (EC2 instances, S3 buckets, etc.) +### AWS Knowledge (authoritative procedures and documentation) +| Tool | Use For | +|------|---------| +| `get_aws_skill` | Load an AWS-authored expert workflow before a multi-step task | +| `search_documentation` | Confirm API behaviour, limits, and pricing rules; discover skill names | +| `read_documentation` | Read a full documentation page when a search excerpt is insufficient | + +**Before any cost audit, optimization review, or commitment (Savings Plans / RI) +analysis, call `get_aws_skill` with `skill_name: aws-billing-and-cost-management` +and follow the workflow it returns.** It encodes AWS's own procedures and the +mistakes models commonly make on cost data — for example that Cost Explorer +returns an empty `Total` when `GroupBy` is used, that Compute Optimizer requires +opt-in before it returns recommendations, and that the Budgets API only works in +`us-east-1`. The skill cites further files such as `references/cost-audit.md`; +retrieve those by passing `file` alongside the same `skill_name`. + +Prefer `search_documentation` over answering from memory whenever you are about +to state an API limit, a pricing rule, or a service behaviour. + +Two rules from that skill apply to every response, so follow them even if you +skip the skill: +- **Establish the current date with `get_today_date` before any cost query.** + Do not assume the year; a plausible-looking analysis of the wrong period is + worse than an error. +- **Never do arithmetic in your reply.** Sums, averages, percentages, and + comparisons over cost data must be computed with `run_script` and reported + from its output. + --- ## Tool Selection Decision Tree @@ -193,16 +250,16 @@ Orchestrate `cost-explorer-mcp` and `athena-mcp` directly — the agent builds t 2. **Then**: `start_query_execution` → `get_query_results` ### For Savings Plans / Reserved Instances -Use AWS API MCP `call_aws` tool: -- SP Coverage: `aws ce get-savings-plans-coverage ...` -- SP Utilization: `aws ce get-savings-plans-utilization ...` -- RI Coverage: `aws ce get-reservation-coverage ...` -- RI Utilization: `aws ce get-reservation-utilization ...` -- SP Recommendations: `aws ce get-savings-plans-purchase-recommendation ...` +Use AWS API MCP `run_script` tool (Cost Explorer operations not covered by the dedicated tools): +- SP Coverage: `GetSavingsPlansCoverage` +- SP Utilization: `GetSavingsPlansUtilization` +- RI Coverage: `GetReservationCoverage` +- RI Utilization: `GetReservationUtilization` +- SP Recommendations: `GetSavingsPlansPurchaseRecommendation` ### For Anomaly Detection -Use AWS API MCP `call_aws` tool: -- `aws ce get-anomalies --date-interval StartDate=YYYY-MM-DD,EndDate=YYYY-MM-DD` +Use AWS API MCP `run_script` tool: +- `GetAnomalies` with `DateInterval={'StartDate': 'YYYY-MM-DD', 'EndDate': 'YYYY-MM-DD'}` --- ## Athena Configuration @@ -258,9 +315,23 @@ Then call `get_query_results` with the returned `query_execution_id`. ### Get Savings Plans coverage (via AWS API MCP fallback) ```json -Tool: call_aws +Tool: run_script +{ + "code": "cov = await call_boto3(service_name='ce', operation_name='GetSavingsPlansCoverage', params={'TimePeriod': {'Start': '2025-01-01', 'End': '2025-01-31'}, 'Granularity': 'MONTHLY', 'GroupBy': [{'Type': 'DIMENSION', 'Key': 'SERVICE'}]})\nresult = cov" +} +``` + +### List EC2 instances in a member account (cross-account) +```json +Tool: list_member_accounts +{} +``` +Then, with the resolved 12-digit ID: +```json +Tool: run_script { - "command": "aws ce get-savings-plans-coverage --time-period Start=2025-01-01,End=2025-01-31 --granularity MONTHLY --group-by Type=DIMENSION,Key=SERVICE --region us-east-1 --output json" + "code": "resp = await call_boto3(service_name='ec2', operation_name='DescribeInstances')\nresult = resp['Reservations']", + "account_id": "" } ``` @@ -374,7 +445,7 @@ Test the agent with these questions: 1. **"What's the current billing period?"** - should use `get_today_date` 2. **"Show me January costs by service"** - should use `get_cost_and_usage` 3. **"Generate CFM report for this month"** - should orchestrate `cost-explorer-mcp` + `athena-mcp` and format against the CFM report prompt -4. **"What's our Savings Plans coverage?"** - should use `call_aws` (fallback) +4. **"What's our Savings Plans coverage?"** - should use `run_script` (fallback) ## Related Documentation diff --git a/examples/member-finops-readonly-role.yaml b/examples/member-finops-readonly-role.yaml new file mode 100644 index 0000000..f135629 --- /dev/null +++ b/examples/member-finops-readonly-role.yaml @@ -0,0 +1,231 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Description: > + Creates the finops-readonly IAM role that the FinOps agent's lambda-proxy + assumes to run read-only AWS API calls inside this account (managed mode). + Deploy this to every account the agent queries, including the Gateway's own + account — the proxy assumes this role there too, not only in member accounts. + + Deploy per account, or org-wide with a CloudFormation StackSet. Read the + parameter values from the Terraform outputs in the Gateway account: + + terraform -chdir=terraform output -raw proxy_role_name + + Because the role has a fixed name, deployment requires CAPABILITY_NAMED_IAM: + + aws cloudformation deploy \ + --template-file examples/member-finops-readonly-role.yaml \ + --stack-name finops-readonly \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides PayerAccountId= + +Parameters: + PayerAccountId: + Type: String + Description: Account ID where the FinOps Gateway and lambda-proxy are deployed + AllowedPattern: '^[0-9]{12}$' + ConstraintDescription: Must be a 12-digit AWS account ID + + ProxyRoleName: + Type: String + Description: > + Name of the lambda-proxy execution role in the Gateway account. Only this + role may assume the role created here. Defaults to -lambda-role + for the default project_name. + Default: finops-mcp-lambda-role + + RoleName: + Type: String + Description: Must match member_role_name in terraform.tfvars + Default: finops-readonly + + PermissionsMode: + Type: String + Description: > + ReadOnly attaches the AWS managed ReadOnlyAccess policy. InventoryOnly + creates a scoped policy in this stack that grants resource and cost + metadata but denies the read actions that return stored data or secrets — + see the ScopedInventoryPolicy resource. InventoryOnly is the tighter + grant; ReadOnly is the broader fallback for querying services the scoped + policy does not cover. + Default: ReadOnly + AllowedValues: [ReadOnly, InventoryOnly] + + ExternalId: + Type: String + Description: > + Optional, and normally left empty. Adds an sts:ExternalId condition on top + of the principal restriction. Only useful if a third party operates the + Gateway account; for an organization querying its own accounts the + principal restriction is the stronger control. Must match + member_role_external_id in terraform.tfvars when set. + Default: '' + NoEcho: true + +Conditions: + HasExternalId: !Not [!Equals [!Ref ExternalId, '']] + UseScopedPolicy: !Equals [!Ref PermissionsMode, InventoryOnly] + +Resources: + FinOpsReadOnlyRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref RoleName + Description: Assumed by the FinOps proxy to run read-only AWS APIs in this account + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + # Access is restricted to the proxy's execution role via + # aws:PrincipalArn rather than by naming that role in Principal + # directly. Both express the same restriction, but a Principal role + # ARN is resolved to a hidden unique ID at save time, so the role must + # already exist and the policy breaks if it is ever deleted and + # recreated. A condition ARN is compared as a string, so this template + # can be deployed before the Gateway account and survives a proxy role + # rebuild. + - Effect: Allow + Principal: + AWS: !Sub 'arn:${AWS::Partition}:iam::${PayerAccountId}:root' + Action: 'sts:AssumeRole' + Condition: !If + - HasExternalId + - ArnEquals: + 'aws:PrincipalArn': !Sub 'arn:${AWS::Partition}:iam::${PayerAccountId}:role/${ProxyRoleName}' + StringEquals: + 'sts:ExternalId': !Ref ExternalId + - ArnEquals: + 'aws:PrincipalArn': !Sub 'arn:${AWS::Partition}:iam::${PayerAccountId}:role/${ProxyRoleName}' + ManagedPolicyArns: + - !If + - UseScopedPolicy + - !Ref ScopedInventoryPolicy + - !Sub 'arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess' + + # Created only when PermissionsMode = InventoryOnly. Declaring it in the same + # stack as the role lets CloudFormation order the two, so no policy has to be + # provisioned separately before this template runs. + ScopedInventoryPolicy: + Type: AWS::IAM::ManagedPolicy + Condition: UseScopedPolicy + Properties: + ManagedPolicyName: !Sub '${RoleName}-inventory' + Description: Resource and cost metadata for the FinOps agent, without access to stored data + PolicyDocument: + Version: '2012-10-17' + Statement: + # Wildcards are fine for services whose read APIs only return + # configuration and metadata. The Deny below covers the exceptions. + - Sid: InventoryAndCostMetadata + Effect: Allow + Action: + - 'account:Get*' + - 'account:List*' + - 'autoscaling:Describe*' + - 'backup:Describe*' + - 'backup:List*' + - 'bedrock:Get*' + - 'bedrock:List*' + - 'ce:Describe*' + - 'ce:Get*' + - 'ce:List*' + - 'cloudfront:Get*' + - 'cloudfront:List*' + - 'cloudwatch:Describe*' + - 'cloudwatch:GetMetric*' + - 'cloudwatch:List*' + - 'compute-optimizer:Describe*' + - 'compute-optimizer:Get*' + - 'cost-optimization-hub:Get*' + - 'cost-optimization-hub:List*' + - 'cur:Describe*' + - 'directconnect:Describe*' + - 'dynamodb:Describe*' + - 'dynamodb:List*' + - 'ec2:Describe*' + - 'ecs:Describe*' + - 'ecs:List*' + - 'efs:Describe*' + - 'eks:Describe*' + - 'eks:List*' + - 'elasticache:Describe*' + - 'elasticache:List*' + - 'elasticloadbalancing:Describe*' + - 'elasticmapreduce:Describe*' + - 'elasticmapreduce:List*' + - 'firehose:Describe*' + - 'firehose:List*' + - 'fsx:Describe*' + - 'globalaccelerator:Describe*' + - 'globalaccelerator:List*' + - 'glue:GetCrawlers' + - 'glue:GetDatabases' + - 'glue:GetJobs' + - 'glue:GetTables' + - 'glue:List*' + - 'kafka:Describe*' + - 'kafka:Get*' + - 'kafka:List*' + - 'kinesis:Describe*' + - 'kinesis:List*' + - 'kinesisanalytics:Describe*' + - 'kinesisanalytics:List*' + - 'lambda:GetAccountSettings' + - 'lambda:List*' + - 'logs:Describe*' + - 'memorydb:Describe*' + - 'network-firewall:Describe*' + - 'network-firewall:List*' + - 'opensearch:Describe*' + - 'opensearch:List*' + - 'organizations:Describe*' + - 'organizations:List*' + - 'pricing:Describe*' + - 'pricing:Get*' + - 'rds:Describe*' + - 'rds:List*' + - 'redshift:Describe*' + - 'resource-explorer-2:List*' + - 'resource-explorer-2:Search' + - 'route53:Get*' + - 'route53:List*' + - 's3:GetBucketLocation' + - 's3:GetBucketTagging' + - 's3:GetBucketVersioning' + - 's3:GetLifecycleConfiguration' + - 's3:GetStorageLens*' + - 's3:List*' + - 'sagemaker:Describe*' + - 'sagemaker:List*' + - 'savingsplans:Describe*' + - 'savingsplans:List*' + - 'sts:GetCallerIdentity' + - 'tag:Get*' + - 'wafv2:Describe*' + - 'wafv2:List*' + Resource: '*' + # These are read actions, so the wildcards above would otherwise allow + # them, but each returns stored data rather than configuration. An + # explicit Deny cannot be overridden by the Allow. + - Sid: DenyReadsReturningStoredData + Effect: Deny + Action: + - 'dynamodb:BatchGetItem' + - 'dynamodb:GetItem' + - 'dynamodb:PartiQLSelect' + - 'dynamodb:Query' + - 'dynamodb:Scan' + - 'ec2:DescribeInstanceAttribute' + - 'ec2:GetConsoleOutput' + - 'ec2:GetConsoleScreenshot' + - 'ec2:GetPasswordData' + - 'lambda:GetFunctionConfiguration' + - 'logs:FilterLogEvents' + - 'logs:GetLogEvents' + - 's3:GetBucketPolicy' + - 's3:GetObject*' + Resource: '*' + +Outputs: + RoleArn: + Description: ARN of the created role + Value: !GetAtt FinOpsReadOnlyRole.Arn diff --git a/scripts/invoke_lambda.py b/scripts/invoke_lambda.py index 49ca9c2..0f47cf0 100755 --- a/scripts/invoke_lambda.py +++ b/scripts/invoke_lambda.py @@ -91,22 +91,24 @@ def main(): body = json.loads(result["body"]) if isinstance(result["body"], str) else result["body"] print(f"Response: {json.dumps(body, indent=2)[:1000]}") - # Test 3: Call a tool (suggest_aws_commands) - print("\n[3] MCP Call Tool: suggest_aws_commands...") - result = mcp_call_tool( - "suggest_aws_commands", - {"query": "list all S3 buckets"}, - ) + # Test 3: Call a tool (list_member_accounts — managed mode) + print("\n[3] MCP Call Tool: list_member_accounts...") + result = mcp_call_tool("list_member_accounts", {}) print(f"Status: {result.get('statusCode')}") if result.get("body"): body = json.loads(result["body"]) if isinstance(result["body"], str) else result["body"] print(f"Response: {json.dumps(body, indent=2)[:1000]}") - # Test 4: Call a tool (call_aws) - print("\n[4] MCP Call Tool: call_aws...") + # Test 4: Call a tool (run_script — managed mode; sandboxed call_boto3) + print("\n[4] MCP Call Tool: run_script...") result = mcp_call_tool( - "call_aws", - {"cli_command": "aws sts get-caller-identity"}, + "run_script", + { + "code": ( + "ident = await call_boto3(service_name='sts', operation_name='GetCallerIdentity')\n" + "result = ident['Account']" + ) + }, ) print(f"Status: {result.get('statusCode')}") if result.get("body"): diff --git a/src/lambda/proxy/lambda_function.py b/src/lambda/proxy/lambda_function.py index a612b8f..a0945d0 100644 --- a/src/lambda/proxy/lambda_function.py +++ b/src/lambda/proxy/lambda_function.py @@ -9,18 +9,141 @@ import contextlib import json import os +import urllib.error +import urllib.request import uuid +from datetime import UTC, datetime, timedelta import boto3 +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import ReadOnlyCredentials RUNTIME_ARN = os.environ.get("RUNTIME_ARN") AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") +# ---- Managed AWS MCP Server mode (enabled when AWS_MCP_ENDPOINT is set) ---- +AWS_MCP_ENDPOINT = os.environ.get("AWS_MCP_ENDPOINT", "") +AWS_MCP_SIGNING_REGION = os.environ.get("AWS_MCP_SIGNING_REGION", "us-east-1") +AWS_MCP_SIGNING_SERVICE = "aws-mcp" +MEMBER_ROLE_NAME = os.environ.get("MEMBER_ROLE_NAME", "finops-readonly") +MEMBER_ROLE_EXTERNAL_ID = os.environ.get("MEMBER_ROLE_EXTERNAL_ID", "") + +# account_id -> (expiry_datetime, ReadOnlyCredentials); module-global for warm reuse +_CRED_CACHE: dict = {} + +# (partition, account_id) of this Lambda's own identity; resolved once per container +_OWN_IDENTITY: list = [] + # Store session ID per Lambda execution context for session continuity session_id = None +def _own_identity(): + """Return (partition, account_id) for this Lambda, cached for warm reuse.""" + if not _OWN_IDENTITY: + arn = boto3.client("sts").get_caller_identity()["Arn"] + _OWN_IDENTITY.append((arn.split(":")[1], arn.split(":")[4])) + return _OWN_IDENTITY[0] + + +def resolve_credentials(account_id): + """AssumeRole into / — the payer's own account when account_id is None. + + The proxy's execution role is a pure pipe (STS + Organizations only); all AWS + read access comes from the per-account role, keeping one permission model for + payer and member accounts alike. + """ + partition, own_account = _own_identity() + account_id = account_id or own_account + cached = _CRED_CACHE.get(account_id) + if cached and cached[0] - datetime.now(UTC) > timedelta(minutes=5): + return cached[1] + params = { + "RoleArn": f"arn:{partition}:iam::{account_id}:role/{MEMBER_ROLE_NAME}", + "RoleSessionName": "finops-mcp-proxy", + "DurationSeconds": 3600, + } + if MEMBER_ROLE_EXTERNAL_ID: + params["ExternalId"] = MEMBER_ROLE_EXTERNAL_ID + c = boto3.client("sts").assume_role(**params)["Credentials"] + creds = ReadOnlyCredentials(c["AccessKeyId"], c["SecretAccessKey"], c["SessionToken"]) + _CRED_CACHE[account_id] = (c["Expiration"], creds) + return creds + + +class McpEndpointClient: + """Minimal MCP streamable-HTTP client with SigV4 signing (stdlib + botocore only).""" + + def __init__(self, endpoint, creds, region=AWS_MCP_SIGNING_REGION, service=AWS_MCP_SIGNING_SERVICE): + self.endpoint = endpoint + self.creds = creds + self.region = region + self.service = service + + def _post(self, url, data, headers): + req = urllib.request.Request(url, data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=110) as resp: + return resp.status, dict(resp.headers), resp.read().decode() + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), e.read().decode() + except urllib.error.URLError as e: + raise RuntimeError(f"MCP endpoint unreachable ({e.reason})") from e + + def _signed_post(self, body, mcp_session_id=None): + data = json.dumps(body).encode() + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + if mcp_session_id: + headers["Mcp-Session-Id"] = mcp_session_id + aws_req = AWSRequest(method="POST", url=self.endpoint, data=data, headers=headers) + SigV4Auth(self.creds, self.service, self.region).add_auth(aws_req) + return self._post(self.endpoint, data, dict(aws_req.headers)) + + def call_tool(self, name, arguments): + status, hdrs, body = self._signed_post( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "finops-mcp-proxy", "version": "1.0"}, + }, + } + ) + if status != 200: + raise RuntimeError(f"MCP initialize failed: HTTP {status}: {body[:300]}") + sid = hdrs.get("Mcp-Session-Id") or hdrs.get("mcp-session-id") + self._signed_post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid) + status, _, body = self._signed_post( + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}, sid + ) + if status != 200: + raise RuntimeError(f"MCP tools/call failed: HTTP {status}: {body[:300]}") + # The endpoint may answer in SSE framing (Accept includes text/event-stream); + # extract the last `data:` payload before parsing. + if body.lstrip().startswith(("data:", "event:")): + data_lines = [ln[5:].strip() for ln in body.splitlines() if ln.startswith("data:")] + body = data_lines[-1] if data_lines else body + response = json.loads(body) + if "error" in response: + err = response["error"] + return {"error": f"MCP error {err.get('code')}: {err.get('message', 'unknown')}", "isError": True} + result = response.get("result", {}) + if "structuredContent" in result: + return result["structuredContent"] + for item in result.get("content", []): + if item.get("type") == "text": + try: + return json.loads(item["text"]) + except json.JSONDecodeError: + return {"text": item["text"], "isError": result.get("isError", False)} + return result + + def get_or_create_session_id(): """Get existing session ID or create a new one.""" global session_id @@ -107,20 +230,74 @@ def detect_tool_from_args(args: dict) -> str: return None -def lambda_handler(event, context): - """Lambda handler for MCP proxy requests.""" - print(f"Received event: {json.dumps(event)}") +def _managed_tool_name(event, context): + try: + name = context.client_context.custom["bedrockAgentCoreToolName"] + return name.split("___")[1] + except (AttributeError, KeyError, TypeError, IndexError): + # Direct invocations (tests, smoke scripts) carry no Gateway tool header; + # infer run_script only from an explicit 'code' argument — never guess + # list_member_accounts, so misrouted events fail loudly instead of + # silently enumerating the organization. + if "code" in event: + print("No bedrockAgentCoreToolName in client_context; inferred run_script from 'code' argument") + return "run_script" + return None + + +def _handle_list_member_accounts(): + orgs = boto3.client("organizations") + accounts = [ + {"account_id": a["Id"], "name": a["Name"], "status": a["Status"]} + for page in orgs.get_paginator("list_accounts").paginate() + for a in page.get("Accounts", []) + ] + return {"accounts": accounts, "count": len(accounts)} + + +def _handle_run_script(event): + args = dict(event) + account_id = args.pop("account_id", None) + if "code" not in args: + return {"error": "run_script requires a 'code' argument"} + try: + creds = resolve_credentials(account_id) + except Exception as e: + # account_id is None for a local query, so name the account explicitly — + # "account None" reads like a bug to whoever sees the error. + target = account_id or _own_identity()[1] + return { + "error": f"Cannot assume role in account {target}: {e}. " + f"Ensure role '{MEMBER_ROLE_NAME}' exists there and trusts this account." + } + client = McpEndpointClient(AWS_MCP_ENDPOINT, creds) + try: + return client.call_tool("aws___run_script", {"code": args["code"]}) + except (RuntimeError, json.JSONDecodeError) as e: + return {"error": f"Managed MCP call failed: {e}", "isError": True} - # Extract request body - if "body" in event: - body = event["body"] - if event.get("isBase64Encoded"): - import base64 - body = base64.b64decode(body).decode("utf-8") - request = json.loads(body) if isinstance(body, str) else body - else: - request = event +# Knowledge tools read AWS documentation instead of the caller's resources, so +# they run under the proxy's own credentials — no member role, no account_id. +_KNOWLEDGE_TOOLS = { + "search_documentation": "aws___search_documentation", + "read_documentation": "aws___read_documentation", + "get_aws_skill": "aws___retrieve_skill", +} + + +def _handle_knowledge_tool(tool, event): + args = {k: v for k, v in event.items() if k != "account_id"} + client = McpEndpointClient(AWS_MCP_ENDPOINT, resolve_credentials(None)) + try: + return client.call_tool(_KNOWLEDGE_TOOLS[tool], args) + except (RuntimeError, json.JSONDecodeError) as e: + return {"error": f"Managed MCP call failed: {e}", "isError": True} + + +def _legacy_handler(event, context): + """Legacy handler body — event is already the parsed request dict.""" + request = event # Check if this is a gateway tool call (no 'method' field, just tool arguments) # Gateway sends: {"cli_command": "aws s3 ls"} or {"query": "list buckets"} @@ -160,3 +337,31 @@ def lambda_handler(event, context): traceback.print_exc() error_body = {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32603, "message": str(e)}} return {"statusCode": 500, "headers": {"Content-Type": "application/json"}, "body": json.dumps(error_body)} + + +def lambda_handler(event, context): + """Lambda handler for MCP proxy requests.""" + print(f"Received event: {json.dumps(event)}") + if "body" in event: + body = event["body"] + if event.get("isBase64Encoded"): + import base64 + + body = base64.b64decode(body).decode("utf-8") + event = json.loads(body) if isinstance(body, str) else body + + if AWS_MCP_ENDPOINT: + tool = _managed_tool_name(event, context) + print(f"Managed mode tool: {tool}") + if tool == "list_member_accounts": + return _handle_list_member_accounts() + if tool == "run_script": + return _handle_run_script(event) + if tool in _KNOWLEDGE_TOOLS: + return _handle_knowledge_tool(tool, event) + return { + "error": f"Unknown or unresolvable tool: {tool}", + "available_tools": ["run_script", "list_member_accounts", *_KNOWLEDGE_TOOLS], + } + + return _legacy_handler(event, context) diff --git a/terraform/config/terraform.tfvars.example b/terraform/config/terraform.tfvars.example index 571213d..7fa5b3d 100644 --- a/terraform/config/terraform.tfvars.example +++ b/terraform/config/terraform.tfvars.example @@ -95,6 +95,25 @@ enable_vpc = true # If not set, AWS managed encryption is used # lambda_kms_key_arn = "arn:aws:kms:us-east-1:123456789012:key/your-key-id" +# ----------------------------------------------------------------------------- +# Cross-account via managed AWS MCP Server (optional) +# ----------------------------------------------------------------------------- + +# Managed AWS MCP Server endpoint. Non-empty enables managed mode (cross-account run_script); +# empty keeps legacy AgentCore Runtime proxying. +# aws_mcp_endpoint = "https://aws-mcp.us-east-1.api.aws/mcp" +# member_role_name = "finops-readonly" +# With managed mode, raise lambda_timeout to >= 120 — multi-region run_script +# inventory scripts routinely exceed the 30s default. +# lambda_timeout = 120 +# +# Access to the per-account finops-readonly role is restricted to the proxy's +# execution role via an aws:PrincipalArn condition, so no shared secret is +# needed. Set member_role_external_id only if a third party operates this +# account and you want an sts:ExternalId condition layered on top; use "auto" +# to have Terraform generate one. +# member_role_external_id = "" + # ----------------------------------------------------------------------------- # Optional Configuration # ----------------------------------------------------------------------------- diff --git a/terraform/main.tf b/terraform/main.tf index bcb5fb2..722e2e1 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -104,6 +104,11 @@ module "lambda_proxy" { timeout = var.lambda_timeout memory_size = var.lambda_memory_size + # Managed AWS MCP Server mode (cross-account) + aws_mcp_endpoint = var.aws_mcp_endpoint + member_role_name = var.member_role_name + member_role_external_id = var.member_role_external_id + # Security subnet_ids = var.enable_vpc ? module.vpc[0].private_subnet_ids : [] security_group_ids = var.enable_vpc ? [module.vpc[0].lambda_security_group_id] : [] diff --git a/terraform/modules/agentcore-gateway/main.tf b/terraform/modules/agentcore-gateway/main.tf index da1a8ab..41672dc 100644 --- a/terraform/modules/agentcore-gateway/main.tf +++ b/terraform/modules/agentcore-gateway/main.tf @@ -42,12 +42,13 @@ resource "aws_bedrockagentcore_gateway" "mcp" { tags = var.tags } -# Gateway Target - AWS API MCP server (from AWS Marketplace), fronted by the -# Lambda proxy which forwards MCP calls to the aws-api-mcp-server container -# running in AgentCore Runtime. +# Gateway Target - AWS API access, fronted by the Lambda proxy. In managed +# mode the proxy forwards to the managed AWS MCP Server (run_script / +# list_member_accounts); in legacy mode it forwards to the aws-api-mcp-server +# container running in AgentCore Runtime. resource "aws_bedrockagentcore_gateway_target" "lambda" { name = "aws-api-mcp" - description = "AWS API MCP server (Marketplace) — exposes call_aws / suggest_aws_commands" + description = "AWS API access via Lambda proxy — run_script / list_member_accounts (managed mode)" gateway_identifier = aws_bedrockagentcore_gateway.mcp.gateway_id @@ -64,7 +65,7 @@ resource "aws_bedrockagentcore_gateway_target" "lambda" { tool_schema { inline_payload { name = "mcp_proxy" - description = "MCP proxy to AWS API server - supports call_aws and suggest_aws_commands tools" + description = "MCP proxy for AWS API access - full tool schemas registered post-apply by update_tool_schemas.py" input_schema { type = "object" diff --git a/terraform/modules/lambda-proxy/iam.tf b/terraform/modules/lambda-proxy/iam.tf index 222ed75..94265f1 100644 --- a/terraform/modules/lambda-proxy/iam.tf +++ b/terraform/modules/lambda-proxy/iam.tf @@ -79,3 +79,31 @@ resource "aws_iam_role_policy_attachment" "lambda_vpc" { role = aws_iam_role.lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } + +# Cross-account access for managed AWS MCP Server mode (only when enabled) +data "aws_iam_policy_document" "cross_account_managed_mode" { + count = var.aws_mcp_endpoint != "" ? 1 : 0 + # checkov:skip=CKV_AWS_111:sts:AssumeRole requires wildcard account for cross-account fan-out; role name is constrained via member_role_name + # checkov:skip=CKV_AWS_356:organizations:ListAccounts does not support resource-level permissions (AWS limitation) + + statement { + sid = "AssumeMemberInventoryRole" + effect = "Allow" + actions = ["sts:AssumeRole"] + resources = ["arn:aws:iam::*:role/${var.member_role_name}"] + } + + statement { + sid = "ListOrganizationAccounts" + effect = "Allow" + actions = ["organizations:ListAccounts"] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "cross_account_managed_mode" { + count = var.aws_mcp_endpoint != "" ? 1 : 0 + name = "${var.project_name}-proxy-cross-account" + role = aws_iam_role.lambda.id + policy = data.aws_iam_policy_document.cross_account_managed_mode[0].json +} diff --git a/terraform/modules/lambda-proxy/main.tf b/terraform/modules/lambda-proxy/main.tf index 721cc53..8ec73d1 100644 --- a/terraform/modules/lambda-proxy/main.tf +++ b/terraform/modules/lambda-proxy/main.tf @@ -67,7 +67,10 @@ resource "aws_lambda_function" "proxy" { environment { variables = { - RUNTIME_ARN = var.runtime_arn + RUNTIME_ARN = var.runtime_arn + AWS_MCP_ENDPOINT = var.aws_mcp_endpoint + MEMBER_ROLE_NAME = var.member_role_name + MEMBER_ROLE_EXTERNAL_ID = var.member_role_external_id # AWS_REGION is set automatically by Lambda } } diff --git a/terraform/modules/lambda-proxy/variables.tf b/terraform/modules/lambda-proxy/variables.tf index d0af561..d61bae4 100644 --- a/terraform/modules/lambda-proxy/variables.tf +++ b/terraform/modules/lambda-proxy/variables.tf @@ -89,3 +89,26 @@ variable "log_retention_in_days" { type = number default = 365 } + +# ----------------------------------------------------------------------------- +# Managed AWS MCP Server Mode +# ----------------------------------------------------------------------------- + +variable "aws_mcp_endpoint" { + description = "Managed AWS MCP Server endpoint. Non-empty enables managed mode (cross-account run_script); empty keeps legacy AgentCore Runtime proxying." + type = string + default = "" +} + +variable "member_role_name" { + description = "IAM role name assumed in member accounts for cross-account API access" + type = string + default = "finops-readonly" +} + +variable "member_role_external_id" { + description = "ExternalId required by the member role trust policy (recommended)" + type = string + default = "" + sensitive = true +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 9e58926..d1a9d75 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -77,7 +77,7 @@ output "gateway_target_schemas" { { "aws-api-mcp" = { schema_file = "aws_api_mcp.json" - description = "AWS API MCP server (Marketplace) — exposes call_aws / suggest_aws_commands" + description = "AWS API access via Lambda proxy — run_script / list_member_accounts (managed mode)" lambda_arn = module.lambda_proxy.function_arn } }, @@ -119,6 +119,21 @@ output "cross_account_enabled" { value = var.management_account_profile != "" } +# ----------------------------------------------------------------------------- +# Managed Mode Outputs (populated when aws_mcp_endpoint is set) +# ----------------------------------------------------------------------------- + +output "proxy_role_name" { + description = "lambda-proxy execution role name. Pass as ProxyRoleName to examples/member-finops-readonly-role.yaml — only this role may assume finops-readonly in a member account." + value = element(split("/", module.lambda_proxy.role_arn), 1) +} + +output "member_role_external_id" { + description = "Optional External ID for the finops-readonly trust policy. Empty unless member_role_external_id is set; pass to examples/member-finops-readonly-role.yaml only when non-empty." + value = var.member_role_external_id + sensitive = true +} + # ----------------------------------------------------------------------------- # Cognito Gateway Auth Outputs (populated when gateway_auth_type = COGNITO) # ----------------------------------------------------------------------------- diff --git a/terraform/tool-schemas/aws_api_mcp.json b/terraform/tool-schemas/aws_api_mcp.json index 3008aa8..2a455f7 100644 --- a/terraform/tool-schemas/aws_api_mcp.json +++ b/terraform/tool-schemas/aws_api_mcp.json @@ -1,30 +1,86 @@ [ { - "name": "call_aws", - "description": "Execute any AWS CLI command. Provide the full CLI command string (e.g., 'aws s3 ls', 'aws ec2 describe-instances'). Returns the command output.", + "name": "run_script", + "description": "Execute Python code against AWS APIs in a sandboxed environment (via the managed AWS MCP Server). Use await call_boto3(service_name=..., operation_name=..., region_name=..., params=...) inside the code; 'import boto3' is not available. operation_name is the PascalCase API name (DescribeInstances, not describe_instances). Set 'result = {...}' as the final expression, or print it. Use this for any arithmetic on cost or usage data instead of calculating in your reply. Supports cross-account execution: pass account_id (12-digit member account ID) to run in that member account; omit to run in this (payer) account. Resolve account names to IDs with list_member_accounts first.", "inputSchema": { "type": "object", "properties": { - "cli_command": { + "code": { "type": "string", - "description": "The full AWS CLI command to execute (e.g., 'aws s3 ls', 'aws ec2 describe-instances --region us-west-2')" + "description": "Python code using await call_boto3(...); assign the final answer to 'result'" + }, + "account_id": { + "type": "string", + "description": "Optional 12-digit member account ID. Omit to run in the payer account. Resolve names via list_member_accounts first." + } + }, + "required": ["code"] + } + }, + { + "name": "list_member_accounts", + "description": "Resolve member account names to 12-digit account IDs. Call this FIRST whenever the user refers to a member account by name. Returns all accounts in the organization with account_id, name, and status.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "get_aws_skill", + "description": "Retrieve an AWS-authored expert workflow for a domain: step-by-step procedures, correct API usage, and known pitfalls. Call this BEFORE a multi-step cost or optimization task rather than relying on prior knowledge. For cost work the skill is 'aws-billing-and-cost-management', covering cost analysis, Savings Plans and Reserved Instance evaluation, right-sizing, budgets, anomaly detection, and querying CUR with Athena. Discover other skill names with search_documentation. skill_name is an opaque ID: copy it verbatim and never guess it. A skill may cite further files, retrieved by also passing 'file'.", + "inputSchema": { + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Exact skill name, e.g. aws-billing-and-cost-management. Copy verbatim from search_documentation; never invent one." + }, + "file": { + "type": "string", + "description": "Optional file path cited inside a skill, e.g. references/cost-audit.md. Omit to get the skill overview." + } + }, + "required": ["skill_name"] + } + }, + { + "name": "search_documentation", + "description": "Search official AWS documentation: API references, service guides, best practices, and the names of available expert skills. Use it to confirm API behaviour, limits, and pricing rules before stating them, and to find a skill_name for get_aws_skill.", + "inputSchema": { + "type": "object", + "properties": { + "search_phrase": { + "type": "string", + "description": "Keywords to search for. Preserve exact error strings verbatim when troubleshooting." + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return." } }, - "required": ["cli_command"] + "required": ["search_phrase"] } }, { - "name": "suggest_aws_commands", - "description": "Get AWS CLI command suggestions based on a natural language query. Useful for discovering the right CLI command to use.", + "name": "read_documentation", + "description": "Fetch a full AWS documentation page as markdown. Use a URL returned by search_documentation, when the search excerpt is not enough to answer accurately.", "inputSchema": { "type": "object", "properties": { - "query": { + "url": { "type": "string", - "description": "Natural language description of what you want to do (e.g., 'list all S3 buckets', 'get EC2 instance details')" + "description": "Documentation URL taken from a search_documentation result. Do not guess URLs." + }, + "max_length": { + "type": "integer", + "description": "Maximum characters to return." + }, + "start_index": { + "type": "integer", + "description": "Character offset for continuing a truncated page." } }, - "required": ["query"] + "required": ["url"] } } ] diff --git a/terraform/variables.tf b/terraform/variables.tf index ec48158..1e67422 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -219,3 +219,26 @@ variable "lambda_kms_key_arn" { type = string default = null } + +# ----------------------------------------------------------------------------- +# Managed AWS MCP Server Mode +# ----------------------------------------------------------------------------- + +variable "aws_mcp_endpoint" { + description = "Managed AWS MCP Server endpoint. Non-empty enables managed mode (cross-account run_script); empty keeps legacy AgentCore Runtime proxying." + type = string + default = "" +} + +variable "member_role_name" { + description = "IAM role name assumed in member accounts for cross-account API access" + type = string + default = "finops-readonly" +} + +variable "member_role_external_id" { + description = "Optional sts:ExternalId the proxy sends when assuming finops-readonly. Leave empty (the default) unless a third party operates this account; when set, pass the same value as ExternalId to examples/member-finops-readonly-role.yaml." + type = string + default = "" + sensitive = true +} diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_proxy_managed_mode.py b/tests/unit/test_proxy_managed_mode.py new file mode 100644 index 0000000..fdda0db --- /dev/null +++ b/tests/unit/test_proxy_managed_mode.py @@ -0,0 +1,222 @@ +"""Offline unit tests for lambda-proxy managed-endpoint mode. No AWS calls.""" + +import importlib.util +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + + +PROXY = Path(__file__).parents[2] / "src" / "lambda" / "proxy" / "lambda_function.py" + + +def load_proxy(monkeypatch, **env): + for k in ("AWS_MCP_ENDPOINT", "MEMBER_ROLE_NAME", "MEMBER_ROLE_EXTERNAL_ID", "RUNTIME_ARN"): + monkeypatch.delenv(k, raising=False) + for k, v in env.items(): + monkeypatch.setenv(k, v) + spec = importlib.util.spec_from_file_location("proxy_mod", PROXY) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_resolve_credentials_none_assumes_role_in_own_account(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + sts = MagicMock() + sts.get_caller_identity.return_value = {"Arn": "arn:aws:sts::999988887777:assumed-role/proxy-role/x"} + sts.assume_role.return_value = { + "Credentials": { + "AccessKeyId": "AK", + "SecretAccessKey": "SK", + "SessionToken": "ST", + "Expiration": mod.datetime.now(mod.UTC) + mod.timedelta(hours=1), + } + } + with patch.object(mod.boto3, "client", return_value=sts): + mod._CRED_CACHE.clear() + mod._OWN_IDENTITY.clear() + creds = mod.resolve_credentials(None) + # None -> assume the same role in the proxy's own account (partition derived, not hardcoded) + assert sts.assume_role.call_args.kwargs["RoleArn"] == "arn:aws:iam::999988887777:role/finops-readonly" + assert creds.access_key == "AK" + + +def test_resolve_credentials_assumes_member_role_with_external_id(monkeypatch): + mod = load_proxy( + monkeypatch, + AWS_MCP_ENDPOINT="https://example.test/mcp", + MEMBER_ROLE_NAME="finops-readonly", + MEMBER_ROLE_EXTERNAL_ID="xyz", + ) + sts = MagicMock() + sts.get_caller_identity.return_value = {"Arn": "arn:aws:sts::999988887777:assumed-role/proxy-role/x"} + sts.assume_role.return_value = { + "Credentials": { + "AccessKeyId": "AK", + "SecretAccessKey": "SK", + "SessionToken": "ST", + "Expiration": mod.datetime.now(mod.UTC) + mod.timedelta(hours=1), + } + } + with patch.object(mod.boto3, "client", return_value=sts): + mod._CRED_CACHE.clear() + mod._OWN_IDENTITY.clear() + creds = mod.resolve_credentials("111122223333") + kwargs = sts.assume_role.call_args.kwargs + assert kwargs["RoleArn"] == "arn:aws:iam::111122223333:role/finops-readonly" + assert kwargs["ExternalId"] == "xyz" + assert creds.access_key == "AK" and creds.token == "ST" + + +def test_mcp_client_call_tool_signs_and_unwraps(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + responses = [ + (200, {"Mcp-Session-Id": "s1"}, json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"serverInfo": {}}})), + (202, {}, ""), + ( + 200, + {}, + json.dumps( + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "content": [{"type": "text", "text": '{"ok": true}'}], + "structuredContent": {"ok": True}, + "isError": False, + }, + } + ), + ), + ] + sent = [] + + def fake_post(url, data, headers): + sent.append((url, json.loads(data) if data else None, headers)) + return responses[len(sent) - 1] + + creds = mod.ReadOnlyCredentials("AK", "SK", "ST") + client = mod.McpEndpointClient("https://example.test/mcp", creds) + with patch.object(client, "_post", side_effect=fake_post): + result = client.call_tool("aws___run_script", {"code": "result = 1"}) + assert result == {"ok": True} + assert sent[0][1]["method"] == "initialize" + assert sent[1][1]["method"] == "notifications/initialized" + assert sent[1][2].get("Mcp-Session-Id") == "s1" + assert sent[2][1]["params"] == {"name": "aws___run_script", "arguments": {"code": "result = 1"}} + + +def _ctx(tool): + ctx = MagicMock() + ctx.client_context.custom = {"bedrockAgentCoreToolName": f"aws-api-mcp___{tool}"} + return ctx + + +def test_handler_list_member_accounts(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + orgs = MagicMock() + orgs.get_paginator.return_value.paginate.return_value = [ + {"Accounts": [{"Id": "111122223333", "Name": "dev", "Status": "ACTIVE"}]} + ] + with patch.object(mod.boto3, "client", return_value=orgs): + out = mod.lambda_handler({}, _ctx("list_member_accounts")) + assert out == {"accounts": [{"account_id": "111122223333", "name": "dev", "status": "ACTIVE"}], "count": 1} + + +def test_handler_run_script_strips_account_id_and_forwards(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + with ( + patch.object(mod, "resolve_credentials", return_value="CREDS") as rc, + patch.object(mod.McpEndpointClient, "call_tool", return_value={"ok": True}) as ct, + ): + out = mod.lambda_handler({"code": "result = 1", "account_id": "111122223333"}, _ctx("run_script")) + rc.assert_called_once_with("111122223333") + ct.assert_called_once_with("aws___run_script", {"code": "result = 1"}) + assert out == {"ok": True} + + +def test_handler_run_script_assume_failure_returns_structured_error(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + with patch.object(mod, "resolve_credentials", side_effect=Exception("AccessDenied")): + out = mod.lambda_handler({"code": "x", "account_id": "111122223333"}, _ctx("run_script")) + assert "error" in out and "111122223333" in out["error"] + + +def test_handler_legacy_mode_untouched(monkeypatch): + mod = load_proxy(monkeypatch) # AWS_MCP_ENDPOINT unset + with patch.object(mod, "invoke_mcp_runtime", return_value={"jsonrpc": "2.0", "id": 1, "result": {}}) as im: + out = mod.lambda_handler({"cli_command": "aws s3 ls"}, MagicMock()) + im.assert_called_once() + assert out["statusCode"] == 200 + + +def test_call_tool_surfaces_jsonrpc_error(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + responses = [ + (200, {"Mcp-Session-Id": "s1"}, json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}})), + (202, {}, ""), + (200, {}, json.dumps({"jsonrpc": "2.0", "id": 2, "error": {"code": -32000, "message": "NameError: x"}})), + ] + client = mod.McpEndpointClient("https://example.test/mcp", mod.ReadOnlyCredentials("AK", "SK", "ST")) + with patch.object(client, "_post", side_effect=lambda *a: responses.pop(0)): + out = client.call_tool("aws___run_script", {"code": "x"}) + assert out["isError"] is True and "NameError" in out["error"] + + +def test_call_tool_parses_sse_framed_body(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + sse = 'data: {"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": {"ok": 1}}}\n\n' + responses = [ + (200, {"Mcp-Session-Id": "s1"}, json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}})), + (202, {}, ""), + (200, {}, sse), + ] + client = mod.McpEndpointClient("https://example.test/mcp", mod.ReadOnlyCredentials("AK", "SK", "ST")) + with patch.object(client, "_post", side_effect=lambda *a: responses.pop(0)): + out = client.call_tool("aws___run_script", {"code": "x"}) + assert out == {"ok": 1} + + +def test_handler_run_script_mcp_failure_returns_structured_error(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + with ( + patch.object(mod, "resolve_credentials", return_value="CREDS"), + patch.object(mod.McpEndpointClient, "call_tool", side_effect=RuntimeError("endpoint unreachable")), + ): + out = mod.lambda_handler({"code": "x"}, _ctx("run_script")) + assert out["isError"] is True and "unreachable" in out["error"] + + +def test_handler_unresolvable_tool_fails_loudly(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + orgs_guard = MagicMock() + with patch.object(mod.boto3, "client", return_value=orgs_guard): + # No client_context tool name, no 'code' key -> must NOT silently list accounts + out = mod.lambda_handler({"query": "list buckets"}, MagicMock(client_context=None)) + orgs_guard.get_paginator.assert_not_called() + assert "error" in out + + +def test_handler_knowledge_tool_routes_without_account_id(monkeypatch): + mod = load_proxy(monkeypatch, AWS_MCP_ENDPOINT="https://example.test/mcp") + captured = {} + + def fake_call_tool(self, name, arguments): + captured["name"] = name + captured["arguments"] = arguments + return {"content": "doc"} + + with ( + patch.object(mod, "resolve_credentials", return_value="creds"), + patch.object(mod.McpEndpointClient, "call_tool", fake_call_tool), + ): + out = mod.lambda_handler( + {"skill_name": "aws-billing-and-cost-management", "account_id": "999988887777"}, + _ctx("get_aws_skill"), + ) + + # Upstream tool name is prefixed, and account_id is stripped: knowledge tools + # read documentation, not the caller's resources. + assert captured["name"] == "aws___retrieve_skill" + assert captured["arguments"] == {"skill_name": "aws-billing-and-cost-management"} + assert out == {"content": "doc"}