Deploy an MCP Server with TypeScript: Streamable HTTP, Hono and Production Setup

Build and deploy a modern MCP server with TypeScript using the current MCP 2026-07-28 architecture. This practical guide covers Hono, Streamable HTTP, tools, testing, authentication, Docker, scaling and production security.

deploy MCP server TypeScript
guide.md READY

Getting an MCP server working locally is usually the easy part.

The questions become more interesting when I want another application, developer or AI client to reach that server over the network.

Now I need to think about HTTP transport, validation, authentication, deployment, scaling, observability and what the tools are actually allowed to do.

That is the part I want to cover in this guide.

We’ll deploy an MCP server with TypeScript using the current Model Context Protocol TypeScript SDK, Hono and Streamable HTTP.

I also want to correct something from the original version of this article.

The old implementation used:

SSEServerTransport

GET /sse

POST /message

That is now legacy MCP architecture.

The current TypeScript SDK uses Streamable HTTP for remote MCP servers, and the older HTTP+SSE transport is kept only for backwards compatibility.

The MCP specification also changed substantially in July 2026. The current protocol has a stateless core, which makes remote MCP servers much easier to run behind normal load balancers without transport-level sticky sessions.

So rather than patching the old tutorial, I am rebuilding it around the way I would start a new MCP deployment today.

Table of Contents

What We Are Going to Build

Our server will expose an MCP endpoint at:

POST /mcp

Conceptually, the architecture will look like this:

Claude / AI client / MCP host
              |
              v
        HTTPS request
              |
              v
           /mcp
              |
              v
        Hono application
              |
              v
       MCP HTTP handler
              |
        +-----+-----+
        |           |
        v           v
      Tool        Tool
        |           |
        v           v
     API / DB    Internal service

For the tutorial, we’ll expose a small get_server_status tool.

The tool itself is deliberately simple. I want the interesting part of this article to be the MCP architecture around it rather than hiding everything behind a complicated example API.

The MCP Transport Model Changed

If you built an MCP server from an older tutorial, you may have something like this:

Client
   |
   v
GET /sse
   |
   +--- long-running SSE connection

Client
   |
   v
POST /message

That was the older HTTP+SSE transport.

For new remote MCP implementations, I would not build that architecture.

The current SDK recommends Streamable HTTP.

Client
   |
   v
POST /mcp
   |
   v
MCP request
   |
   v
MCP response

Streamable HTTP can still use SSE when streaming is required. The important difference is that SSE is no longer the entire remote transport architecture.

For the current implementation details, I recommend checking the official MCP TypeScript SDK documentation.

MCP 2026-07-28 Is Stateless at the Protocol Level

This is another important change for production deployments.

Older Streamable HTTP deployments could maintain protocol sessions identified using an MCP session ID.

The current 2026-07-28 specification removes that protocol-level session model.

Older architecture

Request 1
   |
   v
Session A
   |
   v
Server instance A


Request 2
   |
   v
Must find Session A


Current architecture

Request 1
   |
   v
Any server instance


Request 2
   |
   v
Any server instance

Each modern MCP request carries the information needed to process that request.

That means an MCP endpoint can fit much more naturally behind infrastructure such as:

  • AWS Application Load Balancer
  • API Gateway
  • Kubernetes services
  • Cloudflare Workers
  • normal container platforms

If my application itself needs state between tool calls, that is still possible. I just don’t treat hidden transport-session state as the place where my business data belongs.

The current protocol changes are documented in the MCP 2026-07-28 specification announcement.

Why I Am Using Hono

You do not need Hono to build an MCP server.

The TypeScript SDK supports several hosting approaches, including Node HTTP, Express, Fastify, Hono and web-standard runtimes.

I am using Hono here for three practical reasons.

  • Its request model works naturally with Web Standard Request and Response objects.
  • The MCP SDK now provides a dedicated Hono adapter.
  • The same application style can work across Node and several web-standard environments.

I wouldn’t choose Hono simply because it is “faster.”

For an MCP server calling databases, APIs and other services, those downstream operations will often matter more than a tiny framework benchmark difference.

If you’re interested in the broader framework decision, see my NestJS vs Hono comparison.

