typescript-mcp
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: working ā input_required ā completed / 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:
- Client sends request with
taskparam ā receivestaskId - Client polls via
tasks/getwithtaskId - When status is
completed, client callstasks/resultto get output - Optional: Client can
tasks/cancelto 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
McpServerinstance per HTTP request (never reuse across sessions) - ā
Set
transport.onerrorhandler 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
StreamableHTTPServerTransportfor production (SSE is deprecated)
Never:
- ā Reuse
McpServerinstance across concurrent HTTP sessions - ā Export with object wrapper
- ā Forget to close StreamableHTTPServerTransport
- ā Omit
transport.onerrorhandler - ā Log environment variables or secrets
- ā Use outdated SDK versions (<1.23.0 has schema bugs, <1.25.3 has fetch pollution)