typescript-mcp

jezweb's avatarfrom jezweb

Build stateless MCP servers with TypeScript on Cloudflare Workers using @modelcontextprotocol/sdk. Provides patterns for tools, resources, prompts, and authentication (API keys, OAuth, Zero Trust).Use when exposing APIs to LLMs, integrating Cloudflare services (D1, KV, R2, Vectorize), or troubleshooting export syntax errors, unclosed transport leaks, or CORS misconfigurations.

109stars🔀16forks📁View on GitHub🕐Updated Dec 28, 2025

When & Why to Use This Skill

This Claude skill provides a comprehensive framework and production-ready patterns for building stateless Model Context Protocol (MCP) servers using TypeScript on Cloudflare Workers. It streamlines the integration of LLMs with external tools and Cloudflare services (D1, KV, R2) while proactively preventing over 20 documented production errors, including memory leaks, CORS misconfigurations, and transport-level bugs.

Use Cases

  • Edge-Based AI Tooling: Deploying low-latency MCP servers on Cloudflare's global network to provide LLMs with real-time access to edge databases and vector storage.
  • Secure API Exposure: Implementing robust authentication patterns such as API Keys, OAuth, and Cloudflare Zero Trust to safely expose internal business logic to AI agents.
  • Asynchronous Task Management: Utilizing the MCP Tasks API (v1.24.0+) to handle long-running computations or batch processing that require status polling and result retrieval.
  • Server-Side Agent Orchestration: Leveraging sampling with tools to enable complex reasoning loops and multi-step tool execution directly within the server infrastructure.
  • Production Debugging & Optimization: Troubleshooting common MCP deployment issues like export syntax errors, unclosed transport leaks, and TypeScript compilation memory exhaustion.
nametypescript-mcp
description|
user-invocabletrue
allowed-tools[Read, Write, Edit, Bash, Grep, Glob]

TypeScript MCP on Cloudflare Workers

Last Updated: 2026-01-21 Versions: @modelcontextprotocol/sdk@1.25.3, hono@4.11.3, zod@4.3.5 Spec Version: 2025-11-25


Quick Start

npm install @modelcontextprotocol/sdk@latest hono zod
npm install -D @cloudflare/workers-types wrangler typescript

Transport Recommendation: Use StreamableHTTPServerTransport for production. SSE transport is deprecated and maintained for backwards compatibility only. Streamable HTTP provides better error recovery, bidirectional communication, and simplified deployment.

Basic MCP Server:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { Hono } from 'hono';
import { z } from 'zod';

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

server.registerTool(
  'echo',
  {
    description: 'Echoes back input',
    inputSchema: z.object({ text: z.string() })
  },
  async ({ text }) => ({ content: [{ type: 'text', text }] })
);

const app = new Hono();

app.post('/mcp', async (c) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true
  });

  // CRITICAL: Set error handler to catch transport errors
  transport.onerror = (error) => {
    console.error('MCP transport error:', error);
  };

  // CRITICAL: Close transport to prevent memory leaks
  c.res.raw.on('close', () => transport.close());

  await server.connect(transport);
  await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
  return c.body(null);
});

export default app; // CRITICAL: Direct export, not { fetch: app.fetch }

Deploy: wrangler deploy


Authentication

API Key (KV-based):

app.use('/mcp', async (c, next) => {
  const apiKey = c.req.header('Authorization')?.replace('Bearer ', '');
  const isValid = await c.env.MCP_API_KEYS.get(`key:${apiKey}`);
  if (!isValid) return c.json({ error: 'Unauthorized' }, 403);
  await next();
});

Cloudflare Zero Trust:

const jwt = c.req.header('Cf-Access-Jwt-Assertion');
const payload = await verifyJWT(jwt, c.env.CF_ACCESS_TEAM_DOMAIN);

Tasks (v1.24.0+)

Tasks enable long-running operations that return a handle for polling results later. Useful for expensive computations, batch processing, or operations that may need input.

