vibebuilt
/ Vibe Coding / MCP Server Explained: Build One and Wire It to Claude
Vibe Coding 12 min read

MCP Server Explained: Build One and Wire It to Claude

What an MCP server is, a minimal Python server you can run, the Claude Code command to register it, and the failure modes that make a first server go silent.

MCP Server Explained: Build One and Wire It to Claude

An MCP server is a small program that hands an AI application a list of tools, data, and prompt templates over a standard wire format, so the application can call them without a custom integration. The standard is the Model Context Protocol, an open-source spec that Claude, ChatGPT, VS Code and Cursor all speak. You write the server once, in Python or TypeScript or a few other languages, and any host that supports MCP can discover what it offers and call it.

That's the whole idea. The rest of this article builds one, registers it with Claude Code, proves the tool was actually listed and called, and then walks the failure modes that make a first server sit there doing nothing. Everything below follows the protocol docs at version 2026-07-28, which changed enough that older tutorials will mislead you.

What An MCP Server Is, Precisely

The MCP architecture page names three participants. The host is the AI application, such as Claude Code or Claude Desktop. The host creates one MCP client per server it connects to, and each client keeps a dedicated connection. The server is the program that provides context to that client.

So "MCP" is the protocol, plus the SDKs and the reference tooling. An "MCP server" is one program that implements the server side of it. I find the distinction matters most when you read a marketplace listing, because the listing is advertising a server, and the protocol is what makes that server usable from more than one host.

A server can expose three kinds of things. Tools are functions the model can decide to call, each with a JSON Schema describing its arguments. Resources are data the host can read, like a file or a database schema. Prompts are reusable templates. Most servers people write are tool servers, and the one I'm building below is too.

Underneath, everything is JSON-RPC 2.0. A client asks tools/list, gets back tool descriptors, and later sends tools/call with a tool name and arguments. Since protocol version 2026-07-28 the protocol is stateless. Every request carries its protocol version and the client's capabilities in a _meta field, and a client that wants to know what a server supports sends server/discover. The older initialize handshake survives for backward compatibility, and I'll come back to why that bites.

MCP Versus An API

An MCP server usually wraps an API. It doesn't replace one.

The API is the thing that does the work, whether that's a REST endpoint, a database driver, or a filesystem call. The MCP server sits in front of it and publishes a schema the model can read, so the host application needs no bespoke glue for each service. Three services with three auth styles and three response shapes become three tool lists in one format.

My rule is short. If you already have a documented API and a human calling it, you don't need MCP. You need it when the caller is a model inside somebody else's application and you want that application to discover your functions without a plugin for every host.

Build The Smallest Honest Server

The official build-server guide uses a weather server that calls the US National Weather Service. I'd rather start with something that touches nothing on the network, because then a failure is always in your code or your config, never in someone else's API.

The guide's requirements are Python 3.10 or higher and the Python MCP SDK 2.0.0 or higher. As of today PyPI has mcp at 2.2.0. Set up the project the way the docs do:

uv init notes-server
cd notes-server
uv venv
source .venv/bin/activate
uv add "mcp[cli]"
mkdir notes

Now server.py, one tool, one boundary:

import logging
from pathlib import Path

from mcp.server import MCPServer

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

ROOT = Path(__file__).parent.joinpath("notes").resolve()
mcp = MCPServer("notes")


@mcp.tool()
async def count_words(filename: str) -> str:
    """Count the words in one Markdown file under the notes folder.

    Args:
        filename: File name relative to the notes folder, such as todo.md
    """
    target = (ROOT / filename).resolve()
    if ROOT not in target.parents:
        return "Refused: path escapes the notes folder."
    if not target.is_file():
        return f"Not found: {filename}"
    words = len(target.read_text(encoding="utf-8").split())
    logger.info("Counted %s words in %s", words, target.name)
    return f"{target.name}: {words} words"


if __name__ == "__main__":
    mcp.run(transport="stdio")

