DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

Deploying an MCP Server with CDK: A Step‑By‑Step Guide to Hosting Claude‑Powered Tools on ECS Fargate

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" }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 /mcp like any other HTTP API, making AI‑augmented features composable.

When you keep MCP inside a local Express app, you lose three things:

  1. Reliability – a single machine can crash or run out of memory.
  2. Scalability – handling many concurrent requests requires manual load‑balancing.
  3. 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)
Enter fullscreen mode Exit fullscreen mode
  • 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/stderr from the container, making it easy to spot the dreaded AccessDeniedException.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

What the code does, step by step

  1. VPC – isolates network traffic; required for Fargate.
  2. ECS Cluster – logical grouping of tasks.
  3. ApplicationLoadBalancedFargateService – a higher‑level construct that gives us a load balancer, task definition, and service with one call.
  4. Task Role – a special IAM role that the container assumes at runtime. The bedrock:* statement lets the code call the Bedrock Runtime API.
  5. Log Group – containers write to CloudWatch automatically; we create the group so we can set a retention period.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

Explanation of the server flow

  1. Express receives a POST at /mcp.
  2. The JSON body must contain a tool_use object (the MCP spec).
  3. invokeClaude builds an InvokeModelCommand and sends it with the Bedrock client.
  4. Claude replies with a JSON payload that includes a tool_result.
  5. 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": "*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Tip: If you see AccessDeniedException in 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" }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

You should receive a JSON response similar to:

{
  "success": true,
  "toolResult": {
    "name": "weather_lookup",
    "content": "The current temperature in Tokyo is 22°C."
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

— 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:

  1. Bootstrap the account so CDK can store assets.
  2. Write a CDK stack that provisions a VPC, ECS Fargate service, IAM role, CloudWatch logs, and an HTTP API Gateway.
  3. Containerize a tiny Express server that implements the MCP /mcp endpoint and calls Claude via the Bedrock Runtime SDK.
  4. Deploy with cdk deploy, then test using curl or any HTTP client.
  5. 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)