Task States: workinginput_requiredcompleted / failed / cancelled

Server Capability Declaration:

const server = new McpServer({
  name: 'my-server',
  version: '1.0.0',
  capabilities: {
    tasks: {
      list: {},
      cancel: {},
      requests: {
        tools: { call: {} }
      }
    }
  }
});

Tool with Task Support:

server.registerTool(
  'long-running-analysis',
  {
    description: 'Analyze large dataset',
    inputSchema: z.object({ datasetId: z.string() }),
    execution: { taskSupport: 'optional' }  // 'forbidden' | 'optional' | 'required'
  },
  async ({ datasetId }, extra) => {
    // If invoked as task, extra.task contains taskId
    const result = await performAnalysis(datasetId);
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  }
);

Client Task Request:

{
  "method": "tools/call",
  "params": {
    "name": "long-running-analysis",
    "arguments": { "datasetId": "abc123" },
    "task": { "ttl": 60000 }
  }
}

Task Lifecycle:

  1. Client sends request with task param → receives taskId
  2. Client polls via tasks/get with taskId
  3. When status is completed, client calls tasks/result to get output
  4. Optional: Client can tasks/cancel to abort

📚 Spec: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks


Sampling with Tools (v1.24.0+)

Servers can now include tool definitions in sampling requests, enabling server-side agent loops.

Use Case: Server needs to orchestrate multi-step reasoning using LLM + tools without custom frameworks.

// Server initiates sampling with tools available
const result = await server.requestSampling({
  messages: [{ role: 'user', content: 'Analyze this data and fetch more if needed' }],
  maxTokens: 4096,
  tools: [
    {
      name: 'fetch_data',
      description: 'Fetch additional data from API',
      inputSchema: { type: 'object', properties: { query: { type: 'string' } } }
    }
  ]
});

// Handle tool calls in response
if (result.content[0].type === 'tool_use') {
  const toolResult = await executeLocalTool(result.content[0]);
  // Continue conversation with tool result...
}

Key Points:

  • Server-side agentic behavior as first-class MCP feature
  • Standard MCP primitives (no custom frameworks)
  • Tool definitions follow same schema as tools/list

📚 Spec: SEP-1577


Cloudflare Service Tools

D1 Database:

server.registerTool('query-db', {
  inputSchema: z.object({ query: z.string(), params: z.array(z.union([z.string(), z.number()])).optional() })
}, async ({ query, params }, env) => {
  const result = await env.DB.prepare(query).bind(...(params || [])).all();
  return { content: [{ type: 'text', text: JSON.stringify(result.results) }] };
});

KV, R2, Vectorize: See references/cloudflare-integration.md


Known Issues Prevention

This skill prevents 20 production issues documented in official MCP SDK and Cloudflare repos:

Issue #1: Export Syntax Issues (CRITICAL)

Error: "Cannot read properties of undefined (reading 'map')" Source: honojs/hono#3955, honojs/vite-plugins#237 Why It Happens: Incorrect export format with Vite build causes cryptic errors Prevention:

// ❌ WRONG - Causes cryptic build errors
export default { fetch: app.fetch };

// ✅ CORRECT - Direct export
export default app;

Issue #2: Unclosed Transport Connections

Error: Memory leaks, hanging connections Source: Best practice from SDK maintainers Why It Happens: Not closing StreamableHTTPServerTransport on request end Prevention:

app.post('/mcp', async (c) => {
  const transport = new StreamableHTTPServerTransport(/*...*/);

  // CRITICAL: Always close on response end
  c.res.raw.on('close', () => transport.close());

  // ... handle request
});

Issue #3: Tool Schema Validation Failure

Error: ListTools request handler fails to generate inputSchema Source: GitHub modelcontextprotocol/typescript-sdk#1028 Why It Happens: Zod schemas not properly converted to JSON Schema Prevention:

