Short version: install an MCP SDK, create a server, decorate a function as a tool, and run it over stdio. That is a working MCP server in about 30 lines. Everything after that is making the tools legible to a model and keeping the responses small.
If you do not yet know what MCP is or how hosts, clients, and servers relate, read the plain MCP explainer first. This guide assumes you want to write one.
The smallest server that works (Python)
FastMCP is the fastest path. It reads your type hints and docstring and generates the tool schema for you.
# pip install fastmcp
from fastmcp import FastMCP
mcp = FastMCP("invoices")
@mcp.tool()
def find_invoice(email: str) -> dict:
"""Look up the most recent invoice for a customer by email address.
Returns the invoice id, amount in cents, status, and issue date.
Use this when someone asks about a specific customer's billing.
"""
row = db.query_one(
"select id, amount_cents, status, issued_at from invoices "
"where customer_email = %s order by issued_at desc limit 1",
[email],
)
if not row:
return {"found": False, "reason": f"No invoice for {email}"}
return {"found": True, **row}
if __name__ == "__main__":
mcp.run() That is a complete server. Point a host at it and the model can look up invoices.
The same thing in TypeScript
Reach for this when the server lives beside an existing Node codebase and you would rather not maintain a second runtime.
// npm i @modelcontextprotocol/sdk zod
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "invoices", version: "1.0.0" });
server.tool(
"find_invoice",
"Look up the most recent invoice for a customer by email address. " +
"Returns id, amount in cents, status, and issue date.",
{ email: z.string().email() },
async ({ email }) => {
const row = await db.findInvoice(email);
return {
content: [{ type: "text", text: JSON.stringify(row ?? { found: false }) }],
};
}
);
await server.connect(new StdioServerTransport()); The description is the interface
This is the part people skip, and it is the part that decides whether the server gets used.
A model does not read your code. It reads the name, the description, and the parameter schema, then decides which tool to call. So the description is not documentation for humans. It is the actual interface.
Compare these two:
- Bad: "Gets invoice data."
- Good: "Look up the most recent invoice for a customer by email address. Returns the invoice id, amount in cents, status, and issue date. Use this when someone asks about a specific customer's billing."
The second one tells the model when to reach for it, what it needs, and what comes back. When a tool is being ignored, rewrite its description before you debug anything else. That has been the fix for me more often than any code change.
Return small, structured payloads
Every byte your tool returns becomes context the model has to carry. A tool that dumps 200 rows of JSON will technically work and will quietly ruin the conversation around it.
Three rules I follow:
- Paginate by default. Return 10-25 rows with a total count, not everything.
- Return fields, not records. If the model needs a name and a status, do not send the whole row because it was easier.
- Say why something is empty.
{"found": false, "reason": "No invoice for x@y.com"}lets the model tell the user something useful. A barenullmakes it guess.
I learned this the annoying way, by building a tool that returned a full table and watching every conversation using it fall apart three turns in.
Auth and permissions
A local stdio server inherits your environment, so read credentials from environment variables and never hard-code them. That is usually enough for a personal server.
The moment a server can write, add a second layer. Separate read tools from write tools, name the write ones unambiguously (send_email, not process), and keep genuinely destructive operations out of the tool surface entirely. A model that can delete records will eventually delete records.
For remote servers, do not rely on the host for authorization. Authenticate the request at your server, scope it to one tenant, and enforce that scope in the query rather than in the prompt. If your data lives in Postgres, row-level security is the right place to enforce it.
Test without a model in the loop
Debugging a server through a chat window is miserable, because you cannot tell whether the tool is broken or the model just did not call it.
npx @modelcontextprotocol/inspector python server.py The Inspector lists your tools, shows the exact JSON schema you are advertising, and lets you call each one with your own arguments. Get it clean there first. Most of the bugs I have hit were visible in that schema: an optional parameter that was actually required, or a description that made sense to me and nowhere near enough sense to a model.
What I have built with this
I run MCP servers daily rather than as a demo. One wraps my CRM so an agent can read contacts, log interactions, and create tasks. One wraps a self-hosted SEO stack for keyword and SERP data. One handles cold outbound: leads, campaigns, and reply threads.
The pattern that keeps working is narrow servers. One server, one domain, a handful of well-described tools. Every time I have built a server that tried to do everything, the model got worse at choosing, not better.
FAQ
How do I build an MCP server?
Install an MCP SDK (FastMCP for Python, @modelcontextprotocol/sdk for TypeScript), create a server instance, decorate one function as a tool, and run it over stdio. That is a working server in about 30 lines. The remaining work is writing tool descriptions the model can act on, keeping return payloads small, and handling auth.
What language should I write an MCP server in?
Python with FastMCP is the fastest path if you are wrapping an API or a database, because the decorator generates the schema from your type hints. TypeScript is the better choice when the server ships alongside an existing Node codebase and you want one dependency tree.
How do I test an MCP server?
Use the MCP Inspector (npx @modelcontextprotocol/inspector) to call tools directly without a model in the loop. It shows the exact JSON schema your server advertises, which is where most bugs are. Only after that is clean should you wire it into a host like Claude Code.
Do MCP servers run locally or remotely?
Both. A local server runs as a subprocess and talks over stdio, which is the simplest option and keeps credentials on your machine. A remote server runs as an HTTP service and is what you want when a team needs to share one connection or the server needs to reach private infrastructure.
Why is my MCP tool not being called?
Almost always the description. The model picks tools by reading their descriptions, so a tool described as "gets data" will lose to one described as "look up a customer by email and return their plan, MRR, and last login." Rewrite the description before you touch the code.
If you want an agent connected to your own tools without building the plumbing yourself, that is work I do. Or read how I build the agent that sits on top of these servers.