Step 1: Create the TypeScript Project

Create a new directory:

mkdir production-mcp-server

cd production-mcp-server

npm init -y

Set the project to use ES modules:

npm pkg set type=module

Now install the MCP server package, its Hono integration, Hono itself and Zod:

npm install \
  @modelcontextprotocol/server \
  @modelcontextprotocol/hono \
  hono \
  @hono/node-server \
  zod

Add the development dependencies:

npm install -D \
  typescript \
  tsx \
  @types/node

The current v2 MCP SDK split the older single package:

@modelcontextprotocol/sdk

into focused packages including:

@modelcontextprotocol/server

@modelcontextprotocol/client

@modelcontextprotocol/hono

@modelcontextprotocol/express

@modelcontextprotocol/fastify

@modelcontextprotocol/node

If you’re starting a new server, I would use the v2 packages rather than copying a v1 import from an older tutorial.

Step 2: Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": [
    "src/**/*.ts"
  ]
}

I also add a few scripts to package.json:

{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/server.js"
  }
}

During development I use tsx because it keeps the feedback loop simple.

For the deployment example later, I will build JavaScript into dist/ and run it with Node.

Step 3: Build an MCP Server Factory

Create:

src/server.ts

Start with these imports:

import { serve } from '@hono/node-server';

import {
  createMcpHonoApp
} from '@modelcontextprotocol/hono';

import {
  createMcpHandler,
  McpServer
} from '@modelcontextprotocol/server';

import type {
  Context
} from 'hono';

import * as z from 'zod/v4';

Now create a function that builds the MCP server:

function buildMcpServer(): McpServer {
  const server = new McpServer({
    name: 'production-mcp-server',
    version: '1.0.0'
  });

  return server;
}

Why a factory instead of one global server instance?

The current HTTP model creates an MCP server for the request being handled.

HTTP request
      |
      v
MCP handler
      |
      v
buildMcpServer()
      |
      v
MCP server instance
      |
      v
process request

That fits the stateless request model much better than storing every client’s transport object in one global map.

Step 4: Register a Real MCP Tool

Let’s add a tool that reports information from the actual Node.js process rather than returning a fake CPU percentage.

Add this inside buildMcpServer():

server.registerTool(
  'get_server_status',
  {
    description:
      'Returns runtime information for the MCP server',

    inputSchema: z.object({
      region: z
        .string()
        .min(1)
        .describe(
          'Deployment region to include in the status response'
        )
    })
  },
  async ({ region }) => {
    const memory =
      process.memoryUsage();

    const status = {
      status: 'ok',
      region,
      nodeVersion:
        process.version,

      uptimeSeconds:
        Math.round(
          process.uptime()
        ),

      memory: {
        rssMb:
          Math.round(
            memory.rss /
            1024 /
            1024
          ),

        heapUsedMb:
          Math.round(
            memory.heapUsed /
            1024 /
            1024
          )
      }
    };

    return {
      content: [
        {
          type: 'text',
          text:
            JSON.stringify(
              status,
              null,
              2
            )
        }
      ]
    };
  }
);

One thing I like here is that the tool has a runtime schema.

This:

inputSchema: z.object({
  region: z.string().min(1)
})

is doing real validation.

TypeScript alone cannot protect a server from malformed input sent by another program.

The MCP client is outside our TypeScript compiler, so runtime validation still matters.

The Complete Server Factory

function buildMcpServer(): McpServer {
  const server = new McpServer({
    name: 'production-mcp-server',
    version: '1.0.0'
  });

  server.registerTool(
    'get_server_status',
    {
      description:
        'Returns runtime information for the MCP server',

      inputSchema: z.object({
        region: z
          .string()
          .min(1)
          .describe(
            'Deployment region'
          )
      })
    },
    async ({ region }) => {
      const memory =
        process.memoryUsage();

      const status = {
        status: 'ok',
        region,

        nodeVersion:
          process.version,

        uptimeSeconds:
          Math.round(
            process.uptime()
          ),

        memory: {
          rssMb:
            Math.round(
              memory.rss /
              1024 /
              1024
            ),

          heapUsedMb:
            Math.round(
              memory.heapUsed /
              1024 /
              1024
            )
        }
      };

      return {
        content: [
          {
            type: 'text',
            text:
              JSON.stringify(
                status,
                null,
                2
              )
          }
        ]
      };
    }
  );

  return server;
}