Three details carry the whole thing. MCPServer reads the type hints and the docstring to build the tool descriptor, so the docstring is the description the model sees and filename becomes a required string in the generated schema. The ROOT check is the system boundary; the model can name a file, and it can't walk out of the folder. And logging.basicConfig sends log lines to stderr, which is the only place a stdio server may write anything that isn't a protocol message.

Drop a todo.md with a few lines into notes/. Then run the server once by hand with uv run server.py. It will appear to hang. That's correct, it's waiting on stdin for a client. Press Ctrl+C.

Prove It Works Before Touching A Host

The MCP Inspector is the reference test client, and it has a CLI mode that prints machine-readable output. It needs Node 22.19.0 or newer and runs through npx with no install. Put the server's launch command first and the Inspector's own options after it. The CLI has a --cwd flag for the server's working directory, which is cleaner than passing uv --directory, because a flag in the launch position can get parsed by the Inspector instead of by uv.

npx @modelcontextprotocol/inspector --cli uv run server.py \
  --cwd /ABSOLUTE/PATH/TO/notes-server --method tools/list

You should see one tool, count_words, with an inputSchema whose required list contains filename. If the docstring didn't make it into description, fix that before anything else. My read is that the description is most of what the model uses to decide when to call a tool, and a vague one gets ignored or misused.

Then call it, once properly and once with a path that tries to leave the folder:

npx @modelcontextprotocol/inspector --cli uv run server.py \
  --cwd /ABSOLUTE/PATH/TO/notes-server \
  --method tools/call --tool-name count_words --tool-arg filename=todo.md

npx @modelcontextprotocol/inspector --cli uv run server.py \
  --cwd /ABSOLUTE/PATH/TO/notes-server \
  --method tools/call --tool-name count_words --tool-arg filename=../pyproject.toml

The first returns a text content block with the count. The second returns the refusal as an ordinary tool result. That pair is your boundary test, and I'd keep both commands in the README so the next person can rerun them after any change.

Wire It To Claude Code

Claude Code registers stdio servers with one command. Everything after -- goes to the server untouched, which is how you pass flags to uv without Claude Code interpreting them:

claude mcp add notes -- uv --directory /ABSOLUTE/PATH/TO/notes-server run server.py

The Claude Code MCP reference documents three scopes. The default, local, stores the entry in ~/.claude.json for this project only. --scope project writes .mcp.json in the repository root so it can be committed and shared. --scope user makes it available in every project. For a server that reads a folder on my machine, local is right. For a team server I'd use project scope with ${VAR} expansion for anything secret, since .mcp.json supports ${VAR} and ${VAR:-default}.

Check it from the shell and from inside a session:

claude mcp list
claude mcp get notes

Inside Claude Code, /mcp shows connection status. Then ask something only your tool can answer, like the word count of a specific file, and watch for the tool call in the transcript. If Claude answers from general knowledge instead, either the description is too vague or the server never connected.

Claude Desktop works the same way with a JSON file instead of a command. Add the mcpServers entry to claude_desktop_config.json, which lives under ~/Library/Application Support/Claude/ on macOS, use absolute paths, and fully quit the app. Closing the window doesn't reload the config.

Why A First Server Goes Silent

Most first-server failures produce no error in the chat. The server shows as connected and nothing happens. This is the order I'd check.

Symptom Most Likely Cause Where To Look
Connected, but zero tools or a dropped connection Something wrote to stdout. The stdio spec forbids any non-protocol byte there, and a stray print() is the usual culprit. mcp-server-notes.log under ~/Library/Logs/Claude holds the server's stderr
Server never appears Relative path in the config, invalid JSON, or the app was closed rather than quit Config syntax, pwd for the absolute path, full quit
Works in the Inspector, fails in the host An env var the host never passed. Launched servers inherit only a limited, platform-dependent subset of your environment. env key in the config, or --env KEY=VALUE on claude mcp add
Error -32602 on every request Client and server on different protocol eras. A request missing the _meta version and capability fields is rejected with this code. server/discover through the Inspector, then update the SDK on the older side
Error -32022 The client asked for a protocol version the server doesn't support The error's data field lists the versions the server accepts
Tool result arrives cut off Claude Code warns at 10,000 tokens of tool output and caps at 25,000 by default MAX_MCP_OUTPUT_TOKENS, or return less
Call hangs, then fails The per-call wall-clock limit MCP_TIMEOUT in milliseconds, or a timeout key per server in .mcp.json

