mcp-best-practices
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.
More from tenequm/claude-plugins
chrome-extension-wxt
Build Chrome extensions using WXT framework with TypeScript, React, Vue, or Svelte. Use when creating browser extensions, developing cross-browser add-ons, or working with Chrome Web Store projects. Triggers on phrases like "chrome extension", "browser extension", "WXT framework", "manifest v3", or file patterns like wxt.config.ts.
96shadcn-tailwind
Build UIs with Tailwind CSS v4 and shadcn/ui. Covers CSS variables with OKLCH colors, component variants with CVA, responsive design, dark mode, and Tailwind v4.2 features. Supports Radix UI and Base UI primitives, CLI 3.0, and visual styles. Use when building interfaces with Tailwind, styling shadcn/ui components, implementing themes, or working with utility-first CSS. Triggers on tailwind, shadcn, utility classes, CSS variables, OKLCH, component styling, theming, dark mode, radix ui.
76founder-playbook
Decision validation and thinking frameworks for startup founders. Use when you need to pressure-test a decision, validate your next steps, think through strategic options, or sanity-check your approach. Triggers on phrases like "should I", "help me think through", "is this the right move", "validate my thinking", "what am I missing". Covers fundraising, customer development, runway management, prioritization, and crypto/web3 founder challenges.
72foundry-solidity
Build and test Solidity smart contracts with Foundry toolkit. Use when developing Ethereum contracts, writing Forge tests, deploying with scripts, or debugging with Cast/Anvil. Triggers on Foundry commands (forge, cast, anvil), Solidity testing, smart contract development, or files like foundry.toml, *.t.sol, *.s.sol.
68skill-finder
Find and evaluate Claude skills for specific use cases using semantic search, Anthropic best practices assessment, and fitness scoring. Use when the user asks to find skills for a particular task (e.g., "find me a skill for pitch decks"), not for generic "show all skills" requests.
64solana-security
Audit Solana programs (Anchor or native Rust) for security vulnerabilities. Use when reviewing smart contract security, finding exploits, analyzing attack vectors, performing security assessments, or when explicitly asked to audit, review security, check for bugs, or find vulnerabilities in Solana programs.
46