Step 5: Create the Streamable HTTP Handler

This is the part that replaces the old SSEServerTransport implementation.

const mcpHandler =
  createMcpHandler(
    buildMcpServer
  );

That’s much simpler than manually managing:

activeTransports

session IDs

GET /sse

POST /message

SSEServerTransport

The handler understands the MCP HTTP protocol and connects the HTTP request to the server produced by our factory.

An additional benefit is backwards compatibility.

The current SDK’s createMcpHandler() can serve the modern 2026 protocol and, by default, also provide a stateless compatibility path for 2025-era MCP clients.

That means I don’t need to bring the deprecated SSE transport into a new project simply because some clients have not yet moved to the newest protocol revision.

Step 6: Mount MCP Inside Hono

Create the Hono app:

const app =
  createMcpHonoApp();

Now mount the MCP handler:

app.all(
  '/mcp',
  (c: Context) =>
    mcpHandler.fetch(
      c.req.raw,
      {
        parsedBody:
          c.get(
            'parsedBody'
          )
      }
    )
);

The flow is now:

HTTP /mcp
    |
    v
Hono
    |
    v
mcpHandler.fetch()
    |
    v
MCP server
    |
    v
Tool call

The official MCP Hono adapter also parses JSON bodies and provides sensible Host/Origin protection for localhost development.

Add a Normal Health Endpoint Too

I like keeping operational health separate from MCP tools.

Add:

app.get(
  '/health',
  c =>
    c.json({
      status: 'ok'
    })
);

Why not just call get_server_status from the load balancer?

Because infrastructure health checks should not need to behave like full MCP clients.

Load balancer
     |
     v
GET /health


AI client
     |
     v
POST /mcp

I prefer keeping those responsibilities separate.

Step 7: Start the Server Locally

For local development, I bind explicitly to 127.0.0.1.

const port =
  Number(
    process.env.PORT ??
    3000
  );

serve(
  {
    fetch: app.fetch,
    port,
    hostname:
      '127.0.0.1'
  },
  info => {
    console.log(
      `MCP server: http://127.0.0.1:${info.port}/mcp`
    );

    console.log(
      `Health: http://127.0.0.1:${info.port}/health`
    );
  }
);

Run:

npm run dev

You should now have:

http://127.0.0.1:3000/mcp

http://127.0.0.1:3000/health

Complete Local Server

At this point, src/server.ts looks like this:

import {
  serve
} from '@hono/node-server';

import {
  createMcpHonoApp
} from '@modelcontextprotocol/hono';

import {
  createMcpHandler,
  McpServer
} from '@modelcontextprotocol/server';

import type {
  Context
} from 'hono';

import * as z from 'zod/v4';


function buildMcpServer():
  McpServer {

  const server =
    new McpServer({
      name:
        'production-mcp-server',

      version:
        '1.0.0'
    });


  server.registerTool(
    'get_server_status',
    {
      description:
        'Returns runtime information for the MCP server',

      inputSchema:
        z.object({
          region:
            z
              .string()
              .min(1)
              .describe(
                'Deployment region'
              )
        })
    },

    async ({ region }) => {
      const memory =
        process.memoryUsage();

      const status = {
        status: 'ok',

        region,

        nodeVersion:
          process.version,

        uptimeSeconds:
          Math.round(
            process.uptime()
          ),

        memory: {
          rssMb:
            Math.round(
              memory.rss /
              1024 /
              1024
            ),

          heapUsedMb:
            Math.round(
              memory.heapUsed /
              1024 /
              1024
            )
        }
      };

      return {
        content: [
          {
            type: 'text',

            text:
              JSON.stringify(
                status,
                null,
                2
              )
          }
        ]
      };
    }
  );

  return server;
}


const mcpHandler =
  createMcpHandler(
    buildMcpServer
  );


const app =
  createMcpHonoApp();


app.get(
  '/health',

  c =>
    c.json({
      status: 'ok'
    })
);


