Skip to main content

When to Use This Approach

Choose TypeScript custom containers when you:
  • Need full control over your Docker environment
  • Have complex dependencies or custom build processes
  • Want to implement HTTP transport manually
  • Need specific middleware or custom server logic

Show me the repo

View the fully runnable GitHub repo for this example
Alternative: If you’re using the official MCP SDK and want automatic deployment, consider TypeScript with Smithery CLI instead.

What We’re Building

We’ll build a simple MCP server with a count_characters tool that:
  • Takes text input and counts occurrences of a specific character
  • Validates an API key from Smithery configuration to demonstrate configuration handling
  • Uses custom Express server with full Docker control
  • Supports both HTTP and STDIO transport

Code Migration

Already have HTTP support? If your MCP server already supports HTTP transport, you can skip ahead to Step 5: Update smithery.yaml to configure your deployment settings.

Let’s say you start with this…

Here’s a typical STDIO-based MCP server that you might be starting with:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// Create STDIO server
const server = new McpServer({
  name: "Character Counter",
  version: "1.0.0"
});

// Get server token from environment variable
const serverToken = process.env.SERVER_TOKEN;

// Register tool
server.registerTool("count_characters", {
  description: "Count occurrences of a specific character in text",
  inputSchema: {
    text: { type: "string", description: "The text to search in" },
    character: { type: "string", description: "The character to count" }
  }
}, async ({ text, character }) => {
  // Tool implementation with API key validation, etc.
  // ...
});

// Run with STDIO transport
const transport = new StdioServerTransport();
await server.server.connect(transport);

Step 1: Update Imports and Setup Express Server

Now let’s migrate this to work with custom HTTP containers. First, set up your Express server with the necessary imports and middleware:
// src/index.ts
import express, { Request, Response } from "express";
import cors from "cors";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const app = express();
const PORT = process.env.PORT || 8081;

// CORS configuration for browser-based MCP clients
app.use(cors({
  origin: '*', // Configure appropriately for production
  exposedHeaders: ['Mcp-Session-Id', 'mcp-protocol-version'],
  allowedHeaders: ['Content-Type', 'mcp-session-id'],
}));

app.use(express.json());

Step 2: Configuration Handling (Optional)

Skip this entire step if no configuration needed: If your MCP server doesn’t need any configuration (API keys, settings, etc.), you can skip this entire step. Your server will work perfectly fine without any configuration handling.
If your server needs configuration (like API keys, user preferences, etc.):
// src/index.ts (continued - only add if you need configuration)
import type { Request, Response } from "express"
import { z } from "zod"
import { parseAndValidateConfig } from "@smithery/sdk"

// Define session configuration schema
export const configSchema = z.object({
  serverToken: z.string().optional().describe("Server access token"),
  caseSensitive: z.boolean().optional().default(false).describe("Whether character matching should be case sensitive"),
})

// Use SDK helper in your handler
app.all('/mcp', (req: Request, res: Response) => {
  const result = parseAndValidateConfig(req, configSchema)
  if (result.error) {
    return res.status(result.value.status).json(result.value)
  }
  const config = result.value
  // ... proceed with your server using `config`
})

Step 3: Create Server Function and Register Tools

Create the server function and register your tools:
// src/index.ts
// Create MCP server with your tools
export default function createServer({
  config,
}: {
  config: z.infer<typeof configSchema>;
}) {
  const server = new McpServer({
    name: "Character Counter",
    version: "1.0.0",
  });

  server.registerTool("count_characters", {
    title: "Count Characters",
    description: "Count occurrences of a specific character in text",
    inputSchema: {
      text: z.string().describe("The text to search in"),
      character: z.string().describe("The character to count (single character)"),
    },
  },
    async ({ text, character }) => {
      // Validate server access
      if (!validateServerAccess(config.serverToken)) {
        throw new Error("Server access validation failed. Please provide a valid serverToken.");
      }
      
      // Apply user preferences from config
      const searchText = config.caseSensitive ? text : text.toLowerCase();
      const searchChar = config.caseSensitive ? character : character.toLowerCase();
      
      // Count occurrences of the specific character
      const count = searchText.split(searchChar).length - 1;

      return {
        content: [
          { 
            type: "text", 
            text: `The character "${character}" appears ${count} times in the text.` 
          }
        ],
      };
    }
  );

  return server.server;
}

Step 4: MCP Request Handler

Handle incoming MCP requests at the /mcp endpoint:
// src/index.ts
// Handle MCP requests at /mcp endpoint
app.all('/mcp', async (req: Request, res: Response) => {
  try {
    const result = parseAndValidateConfig(req, configSchema)
    if (result.error) {
      return res.status(result.value.status).json(result.value)
    }

    const server = createServer({ config: result.value });
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: undefined,
    });

    // Clean up on request close
    res.on('close', () => {
      transport.close();
      server.close();
    });

    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
  } catch (error) {
    console.error('Error handling MCP request:', error);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: '2.0',
        error: { code: -32603, message: 'Internal server error' },
        id: null,
      });
    }
  }
});

