🔌 Open protocolJSON-RPC 2.0Beginner to expert⏱ 18 min read

MCP from A to Z. How an AI assistant connects to your data, and how to build one.

Diagrams, real JSON-RPC frames, decision tables, a working server in fifty lines with zero dependencies, and the eight pitfalls we hit while building this site’s own. No prior knowledge required, and nothing skipped at the end.

Start at the beginningWriting a server →This site’s server →

Try it before you read. All 17 tools, running live.

This console calls the same code as the MCP server: same validations, same reference data, same output text. Ask a question, or expand the JSON-RPC frame to see exactly what passes between client and server. The rest of the page explains how it works.

Paypedia MCP console17 tools

Loading Paypedia tools…
Try:
    No IBAN and no full card number ever leaves your machine.Site tools →

    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.

    🔑
    The only analogy you need
    Before MCP, every AI integration was a custom cable: a prompt describing an API plus some glue code, rebuilt for each assistant. MCP is the standard connector. You wire the tool once, and any compatible client can plug into it.
    1
    protocol (JSON-RPC 2.0)
    3
    roles: host, client, server
    4
    primitives, and one is enough
    2
    transports: stdio, HTTP

    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.

    ApproachHow it worksDrawback
    Everything in the promptYou paste the data into the conversationExpensive, stale a minute later, capped by the context window
    Custom integrationGlue code for every assistant and every toolN × M integrations to maintain; switching assistants means rewriting everything
    MCPThe tool is exposed once, and every client plugs into itN + M: you still write the server, but only once
    Three ways to give a model data
    ℹ️
    The least obvious benefit
    The real gain is not less code. It is traceability. A tool returns data you can cite, date, and verify, instead of the model paraphrasing from memory. In a regulated field like payments, that is the difference between an answer you can use and one you should never pass on to a customer.

    The three roles

    The hostClaude Desktop, Claude Code, your agentMCP clientone connection per serverlaunches and managesJSON-RPC 2.0paypedia17 tools · stdiolocal databasesJSON-RPC 2.0githubrepositories · HTTPRemote APIJSON-RPC 2.0your serveryour own business logicdatabase, files…The model never touches the resource: it asks, and the server executes and replies.
    • 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.
    ⚠️
    What the server cannot see
    An MCP server receives the arguments of a call, not your conversation history. That guarantees isolation, and it is also a design constraint: your tool has to be useful with only the arguments declared in its schema.

    The two transports

    stdioHTTP (streamable / SSE)
    Where the server runsOn your machine, launched by the hostOn a remote server
    ChannelThe process's standard input/outputHTTP requests + event stream
    AuthenticationNone: it is already your sessionRequired (OAuth, token…)
    Sensitive dataNever leaves the machineLeaves your network
    Multi-userNo: one process per clientYes
    Best forLocal files, reference datasets, personal toolsSaaS, company APIs, teams
    stdio or HTTP: choose based on where the data lives
    ⚠️
    The golden rule of stdio
    With stdio, standard output is the protocol. A debug 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

    MCP clientServerinitializeversions and capabilities exchangednotifications/initializedno response expectedtools/listthe client discovers the toolstools/callname + arguments validated against the schemaresult.content[]text passed to the modelOver stdio, a single non-protocol byte on stdout breaks the session.

    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.

    1. The client introduces itself, and the server responds
    → {"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
    2. The client discovers the tools, then calls one
    → {"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}}
    MethodTypeRole
    initializerequestNegotiate the protocol version and announce capabilities
    notifications/initializednotificationThe client is ready: send nothing back
    tools/listrequestReturn the catalog: name, description, input schema
    tools/callrequestRun the tool and return content[]
    pingrequestReply {}; the client uses it to check that the process is alive
    Any other methodrequestReturn error -32601 (method not found)
    The only methods a tool server needs
    ℹ️
    Two ways to fail, and why they differ
    A protocol error (invalid JSON, a tool that does not exist) is reported with a JSON-RPC 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: the model acts
    Functions the model decides to call. This is the core primitive, and nearly every server exposes only tools.
    📄
    Resources: the model reads
    Content addressable by URI that the host can attach to the conversation. Useful for documents, not for actions.
    💬
    Prompts: the user invokes
    Request templates prepared by the server and offered in the interface (a command, a form).
    🧠
    Sampling: the server asks
    The server can ask the model for a completion, through the client. Rarely used, and to be handled with care.
    ✅
    Start with tools, and only tools
    A useful server can expose nothing but 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.

    Full declaration of a tool (excerpt from src/lib/mcpToolsBanking.ts)
    {
      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…',
    }
    ScopeRoleClassic mistake
    nameIdentifier the client callsRenaming it: every existing configuration breaks
    descriptionDecides whether the model calls the toolSaying what the tool does without saying when to call it
    inputSchemaInput contract, shown to the modelDeclaring it but never validating it: the schema becomes mere documentation
    OutputText, read by a modelReturning raw JSON where a sentence would be understood right away
    Each field has a job, and a classic way to get it wrong
    🔑
    The description is code
    It is the only element that drives the model's behavior. A prescriptive description (“Call whenever an IBAN is mentioned”) changes how often the tool gets called far more than a perfect schema does. Write it for a busy colleague, not for API docs.

    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.

    server.mjs: run it with `node server.mjs`
    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')
    ℹ️
    Testing it without a client
    Two lines are enough: 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.

    SymptomRoot causeFix
    The client cannot talk to the serverA console.log (or a dependency banner) on stdoutAll logging goes to stderr, no exceptions
    The client complains about an unexpected responseThe server replies to a notification (a message with no id)Reply only if id is present and not null
    The server crashes mid-sessionA null line (valid JSON) gets destructured, then the error handler crashes tooCheck that the message is an object before accessing anything
    A response dumps tens of thousands of lineslimit: -1: nobody validates the bounds in the inputSchemaEnforce bounds on numeric arguments in the tool's code
    A tool reads a file outside its directoryAn argument (country) concatenated into a path: "../other" escapes the directoryValidate the format (/^[A-Z]{2}$/) before any read
    The server returns stale data after a few hoursAn in-memory cache that is never invalidated while a cron job rewrites the filesInvalidate based on the file's modification time
    Returned times are off by two hourstoISOString() then truncation: the UTC conversion stays, but the time zone marker is lostFormat with an explicit offset (16:45 UTC+02:00)
    Tests pass, but the demo is emptyTests called the tools in an environment with no file access, so every tool fell back to its defaultWire data access into the tests and assert on the content, not on the absence of errors
    Symptom, cause, fix

    Security: what to keep in mind

    ⚠️
    A local server runs with your permissions
    It can read whatever you can read and write whatever you can write. An installed MCP server is trusted software, just like a browser extension: read the code, favor verifiable sources, and expose only what is strictly necessary.
    • 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 typeToolsWhat they do
    🏦 Banking (7)iban_check bin_check bank_resolve bic_search cib_search iban_country_info referentials_infoISO 13616 / mod-97 / Luhn validation, institution lookup, SWIFT directory
    📰 News (4)news_latest news_search news_get_article jobs_searchPublished payments news with sources, plus industry job listings
    ⚖️ Disputes (3)litige_start litige_step litige_searchStep-by-step chargeback guidance, legal texts, letter templates
    🎓 Courses (3)formation_search formation_get glossary_lookupCourses, encyclopedia guides, precise definitions
    The 17 tools, by category

    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.

    One definition, two runtimes
    src/lib
    defines the 17 tools
    validation + formatting, no I/O
    mcp/
    injects disk access
    public/referentials/** read locally
    the site
    injects fetch
    the same files, served over HTTP
    safeguard
    a test compares
    displayed catalog ↔ tools/list of the built server
    ✅
    What it means in practice
    You can pass it real IBANs and real card numbers: nothing leaves the machine, no key is needed, and the response cites the database it used, even when two databases contradict each other, which happens with public BIN lists.

    Plugging it into your setup

    .mcp.json: Claude Code (project scope)
    {
      "mcpServers": {
        "paypedia": {
          "command": "node",
          "args": ["/path/to/paypedia/mcp/dist/paypedia-mcp.mjs"],
          "env": { "PAYPEDIA_ROOT": "/path/to/paypedia" }
        }
      }
    }
    CustomerFileReach
    Claude Code.mcp.json at the project rootThe project; approval requested on first launch
    Claude Desktop~/Library/Application Support/Claude/claude_desktop_config.jsonThe app; restart required
    Your own agentWherever you likeUp to you: launch the process and talk to it in JSON-RPC
    Where to declare a server, by client
    ℹ️
    Checking that it works
    A correctly connected server shows up in the client's tool list, prefixed with its name. If it does not, check stderr (that is where your logs are), verify the file's absolute path, and test the process by hand before blaming the client.

    Going further

    • The specification: modelcontextprotocol.io is 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.
    🔑
    The advice that matters
    Write one tool first, with a prescriptive description and a strict schema, and test it from a real client. A server with 17 well-described tools beats a server with 60 tools that the model never calls at the right time.