app.all(
  '/mcp',

  (c: Context) =>
    mcpHandler.fetch(
      c.req.raw,

      {
        parsedBody:
          c.get(
            'parsedBody'
          )
      }
    )
);


const port =
  Number(
    process.env.PORT ??
    3000
  );


serve(
  {
    fetch:
      app.fetch,

    port,

    hostname:
      '127.0.0.1'
  },

  info => {
    console.log(
      `MCP server: http://127.0.0.1:${info.port}/mcp`
    );

    console.log(
      `Health: http://127.0.0.1:${info.port}/health`
    );
  }
);

Test the Health Endpoint First

curl \
  http://127.0.0.1:3000/health

You should receive:

{
  "status": "ok"
}

This only proves that the HTTP server is reachable.

I also want to test MCP using an actual MCP client rather than assuming a 200 response means the protocol is working.

Step 8: Create an MCP Smoke-Test Client

Install the client SDK:

npm install \
  @modelcontextprotocol/client

Create:

src/test-client.ts

Then add:

import {
  Client,
  StreamableHTTPClientTransport
} from '@modelcontextprotocol/client';


const client =
  new Client(
    {
      name:
        'deployment-smoke-test',

      version:
        '1.0.0'
    },

    {
      versionNegotiation: {
        mode: 'auto'
      }
    }
  );


const transport =
  new StreamableHTTPClientTransport(
    new URL(
      'http://127.0.0.1:3000/mcp'
    )
  );


try {
  await client.connect(
    transport
  );


  console.log(
    'Protocol era:',
    client.getProtocolEra()
  );


  const tools =
    await client.listTools();


  console.log(
    'Tools:',
    tools.tools.map(
      tool =>
        tool.name
    )
  );


  const result =
    await client.callTool({
      name:
        'get_server_status',

      arguments: {
        region:
          'ap-south-1'
      }
    });


  console.log(
    JSON.stringify(
      result,
      null,
      2
    )
  );

} finally {
  await client.close();
}

Run it while your server is still running:

npx tsx \
  src/test-client.ts

With version negotiation set to auto, a current server should negotiate the modern MCP protocol.

This test is much more useful than only calling the endpoint manually because it verifies:

  • MCP protocol negotiation
  • tool discovery
  • tool input validation
  • tool invocation
  • response handling

Why I Would Not Test Only with curl

Modern MCP requests include protocol-level details that a real client handles for you.

For example, the 2026 protocol can include headers such as:

MCP-Protocol-Version

Mcp-Method

Mcp-Name

The request also carries client information and capabilities in MCP metadata.

I can hand-build those requests for debugging, but I don’t want my primary integration test to reimplement an MCP client manually.

Step 9: Do Not Expose the Development Server Directly

So far, the server is intentionally bound to:

127.0.0.1

That means another machine cannot reach it directly.

For deployment, I may need to bind to:

0.0.0.0

But I don’t simply replace the hostname and call the application production-ready.

The moment an MCP server becomes reachable over a network, the security model changes.

Local MCP server
      |
      +--- only local processes


Remote MCP server
      |
      +--- network exposure
      +--- authentication
      +--- authorization
      +--- rate limiting
      +--- TLS
      +--- logging
      +--- tool permissions

Host and Origin Validation Matter

The MCP SDK’s Hono integration includes protections against DNS rebinding for local servers.

When I deliberately bind a production application to all interfaces, I explicitly define the host I expect to serve.

For example:

const app =
  createMcpHonoApp({
    host:
      '0.0.0.0',

    allowedHosts: [
      'mcp.example.com'
    ]
  });

Then the Node server can bind externally:

serve({
  fetch:
    app.fetch,

  port:
    3000,

  hostname:
    '0.0.0.0'
});

In a real deployment, I would normally put TLS termination and network policy in front of that process rather than expose a raw Node port directly to the internet.

The Old Shared Secret Example Was Not Enough

The previous version of this article used something like:

const token =
  process.env.MCP_SECRET_TOKEN;

app.use(
  '*',
  bearerAuth({
    token
  })
);

A shared Bearer secret can be useful in a private controlled integration, but I would not present that as the complete production authorization architecture for an interoperable remote MCP server.

Current MCP authorization is built around OAuth concepts.

