From tenequm-skills
Build production MCP servers with the TypeScript SDK. Covers spec 2025-11-25, SDK v1.28+/v2, transport selection, tool design, error handling, security, performance, known bugs with workarounds, MCP extensions, MCP Apps (interactive UIs), authorization extensions, and the MCP Registry. Use this skill whenever building MCP servers, designing MCP tools, choosing MCP transports, handling MCP errors, migrating to MCP v2, reviewing MCP security, optimizing MCP token usage, building MCP Apps, using MCP extensions, publishing to the MCP Registry, or working with registerTool, McpServer, streamable HTTP, outputSchema, structuredContent, tool annotations, ext-apps, or ext-auth.
npx claudepluginhub tenequm/skills --plugin gh-cliThis skill uses the workspace's default tool permissions.
Decision reference for building production MCP servers with the TypeScript SDK. Not a tutorial - assumes you already have a working server and need to make it correct, fast, and secure.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Decision reference for building production MCP servers with the TypeScript SDK. Not a tutorial - assumes you already have a working server and need to make it correct, fast, and secure.
| Component | Current | Next |
|---|---|---|
| Spec | 2025-11-25 (spec.modelcontextprotocol.io) | - |
| TS SDK (stable) | v1.28.0 (@modelcontextprotocol/sdk) | v2 pre-alpha on main |
| TS SDK (v2) | Pre-alpha (@modelcontextprotocol/server, /client, /core) | Q1 2026 stable |
| JSON Schema | 2020-12 default (explicit $schema supported) | - |
| Transport | Streamable HTTP (remote), stdio (local) | SSE removed in v2 |
| Extensions | MCP Apps (GA), Auth Extensions (official) | Domain-specific WGs |
| Registry | Preview (registry) | GA pending |
v1 imports (production today):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
v2 imports (when stable):
import { McpServer } from "@modelcontextprotocol/server";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server";
| Scenario | Transport | Key Config |
|---|---|---|
| Remote, stateless (K8s, CF Workers) | WebStandardStreamableHTTPServerTransport | sessionIdGenerator: undefined, enableJsonResponse: true |
| Remote, stateful (long tasks, SSE) | WebStandardStreamableHTTPServerTransport | sessionIdGenerator: () => randomUUID() |
| Local CLI / Claude Desktop | StdioServerTransport | Default |
| Legacy SSE clients | SSE removed in v2 - migrate to Streamable HTTP | - |
Per-request server+transport creation is the canonical pattern. Maintainer @ihrpr confirms: "each transport should have an instance of MCPServer" (#343). Sharing instances leaks cross-client data (GHSA-345p-7cg4-v4c7).
app.post("/mcp", async (c) => {
const server = new McpServer({ name: "my-server", version: "1.0.0" });
// Register tools, resources, prompts...
registerTools(server);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless - no session tracking
enableJsonResponse: true, // JSON responses, no SSE streaming
});
// All tools/resources must be registered before connect() (#893)
try {
await server.connect(transport);
return transport.handleRequest(c.req.raw);
} finally {
await transport.close();
await server.close();
}
});
What to hoist to module level (don't recreate per request):
{ readOnlyHint: true, ... })The McpServer itself must be per-request, but its constant inputs should not be.
For deep dive on transports, sessions, HTTP/2 gotchas, and K8s deployment: see
references/transport-patterns.md
Hono (web-standard):
import { Hono } from "hono";
const app = new Hono();
app.post("/mcp", handleMcpRequest); // WebStandardStreamableHTTPServerTransport
app.get("/mcp", handleMcpSse); // Optional: SSE for server notifications
app.delete("/mcp", handleMcpDelete); // Optional: session termination
Cloudflare Workers: Same pattern - WebStandardStreamableHTTPServerTransport works natively in Workers runtime.
Express/Node (v2): Use @modelcontextprotocol/express middleware with NodeStreamableHTTPServerTransport (wraps the Web Standard transport for IncomingMessage/ServerResponse).
v1 (current stable) - server.tool() works but has ambiguous overloads. Prefer the config-object form when possible:
server.tool("search_docs", "Search documents", {
query: z.string().describe("Search query"),
max_results: z.number().optional().describe("Max results (default 20)"),
}, { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
async ({ query, max_results }) => { /* handler */ }
);
v2 (migration target) - registerTool() with config object:
server.registerTool("search_docs", {
title: "Document Search",
description: "Search documents by keyword or phrase",
inputSchema: z.object({
query: z.string().describe("Search query"),
max_results: z.number().optional().describe("Max results (default 20)"),
}),
outputSchema: z.object({
results: z.array(z.object({ id: z.string(), text: z.string() })),
has_more: z.boolean(),
}),
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
}, async ({ query, max_results }) => {
const result = await fetchDocs(query, max_results);
return {
structuredContent: result,
content: [{ type: "text", text: JSON.stringify(result) }],
};
});
Spec (2025-11-25): 1-128 chars, case-sensitive. Allowed: A-Za-z0-9_-.
DO: search_docs, get_user_profile, admin.tools.list
DON'T: search (too generic, collides across servers), Search Docs (spaces not allowed)
Service-prefix your tools (github_*, jira_*) when multiple servers are active - LLMs confuse generic names across servers.
.describe() on every field - this is what LLMs use for argument generation.
For complete Zod-to-JSON-Schema conversion rules, what breaks silently, outputSchema/structuredContent patterns: see
references/tool-schema-guide.md
Critical bugs:
z.union() / z.discriminatedUnion() silently produce empty schemas (#1643). Use flat z.object() with z.enum() discriminator field instead.z.transform() stripped during conversion - JSON Schema can't represent transforms (#702).All are optional hints (untrusted from untrusted servers per spec):
| Annotation | Default | Meaning |
|---|---|---|
readOnlyHint | false | Tool doesn't modify its environment |
destructiveHint | true | May perform destructive updates (only when readOnly=false) |
idempotentHint | false | Repeated calls with same args have no additional effect |
openWorldHint | true | Interacts with external entities (APIs, web) |
Set them accurately - clients use them for consent prompts and auto-approval decisions.
Open SEPs expanding annotations:
#1913 Trust and Sensitivity - data classification hints#1984 Comprehensive annotations for governance/UX#1561 unsafeOutputHint - output may contain untrusted content#1560 secretHint - tool handles secrets/credentials#1487 trustedHint - server attestation of tool trustworthinessThe "Lethal Trifecta": Combining (1) access to private data + (2) exposure to untrusted content + (3) external communication ability creates data theft conditions. Researchers demonstrated this with a malicious calendar event, an MCP calendar server, and a code execution tool. Design tool sets to avoid granting all three simultaneously.
Evaluation framework for new annotation proposals:
_meta handle it? (Namespaced metadata better for single-deployment needs)Two distinct mechanisms with different LLM visibility:
| Type | LLM Sees It? | Use For |
|---|---|---|
Tool error (isError: true in CallToolResult) | Yes - enables self-correction | Input validation, API failures, business logic errors |
| Protocol error (JSON-RPC error response) | Maybe - clients MAY expose | Unknown tool, malformed request, server crash |
Per SEP-1303 (merged into spec 2025-11-25): input validation errors MUST be tool execution errors, not protocol errors. The LLM needs to see "date must be in the future" to self-correct.
// DO: Tool execution error - LLM can self-correct
return {
isError: true,
content: [{ type: "text", text: "Date must be in the future. Current date: 2026-03-25" }],
};
// DON'T: Protocol error for validation - LLM can't see this
throw new McpError(ErrorCode.InvalidParams, "Invalid date");
Known bug: The SDK loses error.data when converting McpError to tool results (PR #1075). If you embed structured data in McpError's data field, it may not reach the client. Use isError: true tool results with structured content instead.
For full error taxonomy, code examples, and payment error patterns: see
references/error-handling.md
Set in the initialization response - acts as a system-level hint to the LLM about how to use your server:
const server = new McpServer({
name: "docs-api",
version: "1.0.0",
instructions: "Knowledge base API. Use search_docs for full-text search, get_doc for retrieval by ID. All tools are read-only.",
});
Expose documentation or structured data via docs:// URI scheme:
server.resource("search-operators", "docs://search-operators", {
title: "Search Operators Guide",
description: "Supported search operators and syntax",
mimeType: "text/markdown",
}, async () => ({
contents: [{ uri: "docs://search-operators", text: operatorsMarkdown }],
}));
The McpServer must be per-request, but everything else can be shared:
// Module-level (created once)
const SCHEMAS = {
search: z.object({ query: z.string().describe("Search query") }),
fetch: z.object({ id: z.string().describe("Resource ID") }),
};
const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true,
} as const;
// Per-request (created each time)
function createMcpServer(ctx: Context) {
const server = new McpServer({ name: "my-server", version: "1.0.0" });
server.tool("search", "Search", SCHEMAS.search, READ_ONLY_ANNOTATIONS, handler);
return server;
}
Tool definitions consume context window before any conversation starts. GitHub MCP: 20,444 tokens for 80 tools (SEP-1576).
Strategies:
track_order(email) not get_user + list_orders + get_status).outputSchema + structuredContent - lets clients process data programmatically without LLM parsing overhead.?tools=search,fetch query parameter).For tools with no inputs, use explicit empty schema:
inputSchema: { type: "object" as const, additionalProperties: false }
| Attack | Example | Mitigation |
|---|---|---|
| Tool poisoning | Hidden instructions in descriptions (WhatsApp MCP, Apr 2025) | Review tool descriptions; clients should display them |
| Supply chain | Malicious npm packages (Smithery breach, Oct 2025) | Pin versions, audit dependencies |
| Command injection | child_process.exec with unsanitized input (CVE-2025-53967) | Never interpolate user input into shell commands |
| Cross-server shadowing | Malicious server overrides legitimate tool names | Service-prefix tool names; validate tool sources |
| Token theft | Over-privileged PATs with broad scopes | Minimal scopes; OAuth 2.1 Resource Indicators (RFC 8707) |
| Token passthrough | Server accepts/forwards tokens not issued for it | Validate audience claim; never transit client tokens to upstream APIs |
| SSRF | Malicious OAuth metadata URLs targeting internal services | HTTPS enforcement, block private IPs, validate redirect targets |
| Confused deputy | Proxy server consent cookies exploited via DCR | Per-client consent before forwarding to third-party auth |
| Session hijacking | Stolen/guessed session IDs for impersonation | Cryptographically random IDs, bind to user identity, never use for auth |
Origin header - respond 403 for invalid origins (2025-11-25 requirement)MCP-Protocol-Version header on all requests after initialization (spec 2025-06-18+)MCP normatively requires OAuth 2.1 (draft-ietf-oauth-v2-1-13). The spec states: "Authorization servers MUST implement OAuth 2.1." PKCE is mandatory, implicit flow is removed. Always build against OAuth 2.1 - not 2.0.
MCP servers are OAuth 2.1 Resource Servers. Clients MUST include Resource Indicators (RFC 8707) binding tokens to specific servers. Key requirements:
S256 code challenge methodWWW-Authenticate challengesFor full security attack/mitigation patterns and auth implementation details: see
references/security-auth.md
| Issue | Severity | Status | Workaround |
|---|---|---|---|
#1643 - z.union()/z.discriminatedUnion() silently dropped | High | Open | Use flat z.object() + z.enum() |
| #1699 - Transport closure stack overflow (15-25+ concurrent) | High | Open | uncaughtException handler + process restart |
| #1619 - HTTP/2 + SSE Content-Length error | Medium | Open | Use enableJsonResponse: true or avoid HTTP/2 upstream |
| #893 - Dynamic registration after connect blocked | Medium | Open | Register all tools/resources before connect() |
| #1596 - Plain JSON Schema silently dropped | Fixed | v1.28.0 | Upgrade to v1.28+ |
| GHSA-345p-7cg4-v4c7 - Shared instances leak cross-client data | Critical | v1.26.0 | Per-request server+transport (the canonical pattern) |
For comprehensive migration guide with all breaking changes and before/after code: see
references/v2-migration.md
Key breaking changes:
@modelcontextprotocol/sdk -> @modelcontextprotocol/server + /client + /coreMcpError -> ProtocolError (from @modelcontextprotocol/core)extra parameter -> structured ctx with ctx.mcpReqserver.tool() -> registerTool() (config object, not positional args)@modelcontextprotocol/hono and @modelcontextprotocol/express middleware packagesv1.x gets 6 more months of support after v2 stable ships. No rush, but write new code with v2 patterns in mind.
MCP extensions are optional, strictly additive capabilities on top of the core protocol. Both sides negotiate support during initialization via extensions in capabilities.
Identifiers: {vendor-prefix}/{extension-name}. Official: io.modelcontextprotocol/*. Third-party: reversed domain (e.g., com.example/my-ext).
| Extension | Identifier | Purpose |
|---|---|---|
| MCP Apps | io.modelcontextprotocol/ui | Interactive HTML UIs in chat (charts, forms, dashboards) |
| OAuth Client Credentials | io.modelcontextprotocol/oauth-client-credentials | Machine-to-machine auth (CI/CD, daemons, server-to-server) |
| Enterprise-Managed Auth | io.modelcontextprotocol/enterprise-managed-authorization | Centralized access control via enterprise IdP |
Client support: Claude (web + Desktop), ChatGPT, VS Code Copilot, Goose, Postman, MCPJam all support MCP Apps. Auth extensions not yet widely adopted.
For MCP Apps architecture, ext-apps SDK, and build patterns: see
references/mcp-apps.mdFor extensions system, auth extensions, and MCP Registry: seereferences/extensions-registry.md
| Capability | Purpose | v2 API |
|---|---|---|
| Elicitation | Request structured user input mid-tool | ctx.mcpReq.elicitInput() |
| Sampling | Request LLM completion from client | ctx.mcpReq.requestSampling() |
| Tasks (SEP-1686) | Long-running ops with lifecycle management | Pending |
| Progress | Incremental progress on requests | ctx.mcpReq.sendProgress() |