Loading...
Loading...
Build MCP servers with TypeScript on Cloudflare Workers. Covers tools, resources, prompts, tasks, authentication (API keys, OAuth, Zero Trust), and Cloudflare service integrations. Prevents 20 documented errors. Use when exposing APIs to LLMs or troubleshooting export syntax errors, transport leaks, server instance reuse bugs, CORS misconfigurations, or task validation errors.
npx skill4agent add jezweb/claude-skills typescript-mcpnpm install @modelcontextprotocol/sdk@latest hono zod
npm install -D @cloudflare/workers-types wrangler typescriptStreamableHTTPServerTransportimport { 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 }wrangler deployapp.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();
});const jwt = c.req.header('Cf-Access-Jwt-Assertion');
const payload = await verifyJWT(jwt, c.env.CF_ACCESS_TEAM_DOMAIN);workinginput_requiredcompletedfailedcancelledconst server = new McpServer({
name: 'my-server',
version: '1.0.0',
capabilities: {
tasks: {
list: {},
cancel: {},
requests: {
tools: { call: {} }
}
}
}
});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) }] };
}
);{
"method": "tools/call",
"params": {
"name": "long-running-analysis",
"arguments": { "datasetId": "abc123" },
"task": { "ttl": 60000 }
}
}tasktaskIdtasks/gettaskIdcompletedtasks/resulttasks/cancel// 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...
}tools/listserver.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) }] };
});references/cloudflare-integration.md"Cannot read properties of undefined (reading 'map')"// ❌ WRONG - Causes cryptic build errors
export default { fetch: app.fetch };
// ✅ CORRECT - Direct export
export default app;app.post('/mcp', async (c) => {
const transport = new StreamableHTTPServerTransport(/*...*/);
// CRITICAL: Always close on response end
c.res.raw.on('close', () => transport.close());
// ... handle request
});ListTools request handler fails to generate inputSchema// ✅ 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 validationundefinedconst 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) }] };
}
);import { cors } from 'hono/cors';
app.use('/mcp', cors({
origin: ['http://localhost:3000', 'https://your-app.com'],
allowMethods: ['POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization']
}));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...
});Out of memorytsc# Add to package.json scripts
"build": "NODE_OPTIONS='--max-old-space-size=4096' tsc && vite build"// ❌ 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);
}AbortError: This operation was abortedServer.connect(transport)// ✅ 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!
});Type 'undefined' is not assignable to type '() => string'exactOptionalPropertyTypes: true// 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
});global.fetch// 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// 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
}
}
);"expected": "object", "received": "undefined"arguments// ❌ 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);MaxListenersExceededWarning: Possible EventEmitter memory leak detectedsendToolListChanged()// 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 APIonerror// ✅ 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);qsarrayLimit?foo[99999999]=bar// 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
});// 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;
}
}
);can't resolve reference #/$defs/...cacheToolOutputSchemaslistTools()# Local
wrangler dev # http://localhost:8787/mcp
# Production
wrangler deploynpx @modelcontextprotocol/inspectorbasic-mcp-server.tstool-server.tsresource-server.tsauthenticated-server.tstasks-server.tswrangler.jsonctool-patterns.mdauthentication-guide.mdtesting-guide.mdcloudflare-integration.mdcommon-errors.mdMcpServertransport.onerrorc.res.raw.on('close', () => transport.close())export default app{ fetch: app.fetch }StreamableHTTPServerTransportMcpServertransport.onerror