CDK

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 /mcp keepalive is delivered as a body instead of a silent empty 200
  • 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 AppTheoryRemoteMcpServer instead

Related docs:

  • docs/integrations/agentcore-mcp.md
  • docs/cdk/mcp-server-remote-mcp.md
  • docs/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
  • read/write permissions granted to your Lambda
  • Lambda env vars:
    • MCP_SESSION_TABLE
    • MCP_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 certificate or certificateArn
  • 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.