Repository navigation
feat(agentcore): serve the built-in MCP through AgentCore Gateway (opt-in) - #613
Conversation
| if (!grant) { | ||
| throw Object.assign(new Error('Gateway MCP requires a control-plane grant'), { | ||
| code: 'GATEWAY_GRANT_MISSING', | ||
| }); |
There was a problem hiding this comment.
[P1] Preserve existing executions before enabling the mandatory-grant runtime path
@eipasteur An execution started with Gateway disabled can park at a human gate, lose its warm runtime session, and then resume after enable_mcp_gateway is enabled. Its published durable orchestrator still has the old configuration and no grant issuer, while the standard runtime can restart on the new configuration: standard revisions have runtimeEndpoint: null, and resolveRuntimeTarget() does not pin the invocation using runtimeVersion. The resumed request therefore reaches this check without a grant and fails with GATEWAY_GRANT_MISSING.
This also affects executions started after deploying the new code with flags off. In #614's engine-data mode, the request fails even earlier: resolveInvocationAgentAuth() reads META through the remote store before calling the broker. My local reproduction using the real dispatcher/auth resolver/store returned HTTP 500 with No engine data grant for this invocation; broker and handler calls were both zero. Leaving enforce_runtime_grants=false does not protect that path.
Please either keep existing executions routed to a compatible pinned runtime revision/configuration, or require and enforce draining them before the first remote-path flag is enabled. The rollout guide in #616 currently drains them only after enabling Gateway and engine-data. Keep the missing-grant rejection itself; an unauthenticated fallback would weaken the boundary.
Regression/acceptance checks:
- Start an execution with flags off, park it, stop its runtime session, enable Gateway, and resume it.
- Repeat for engine-data transition mode with strict enforcement off.
- Verify compatible resumption, or prevent activation while such executions remain; include parked executions in the drain condition.
- Keep missing/forged grants rejected on the remote path.
The failure was reproduced locally at stack head 22ec812c; the live AWS migration scenario was not exercised.
There was a problem hiding this comment.
Agreed, thanks for the repro. I kept the missing-grant rejection and went with draining before activation, enforced rather than documented:
scripts/check-active-executions.mjsruns indeploy-terraform.shon every saved plan (and again at apply). It detects the runtime gaining the Gateway (this PR) or engine-data (feat(engine-data): route the runtime's engine data through grant-verified engine-data (opt-in) #614) configuration, so a retry after an apply that failed before the runtime was updated is still caught, and refuses the plan while any execution is CREATED, RUNNING or WAITING, listing them. Fresh installs and plans that switch nothing skip the table read.- Operator path: stop starting intents until the deployment has succeeded, let running executions park or finish (
/canceldoes not accept RUNNING), cancel the parked and queued ones, apply, then rewind each to its current stage (its first stage if it had not reached one). The rewind starts a new orchestrator run on the new configuration, so it relaunches with grants; artifacts and workspace are kept. AIDLC_ALLOW_ACTIVE_EXECUTIONS=1overrides, and still prints the executions that will fail.- The rollout guide in feat(terraform): remove the runtime role's direct data access under enforced runtime grants #616 now drains before each of the two flags, not after.
Tests cover activation per flag, retry after a partial apply, queued/running/parked executions vs drafts, fresh install, read failures and the override; the script was also run against a real process table. Not exercised: the live migration scenario on a deployed stack. A direct terraform apply skips the check (documented), and new starts during the apply are prevented by the procedure, not enforced. Pinning the runtime revision per execution would allow migrating without the drain, but it is a much larger change; happy to take it as a follow-up if we need hot migration.
| if (requireActive && !ACTIVE_EXECUTION_STATUSES.includes(execution.status)) { | ||
| throw new Error('MCP execution is not active'); | ||
| } |
There was a problem hiding this comment.
P2 — Reject grants after the execution’s run ID changes.
startup-context.js:149–151 checks the execution’s status, but compares orchestratorRunId only against the stage row. Recompose retires the execution’s run ID before resetting stage rows. During that interval, the execution can remain WAITING and the stage still carries the old run/callback IDs. The Gateway therefore accepts the retired grant. I reproduced this through the real grant signer, verifier, and Gateway handler: a signed old-run grant reached a stubbed createArtifact writer and was audited as allow/ok. DynamoDB write fencing does not protect that graph mutation, so stale work can modify artifacts after recompose has archived them.
Fix: compare the signed run ID with execution META during authorization, alongside the existing stage check. Add a regression test where META is retired but the stage still names the old run. This belongs in #613’s existing authorization logic; it does not require importing #615’s retirement machinery.
There was a problem hiding this comment.
Fixed in 3463512: authorization now also compares the signed run with META.orchestratorRunId. The regression test has META retired while the stage row still names the old run; it fails without the fix. The same check is in engine-data's stage grants (#614, 84e5efe), since graph writes have no store-side fence there either.
2bfafbb to
0bd1cc0
Compare
|
Rebased on |
0bd1cc0 to
89d4922
Compare
Complexity and test coverageAutomated quality report using code complexity and test coverage to highlight potential hot spots in this PR.
🟢 Improving · 🟠 Needs attention · 🔴 High risk · 🟣 Highest risk — = unavailable · n/a = not applicable · * = incomplete tests |
There was a problem hiding this comment.
@eipasteur Previous findings are solved, thank you.
Two focused questions on the Gateway integration after our meeting this morning:
- JWT validation: Is there a reason we use
AWS_IAMinbound plus a custom JWT verified in the target Lambda, rather than Gateway’s nativeCUSTOM_JWTauthorizer? I understand execution/stage checks would remain application-owned, and direct Lambda calls still need verification. Would native validation simplify the MCP path, or introduce more infrastructure than it removes? - Tool permissions: Did you consider AgentCore Policy for the tool allowlist? Would it replace meaningful application code, or mostly duplicate the existing shared rules? Since the runtime currently asserts the role, would trusted role claims be a prerequisite?
Please also rebase the stack.
89d4922 to
ef53c76
Compare
|
Tool permissions. Not in this PR. With Rebase. The stack is rebased on
I also documented the Gateway trust boundary in |
|
Thanks. Both are now addressed in the PR. JWT validation. Inbound stays Tool permissions. No Policy for now. With |
56c2be9 to
e203756
Compare
…t-in) With enable_mcp_gateway (requires VPC networking) the runtime no longer runs the built-in aidlc MCP server in the agent's microVM. A stdio proxy registers the same tools and forwards each call, SigV4-signed, to an AWS_IAM AgentCore Gateway whose Lambda target (mcp-tools) runs the existing graph and process handlers. The orchestrator and discussions Lambdas sign a short ES256 grant with a KMS key only they may use. It names the space, intent, execution, stage lane and leg, mode and release-policy digest, travels in a propagated header and never reaches command handlers or the agent CLI. On every call the tools Lambda verifies it, rejects session context outside it, re-reads the execution and stage row (a finished, cancelled or superseded leg is denied), enforces the role's tool catalog and drops unknown arguments, and audits each allow, deny and handler error with its scope. Without a grant the runtime refuses to start the MCP instead of falling back. The tools Lambda gets scoped DynamoDB KMS access for customer-managed keys and may only post to websocket connections. The MCP core moves to lambda/shared/mcp with re-export shims. The runtime role is unchanged.
… grants are active
…ve JWT would replace
…nterceptor Moves grant, session and live-scope verification for the built-in aidlc MCP into one place in front of the Gateway target. lambda/shared/mcp/gateway-authorization.js is the authorizer as pure orchestration over injected clients: it verifies the grant, rejects modes the built-in MCP does not serve, binds the session to the grant (policy digest and stage leg included), checks the target and the role's tool catalog before any data access, then re-reads execution, stage leg and intent ownership as ACTIVE. Duplicate or multi-valued grant/session headers are rejected rather than resolved. lambda/shared/mcp/gateway-context.js encodes what the target acts on: a versioned, exact-field context with granted, live-verified and runtime-asserted sections, plus the resolved policy in its own header bound to the signed digest. Each header is capped at 4 KB; decoding is strict (version, fields, types, size, digest, expiry). The new mcp-interceptor Lambda handles the Gateway REQUEST/RESPONSE contract: every method it lets through (initialize, ping, tools/list, tools/call, notifications) must pass the authorizer; unknown methods, targets and tools are denied with a JSON-RPC -32001 error carrying the reason code, so the target is never invoked. An allowed request keeps its original body and only the injected context headers; no caller header is ever copied. tools/list responses are filtered with the same predicate tools/call enforces, re-derived from the request headers; a REQUEST-time denial and every other response pass through unchanged. Any failure denies.
The tools Lambda no longer verifies the grant, the session or the live scope. It requires the x-aidlc-context / x-aidlc-context-policy headers from bedrockAgentCorePropagatedHeaders, decodes them strictly, and denies with the same tool-error text when they are missing, ambiguous, malformed, oversized, of another version, policy-mismatched or expired. It still re-checks the role's tool catalog from the context, parses arguments with the tool's zod shape, pins a lane session to its unit, and leaves every process-table write behind the store's transactional ownership checks; handlers receive the same scope as before. The KMS public-key wiring and @aws-sdk/client-kms dependency go away. The runtime proxy turns a Gateway -32001 denial back into the local server's denial text, so the agent still reads "Stop and end your turn" for an inactive stage. Tests keep the handler, bridge and audit regressions on the context path, add the context negatives, and run a question write against DynamoDB Local to show a rewind landing after authorization is still rejected by the store.
With enable_mcp_gateway the Gateway now runs the mcp-interceptor Lambda on REQUEST and RESPONSE with pass_request_headers, and the aidlc target forwards only x-aidlc-context and x-aidlc-context-policy; the grant and session headers stop at the interceptor. The interceptor runs in the AgentCore subnets with its own egress-only security group and a read-only role: kms:GetPublicKey on the grant key, Neptune read/connect, and dynamodb:GetItem on the process table (plus the DynamoDB KMS grant). It is built and hashed like mcp-tools. The tools Lambda loses kms:GetPublicKey and GATEWAY_GRANT_KEY_IDS. The Gateway role is the only principal granted lambda:InvokeFunction on either function; the runtime role keeps only the broker and source-control functions. tests/mcp-gateway.tftest.hcl asserts the interceptor wiring, the header allowlist, the invoke grants, that the runtime role cannot invoke either function under any qualifier, and that nothing exists with the Gateway off; CI runs it. Docs: the architecture Gateway section and trust boundary now describe the interceptor as the verifier, the target's trust in the injected context, invocation isolation, and the preflight limit of live checks.
e203756 to
956f48f
Compare
…ispatch The gateway grant test from aws-samples#613 looked up the synchronous init-ws payload, which new runs no longer send, so it asserted on undefined.
In short
Opt-in path that serves the built-in
aidlcMCP through AgentCore Gateway instead of inside the agent's microVM (enable_mcp_gateway, defaultfalse, requiresnetwork_mode = "VPC").AWS_IAMGateway. The CLIs keep the sameaidlcserver and tool names.lambda/mcp-tools) runs the existing graph and process handlers.v2-orchestratoranddiscussionssign an ES256 grant with an asymmetric KMS key. The key policy allowskms:Signonly to those roles; verifiers hold onlykms:GetPublicKey.lambda/mcp-interceptor, REQUEST and RESPONSE,pass_request_headers), in front of the target. On every request it lets through (initialize,ping,tools/list,tools/call, notifications) it verifies the grant, binds the session to it (policy digest and stage leg included), rejects duplicate or multi-valued grant/session headers, checks the target and the role's tool allowlist, then re-reads the execution, stage leg and intent ownership as active. A finished, cancelled or superseded leg is denied; a grant is refused once its run is retired on the execution, even while a stage row still names that run. Unknown methods, targets and tools are denied. A denial is answered by the Gateway with a JSON-RPC-32001error carrying the reason code; the target is never invoked. Any failure in the interceptor denies.x-aidlc-context(versioned, exact fields, with granted, live-verified and runtime-asserted sections) andx-aidlc-context-policy(the resolved release policy, bound to the signed digest). Each header is capped at 4 KB. The interceptor never copies a caller header,Authorizationand AWS tokens included; the grant and session headers stop at the interceptor.tools/listwith the same predicatetools/callenforces, re-derived from the request headers. Other responses pass through unchanged.mcp-toolsno longer verifies the grant, the session or the live scope. It requires the two context headers frombedrockAgentCorePropagatedHeaders, decodes them strictly (version, fields, types, size, digest, expiry) and denies on anything else. It still parses arguments with each tool's Zod shape, pins a lane session to its unit, re-checks the role's tool catalog from the context, and leaves every process-table write behind the store's transactional ownership checks. The KMS dependency goes away from the tools Lambda.lambda:InvokeFunctiononmcp-toolsand the interceptor; the runtime role keeps only the broker and source-control functions.terraform/tests/mcp-gateway.tftest.hclasserts the interceptor wiring, the target's header allowlist, the invoke grants, that the runtime role cannot invoke either function under any qualifier, and that nothing exists with the Gateway off. The interceptor runs in the AgentCore subnets with an egress-only security group and a read-only role (kms:GetPublicKeyon the grant key, Neptune read,dynamodb:GetItemon the process table).mcp-tools. The runtime proxy turns a Gateway-32001denial back into the local server's denial text, so the agent still reads "Stop and end your turn" for an inactive stage.MCP core modules move to
lambda/shared/mcp/; the authorizer (gateway-authorization.js) and the context codec (gateway-context.js) live there as pure orchestration over injected clients. Re-export shims inlambda/agentcore/mcp/keep existing imports working.Why inbound stays
AWS_IAMThe native
CUSTOM_JWTauthorizer was evaluated and not adopted. It would replace only the Gateway's inbound check (signature, issuer, audience, expiry); the interceptor and its live checks would stay, because the authorizer validates a token, not the execution's state. Grant retirement, the direct calls the runtime still makes to engine-data, the credential broker and the source-control alias, and the write-time ownership guards are all ours either way. A Gateway has one authorizer type, so switching would also remove SigV4 for the runtime. And it would need an OIDC discovery document and JWKS for a KMS-held key, plus ES256 acceptance by the inbound authorizer, neither of which is documented or verified.Trust boundary and limits
The Gateway accepts calls signed with the runtime role, and the interceptor admits only those that also carry a valid grant. These are two checks, not two independent barriers: code in the microVM can read both. What the grant adds is that the runtime cannot widen its own scope: it cannot mint a grant for another space, execution or stage, and a superseded leg or run is refused on the next call.
mcp-toolstrusts the injected context because of the two properties Terraform enforces and tests: only the Gateway may invoke it, and the target forwards no request header except the two the interceptor replaces on every allowed request.Explicit limits:
Deliberately not included: an agent or persona identity in the grant. The persona is known only to the runtime, so an unsigned value would be forgeable by a compromised agent. Per-persona grants make sense together with CLI sandboxing and are a follow-up.
Scope
First of four PRs: this one, then engine-data (#614), then the credential broker, source-control alias and customer MCP targets (#615), then the removal of the runtime role's direct data permissions (#616). This PR changes where the built-in tools run and how each call is authorized. It does not reduce the runtime role's permissions on its own.
Validation
main. On the stack tip:npm test7349 passed, 2 todo;test:release172 passed;terraform test49/49 (mcp-gateway.tftest.hclincluded); lint,format:check,dep:check,secretlintandsdk:checkpass. The interceptor and authorizer have their own suites (grant, session, context negatives, duplicate headers,tools/listfiltering, deny shapes);mcp-toolskeeps the handler, bridge and audit regressions on the context path and runs a question write against DynamoDB Local to show a rewind landing after authorization is still rejected by the store.mcp-toolsthroughbedrockAgentCorePropagatedHeaders; that the MCP SDK client surfaces the-32001denial body; that the interceptor is invoked forinitializeandtools/list; thatMcp-Session-Idsurvives the RESPONSE passthrough; that the Gateway is updated in place rather than replaced; and the runtime role's inability to invoke either Lambda.Upgrade notes
With the flag off, every new resource is
count-gated and the grant key env var is not set. Enabling it creates the Gateway, interceptor, target, tools Lambda, KMS key, security groups and schema object, and switches the runtime to the proxy. The new roles followiam_role_pathandiam_permissions_boundary_arn. The interceptor is configured whenever the Gateway exists; it is not a separate option.Executions started before the flag have no grant issuer and would fail the next time they invoke the runtime.
deploy-terraform.shtherefore refuses a plan that switches the runtime to the Gateway while any execution is started, running or parked, and lists them. Stop starting intents until the deployment has succeeded, let running executions park or finish, cancel the parked and queued ones, apply, then rewind each to its current stage (its first stage if it had not reached one): the rewind relaunches it with grants.AIDLC_ALLOW_ACTIVE_EXECUTIONS=1applies anyway. A directterraform applyskips the check.