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.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 |
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_tweets", "Search Twitter", {
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_tweets", {
title: "Tweet Search",
description: "Search Twitter posts 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({
tweets: 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 fetchTweets(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: surf_twitter_search, get_user_profile, admin.tools.list
DON'T: search (too generic, collides across servers), Search Tweets (spaces not allowed)
Service-prefix your tools (surf_twitter_*, surf_reddit_*) 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 (#1643). 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).
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 | Interacts with external entities (APIs, web) |
Set them accurately - clients use them for consent prompts and auto-approval decisions.
Error Handling
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
Resources and Instructions
Server Instructions
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: "surf-api",
version: "1.0.0",
instructions: "Data API for Twitter, Reddit, and web search. Use surf_twitter_search for social media, surf_web_search for general queries. All tools are read-only and paid via x402.",
});
Resource Registration
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 }],
}));
Performance
Module-Level Caching
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;
}
Token Bloat Mitigation
Tool definitions consume context window before any conversation starts. GitHub MCP: 20,444 tokens for 80 tools (SEP-1576).
Strategies:
- 5-15 tools per server - community sweet spot. Split beyond that.
- Outcome-oriented tools - bundle multi-step operations into single tools (e.g.,
track_order(email)notget_user+list_orders+get_status). - Response granularity - return curated results, not raw API dumps. 800-token user object vs 20-token summary.
outputSchema+structuredContent- lets clients process data programmatically without LLM parsing overhead.- Dynamic tool loading - register only relevant tool subsets based on request context (e.g.,
?tools=search,fetchquery parameter).
No-Parameter Tools
For tools with no inputs, use explicit empty schema:
inputSchema: { type: "object" as const, additionalProperties: false }
Security
Top Threats (real-world incidents, 2025)
| 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 Resource Indicators (RFC 8707) |
Server-Side Requirements (spec normative)
- Validate all inputs at tool boundaries
- Implement access controls per user/session
- Rate limit tool invocations
- Sanitize outputs before returning to client
- Validate
Originheader - respond 403 for invalid origins (2025-11-25 requirement) - Require
MCP-Protocol-Versionheader on all requests after initialization (spec 2025-06-18+) - Bind local servers to localhost (127.0.0.1) only
Auth
MCP servers are OAuth 2.0 Resource Servers (spec 2025-06-18+). Clients must include Resource Indicators (RFC 8707) binding tokens to specific servers. For programmatic/agent auth without browser redirects, see ext-auth#19.
Known SDK Bugs
| 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) |
V2 Migration
For comprehensive migration guide with all breaking changes and before/after code: see
references/v2-migration.md
Key breaking changes:
- Package split:
@modelcontextprotocol/sdk->@modelcontextprotocol/server+/client+/core - ESM only, Node.js 20+
- Zod v4 required (or any Standard Schema library)
McpError->ProtocolError(from@modelcontextprotocol/core)extraparameter -> structuredctxwithctx.mcpReqserver.tool()->registerTool()(config object, not positional args)- SSE server transport removed (clients can still connect to legacy SSE servers)
@modelcontextprotocol/honoand@modelcontextprotocol/expressmiddleware packages- DNS rebinding protection enabled by default for localhost servers
v1.x gets 6 more months of support after v2 stable ships. No rush, but write new code with v2 patterns in mind.