The stdout row deserves a plain sentence. The stdio transport spec says the server MUST NOT write anything to its stdout that is not a valid MCP message, and the quickstart says a stray write will break the server. How loudly it breaks depends on the host. Some clients skip a line they can't parse and carry on, so a debugging print that seems harmless in one tool can still take the server down in another. Treat any stdout write as a bug even when today's client forgives it.

Stdio Or Streamable HTTP

There are two standard transports, and picking wrong costs you a rewrite of the auth story rather than the tool code.

Question Stdio Streamable HTTP
Where it runs On the host's machine, launched as a subprocess Anywhere reachable over HTTP
Clients served Typically one Typically many
Access boundary The process. It runs with the client's privileges. Bearer tokens or OAuth. The server must check the token was issued for it.
Logs stderr, captured by the host Your own aggregation; the client never sees stderr
Inspector Pass the launch command --server-url <url> --transport http
Pick it for Personal tools over local files and repo scripts Anything a team or a product shares

The security best practices page is direct about the local case. Servers meant to run locally should use stdio so only the client can reach them, and a local server that insists on HTTP should still require a token. The same page says servers MUST NOT accept tokens that were not issued to them, which rules out the tempting shortcut of forwarding whatever bearer token the client already had.

My default is stdio. I move a server to HTTP when a second person needs it, and I treat that move as a new security review rather than a config change.

Does ChatGPT Use MCP?

Yes. OpenAI's MCP documentation covers connecting a server URL to ChatGPT in developer mode, and an mcp tool type in the Responses API that takes a server_url, an allowed_tools list, and a require_approval setting. Their guidance keeps approval on for any tool that can modify data, and their deep research feature expects a server to expose search and fetch tools specifically. Those are remote servers, so the stdio server on your laptop is not what ChatGPT connects to.

The intro page on modelcontextprotocol.io lists Claude, ChatGPT, VS Code, Cursor and MCPJam as clients. Build once, and the registration step is the only part that changes per host.

Why You'd Bother

The honest answer is that you'd bother when a model keeps guessing at something your system already knows.

In my own monorepo the temptation is to expose one wide tool that runs any shell command, because then the agent can do anything. That's a bad server. A good server exposes the three or four operations you'd let a new hire run without looking over their shoulder, with a schema tight enough that the model can't ask for something outside them. The count_words example is deliberately boring for that reason. Its boundary is one folder and one operation, and the boundary is proven by the second Inspector command, not assumed.

If that describes a problem you have, an MCP server is a few dozen lines. If it doesn't, pasting the information into a prompt is cheaper and easier to review. The AI coding agents guide covers why tool access raises the stakes of everything else the agent does, and the vibe coding security risks post has the prompt injection angle, which applies with full force to any tool that returns text from the outside world. Claude Code's own docs carry the same warning next to the add command.

Keep The Loop Small

Write one tool. List it with the Inspector. Call it with a good argument and a bad one. Register it with claude mcp add. Ask a question only the tool can answer and watch for the call. That sequence is the inspect, edit, verify rhythm from the Claude Code tutorial, applied to the thing the agent calls instead of the thing it edits.

When you outgrow one tool, the next piece in this cluster on how to build an AI agent covers what changes when the model is choosing between several of them. Get the single-tool version proven first. I think the boring version teaches the protocol faster than the impressive one, because every failure has exactly one place to be.