The Model Context Protocol is a well-designed standard, but implementing it from scratch surfaces a handful of non-obvious decisions that affect everything from performance to debuggability. Here’s what we’ve learned building MCP servers for hundreds of repositories.
1. Schema Validation Is Non-Negotiable
Every tool in an MCP server accepts structured input defined as a JSON Schema. When an AI client calls your tool, it sends arguments based on that schema. If your schema is imprecise or your server doesn’t validate incoming arguments, you’ll get runtime errors that are hard to attribute back to a schema mismatch.
Use a typed schema library from the start. In TypeScript, Zod is the standard choice:
import { z } from 'zod';
const SearchParams = z.object({
query: z.string().min(1).describe("Search term"),
limit: z.number().int().min(1).max(100).default(10),
filter: z.enum(['active', 'archived', 'all']).optional(),
});
type SearchParams = z.infer<typeof SearchParams>;
Two benefits: runtime validation catches bad inputs before they reach your business logic, and the schema is the single source of truth for both documentation and the JSON Schema the protocol requires.
Coerce types where it makes sense (z.coerce.number() for values that might come in as strings) and add .describe() annotations — AI clients use those descriptions to understand when and how to call your tool.
2. Pick Your Transport Early (It Affects Everything)
MCP supports stdio, HTTP+SSE, and WebSocket transports. The choice looks like a deployment detail, but it affects your development workflow, error handling, authentication model, and scalability.
stdio: The server runs as a local process. Zero network overhead, simple auth (it runs as you), and trivial to debug. Best for development and for tools that only need local access. The limitation: one client at a time, and it can’t be shared across a team.
HTTP + SSE: The server runs as a web service. Enables remote access, multi-client connections, and standard web auth (bearer tokens, API keys). Required for cloud deployments and team-shared servers. More setup, but vastly more flexible.
Choose stdio if you’re building a personal productivity tool or prototyping. Choose HTTP if you’re building something your team or customers will use.
3. Error Handling Has a Specific Shape
MCP defines error codes and a structured error response format. Don’t just throw exceptions and hope the client handles them gracefully — clients vary in how well they recover from malformed error responses.
The two error categories you’ll use most:
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
// For invalid inputs — the client should not retry
throw new McpError(
ErrorCode.InvalidParams,
`Product ID must be a positive integer, received: ${id}`
);
// For temporary failures — the client can retry
throw new McpError(
ErrorCode.InternalError,
'Database connection unavailable, please retry'
);
Include enough context in the error message that an AI can explain the failure to a user. Terse error messages (“invalid input”) don’t help anyone.
4. Keep Tools Focused and Composable
The most common mistake when exposing a codebase as MCP tools is creating overly broad tools that try to do too much. An AI assistant works best when it can compose multiple specific tools rather than call one opaque operation.
Instead of:
// Too broad — the AI has to guess which operation you mean
managePosts({ action: 'create' | 'update' | 'delete', ... })
Prefer:
createPost({ title, content, tags })
updatePost({ id, changes })
deletePost({ id, confirm: true })
listPosts({ status, limit, offset })
Each tool should do one thing with clear semantics. The AI figures out the right sequence; your job is to make each individual step reliable and well-described.
When exposing read-only lookups, consider using MCP resources instead of tools — they’re the right primitive for data retrieval, and they allow clients to subscribe to changes.
5. Test Against Real Clients Before You Ship
Unit testing your tool handlers is necessary but not sufficient. The MCP lifecycle involves a handshake, capability negotiation, and a specific message format. Things that pass unit tests regularly fail against a real client because the integration layer — schema serialization, capability exchange, transport framing — wasn’t tested end-to-end.
Test at minimum against the stdio transport using the MCP inspector tool:
npx @modelcontextprotocol/inspector node ./dist/server.js
This runs your server and provides a web UI to call tools, read resources, and inspect the protocol messages. You’ll catch schema serialization issues, incorrect capability flags, and unexpected error codes that unit tests miss.
For HTTP servers, also test with multiple concurrent clients — connection management bugs only appear under load.
The Pattern That Ties It Together
Build your MCP layer as a thin adapter over your existing business logic. Your tools should validate input, call into well-tested service functions, and format the output. Keep business logic out of the MCP layer entirely.
This separation means your MCP server can evolve independently of your application logic. When the MCP spec updates (and it will), you’re updating an adapter, not refactoring your core codebase.
That’s also why generated servers — like the ones MCPfy produces — are easier to maintain than hand-rolled ones. The generator encodes these patterns consistently, so you get the right structure from the start without having to rediscover it.