A protected MCP resource advertises metadata that tells the client where authorization happens.

MCP client
    |
    v
POST /mcp
    |
    v
401 Unauthorized
    |
    +--- WWW-Authenticate
    |
    +--- resource metadata
    |
    v
Authorization server
    |
    v
User authorizes
    |
    v
Access token
    |
    v
POST /mcp

This is much more than comparing a request header against one static environment variable.

The current MCP SDK provides resource-server helpers for validating Bearer tokens and advertising Protected Resource Metadata. For the full flow, follow the official MCP authorization guide.

What I Would Use for Production Authentication

The exact choice depends on who is calling the server.

DeploymentWhat I would consider
Local developer serverNo public exposure; localhost protections
Private internal servicePrivate network + gateway/IAM/JWT policy
Machine-to-machine MCPOAuth client-credentials style authorization where appropriate
Users connecting with different MCP clientsMCP OAuth authorization flow and resource metadata
Enterprise environmentOrganization identity provider / centrally managed authorization

What I would not do is leave a powerful MCP endpoint publicly accessible because “the tool names are hard to guess.”

Authentication Is Not Authorization

This distinction matters even more for MCP than for a normal read-only API.

Authentication answers:

Who is calling?

Authorization answers:

Which MCP tools is that caller allowed to use?

Authenticated user
      |
      v
Can read deployment status
      |
      +--- yes


Authenticated user
      |
      v
Can restart production
      |
      +--- maybe not

I don’t want every authenticated caller automatically receiving permission to every tool.

If a server exposes tools with very different risk levels, scopes or per-tool authorization become important.

Design MCP Tools with Narrow Permissions

The best security improvement is often not another middleware package.

It is designing a smaller tool.

For example, I would rather expose:

get_order_status(orderId)

than something like:

execute_sql(query)

The first tool has a narrow business purpose.

The second gives the model a generic database execution surface.

Even if both are authenticated, their risk profile is completely different.

Read-Only Tools Are a Good Starting Point

When I first deploy an MCP integration, I prefer starting with operations such as:

  • search documentation
  • read deployment status
  • inspect logs
  • look up an order
  • read monitoring data

before immediately exposing operations such as:

  • delete customer
  • deploy production
  • refund payment
  • drop database
  • modify infrastructure

I can add write capability later when I understand the workflow and have the right approval boundaries.

Don’t Trust Tool Arguments Because a Model Created Them

MCP tool input is still untrusted input.

If a tool accepts:

{
  "orderId": "123"
}

I validate the value.

If it accepts a filename, URL, shell argument or SQL-related value, I become even more careful.

AI-generated argument
       |
       v
Runtime validation
       |
       v
Authorization check
       |
       v
Business rule
       |
       v
External system

The model should not become a shortcut around the backend’s normal security rules.

Be Careful with Generic Shell Tools

This may be convenient:

run_command({
  command:
    "anything the model wants"
})

It is also extremely powerful.

I prefer exposing specific operations:

check_service_health()

read_application_logs()

restart_worker()

where each tool can enforce its own constraints.

Giving an agent unrestricted shell access should be an intentional security decision, not the easiest way to avoid writing three tools.

Step 10: Build the Production Artifact

Once the server works locally:

npm run build

You should get:

dist/
   |
   +--- server.js

Then run:

npm start

I want the same built artifact that I test to be the one I deploy.

Containerize the MCP Server

A container is not required, but it gives us a straightforward deployment unit.

Create Dockerfile:

FROM node:24-alpine AS build

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY tsconfig.json ./
COPY src ./src

RUN npm run build


FROM node:24-alpine

WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000

COPY package*.json ./

RUN npm ci --omit=dev

COPY --from=build \
  /app/dist \
  ./dist

USER node

EXPOSE 3000

CMD [
  "node",
  "dist/server.js"
]

I use an LTS Node.js image for production rather than blindly tracking the newest Current release.

Node.js release status changes over time, so check the official Node.js release schedule when you update your image.

Add a .dockerignore

node_modules
dist
.git
.env
*.log

I especially don’t want local environment files accidentally copied into an image build context.

Run the Container Locally

docker build \
  -t production-mcp .

