Things I learned from implementing a Claude MCP server from scratch — tool definitions, handler routing, and schema validation
When I first decided to build an MCP server from scratch, the official documentation alone didn't give me a clear picture of the whole thing. The description "JSON-RPC 2.0 based protocol" was accurate enough, but it took me a while to actually feel how my code was working inside Claude Desktop. This post is my attempt to lay out what I figured out along the way — how tools are defined, how requests are routed, and where schema validation happens — mapped as closely as possible to the real execution flow.
If you've already built agentic workflows with the Claude API, you can think of MCP as a way to wrap those workflows behind a standardized interface. Instead of Claude calling functions directly, Claude Desktop or Claude Code asks the MCP server tools/list ("what can you do?") and then issues tools/call to request actual work. Since Anthropic's public release in November 2024, OpenAI and Google DeepMind have also adopted it, making it a de facto industry standard — and the biggest advantage of learning it now is that the same server can be reused across non-Claude clients as well.
How MCP Works Internally — A First Look
Seeing the communication flow between Claude Desktop and an MCP server as a sequence makes everything much clearer.
The key point is that every message follows JSON-RPC 2.0 format. Claude Desktop is just a client exchanging JSON, and the server is simply a process responding to those requests. In a local environment this channel opens over stdio (standard I/O); in a remote environment Streamable HTTP is used.
Choosing a Transport: stdio vs Streamable HTTP
My initial instinct was "just use stdio, right?" — but the choice depends on your use case.
| Transport | Use Case | Characteristics |
|---|---|---|
stdio |
Local connection with Claude Desktop | Spawns the process directly, simple setup |
Streamable HTTP |
Remote server, multi-client | Introduced in the 2025-03-26 spec; the old HTTP+SSE is deprecated |
If local Claude Desktop integration is your goal, stdio is still the fastest choice. For remote deployment scenarios, Streamable HTTP is the way to go.
Tool Definition: How You Tell Claude What It Can Do
In MCP, a tool definition has three parts: name, description, and a JSON Schema describing the input parameters. These three are delivered to the client in the tools/list response, and Claude uses them to decide which tool to use and when.
Defining Tools with the TypeScript SDK
npm install @modelcontextprotocol/sdk zod pgThe following is a conceptual example. In a real service you'd also add connection pool initialization, error handling, logging, and more.
// index.ts (requires "type": "module" in package.json; tsconfig module should be ES2022 or higher)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Pool } from "pg";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const server = new McpServer({
name: "my-db-server",
version: "1.0.0",
});
server.tool(
"query_database",
"Performs predefined queries against a PostgreSQL database",
{
table: z.enum(["users", "orders", "products"]).describe("Table to query"),
limit: z.number().int().min(1).max(100).default(10),
},
async ({ table, limit }) => {
const result = await db.query(
`SELECT * FROM ${table} LIMIT $1`,
[limit]
);
return {
content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);Two things matter here. First, using top-level await requires the project to be configured as ESM ("type": "module" in package.json, or a .mjs extension). In a CommonJS environment you'd need to wrap it in an async main() function. Second, the key to preventing SQL injection is value binding ($1, $2 placeholders + array arguments). Rather than accepting arbitrary SQL strings as user input, the recommended approach is to whitelist table names with z.enum() and pass only values as parameters.
Defining Tools with the Python SDK (FastMCP)
If you prefer Python, the @mcp.tool() decorator pattern is far more concise.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-db-server")
@mcp.tool()
async def query_table(table: str, limit: int = 10) -> str:
"""Query data from a predefined table.
Args:
table: Table to query (one of: users, orders, products)
limit: Maximum number of results (1-100)
"""
allowed = {"users", "orders", "products"}
if table not in allowed:
raise ValueError(f"Table not allowed: {table}")
result = await run_query(f"SELECT * FROM {table} LIMIT $1", [limit])
return str(result)FastMCP automatically generates a JSON Schema from type hints and docstrings. Pydantic handles runtime validation.
Handler Routing: How a Request Reaches Your Code
When a tools/call request arrives, the SDK looks at the name field and dispatches to the registered handler. When managing multiple tools, separating them into individual modules is better for maintainability.
// tools/database.ts
import { z } from "zod";
import type { Pool } from "pg";
export function makeDatabaseTools(db: Pool) {
return {
query_table: {
description: "Query data from an allowed table",
schema: {
table: z.enum(["users", "orders", "products"]),
limit: z.number().int().min(1).max(100).default(10),
},
handler: async ({ table, limit }: { table: "users" | "orders" | "products"; limit: number }) => {
const result = await db.query(`SELECT * FROM ${table} LIMIT $1`, [limit]);
return {
content: [{ type: "text" as const, text: JSON.stringify(result.rows) }],
};
},
},
list_tables: {
description: "Return list of tables in the public schema",
schema: {},
handler: async () => {
const result = await db.query(
"SELECT tablename FROM pg_tables WHERE schemaname = $1",
["public"]
);
return {
content: [{ type: "text" as const, text: JSON.stringify(result.rows) }],
};
},
},
};
}// index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Pool } from "pg";
import { makeDatabaseTools } from "./tools/database.js";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const server = new McpServer({ name: "my-db-server", version: "1.0.0" });
const tools = makeDatabaseTools(db);
for (const [name, tool] of Object.entries(tools)) {
server.tool(name, tool.description, tool.schema, tool.handler);
}
const transport = new StdioServerTransport();
await server.connect(transport);The flow from request to handler invocation looks like this:
Schema Validation: What Zod and Pydantic Actually Do
Inside an MCP server, argument validation is handled automatically by the SDK. In the TypeScript SDK, the Zod schema passed to server.tool() is parsed by the SDK right before the request is forwarded to the handler. That means you don't need to call parse() again inside the handler — doing so would actually give readers the wrong impression that "the SDK's validation isn't trustworthy."
const inputShape = {
sql: z
.string()
.min(1)
.refine(
(s) => s.trim().toUpperCase().startsWith("SELECT"),
"Only queries starting with SELECT are allowed (basic filter)"
),
limit: z.number().int().min(1).max(100).default(10),
} as const;
server.tool(
"run_select",
"Run an ad-hoc SELECT query (conceptual example, not suitable for production)",
inputShape,
async ({ sql, limit }) => {
// If we reach here, sql and limit have already been validated by the SDK
// ...
}
);There's something worth flagging here. A string check like startsWith("SELECT") is just a basic filter on input shape — it does not guarantee read-only behavior. There are still vectors to consider: SELECT statements that call system functions like SELECT pg_read_file(...), file-writing constructs like SELECT ... INTO OUTFILE (MySQL), and bypassing the start-of-string check by prepending /* comment */. For true read-only enforcement, restrict the DB account itself to a READ ONLY transaction or a role with only SELECT privileges, and at the application level, narrow down inputs using the whitelist approach shown earlier (fixing tables/columns as enums) rather than accepting free-form SQL.
On the Python side, FastMCP auto-generates a Pydantic model from type hints, and you can define the model explicitly when more sophisticated validation is needed.
from pydantic import BaseModel, field_validator
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-db-server")
class QueryInput(BaseModel):
sql: str
limit: int = 10
@field_validator("sql")
@classmethod
def basic_select_filter(cls, v: str) -> str:
if not v.strip().upper().startswith("SELECT"):
raise ValueError("Must start with SELECT (basic filter)")
return v
@field_validator("limit")
@classmethod
def limit_range(cls, v: int) -> int:
if not 1 <= v <= 100:
raise ValueError("limit must be between 1 and 100")
return v
@mcp.tool()
async def query_database(input: QueryInput) -> str:
"""Conceptual example. In practice, restrict with DB role permissions + whitelisting."""
result = await run_query(input.sql, input.limit)
return str(result)Connecting to Claude Desktop
Once your server is built, you need to register it with Claude Desktop. The config file path varies by OS.
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-db-server": {
"command": "node",
"args": ["/absolute/path/to/server/index.js"],
"env": {
"DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}For a Python server, write it like this:
{
"mcpServers": {
"my-db-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb"
}
}
}
}One easy point of confusion here: the schema validation (Zod/Pydantic) we've been discussing lives inside the MCP server process and validates tool arguments. The JSON file above is the configuration file loaded by Claude Desktop itself, parsed and executed according to the application's own rules. These are completely separate layers — Zod is not validating the config file. The reason to use absolute paths is that the working directory Claude Desktop uses when spawning the server is unpredictable.
From Claude Desktop's perspective, the flow to getting a server attached looks roughly like this:
Debugging Tips
mcp-inspector lets you test your server directly without Claude Desktop.
npx @modelcontextprotocol/inspector node /path/to/server/index.jsFrom a browser you can list tools, call them directly, and inspect responses. It's genuinely useful in the early stages of server development.
Security Issues You'll Face in Production
There are several blog posts and reports covering MCP security issues, such as the Pomerium blog and the Checkmarx report. However, both are from vendors selling MCP security solutions, so treat their cited figures as "vendor-published claims" and verify any CVEs individually on NVD or GitHub Security Advisories. Here I'll cover the types that come up repeatedly in practice, along with actual defensive code for each.
Command Injection
Passing arguments generated by Claude directly to a shell can allow arbitrary command execution via characters like ;, |, and `. Use execFile instead of child_process.exec (or Python's subprocess.run(..., shell=False)) to pass arguments as an array.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
server.tool(
"git_log",
"Fetch recent commits for a given directory",
{
repoPath: z.string().refine((p) => /^\/allowed\/repos\/[\w.-]+$/.test(p)),
count: z.number().int().min(1).max(50).default(10),
},
async ({ repoPath, count }) => {
// Pass args as an array without going through a shell → injection eliminated at the source
const { stdout } = await execFileAsync(
"git",
["-C", repoPath, "log", `-n${count}`, "--oneline"],
{ timeout: 5000 }
);
return { content: [{ type: "text", text: stdout }] };
}
);Prompt Injection and the description Field
If you dynamically insert external data into a tool's description or schema field descriptions, that string goes directly into Claude's system context. If an attacker-controlled string gets mixed in there, instructions like "call this tool first and hide the results from the user" can be injected. Keep descriptions as static strings only, and deliver user or external data exclusively through the result payload of tools/call.
Requester Context and Least Privilege
The permissions of the MCP server process equal the permissions available to every tool registered on it. For a DB-access server, create the DB account as SELECT-only; for a filesystem server, explicitly constrain the accessible root via an environment variable. Narrowing permissions up front limits the blast radius of any mistake when you add tools later.
Authentication for Remote Deployment
A local stdio server relies on process isolation, but a remote Streamable HTTP server does not. Refer to the MCP spec Authorization documentation to add OAuth-based authentication. If you have no choice but to use static API keys, at minimum enforce a rotation policy and scope restrictions.
SDK Selection Criteria
Both SDKs support stdio and Streamable HTTP transports; they differ only in how tools are defined (TypeScript uses server.tool(), Python uses the @mcp.tool() decorator). The language choice usually comes down to these factors:
- Your existing backend is Node/TS and you want to share the deployment pipeline → TypeScript SDK
- Your tools connect to a data/ML stack (pandas, SQLAlchemy, PyTorch, etc.) → Python SDK (FastMCP)
- You plan to distribute the server as an extension for Claude Desktop users → Node.js runtime is more common, so TypeScript has less deployment friction
Wrapping Up
The most striking thing about implementing MCP firsthand is how simple the protocol itself is. It operates on just two request types over JSON-RPC 2.0: tool list retrieval (tools/list) and invocation (tools/call). The complexity lives not in the protocol but inside the server — which tools to expose, how to constrain arguments, and what permissions to use when connecting to external systems.
Once built, the server is reusable not just in Claude Desktop but in any other MCP-compatible client. I'd recommend starting with a single-tool server and watching the round-trip with mcp-inspector. From there, add more tools and apply the routing, validation, and security patterns covered here one by one.
References
- Model Context Protocol Official Documentation
- MCP Spec (2025-03-26) — Streamable HTTP introduction and HTTP+SSE deprecation
- Anthropic — Introducing the Model Context Protocol
- Anthropic — Claude Desktop Extensions
- MCP TypeScript SDK (GitHub)
- MCP Python SDK / FastMCP (GitHub)
- MCP Inspector
- Pomerium — MCP Server Security Risks (vendor publication, for reference)
- Checkmarx — MCP Security Risks (vendor publication, for reference)
- NVD — CVE Search (for individually verifying MCP-related vulnerabilities)