Custom MCP Servers
When the prebuilt MCP servers run out of road, you write your own. The protocol is a four-method handshake, the transport is stdio or HTTP, and the whole thing fits in 200 lines of TypeScript.
View companion repoOf the 132 plugins installed under ~/.claude/plugins/, 47 of them ship an mcp.json file declaring at least one MCP server. The Model Context Protocol is the standard way to extend Claude Code with tools the platform doesn't ship. Most of the time, you can install a prebuilt one — mcp-server-filesystem, mcp-server-github, mcp-server-postgres, the catalog is large and growing.
Then comes the day a prebuilt one doesn't exist for what you need. I needed an MCP server that read from a Supabase view I'd built specifically for syndication scheduling. There's no mcp-server-supabase-views. There's an mcp-server-postgres, but it doesn't speak the row-level security context I needed. So I wrote my own. It took an afternoon. The protocol is small.
The Four-Method Handshake
MCP is a JSON-RPC protocol over stdio (most common) or HTTP. The Claude Code client and your server exchange four methods, in order:
That's it. There are optional methods for resources and prompts, but for an MCP server that just exposes a few tools, you can implement the four above and have a working server.
The transport: stdio. The client spawns your server as a subprocess, writes JSON-RPC requests to stdin, reads responses from stdout. Each message is a single line of JSON terminated by newline. There's no framing protocol beyond that. If your server prints to stdout outside of a response, the protocol breaks. So you log to stderr.
That's the entire dispatcher. Twenty-eight lines. Read a line, parse JSON-RPC, look up the method handler, call it, write the response back. Errors go to stderr. Responses go to stdout. The framing is newlines.
The Initialize Handshake
The first message you'll get from the client is initialize. The client tells you what protocol version it speaks and what capabilities it has. You respond with the same in reverse.
Eighteen lines including comments and braces. The capabilities.tools.listChanged: false says your server's tool catalog is static — once you've reported it, it won't change. If you set this to true, the client will know to re-fetch the tool list periodically.
protocolVersion is the wire-format version. As of this writing the active version is 2024-11-05. New versions are introduced rarely; if the client speaks a different version, you can either upgrade or refuse the connection.
tools/list — Your Catalog
Once initialized, the client will ask tools/list. You return an array of tool descriptors. Each one has a name, a description, and a JSON Schema for its input.
The description field is the trigger surface. The model reads these descriptions when deciding which tool to call. Same rules as skill descriptions: precise, assertive, includes the use case. "Query a Supabase view with row-level security context" tells the model exactly when to reach for this tool.
The inputSchema is JSON Schema. The client uses it to validate parameters before sending them to you. You don't have to re-validate — the client has already enforced the schema.
tools/call — The Actual Work
When the model decides to use one of your tools, the client sends tools/call with the tool name and parameters. You do the work and return a content array.
The response shape is { content: [{ type, text }], isError? }. Content is an array because tools can return multiple chunks (text + image, text + structured data). Most tools return one text chunk. The model parses it and continues the conversation.
The isError: true flag tells the model the call failed. Set it when something went wrong but the call itself completed (e.g., the database returned an error). Reserve actual JSON-RPC errors for protocol-level failures.
Registering Your Server
Once your server runs, you tell Claude Code about it via a .mcp.json file in your project root or ~/.claude/mcp.json for global registration:
The command is what Claude Code spawns. The args are the arguments to pass. The env is environment variables, with ${VAR} syntax for expansion from the parent process's environment. Restart Claude Code and the new server shows up in /mcp along with its tool catalog.
The Tool Naming Convention
When your server's tools surface in the model's context, they get prefixed with the server name and a double underscore: supabase-views__supabase_view_query. That's how the client disambiguates tools from different servers.
In the JSONL session log you'll see this prefix on every call:
Keep your tool names short. The full prefixed name shows up in every tool definition the model sees, eating cache prefix space.
When to Build vs When to Use Prebuilt
The prebuilt MCP catalog at the official mcp-servers repo covers maybe 80% of what people need: filesystem, Git, GitHub, GitLab, Postgres, SQLite, Slack, Linear, Notion, Sentry, browser automation. If your need fits the catalog, install the prebuilt one. Don't reinvent.
You build your own when:
- 01The data source is yours (a private API, an internal database, a custom SaaS view)
- 02The prebuilt server doesn't expose the slice you need (the postgres server is generic; you want a tool that knows your schema)
- 03You need RLS or auth context the prebuilt server doesn't propagate
- 04You want a tool description optimized for your team's phrasing, not generic descriptions
That last one is undervalued. The whole point of MCP tools is that the model picks them up by description. A custom MCP server with descriptions like "Query the syndication_log table for posts published to LinkedIn in the last week" gets activated reliably by the team that uses it. The generic postgres server's query tool gets activated for everything and nothing.
What Goes Wrong
Three failure modes I've hit:
1. stdout pollution. Anything printed to stdout that isn't a JSON-RPC response breaks the protocol. console.log debug calls left in the code, library warnings, anything. The first time this happens, the client closes the connection silently and your server appears broken. Always log to stderr.
2. Slow responses. The client has a default timeout (around 30 seconds). If your tool takes longer, the client kills the call. For long-running operations, return a job ID immediately and provide a separate tool for polling status.
3. Schema drift. If your tool's parameters change without updating inputSchema, the client validates against the old schema and rejects valid calls. Bump your schema every time the parameters change. The model will pick up the new shape automatically.
“The 47 MCP servers in my plugin cache cover everything from PR review to code search to database access. The pattern is the same across all of them: small server, focused toolset, descriptions tuned to the team's phrasing. Once you've written one, the next one takes an hour. The protocol's minimalism is the point.”