For container deployment, remember that the application must listen on an externally reachable interface rather than only 127.0.0.1.

I normally make that configurable instead of hardcoding it.

const hostname =
  process.env.HOST ??
  '127.0.0.1';

Then the container can use:

HOST=0.0.0.0

while local development remains loopback-only.

Production Host Validation Must Match the Deployment

If my public MCP URL is:

https://mcp.example.com/mcp

I configure Hono around that expected host rather than accepting arbitrary hosts.

const app =
  createMcpHonoApp({
    host:
      '0.0.0.0',

    allowedHosts: [
      'mcp.example.com'
    ]
  });

This is one reason I would not simply take the localhost source file and expose port 3000 publicly.

Where Can I Deploy It?

Once the server speaks standard HTTP, the infrastructure choices become much less unusual.

Depending on the application, options include:

  • AWS ECS / Fargate
  • Kubernetes
  • a normal VPS
  • container hosting platforms
  • AWS Lambda / API Gateway where the application model fits
  • Cloudflare Workers for web-standard implementations
  • other serverless or edge platforms supported by the chosen framework/runtime

I would choose based on the tool workload rather than selecting infrastructure because Hono technically runs there.

If a tool opens long-running database connections, runs heavy Node-specific libraries or needs private VPC access, that may influence the deployment more than the HTTP framework does.

What Changed for AWS Scaling?

This is one area where the new MCP protocol model is genuinely useful.

With older stateful MCP HTTP setups, I had to think about whether later requests would reach the same session-owning process.

Load balancer
   |
   +--- Instance A owns session 123
   |
   +--- Instance B does not


Next request
   |
   +--- must reach A?

That complicates ordinary horizontal scaling.

The modern stateless MCP protocol removes the protocol-level session dependency.

Load balancer
     |
     +--- Instance A
     |
     +--- Instance B
     |
     +--- Instance C


Any request
     |
     v
Any healthy instance

That doesn’t mean application state magically disappears.

If my tools need persistent state, I put it somewhere designed to survive process changes:

  • PostgreSQL
  • Redis
  • DynamoDB
  • S3/object storage
  • another durable service

Do Not Keep Important State Only in Memory

For example:

const jobs =
  new Map();

may be fine for a tutorial.

It becomes a production problem if:

Instance A
   |
   +--- job 123 in memory


Instance A restarts
   |
   v
job 123 disappears

or:

Request creates job
        |
        v
Instance A


Next request
        |
        v
Instance B
        |
        v
job not found

The stateless transport helps horizontal scaling, but the application still needs sane state management.

Long-Running Tools Need a Different Design

I would not keep an HTTP request open for twenty minutes while a tool performs a large batch operation if there is a cleaner background-job design.

Tool call
   |
   v
Create job
   |
   v
Return job handle


Worker
   |
   v
Run long operation


Later
   |
   v
Check job result

The modern MCP ecosystem also has a Tasks extension for long-running agent work, but I would use it intentionally rather than making every ordinary tool a task.

Timeout Every External Dependency

An MCP tool is still backend code.

If the tool calls an external API, I do not want it waiting forever.

const response =
  await fetch(
    externalUrl,

    {
      signal:
        AbortSignal.timeout(
          5000
        )
    }
  );

If the operation takes too long, fail clearly.

Without timeouts, one failing dependency can slowly consume server resources while clients wait for responses that may never arrive.

Be Careful with Retries on Write Tools

Imagine a tool:

refund_payment()

The request times out after the payment provider processed the refund but before our server received the response.

If I blindly retry it, what happens?

Potentially a second refund.

For operations with side effects, I think about:

  • idempotency keys
  • duplicate requests
  • retry-safe status codes
  • transaction boundaries
  • human confirmation for destructive actions

MCP doesn’t remove normal distributed-systems problems.

Logs Need Tool Context

For a production MCP server, a generic log like:

Error happened

is not useful.

I want structured information such as:

{
  "level":
    "error",

  "tool":
    "get_order_status",

  "requestId":
    "req-123",

  "durationMs":
    821,

  "errorCode":
    "DATABASE_TIMEOUT"
}

