The bottom line
The MCP (Model Context Protocol) is an open protocol that defines how an AI assistant talks to an external tool: a database, an API, a reference dataset, a directory of files. Its job is not to let two models talk to each other. It gives the model actions and verifiable data, described in a format the model understands.
Anthropic released MCP in late 2024, and other vendors have since adopted it. It is a specification, not a product: it requires no particular language, library, or hosting provider. This site's server, for example, is a single Node file with no dependencies.
The problem it solves
On its own, a model knows only what it learned in training. To get accurate answers about your data, teams have used three approaches, and two of them are aging badly.
| Approach | How it works | Drawback |
|---|---|---|
| Everything in the prompt | You paste the data into the conversation | Expensive, stale a minute later, capped by the context window |
| Custom integration | Glue code for every assistant and every tool | N × M integrations to maintain; switching assistants means rewriting everything |
| MCP | The tool is exposed once, and every client plugs into it | N + M: you still write the server, but only once |
The three roles
- The host: the application you use (Claude Desktop, Claude Code, your own agent). It decides which servers to launch and asks for your consent.
- The client: the part of the host that speaks the protocol. There is one connection per server, isolated from the others, so a server sees neither the other servers nor the full conversation.
- The server: your code. It declares what it can do and executes the calls. It does not “talk” to the model. It answers the client, which passes the response to the model.
The two transports
| stdio | HTTP (streamable / SSE) | |
|---|---|---|
| Where the server runs | On your machine, launched by the host | On a remote server |
| Channel | The process's standard input/output | HTTP requests + event stream |
| Authentication | None: it is already your session | Required (OAuth, token…) |
| Sensitive data | Never leaves the machine | Leaves your network |
| Multi-user | No: one process per client | Yes |
| Best for | Local files, reference datasets, personal tools | SaaS, company APIs, teams |
console.log, a startup banner, a dependency warning: any byte that is not JSON breaks the session, and the client shows a cryptic parsing error. All logs go to stderr.The handshake, step by step
Everything runs on JSON-RPC 2.0, one JSON message per line. A request carries an id and expects a response. A notification has no id and expects nothing. Here is a real session, stripped down to the essentials.
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"claude-code","version":"1.0"}}}
← {"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":false}},
"serverInfo":{"name":"paypedia","version":"1.0.0"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"} ← no id: NO response→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"iban_check","description":"Validates one or more IBANs…",
"inputSchema":{"type":"object","properties":{"input":{"type":"string"}},
"required":["input"],"additionalProperties":false}} ]}}
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"iban_check","arguments":{"input":"FR7630006000011234567890189"}}}
← {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"## FR76 3000 6000 …\n**VALID** · France (FR)…"}],
"isError":false}}| Method | Type | Role |
|---|---|---|
initialize | request | Negotiate the protocol version and announce capabilities |
notifications/initialized | notification | The client is ready: send nothing back |
tools/list | request | Return the catalog: name, description, input schema |
tools/call | request | Run the tool and return content[] |
ping | request | Reply {}; the client uses it to check that the process is alive |
| Any other method | request | Return error -32601 (method not found) |
error object. An execution error (an unreadable IBAN, a missing file) goes in the result with isError: true, so the model can read it and correct itself. Mix the two up, and you either break the session or hide the failure.The four primitives
tools. This site's server does exactly that: 17 tools, no resources, no prompts. Adding primitives “just in case” only increases what you have to maintain and gives the model nothing.Anatomy of a tool
A tool has four parts: a name, a description, an input schema, and code. Here is the actual declaration of one of this site's 17 tools, unedited.
{
name: 'bank_resolve', // stable identifier, snake_case
title: 'Resolve a bank code', // human-readable label (optional)
description:
"Returns a bank's name and BIC from its national bank code (the one " +
"embedded in the IBAN) and its country. Registry of ~22,900 codes in 44 countries. " +
"Call this when you have a bank code (e.g., 30004 in France) but do not know " +
"which institution it belongs to.", // ← THIS is what triggers the call
inputSchema: {
type: 'object',
properties: {
country: { type: 'string', maxLength: 8, description: 'ISO 3166-1 alpha-2 country code, e.g., "FR", "DE", "ES".' },
bankCode: { type: 'string', maxLength: 16, description: 'National bank code, e.g., "30004".' },
},
required: ['country', 'bankCode'],
additionalProperties: false,
},
run: async (args) => '…the text handed to the model…',
}| Scope | Role | Classic mistake |
|---|---|---|
name | Identifier the client calls | Renaming it: every existing configuration breaks |
description | Decides whether the model calls the tool | Saying what the tool does without saying when to call it |
inputSchema | Input contract, shown to the model | Declaring it but never validating it: the schema becomes mere documentation |
| Output | Text, read by a model | Returning raw JSON where a sentence would be understood right away |
A minimal server with no dependencies
Official SDKs exist, but you do not need them: the protocol is simple enough to implement by hand. Here is a complete, working stdio server. It is the skeleton of this site's server.
const TOOLS = [{
name: 'sum',
description: 'Adds two numbers. Call this for any addition.',
inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } },
required: ['a', 'b'], additionalProperties: false },
run: ({ a, b }) => `${a} + ${b} = ${Number(a) + Number(b)}`,
}]
const send = (m) => process.stdout.write(JSON.stringify(m) + '\n') // stdout = protocol
const log = (m) => process.stderr.write(m + '\n') // stderr = logs
let buf = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
buf += chunk
let nl
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
if (!line) continue
let msg
try { msg = JSON.parse(line) } catch { send({ jsonrpc: '2.0', id: null,
error: { code: -32700, message: 'Invalid JSON' } }); continue }
if (!msg || typeof msg !== 'object') continue // 'null' is valid JSON
const isRequest = msg.id !== undefined && msg.id !== null
if (msg.method === 'initialize') {
send({ jsonrpc: '2.0', id: msg.id, result: {
protocolVersion: msg.params?.protocolVersion ?? '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'example', version: '1.0.0' } } })
} else if (msg.method === 'tools/list' && isRequest) {
send({ jsonrpc: '2.0', id: msg.id, result: {
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) } })
} else if (msg.method === 'tools/call' && isRequest) {
const tool = TOOLS.find((t) => t.name === msg.params?.name)
if (!tool) return send({ jsonrpc: '2.0', id: msg.id,
error: { code: -32602, message: 'Unknown tool' } })
try {
send({ jsonrpc: '2.0', id: msg.id,
result: { content: [{ type: 'text', text: tool.run(msg.params.arguments ?? {}) }] } })
} catch (e) { // EXECUTION error: goes in the result
send({ jsonrpc: '2.0', id: msg.id,
result: { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true } })
}
} else if (isRequest) {
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } })
}
// no id: it is a notification → reply with NOTHING
}
})
log('ready')printf the messages into the process and read the output. That is how this site's server is tested, with 28 real calls covering all 17 tools, edge cases included: an unreadable IBAN, a BIN that is too short, an article ID that tries to escape its directory.Eight pitfalls (all hit in real life)
This list does not come from the specification. It comes from building this site's server: each row is a bug that actually happened, with its symptom and its fix.
| Symptom | Root cause | Fix |
|---|---|---|
| The client cannot talk to the server | A console.log (or a dependency banner) on stdout | All logging goes to stderr, no exceptions |
| The client complains about an unexpected response | The server replies to a notification (a message with no id) | Reply only if id is present and not null |
| The server crashes mid-session | A null line (valid JSON) gets destructured, then the error handler crashes too | Check that the message is an object before accessing anything |
| A response dumps tens of thousands of lines | limit: -1: nobody validates the bounds in the inputSchema | Enforce bounds on numeric arguments in the tool's code |
| A tool reads a file outside its directory | An argument (country) concatenated into a path: "../other" escapes the directory | Validate the format (/^[A-Z]{2}$/) before any read |
| The server returns stale data after a few hours | An in-memory cache that is never invalidated while a cron job rewrites the files | Invalidate based on the file's modification time |
| Returned times are off by two hours | toISOString() then truncation: the UTC conversion stays, but the time zone marker is lost | Format with an explicit offset (16:45 UTC+02:00) |
| Tests pass, but the demo is empty | Tests called the tools in an environment with no file access, so every tool fell back to its default | Wire data access into the tests and assert on the content, not on the absence of errors |
Security: what to keep in mind
- Consent per action: the host asks for permission, but your server decides how granular it is. Separate “read” and “write” tools are better than one “do everything” tool.
- Content is not instructions: if your tool returns text from elsewhere (a web page, a file, a ticket), that text may contain instructions aimed at the model. Do not execute it, and do not present it as a directive.
- Validate arguments: the schema is documentation for the model, not a firewall. Nothing checks it for you.
- Return only what is needed: a tool that dumps an entire file leaks more data than intended and floods the context window along the way.
- Secrets: with stdio, they stay on the machine; with HTTP, they cross the network. That calls for a different risk assessment.
Case study: this site's server
The paypedia server makes this site's expertise available to any assistant: 17 tools, zero runtime dependencies, zero API keys, zero network calls. Everything is computed locally from the same databases the site uses.
| Card type | Tools | What they do |
|---|---|---|
| 🏦 Banking (7) | iban_check bin_check bank_resolve bic_search cib_search iban_country_info referentials_info | ISO 13616 / mod-97 / Luhn validation, institution lookup, SWIFT directory |
| 📰 News (4) | news_latest news_search news_get_article jobs_search | Published payments news with sources, plus industry job listings |
| ⚖️ Disputes (3) | litige_start litige_step litige_search | Step-by-step chargeback guidance, legal texts, letter templates |
| 🎓 Courses (3) | formation_search formation_get glossary_lookup | Courses, encyclopedia guides, precise definitions |
The architecture choice that makes the demo possible
The tools are defined only once, in the site's code, with an injected data source: the server supplies disk reads, and the web page supplies fetch. That is why the demo console can show the exact text an MCP client receives. It is not a mockup. It is the same code.
Plugging it into your setup
{
"mcpServers": {
"paypedia": {
"command": "node",
"args": ["/path/to/paypedia/mcp/dist/paypedia-mcp.mjs"],
"env": { "PAYPEDIA_ROOT": "/path/to/paypedia" }
}
}
}| Customer | File | Reach |
|---|---|---|
| Claude Code | .mcp.json at the project root | The project; approval requested on first launch |
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json | The app; restart required |
| Your own agent | Wherever you like | Up to you: launch the process and talk to it in JSON-RPC |
Going further
- The specification:
modelcontextprotocol.iois the reference, versioned by date (2024-11-05,2025-03-26,2025-06-18). A well-behaved server accepts the version the client requests if it supports it, and otherwise announces its own. - The official SDKs: Python and TypeScript first, plus half a dozen other languages (Java, Kotlin, C#, Go, Ruby…). Useful for rich servers (resources, prompts, sampling), unnecessary for a stdio tool server.
- The Inspector: the official testing tool. It opens a session, lists your tools, and lets you call them by hand. Reach for it before concluding that “the client is broken.”
- The reference servers (filesystem, git, fetch, memory): read them as examples of how to split tools, not as architecture templates.