MCP Server for Bedrock AgentCore
This guide shows how to deploy an MCP endpoint for Bedrock AgentCore using AppTheory CDK.
AppTheoryMcpServer defaults to the canonical four-kind route family and its full OAuth facade. AgentCore’s
singleton, POST-only client shape must select routeFamily: { patterns: ["/mcp"] } explicitly and use
application-owned runtime registration. runtime/mcpfacade.RegisterMCPFacade serves only the canonical four-pattern
family; it is not configurable as the singleton runtime counterpart. See the
MCP Server Facade Construct.
The minimal examples below also select transportPosture: AppTheoryMcpTransportPosture.NONE_PUBLIC because their
application registers only POST /mcp. That posture removes the CDK OAuth facade and leaves the transport routes
public; it does not authenticate them, so authentication remains application-owned. It also carries the mandatory
defense-in-depth floor: originReachability plus an explicit stage-throttling decision.
It also supports:
- configurable DynamoDB session state, enabled by default (TTL + permissions + env vars)
- optional custom domain + Route53 CNAME
- optional stage options (name, access logs, throttling)
If you’re looking for the Go runtime implementation (tools + handler), see docs/integrations/agentcore-mcp.md.
Note on SSE progress streaming:
- this construct serves HTTP API v2, which delivers buffered responses only: it cannot stream SSE incrementally
- the v2 adapter drains a terminating streaming body into the buffered response (bounded to 4 MiB / 5 seconds), so a
short-lived response such as the initial
GET /mcpkeepalive is delivered as a body instead of a silent empty200 - a streaming body that does not terminate within that budget (for example a long-lived session listener or a live replay) fails closed with HTTP 500 and a JSON error body, so clients get a signal instead of an EOF reconnect loop
- if you require true incremental SSE streaming, use an API Gateway REST API v1 streaming pattern
- for Claude Remote MCP, use
AppTheoryRemoteMcpServerinstead
Related docs:
docs/integrations/agentcore-mcp.mddocs/cdk/mcp-server-remote-mcp.mddocs/cdk/rest-api-router-streaming.md
Minimal TypeScript stack
import * as cdk from "aws-cdk-lib";
import { Duration } from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
import { AppTheoryMcpServer } from "@theory-cloud/apptheory-cdk";
export class AgentCoreMcpStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const handler = new lambda.Function(this, "McpHandler", {
runtime: lambda.Runtime.PROVIDED_AL2023,
handler: "bootstrap",
code: lambda.Code.fromAsset("dist/mcp-handler"),
memorySize: 1024,
timeout: Duration.seconds(30),
});
const mcp = new AppTheoryMcpServer(this, "McpServer", {
handler,
routeFamily: { patterns: ["/mcp"] },
transportPosture: AppTheoryMcpTransportPosture.NONE_PUBLIC,
// Locked front door, or AppTheoryMcpOriginReachability.DISABLED when the
// execute-api default endpoint is disabled.
originReachability: AppTheoryMcpOriginReachability.LOCKED,
// `none-public` requires an explicit owned-stage throttling decision.
ownedApi: { stage: { throttlingEnabled: true } },
});
new cdk.CfnOutput(this, "McpEndpoint", { value: mcp.endpoints[0] });
}
}
This deploys an HTTP API v2 with the /mcp transport routes and no OAuth facade. AgentCore calls POST /mcp; the
application must register the matching runtime handler, for example app.Post("/mcp", srv.Handler()). The construct
no longer writes MCP_ENDPOINT; mcp.endpoints[0] is the URL to configure in AgentCore, and the bearer challenge is
app-owned configuration.
If the singleton needs the full OAuth facade, drop transportPosture and register handlers for every derived entry in
mcp.routeInventory in application code. Do not pair that singleton deployment with RegisterMCPFacade.
Minimal Python stack
from aws_cdk import (
CfnOutput,
Duration,
Stack,
)
from aws_cdk import aws_lambda as _lambda
from constructs import Construct
from apptheory_cdk import (
AppTheoryMcpOriginReachability,
AppTheoryMcpRouteFamily,
AppTheoryMcpServer,
AppTheoryMcpServerOwnedApiOptions,
AppTheoryMcpServerStageOptions,
AppTheoryMcpTransportPosture,
)
class AgentCoreMcpStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
super().__init__(scope, construct_id, **kwargs)
handler = _lambda.Function(
self,
"McpHandler",
runtime=_lambda.Runtime.PROVIDED_AL2023,
handler="bootstrap",
code=_lambda.Code.from_asset("dist/mcp-handler"),
memory_size=1024,
timeout=Duration.seconds(30),
)
mcp = AppTheoryMcpServer(
self,
"McpServer",
handler=handler,
route_family=AppTheoryMcpRouteFamily(patterns=["/mcp"]),
transport_posture=AppTheoryMcpTransportPosture.NONE_PUBLIC,
origin_reachability=AppTheoryMcpOriginReachability.LOCKED,
owned_api=AppTheoryMcpServerOwnedApiOptions(
stage=AppTheoryMcpServerStageOptions(throttling_enabled=True)
),
)
CfnOutput(self, "McpEndpoint", value=mcp.endpoints[0])
Session state (DynamoDB enabled by default)
To configure the default DynamoDB session table:
const mcp = new AppTheoryMcpServer(this, "McpServer", {
handler,
routeFamily: { patterns: ["/mcp"] },
transportPosture: AppTheoryMcpTransportPosture.NONE_PUBLIC,
originReachability: AppTheoryMcpOriginReachability.LOCKED,
ownedApi: { stage: { throttlingEnabled: true } },
sessionState: { enabled: true, ttlMinutes: 60 },
});
What you get:
- a DynamoDB table with:
- partition key:
sessionId(string) - TTL attribute:
expiresAt
- partition key:
- read/write permissions granted to your Lambda
- Lambda env vars:
MCP_SESSION_TABLEMCP_SESSION_TTL_MINUTES
Important:
- the CDK construct does not automatically switch your runtime to DynamoDB-backed sessions
- in Go, choose the Dynamo session store explicitly (see
docs/integrations/agentcore-mcp.md)
Custom domain (optional)
AppTheory is a framework, so your platform can apply a custom domain when it makes sense.
import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as route53 from "aws-cdk-lib/aws-route53";
const zone = route53.HostedZone.fromLookup(this, "Zone", { domainName: "example.com" });
const cert = acm.Certificate.fromCertificateArn(this, "Cert", "arn:aws:acm:...");
const mcp = new AppTheoryMcpServer(this, "McpServer", {
handler,
routeFamily: { patterns: ["/mcp"] },
transportPosture: AppTheoryMcpTransportPosture.NONE_PUBLIC,
originReachability: AppTheoryMcpOriginReachability.LOCKED,
ownedApi: {
stage: { throttlingEnabled: true },
domain: {
domainName: "mcp.example.com",
certificate: cert,
hostedZone: zone,
},
},
});
Notes:
- provide either
certificateorcertificateArn - if you omit
hostedZone, the domain is created but DNS is not - with a custom domain, the endpoint is always
https://mcp.example.com/mcp
Stage options (logging + throttling)
AppTheoryMcpServer defaults to the $default stage.
To create an explicit stage and enable access logs or throttling:
const mcp = new AppTheoryMcpServer(this, "McpServer", {
handler,
routeFamily: { patterns: ["/mcp"] },
transportPosture: AppTheoryMcpTransportPosture.NONE_PUBLIC,
originReachability: AppTheoryMcpOriginReachability.LOCKED,
ownedApi: {
stage: {
stageName: "prod",
accessLogging: true,
throttlingEnabled: true,
throttlingRateLimit: 50,
throttlingBurstLimit: 100,
},
},
});
When you’re using the execute-api hostname and a non-$default stage, the stage path is part of the URL:
https://{apiId}.execute-api.{region}.amazonaws.com/prod/mcp
When you’re using a custom domain, the construct maps the stage to the domain root:
https://mcp.example.com/mcp
Lambda permission policy size
Every route the construct wires adds its own route-scoped Lambda invoke permission by default. The canonical four-pattern family derives 32 routes, so an application that shares one Lambda across the MCP facade and its own route bundle can exhaust the 20 KB Lambda resource-policy limit.
For large MCP route bundles that share one Lambda, set scopePermissionToRoute: false to collapse per-route invoke
permissions into one API-scoped permission per Lambda. If you attach the construct to an application-owned HTTP API
(api), that one permission is scoped to the whole front door.
The trade-off is explicit: the API-scoped permission lets every route on that API invoke the handler, not only the routes this construct owns.
Security and migration note
transportPosture: AppTheoryMcpTransportPosture.NONE_PUBLIC is a deployment-facade opt-in, not an instruction to ship
an open tool endpoint. Protect the application-owned POST /mcp registration through the normal AppTheory middleware
chain. If the application owns a singleton OAuth facade, it must register every route in routeInventory;
RegisterMCPFacade remains canonical-family only.
The v3.1.x issuer/JWKS construct props no longer configure runtime discovery or emit environment variables. Move
those values into application-owned mcpfacade.FacadeConfig for the canonical family, or into the singleton’s own
runtime registration. See the redesign guide and the
UPGRADING migration note.