MCP Best Practices
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.
Quick Reference
| Component | Current | Next |
|---|---|---|
| Spec | 2025-11-25 (spec.modelcontextprotocol.io) | - |
| TS SDK (stable) | v1.29.0 (@modelcontextprotocol/sdk) | v2 alpha published |
| TS SDK (v2) | Alpha (2.0.0-alpha.2 on npm, Apr 2026): /server, /client, /core, /hono, /express, /node, /fastify | Stable pending; only alpha published |
| JSON Schema | 2020-12 default (explicit $schema supported) | - |
| Transport | Streamable HTTP (remote), stdio (local) | SSE + WebSocket removed in v2 |
| Extensions | MCP Apps (Stable, SEP-1865), Auth Extensions (official) | Domain-specific WGs |
| Registry | Preview with v0.1 API freeze since 2025-10-24 (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";
Server Setup
Transport Decision
| 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 | - |
Stateless Pattern (recommended for remote deployment)
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):
- Zod schemas (they never change)
- Annotation objects (
{ readOnlyHint: true, ... }) - Tool description strings
- Payment configs, upstream API clients
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
Framework Integration
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).
Tool Design
Registration API
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) }],
};
});
Naming
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.
Schema Rules
.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 on v1.x (#1643). The fix landed in the v2 line (PR #1796); the v1.x backport (PR #2017) is still open, so the bug is present on every released v1 version. Use flatz.object()withz.enum()discriminator field instead.- Plain JSON Schema objects silently dropped before v1.28.0. Fixed in v1.28 - now throws at registration (#1596).
z.transform()stripped during conversion - JSON Schema can't represent transforms (#702).- Client-side AJV strict-mode rejection: Zod v4
z.object()produces JSON Schema withadditionalProperties: false. The SDK's client validatesstructuredContentagainstoutputSchemawith AJV strict mode and rejects extra fields. Server-side.parse()strips extras silently, but the originalstructuredContentis sent to the client unchanged - so the server thinks it's fine and the client errors. Fix: explicitly.parse()upstream data before assigning tostructuredContent, or use.passthrough()on schemas that intentionally pass through extra fields.
Annotations
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 | Inte |