skills/jezweb/claude-skills/typescript-mcp

# typescript-mcp

SKILL.md

# 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:

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)

Weekly Installs
338
Repository
jezweb/claude-skills
First Seen
Jan 19, 2026
Security Audits
Gen Agent Trust Hub Warn
Socket Pass
Snyk Warn
Installed on
claude-code 281
opencode 226
gemini-cli 221
cursor 214
antigravity 201
codex 198