// ✅ CORRECT - SDK handles Zod schema conversion automatically
server.registerTool(
  'tool-name',
  {
    inputSchema: z.object({ a: z.number() })
  },
  handler
);

// No need for manual zodToJsonSchema() unless custom validation

Issue #4: Tool Arguments Not Passed to Handler

Error: Handler receives undefined arguments Source: GitHub modelcontextprotocol/typescript-sdk#1026 Why It Happens: Schema type mismatch between registration and invocation Prevention:

const schema = z.object({ a: z.number(), b: z.number() });
type Input = z.infer<typeof schema>;

server.registerTool(
  'add',
  { inputSchema: schema },
  async (args: Input) => {
    // args.a and args.b properly typed and passed
    return { content: [{ type: 'text', text: String(args.a + args.b) }] };
  }
);

Issue #5: CORS Misconfiguration

Error: Browser clients can't connect to MCP server Source: Common production issue Why It Happens: Missing CORS headers for HTTP transport Prevention:

import { cors } from 'hono/cors';

app.use('/mcp', cors({
  origin: ['http://localhost:3000', 'https://your-app.com'],
  allowMethods: ['POST', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization']
}));

Issue #6: Missing Rate Limiting

Error: API abuse, DDoS vulnerability Source: Production security best practice Why It Happens: No rate limiting on MCP endpoints Prevention:

app.post('/mcp', async (c) => {
  const ip = c.req.header('CF-Connecting-IP');
  const rateLimitKey = `ratelimit:${ip}`;

  const count = await c.env.CACHE.get(rateLimitKey);
  if (count && parseInt(count) > 100) {
    return c.json({ error: 'Rate limit exceeded' }, 429);
  }

  await c.env.CACHE.put(
    rateLimitKey,
    String((parseInt(count || '0') + 1)),
    { expirationTtl: 60 }
  );

  // Continue...
});

Issue #7: TypeScript Compilation Memory Issues

Error: Out of memory during tsc build Source: GitHub modelcontextprotocol/typescript-sdk#985 Why It Happens: Large dependency tree in MCP SDK Prevention:

# Add to package.json scripts
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsc && vite build"

Issue #8: UriTemplate ReDoS Vulnerability

Error: Server hangs on malicious URI patterns Source: GitHub modelcontextprotocol/typescript-sdk#965 (Security) Why It Happens: Regex denial-of-service in URI template parsing Prevention: Update to SDK v1.20.2 or later (includes fix)

Issue #9: Authentication Bypass

Error: Unauthenticated access to MCP tools Source: Production security best practice Why It Happens: Missing or improperly implemented authentication Prevention: Always implement authentication for production servers (see Authentication Patterns section)

Issue #10: Environment Variable Leakage

Error: Secrets exposed in error messages or logs Source: Cloudflare Workers security best practice Why It Happens: Environment variables logged or returned in responses Prevention:

// ❌ WRONG - Exposes secrets
console.log('Env:', JSON.stringify(env));

// ✅ CORRECT - Never log env objects
try {
  // ... use env.SECRET_KEY
} catch (error) {
  // Don't include env in error context
  console.error('Operation failed:', error.message);
}

Issue #11: Server Instance Reuse Breaks Concurrent HTTP Sessions (CRITICAL)

Error: AbortError: This operation was aborted Source: GitHub Issue #1405 Why It Happens: Calling Server.connect(transport) silently overwrites the previous transport without warning, breaking all earlier connections Prevention:

// ✅ CORRECT - Create fresh McpServer per HTTP session
app.post('/mcp', async (c) => {
  const server = new McpServer({ name: 'my-server', version: '1.0.0' });

  // Register tools per request
  server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) },
    async ({ text }) => ({ content: [{ type: 'text', text }] })
  );

  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true
  });

  transport.onerror = (error) => console.error('Transport error:', error);
  c.res.raw.on('close', () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(c.req.raw, c.res.raw, await c.req.json());
  return c.body(null);
});

