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"}