I do not log:

  • access tokens
  • passwords
  • private API keys
  • full sensitive tool arguments
  • unnecessary personal data

Agent tooling can move sensitive data through more places than a basic public API, so log review matters.

Metrics I Would Watch

At minimum, I want to know:

  • request count
  • tool-call count by tool
  • tool latency
  • failure rate
  • authentication failures
  • rate-limit events
  • external dependency latency
  • CPU and memory

If one MCP tool suddenly goes from 200 ms to 12 seconds, I want to detect that before users tell me the AI “feels slow.”

Add Rate Limits Where They Matter

An authenticated client can still make too many requests.

And some tools are much more expensive than others.

search_docs()
    |
    +--- cheap


generate_monthly_report()
    |
    +--- expensive database work


rebuild_search_index()
    |
    +--- very expensive

I don’t necessarily want the same rate limit for all three.

The current MCP protocol also exposes method and tool names through HTTP headers in the modern protocol, which can make gateway-level routing, metering and policy easier.

Graceful Shutdown Still Matters

When a container receives SIGTERM during deployment, I don’t want it vanishing in the middle of work.

A production server should have a shutdown strategy for resources such as:

  • HTTP server
  • database connections
  • Redis
  • queue consumers
  • telemetry exporters

The exact implementation depends on the runtime and resources your tools use, but I include shutdown behaviour in deployment planning instead of adding it after the first broken rollout.

Local stdio and Remote HTTP Solve Different Problems

I wouldn’t replace every local MCP server with HTTP.

stdio remains a very good transport when the MCP host launches the server locally as a child process.

Claude Code
     |
     v
launch local process
     |
     v
stdio MCP server

There is no public network service to operate.

Remote HTTP makes sense when:

MCP client
     |
 Internet / network
     |
     v
Remote MCP server

Examples include:

  • a team sharing the same MCP capability
  • a cloud AI application accessing an internal service
  • centrally managed enterprise tools
  • an MCP service running independently from the client

I choose the transport based on the architecture rather than assuming remote HTTP is automatically more advanced.

Migrate an Old SSE MCP Server

If you already have a server using:

@modelcontextprotocol/sdk

SSEServerTransport

GET /sse

POST /message

I would treat that as migration work rather than building more features on top of the old transport.

The general direction is:

Old SDK
   |
   v
Upgrade to v2 packages
   |
   v
McpServer factory
   |
   v
createMcpHandler()
   |
   v
single /mcp endpoint
   |
   v
Streamable HTTP

The official TypeScript SDK provides a migration codemod:

npx \
  @modelcontextprotocol/codemod@latest \
  v1-to-v2 \
  .

I would still review the migration manually afterward, especially around transports and authentication.

The official v1-to-v2 migration guide documents the package and transport changes.

What About Old MCP Clients?

I wouldn’t add deprecated HTTP+SSE support automatically to every new server.

The current SDK already provides compatibility for previous stateless Streamable HTTP clients through createMcpHandler().

A client that only understands the much older HTTP+SSE transport is a different case.

If I genuinely need to support one, I would follow the SDK’s legacy compatibility guidance rather than making the legacy transport the foundation of the new application.

A Production Architecture I Would Be Comfortable Starting With

                 Internet
                    |
                    v
              HTTPS / WAF
                    |
                    v
           Authentication
                    |
                    v
             Load Balancer
              /     |     \
             /      |      \
            v       v       v
          MCP A   MCP B   MCP C
            \       |       /
             \      |      /
              +-----+-----+
                    |
          +---------+---------+
          |         |         |
          v         v         v
      PostgreSQL  Redis    External API
                    |
                    v
                  Queue

The MCP service itself can stay relatively boring.

That’s a good thing.

I don’t want transport session state, authentication logic, database state and tool execution all mixed into one giant process-level map.

My MCP Production Checklist