// ❌ WRONG - Reusing server instance across sessions
const sharedServer = new McpServer({ name: 'my-server', version: '1.0.0' });
app.post('/mcp', async (c) => {
  await sharedServer.connect(transport); // Breaks previous sessions!
});

Issue #12: sessionIdGenerator Type Error with TypeScript Strict Mode

Error: Type 'undefined' is not assignable to type '() => string' Source: GitHub Issue #1397 Why It Happens: SDK 1.25.2 types break projects using exactOptionalPropertyTypes: true in tsconfig.json Prevention:

// With exactOptionalPropertyTypes: true

// ✅ CORRECT - Omit the property instead of setting to undefined
const transport = new StreamableHTTPServerTransport({
  enableJsonResponse: true
  // sessionIdGenerator omitted entirely
});

// ❌ WRONG - Setting to undefined causes type error in SDK 1.25.2
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined,  // Type error!
  enableJsonResponse: true
});

// Alternative: Provide a generator function
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => crypto.randomUUID(),
  enableJsonResponse: true
});

Issue #13: Global fetch Pollution from Hono (SDK 1.25.0-1.25.2)

Error: Native Node.js fetch behavior breaks after importing SDK Source: GitHub Issue #1376 Why It Happens: Hono's server code globally overwrites global.fetch, breaking libraries expecting native behavior Prevention:

// FIXED in SDK v1.25.3 - Update to latest version
npm install @modelcontextprotocol/sdk@1.25.3

// Workaround for older versions (1.25.0-1.25.2):
const nativeFetch = global.fetch;
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
global.fetch = nativeFetch; // Restore if needed

Issue #14: Task Error Wrapping Masks Validation Errors

Error: Confusing error message hides actual validation failure Source: GitHub Issue #1385 Why It Happens: When task-augmented tool call fails validation before task creation, SDK wraps error incorrectly Prevention:

// Expected error for invalid input:
// "Invalid arguments: Too small: expected number to be >=500"

// Actual error (confusing):
// "Invalid task creation result: expected object, received undefined"

// WORKAROUND: Add explicit validation before task logic
server.experimental.tasks.registerToolTask(
  'batch_process',
  {
    inputSchema: z.object({
      itemCount: z.number().min(1).max(10),
      processingTimeMs: z.number().min(500).max(5000).optional()
    })
  },
  {
    createTask: async (args, extra) => {
      // SDK should fix this - currently no workaround
      // Validation errors are masked by task wrapping
    }
  }
);

Issue #15: Tool Schema with All Optional Fields Causes InvalidParams

Error: "expected": "object", "received": "undefined" Source: GitHub Issue #400 Why It Happens: Some LLM clients omit arguments field when all schema properties are optional Prevention:

// ❌ WRONG - All optional fields may cause issues
server.registerTool('fetch-records', {
  inputSchema: z.object({
    limit: z.number().optional()
  })
}, handler);

// ✅ CORRECT - Always include at least one required field
server.registerTool('fetch-records', {
  inputSchema: z.object({
    action: z.literal('fetch').default('fetch'),  // Required
    limit: z.number().optional()
  })
}, handler);

// Alternative: Use empty object schema
server.registerTool('fetch-records', {
  inputSchema: z.object({}).passthrough()
}, handler);

Issue #16: Bulk Tool Registration Triggers EventEmitter Memory Leak Warnings

Error: MaxListenersExceededWarning: Possible EventEmitter memory leak detected Source: GitHub Issue #842 Why It Happens: Registering 80+ tools in a loop overwhelms stdout buffer with rapid sendToolListChanged() notifications Prevention:

// Workaround: Increase maxListeners before bulk registration
process.stdout.setMaxListeners(100);

const tools = [...]; // Array of 80+ tool definitions
for (const tool of tools) {
  server.registerTool(tool.name, tool.schema, tool.handler);
}

// Future SDK may provide batch registration API

Issue #17: Silent Transport Errors Without onerror Handler

