Imagine you could expose any Claude tool as a standard HTTP endpoint and let teams call it just like a microservice. Most tutorials stop at a local Express server – scaling that pattern securely is the real missing piece. This post shows how to turn a simple MCP prototype into a production‑grade, IaC‑driven service on AWS.
In plain English: We’ll take a tiny Node.js server that talks to Claude, put it in a Docker container, and let CDK spin up the whole thing (ECS Fargate + API Gateway + IAM) for you.
Prerequisites
| What you need | Why it matters |
|---|---|
| Node.js 22 (or later) installed locally | To run the MCP server code and CDK CLI. |
| AWS CLI configured with credentials that can create IAM roles, ECS clusters, and API Gateway resources | CDK ultimately calls the same APIs, so the CLI must have permission. |
| Docker installed and able to build images | ECS Fargate runs containers, so we need a built image. |
aws-cdk-lib (v2) and cdk installed globally (npm i -g aws-cdk) |
CDK is the “infrastructure as code” engine we’ll use. |
| Basic knowledge of TypeScript or JavaScript | The stack and server code are written in TypeScript. |
If any of these are missing, the deployment will fail early and CloudWatch logs will be of little help.
Why MCP Matters for AI Tooling
MCP stands for Model‑Centric Prompting. It is a lightweight JSON‑based protocol that lets you describe a tool (a piece of code, a database query, an external API) to Claude. Instead of hard‑coding a prompt every time, you send a structured request like:
{
"tool_use": {
"name": "weather_lookup",
"input": { "city": "Paris" }
}
}
Claude reads the tool_use block, runs the tool, and returns a JSON answer.
Key takeaway: MCP turns a one‑off prompt into a reusable micro‑service contract. Teams can call
/mcplike any other HTTP API, making AI‑augmented features composable.
When you keep MCP inside a local Express app, you lose three things:
- Reliability – a single machine can crash or run out of memory.
- Scalability – handling many concurrent requests requires manual load‑balancing.
- Security – exposing your Bedrock credentials from a laptop is risky.
Deploying to ECS Fargate solves all three. Fargate runs your container on demand, scales automatically, and keeps secrets in IAM roles instead of environment files.
Designing the MCP Server Architecture
Before writing any code, sketch the pieces that will talk to each other:
[Client] ──> API Gateway (HTTP API) ──> ECS Service (Fargate) ──> Bedrock Runtime
│
└─> CloudWatch Logs (for debugging)
- API Gateway receives the public HTTP request and forwards it to the container. It also terminates TLS, so you don’t need to manage certificates.
-
ECS Fargate Service runs a single Docker image that hosts an Express server exposing
/mcp. The service lives in a private subnet, never exposed directly to the internet. -
IAM Task Role attached to the ECS task grants
bedrock:*permission. This is the only place where the AWS credentials live, so the container never sees raw keys. -
CloudWatch Log Group captures
stdout/stderrfrom the container, making it easy to spot the dreadedAccessDeniedException.
Analogy: Think of the architecture as a restaurant. API Gateway is the front door, the ECS task is the kitchen, and Bedrock is the pantry. The kitchen (task role) is the only place that has a key to the pantry; the front door never holds that key.
Provisioning ECS Fargate and API Gateway with CDK
1. Bootstrap the account (first‑time only)
CDK needs a small S3 bucket to store assets (Docker images, Lambda code). Run once per account/region:
cdk bootstrap aws://123456789012/us-east-1
Tip: Forgetting this step shows a cryptic “Missing required bucket” error during
cdk deploy.
2. Create a new CDK app
mkdir mcp-ecs && cd mcp-ecs
cdk init app --language typescript
npm install @aws-cdk/aws-ecs @aws-cdk/aws-ecs-patterns @aws-cdk/aws-apigatewayv2 @aws-cdk/aws-apigatewayv2-integrations @aws-cdk/aws-iam @aws-cdk/aws-logs
3. The stack code (src/mcp-stack.ts)
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecspatterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
export class McpEcsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// 1️⃣ Create a VPC with isolated subnets (default is fine for a demo)
const vpc = new ecs.Vpc(this, 'McpVpc', { maxAzs: 2 });
// 2️⃣ Define an ECS cluster that lives in the VPC
const cluster = new ecs.Cluster(this, 'McpCluster', { vpc });
// 3️⃣ Build a Fargate service using the "ApplicationLoadBalancedFargateService" pattern.
// This pattern creates a Service, a Load Balancer, and a Listener for us.
const fargate = new ecspatterns.ApplicationLoadBalancedFargateService(this, 'McpFargate', {
cluster,
cpu: 256, // 0.25 vCPU – enough for a small Express app
memoryLimitMiB: 512, // 0.5 GB RAM
desiredCount: 1, // start with one task; autoscaling can add more later
taskImageOptions: {
// Docker image built from the local "./docker" folder
image: ecs.ContainerImage.fromAsset('docker'),
// 4️⃣ Grant the task role permission to call Bedrock
// This is the crucial "bedrock:*" policy that many miss.
taskRole: new iam.Role(this, 'McpTaskRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com')
})
},
publicLoadBalancer: false // keep the service private; API GW will be the entry point
});
// Attach Bedrock permissions to the task role
fargate.taskDefinition.taskRole.addToPolicy(new iam.PolicyStatement({
actions: ['bedrock:*'],
resources: ['*']
}));
// 5️⃣ Create a CloudWatch Log Group for the container
const logGroup = new logs.LogGroup(this, 'McpLogGroup', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY
});
fargate.taskDefinition.addContainer('McpContainer', {
// we already defined the container above; this is just to attach logging
logging: ecs.LogDrivers.awsLogs({ logGroup, streamPrefix: 'Mcp' })
});
// 6️⃣ Set up an HTTP API (API Gateway v2) that forwards /mcp to the load balancer
const httpApi = new apigwv2.HttpApi(this, 'McpHttpApi', {
apiName: 'McpHttpApi',
createDefaultStage: true
});
// Integration uses the load balancer's DNS name as a HTTP_PROXY target
const integration = new integrations.HttpAlbIntegration({
listener: fargate.listener,
// By default the integration forwards the whole request path
// so /mcp on the API becomes /mcp on the container.
method: apigwv2.HttpMethod.ANY
});
// Route all traffic to the Fargate service
httpApi.addRoutes({
path: '/{proxy+}',
methods: [apigwv2.HttpMethod.ANY],
integration
});
// Output the public endpoint – copy‑paste this into your client code.
new cdk.CfnOutput(this, 'ApiUrl', {
value: httpApi.url!,
description: 'Base URL for the MCP HTTP API'
});
}
}
What the code does, step by step
- VPC – isolates network traffic; required for Fargate.
- ECS Cluster – logical grouping of tasks.
- ApplicationLoadBalancedFargateService – a higher‑level construct that gives us a load balancer, task definition, and service with one call.
-
Task Role – a special IAM role that the container assumes at runtime. The
bedrock:*statement lets the code call the Bedrock Runtime API. - Log Group – containers write to CloudWatch automatically; we create the group so we can set a retention period.
- API Gateway HTTP API – lightweight, cheaper than REST API, and perfect for proxying to a container.
Gotcha: If you attach the permission to the execution role instead of the task role, the container will still get
AccessDeniedException. The execution role only controls pulling the image and writing logs.
4️⃣ Deploy the stack
cdk synth # shows the CloudFormation template; useful to verify token resolution
cdk deploy # provisions everything
The first deploy may take 5–10 minutes because CDK has to upload the Docker image to ECR and spin up the load balancer.
Implementing the MCP Endpoint in Node.js 22
Create a folder docker with a Dockerfile and source code.
Dockerfile
# Use the official Node.js 22 image (LTS)
FROM node:22-alpine AS builder
# Set working directory inside container
WORKDIR /app
# Copy only package files first – enables Docker layer caching
COPY package*.json ./
# Install dependencies (no dev deps needed at runtime)
RUN npm ci --only=production
# Copy the rest of the source code
COPY . .
# Build step – if you use TypeScript, transpile here
# (For plain JavaScript this is a no‑op)
RUN npm run build || true
# Runtime image – smaller alpine base
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app /app
# Expose the port the Express server will listen on
EXPOSE 8080
# Start the server
CMD ["node", "dist/server.js"]
package.json (minimal)
{
"name": "mcp-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"build": "tsc"
},
"dependencies": {
"express": "^4.19.2",
"@aws-sdk/client-bedrockruntime": "^3.560.0"
},
"devDependencies": {
"typescript": "^5.5.2"
}
}
src/server.js (plain JavaScript for simplicity)
// server.js – a tiny Express app that implements the /mcp endpoint
import express from 'express';
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrockruntime';
// ------------------------------------------------------------------
// 1️⃣ Create the Express application
// ------------------------------------------------------------------
const app = express();
app.use(express.json()); // parse JSON bodies automatically
// ------------------------------------------------------------------
// 2️⃣ Initialise the Bedrock client.
// No credentials are needed – the SDK picks them up from the
// task role attached to the container.
// ------------------------------------------------------------------
const bedrock = new BedrockRuntimeClient({ region: process.env.AWS_REGION || 'us-east-1' });
// ------------------------------------------------------------------
// 3️⃣ Helper: call Claude with a tool_use payload.
// ------------------------------------------------------------------
async function invokeClaude(toolPayload) {
const command = new InvokeModelCommand({
// Claude‑3‑Sonnet‑20240229 is a common model ID; replace with your own if needed
modelId: 'anthropic.claude-3-sonnet-20240229-v1:0',
// The body must be a UTF‑8 encoded JSON string
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({
// The MCP protocol lives inside the "messages" array
messages: [
{ role: 'user', content: [{ tool_use: toolPayload }] }
],
// Tell Claude we want a tool response
toolChoice: { type: 'any' }
})
});
const response = await bedrock.send(command);
// The response body is a Uint8Array; convert to string then JSON
const text = Buffer.from(response.body).toString('utf8');
return JSON.parse(text);
}
// ------------------------------------------------------------------
// 4️⃣ Define the /mcp route
// ------------------------------------------------------------------
app.post('/mcp', async (req, res) => {
const tool = req.body?.tool_use;
if (!tool) {
return res.status(400).json({ error: 'Missing tool_use payload' });
}
try {
const result = await invokeClaude(tool);
// Extract Claude's tool response (if any) and forward to the caller
const toolResult = result?.content?.[0]?.tool_result || {};
res.json({ success: true, toolResult });
} catch (err) {
console.error('Error invoking Claude:', err);
// The most common error here is AccessDeniedException
res.status(500).json({ error: err.message });
}
});
// ------------------------------------------------------------------
// 5️⃣ Start the server on the port expected by the CDK (8080)
// ------------------------------------------------------------------
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`MCP server listening on http://0.0.0.0:${PORT}`);
});
Explanation of the server flow
-
Express receives a POST at
/mcp. - The JSON body must contain a
tool_useobject (the MCP spec). -
invokeClaudebuilds anInvokeModelCommandand sends it with the Bedrock client. - Claude replies with a JSON payload that includes a
tool_result. - The server extracts that result and sends it back to the original HTTP caller.
Plain English: The server is a tiny “translator”. It takes your structured request, asks Claude to run the tool, and hands the answer back—all without you writing any prompt strings.
Connecting Claude via Bedrock and Testing the Loop
1️⃣ Verify IAM permissions
Open the IAM console, locate the McpTaskRole, and ensure the attached policy looks like:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "bedrock:*",
"Resource": "*"
}
]
}
Tip: If you see
AccessDeniedExceptionin CloudWatch, double‑check that this policy is attached to the task role, not the execution role.
2️⃣ Quick curl test
API_URL=$(aws cloudformation describe-stacks \
--stack-name McpEcsStack \
--query "Stacks[0].Outputs[?OutputKey=='ApiUrl'].OutputValue" \
--output text)
curl -X POST "$API_URL/mcp" \
-H "Content-Type: application/json" \
-d '{
"tool_use": {
"name": "weather_lookup",
"input": { "city": "Tokyo" }
}
}'
You should receive a JSON response similar to:
{
"success": true,
"toolResult": {
"name": "weather_lookup",
"content": "The current temperature in Tokyo is 22°C."
}
}
3️⃣ Spot‑checking CloudWatch logs
Navigate to Logs → Log groups → /aws/ecs/McpLogGroup. A healthy request will show lines like:
2026-09-28T12:34:56.789Z INFO: MCP server listening on http://0.0.0.0:8080
2026-09-28T12:35:02.123Z INFO: Received tool_use request for weather_lookup
2026-09-28T12:35:03.456Z INFO: Claude response received, forwarding result
If you see AccessDeniedException:
2026-09-28T12:35:03.001Z ERROR: Error invoking Claude: AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/McpTaskRole/... is not authorized to perform: bedrock:InvokeModel
— that’s the gotcha we warned about. Add the bedrock:* policy and redeploy (cdk deploy).
The Takeaway
In plain English: Deploying an MCP server is no longer a “run‑locally‑and‑pray” exercise. With CDK you get a repeatable, secure, and auto‑scaled stack that talks to Claude through Bedrock.
- MCP gives you a clean JSON contract for AI tools, turning prompts into micro‑service calls.
- ECS Fargate removes the need to manage servers; it runs your Docker image on demand.
- API Gateway provides a public HTTPS endpoint while keeping the container in a private subnet.
-
IAM task role must have
bedrock:*; attaching the policy to the wrong role causes silent failures. - CDK bootstrap is required once per account/region; without it the deployment will abort.
- CloudWatch logs are the fastest way to verify that the Bedrock call succeeded or to spot permission errors.
Conclusion & Next Steps
We walked through the full lifecycle:
- Bootstrap the account so CDK can store assets.
- Write a CDK stack that provisions a VPC, ECS Fargate service, IAM role, CloudWatch logs, and an HTTP API Gateway.
-
Containerize a tiny Express server that implements the MCP
/mcpendpoint and calls Claude via the Bedrock Runtime SDK. -
Deploy with
cdk deploy, then test usingcurlor any HTTP client. -
Debug via CloudWatch; fix the common
bedrock:*permission miss if needed.
From here you can:
- Add auto‑scaling based on CPU or request count (
service.autoScaleTaskCount). - Enable Service Connect if you need internal service‑to‑service communication (remember the added latency).
- Store tool definitions in DynamoDB and have the MCP server fetch them at runtime, turning the service into a full‑featured tool registry.
Final tip: Keep the CDK stack small and modular. Large stacks (>500 resources) hit CloudFormation limits and make deploys sluggish. Split networking, compute, and API layers into separate stacks and use CDK Aspects only for cross‑cutting concerns like tags—never to mutate resources after synthesis.
Happy building, and enjoy watching your AI‑powered tools scale like any other microservice!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-09-28 · Primary focus: CDK
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)