Step 5: Maintaining STDIO Compatibility (Optional)

Now that we have our HTTP server ready, we can optionally add STDIO support for backward compatibility. This gives you the best of both worlds:
  • HTTP deployment: Custom Express server with full control for scalable HTTP deployment
  • Local development: Keep the familiar STDIO transport for backwards compatibility
  • NPM distribution: Publish your server so others can install and run it locally
To enable STDIO support, add a condition to your main() function:
// src/index.ts
// Main function to start the server in the appropriate mode
async function main() {
  const transport = process.env.TRANSPORT || 'stdio';
  
  if (transport === 'http') {
    // Run in HTTP mode
    app.listen(PORT, () => {
      console.log(`MCP HTTP Server listening on port ${PORT}`);
    });
  } else {
    // Optional: if you need backward compatibility, add stdio transport
    const serverToken = process.env.SERVER_TOKEN;
    const caseSensitive = process.env.CASE_SENSITIVE === 'true';

    // Create server with configuration
    const server = createServer({
      config: {
        serverToken,
        caseSensitive,
      },
    });

    // Start receiving messages on stdin and sending messages on stdout
    const stdioTransport = new StdioServerTransport();
    await server.connect(stdioTransport);
    console.error("MCP Server running in stdio mode");
  }
}

// Start the server
main().catch((error) => {
  console.error("Server error:", error);
  process.exit(1);
});
How it works: When you deploy with custom containers, your Express server handles HTTP requests at the /mcp endpoint. The main() function only runs STDIO mode when you execute the file directly (like node dist/index.js), giving you STDIO support for local development.

Configuration Changes

Step 6: Update smithery.yaml

startCommand:
  type: stdio
  commandFunction: |
    (config) => ({
      command: 'node',
      args: ['dist/index.js'],
      env: {
        SERVER_TOKEN: config.serverToken,
        CASE_SENSITIVE: config.caseSensitive
      }
    })
  configSchema:
    type: object
    properties:
      serverToken:
        type: string
        description: Server access token
      caseSensitive:
        type: boolean
        description: Whether character matching should be case sensitive
        default: false
  exampleConfig:
    serverToken: "your-server-token"
    caseSensitive: false

Step 7: Update Dockerfile

FROM node:22-slim

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies
RUN npm ci --only=production

# Copy source code
COPY . .

# Build TypeScript code
RUN npm run build

# STDIO servers don't expose ports
CMD ["node", "dist/index.js"]

Step 8: Update package.json

Configure package.json for both HTTP and STDIO workflows:
{
  "name": "smithery-typescript-custom",
  "version": "1.0.0",
  "description": "Custom TypeScript MCP server with Express HTTP transport",
  "type": "module",
  "scripts": {
    "dev": "TRANSPORT=http npx tsx src/index.ts",
    "build": "npx tsc",
    "start": "npm run start:http",
    "start:http": "TRANSPORT=http node dist/index.js",  
    "start:stdio": "node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.18.2",
    "@smithery/sdk": "^1.6.4",
    "express": "^4.18.0",
    "cors": "^2.8.5",
    "zod": "^3.25.46"
  },
  "devDependencies": {
    "tsx": "^4.19.4",
    "@smithery/cli": "^1.4.0"
  }
}
Install your dependencies:
npm i @modelcontextprotocol/sdk @smithery/sdk express cors zod
npm i -D @smithery/cli tsx typescript

Local Testing

  1. Build and run your server:
    npm run build
    npm run start:http
    
  2. Or with Docker:
    docker build -t my-mcp-server .
    docker run -p 8080:8080 -e PORT=8080 my-mcp-server
    
Local vs Deployed Ports: For local testing, you can use any port (like 8080 above). However, when deployed to Smithery, your server must listen on the PORT environment variable, which Smithery will set to 8081.
  1. Test interactively: Once your server is running in HTTP mode, you can test it interactively using the Smithery playground:
    npx @smithery/cli playground --port 8080
    
    Smithery Interactive Playground
    Config Handling Limitation: The Smithery playground doesn’t currently support config handling for custom containers (Python/TypeScript). Your deployed server will support configuration, but local testing with the playground will use default/empty config values. We’re actively working on adding this support.

Deployment

  1. Push Code: Push your updated code (including Dockerfile and smithery.yaml) to GitHub
  2. Deploy: Go to smithery.ai/new and connect your GitHub repository
  3. Verify: Test your deployed server through the Smithery interface
Smithery will automatically build your container and host your MCP server.

Summary

This guide showed you how to migrate a TypeScript MCP server from STDIO to HTTP transport using custom Docker containers. You’ll implement an Express server with proper CORS configuration, handle MCP requests at the /mcp endpoint, parse configuration from query parameters, and create a Dockerfile for containerized deployment. This approach gives you full control over your server environment and middleware while supporting both HTTP and STDIO transport modes.
Need help? Join our Discord or email support@smithery.ai for assistance.
I