Error: Transport errors vanish without logs or exceptions Source: GitHub Issue #1395 Why It Happens: SDK silently swallows transport errors if onerror callback is not set Prevention:

// ✅ CORRECT - Always set onerror handler
const transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableJsonResponse: true
});

transport.onerror = (error) => {
  console.error('Transport error:', error);
  // Handle error appropriately
};

await server.connect(transport);

Issue #18: DoS via Query String Array Limit Bypass

Error: Memory exhaustion from malicious query parameters Source: GitHub Issue #1368 Why It Happens: The qs library's arrayLimit can be bypassed using bracket notation like ?foo[99999999]=bar Prevention:

// Validate query parameters to prevent DoS
app.post('/mcp', async (c) => {
  const queryParams = c.req.query();

  // Reject malicious patterns
  if (Object.keys(queryParams).some(key => /\[\d{6,}\]/.test(key))) {
    return c.json({ error: 'Invalid query parameters' }, 400);
  }

  // ... handle request
});

Issue #19: Request Handlers Not Cancelled on Transport Close

Error: Long-running handlers continue executing after client disconnect, wasting resources Source: GitHub Issue #611 Why It Happens: SDK doesn't automatically cancel request handlers when transport connection closes Prevention:

// Workaround: Use AbortController pattern manually
server.registerTool(
  'long-running-task',
  { inputSchema: z.object({ duration: z.number() }) },
  async ({ duration }, extra) => {
    const abortController = new AbortController();

    // Listen for transport close
    const transport = extra.transport;
    if (transport) {
      const originalOnClose = transport.onclose;
      transport.onclose = () => {
        abortController.abort();
        if (originalOnClose) originalOnClose();
      };
    }

    try {
      await longRunningTask(duration, abortController.signal);
      return { content: [{ type: 'text', text: 'Done' }] };
    } catch (error) {
      if (error.name === 'AbortError') {
        return { content: [{ type: 'text', text: 'Cancelled' }], isError: true };
      }
      throw error;
    }
  }
);

Issue #20: $defs Schema References Failed in SDK 1.22.0-1.22.x

Error: can't resolve reference #/$defs/... Source: GitHub Issue #1175 Why It Happens: SDK 1.22.0 regression in cacheToolOutputSchemas broke listTools() with complex JSON Schema Prevention: Update to SDK v1.23.0 or later (fixed). If on 1.22.x, upgrade immediately.


Deployment

# Local
wrangler dev  # http://localhost:8787/mcp

# Production
wrangler deploy

Testing: npx @modelcontextprotocol/inspector (connect to http://localhost:8787/mcp)


Templates & References

Templates: basic-mcp-server.ts, tool-server.ts, resource-server.ts, authenticated-server.ts, tasks-server.ts, wrangler.jsonc

References: tool-patterns.md, authentication-guide.md, testing-guide.md, cloudflare-integration.md, common-errors.md


Critical Rules

Always:

  • ✅ Create fresh McpServer instance per HTTP request (never reuse across sessions)
  • ✅ Set transport.onerror handler to catch silent errors
  • ✅ Close transport on response end (c.res.raw.on('close', () => transport.close()))
  • ✅ Use direct export (export default app, NOT { fetch: app.fetch })
  • ✅ Implement authentication for production
  • ✅ Update to SDK v1.25.3+ for security fixes, Tasks support, and fetch pollution fix
  • ✅ Include at least one required field in tool schemas (avoid all-optional)
  • ✅ Use StreamableHTTPServerTransport for production (SSE is deprecated)

Never:

  • ❌ Reuse McpServer instance across concurrent HTTP sessions
  • ❌ Export with object wrapper
  • ❌ Forget to close StreamableHTTPServerTransport
  • ❌ Omit transport.onerror handler
  • ❌ Log environment variables or secrets
  • ❌ Use outdated SDK versions (<1.23.0 has schema bugs, <1.25.3 has fetch pollution)