Before I call a remote MCP server production-ready, I want to answer these questions:

  • Are we using the current MCP TypeScript SDK rather than an obsolete SSE example?
  • Is the remote endpoint using Streamable HTTP?
  • Are tool inputs validated at runtime?
  • Does each tool expose the minimum capability required?
  • Is the endpoint protected by authentication?
  • Is authorization enforced per user/tool where required?
  • Is traffic encrypted with HTTPS?
  • Are Host and Origin rules correct for the deployment?
  • Are external calls protected by timeouts?
  • Are write operations safe to retry?
  • Do destructive tools require stronger controls?
  • Is persistent state stored outside process memory?
  • Can the server scale horizontally?
  • Do we have health checks?
  • Do we have structured logs?
  • Can we measure tool latency and errors?
  • Are secrets kept outside source control?
  • Does deployment support graceful shutdown?
  • Have we tested the deployed endpoint with a real MCP client?

Common Mistakes I Would Avoid

1. Following an Old SSE Tutorial

If a new tutorial starts by creating separate /sse and /message endpoints using SSEServerTransport, I would check its publication date and SDK version.

2. Calling a Static Token “Production Security”

A private shared secret may be enough for a controlled internal scenario. It is not the complete authorization architecture for a broadly interoperable remote MCP service.

3. Giving the Model a Generic Shell Tool

I prefer purpose-built tools with narrow permissions.

4. Treating TypeScript as Runtime Validation

External MCP requests still need runtime schemas.

5. Keeping Important State in a Map

Process memory disappears on restart and is not shared across instances.

6. Forgetting Timeouts

A slow downstream API should not hold an MCP tool call indefinitely.

7. Blindly Retrying Destructive Tools

Side effects need idempotency and deliberate retry behaviour.

8. Exposing Port 3000 Directly to the Internet

Production networking, TLS, authentication and host validation should be intentional.

9. Assuming “Stateless MCP” Means the Application Has No State

The protocol can be stateless while your tools still use databases, queues and durable application state.

10. Testing Only Locally

Load balancers, proxies, authentication, TLS and network timeouts can expose problems that localhost never shows.

What I Would Build Next

Once this basic MCP server is deployed, I would add complexity gradually.

Step 1
Read-only status tool
      |
      v
Step 2
Real database/API integration
      |
      v
Step 3
OAuth authorization
      |
      v
Step 4
Per-tool permissions
      |
      v
Step 5
Structured logs + metrics
      |
      v
Step 6
Background work
      |
      v
Step 7
Carefully controlled write tools

I would not start by connecting production databases, cloud administrator credentials and unrestricted shell execution on day one.

An MCP server becomes more useful as its tools gain authority.

It also becomes more dangerous.

I want those two things to grow together with the appropriate controls.

Final Thoughts

Deploying an MCP server today is much cleaner than the original version of this article suggested.

I no longer need to start a new remote implementation by creating an SSE connection endpoint, a second message endpoint and an in-memory map of active transports.

The current architecture is much closer to a normal modern web service:

HTTPS
   |
   v
/mcp
   |
   v
authentication
   |
   v
MCP handler
   |
   v
validated tools
   |
   v
application services

That is a much easier architecture for me to reason about and operate.

The part I would spend most of my production effort on is not the transport.

I would spend it on the boundaries around the tools:

  • who can call them
  • what they are allowed to access
  • what happens when dependencies fail
  • whether actions can safely be repeated
  • what gets logged
  • how destructive operations are controlled

Getting an MCP server to respond is the beginning.

Building one that I would trust with real systems requires the same engineering discipline I would apply to any production backend.

Use the protocol to expose capabilities. Use normal backend engineering to decide how much capability the agent should actually receive.

Continue Learning

If you’re building MCP servers and AI development workflows, these guides continue from here:

Share this guideLinkedInPost

ARTICLE TOOLKIT

Save or share this guide

Keep the reference nearby or send it to a teammate solving the same problem.

Share this guideLinkedInPost

QUALITY NOTE

Written from practical development experience and reviewed for clarity. Found an outdated step?

Report a correction →

Jaydip Barad

WRITTEN BY

Jaydip Barad

Senior full-stack developer sharing production-tested lessons from 14+ years of building backend systems, WordPress platforms and modern JavaScript applications.

Node.jsTypeScriptWordPressArchitecture
Previous guide
Next guide

THE PRACTICAL DEVELOPER LETTER

Get useful engineering lessons without the noise.

New tutorials, architecture notes and tools worth knowing—delivered occasionally.




    Occasional practical tutorials. Unsubscribe any time.