All WordPress HTML Templates Forms & Webhooks AI & Tools
AI & Tools

Model Context Protocol: Why MCP Matters for Tooling

Model Context Protocol explained by a studio that ships MCP servers: tool budgets, stdio pitfalls, OAuth rules, prompt injection risk and when to skip MCP.

The first agent we shipped with MCP had 61 tools registered across four servers. It worked in demos. In production it kept calling list_projects when the user asked about invoices, and once the conversation got past six turns it started forgetting that a file-write tool existed at all. Nothing was broken. The protocol did exactly what it promised. We’d just handed the model a menu it couldn’t read.

That’s the thing the introductory posts miss. The Model Context Protocol solves a plumbing problem, cleanly and well, and then hands you an entirely different problem: designing a tool surface a language model can actually navigate. The plumbing takes an afternoon. The design takes a few iterations and some honest evaluation.

This is what we’ve learned building and running MCP servers for client work since the spec stabilised, including the parts that bit us.

Key Takeaways

  • MCP is JSON-RPC 2.0 over stdio or Streamable HTTP, with three server primitives: tools (model-controlled), resources (application-controlled) and prompts (user-controlled). Most clients in 2026 still implement tools far better than the other two, so put anything load-bearing in a tool.
  • Tool definitions are sent on every single request. Forty tools at roughly 250 tokens each is 10,000 tokens of overhead before the user has typed anything, on every turn, paid for every time.
  • The single most common stdio failure is a library writing to stdout. Anything that isn’t a JSON-RPC frame on stdout corrupts the stream. Log to stderr, always.
  • Token passthrough is explicitly forbidden by the spec: an MCP server must never accept a client’s access token and forward it upstream. Use OAuth 2.1 with Resource Indicators (RFC 8707) so tokens are audience-bound to your server.
  • Build the CLI or HTTP API first, then wrap it in MCP. The protocol layer should be about 100 lines of adapter. If it’s more, your business logic has leaked into the transport.

Model Context Protocol, explained without the marketing

MCP is a standard way for an LLM application (the host) to talk to third-party capabilities (servers) through a per-server client connection. Anthropic published it in November 2024 and open-sourced the spec and SDKs. By mid-2025 OpenAI, Google and Microsoft had all shipped support in their own agent products, and stewardship has since moved to a vendor-neutral foundation. That matters less for the technology than for the politics: nobody has to bet their integration layer on a single vendor’s roadmap.

Underneath, it is unglamorous. JSON-RPC 2.0 messages. A capability negotiation handshake on initialize. A handful of methods: tools/list, tools/call, resources/read, prompts/get, plus notifications like notifications/tools/list_changed when your server’s capabilities change at runtime.

The useful mental model is USB-C, which is the analogy the spec authors themselves reach for. Before MCP, every host application wrote a bespoke adapter for every data source: M hosts times N sources. Now each side implements one protocol. We’ve felt that arithmetic directly. The same internal documentation server we built for one agent runtime now plugs into a code editor, a desktop chat app and a CI job without a line of change.

The three primitives, and which one you’ll actually use

Tools are model-controlled: the model decides to call them. Resources are application-controlled: the host decides what to attach to context, like a file picker. Prompts are user-controlled, essentially slash commands.

Here’s the practical caveat nobody writes down. Client support is uneven. Tools are universally implemented. Resources are patchy, and in several popular clients a resource is only reachable if the user manually attaches it. If your server’s value depends on the model reading a resource unprompted, it will look broken to most of your users. Expose the same data through a tool as well. Yes, that’s duplication. Ship it anyway.

Designing a server that doesn’t waste context

Start from the tool budget, not the feature list. Every tool name, description and JSON schema goes into the model’s context on every request. That cost is real, it is per-turn, and it degrades selection accuracy long before it breaks anything outright.

Our rule on client projects: no more than 12 to 15 tools per server. If you need more, split by role and let the host enable one set at a time. Verb-noun naming with a namespace prefix (crmfindcontact, not search) stops collisions when three servers are connected at once.

A minimal, correct TypeScript server using the official SDK:

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: "docs-search", version: "1.2.0" });

server.registerTool(
  "docssearchcomponents",
  {
    title: "Search component docs",
    // The description IS the prompt. Say when NOT to call it.
    description:
      "Search the internal component library by name or behaviour. " +
      "Use for questions about markup, props or variants. " +
      "Do not use for billing, deployment or account questions.",
    inputSchema: {
      query: z.string().min(2).describe("Free text, e.g. 'sticky header dark mode'"),
      limit: z.number().int().min(1).max(10).default(5)
    },
    // Annotations are hints to the host UI, not security boundaries.
    annotations: { readOnlyHint: true, openWorldHint: false }
  },
  async ({ query, limit }) => {
    try {
      const hits = await searchIndex(query, limit);
      if (hits.length === 0) {
        return {
          content: [{ type: "text", text: No components matched "${query}". Try a broader term. }]
        };
      }
      return {
        content: [{
          type: "text",
          text: hits.map(h => ## ${h.name}\n${h.summary}\nPath: ${h.path}).join("\n\n")
        }]
      };
    } catch (err) {
      // isError keeps this INSIDE the result so the model can recover.
      // Throwing here becomes a protocol error the model never sees.
      return { isError: true, content: [{ type: "text", text: Search failed: ${err.message} }] };
    }
  }
);

await server.connect(new StdioServerTransport());

Three deliberate choices in there. The description tells the model when not to call the tool, which cuts misrouting more than any amount of positive description. The empty-result branch returns guidance rather than an empty array, because a model handed [] will usually just call the tool again with the same query. And errors come back as isError: true results, not thrown exceptions, so the model gets a chance to fix its own input.

Return text the model can use, not your database schema

Dumping raw JSON into content is the laziest possible choice and it costs you tokens every call. Format for reading. If a downstream program needs machine-readable output, declare an outputSchema and return structuredContent alongside the text. That pairing landed in the 2025-06-18 spec revision and is well supported now. Pagination matters too: a tool that can return 400 rows will eventually return 400 rows into a 200k context window and blow the turn.

Transport and auth: where remote servers get hard

Two transports matter. stdio for local servers, where the host spawns your process and speaks JSON-RPC over stdin and stdout. Streamable HTTP for remote, which replaced the old HTTP+SSE pair in the 2025-03-26 revision. If a tutorial tells you to open a long-lived /sse endpoint, it predates the current spec.

Local config still looks like this in most clients:

{
  "mcpServers": {
    "docs-search": {
      "command": "node",
      "args": ["/srv/mcp/docs-search/build/index.js"],
      "env": { "DOCSINDEXPATH": "/srv/data/index.sqlite" }
    }
  }
}

For remote servers, the spec classifies the MCP server as an OAuth 2.1 Resource Server with a separate Authorization Server, and requires Resource Indicators (RFC 8707) so the token you receive is audience-bound to you and useless elsewhere. The rule that trips teams up: you may not take the token a client presents to you and pass it through to the upstream API you’re wrapping. Exchange it. Mint your own. The confused deputy attack that ban prevents is not theoretical.

Before you connect any server to a real host, run it through the Inspector:

# Interactive test harness: lists tools, validates schemas, shows raw JSON-RPC
npx @modelcontextprotocol/inspector node build/index.js

The failure modes we keep hitting

Something wrote to stdout. A stray console.log, a dependency printing a deprecation warning, a Python library announcing itself on import. The transport frames JSON-RPC on stdout, so any other byte corrupts the stream and the client reports a vague “server disconnected”. Route every log line to stderr and make that a lint rule.

Tool sprawl by committee. Each team adds “just one more” tool to the shared server. Six months later you have 40 and the agent’s accuracy has quietly halved. Treat the tool list like a public API with a review gate.

Prompt injection through tool output. This is the one that should keep you up. If your server returns content from an untrusted source (a scraped page, a customer support ticket, a GitHub issue) and the same agent also has a tool that can send data outward, you’ve assembled what Simon Willison calls the lethal trifecta: private data access, untrusted content, and an exfiltration channel. MCP doesn’t protect you here and doesn’t claim to. Keep read-untrusted and write-anywhere capabilities in separate agents, or gate the write behind human approval.

Annotations mistaken for permissions. readOnlyHint is a hint. A malicious or buggy server can declare it and still delete your database. Enforce on the server side.

Silent schema drift. You change a parameter from string to enum, existing conversations keep sending the old shape, and the failures look like model errors. Version your server, emit tools/list_changed, and accept the old shape for a deprecation window.

When MCP is the wrong choice

If you control both ends and there’s exactly one consumer, MCP buys you nothing but a process boundary and a serialisation hop. A plain function call in your agent loop is faster to write, easier to debug and doesn’t need a transport. We’ve ripped MCP out of two internal projects for precisely this reason.

It’s also the wrong layer for deterministic pipelines. If step two always follows step one, write the code. Handing that sequence to a model and hoping it calls the tools in order is a decision you will regret at 2am, and it costs a full round-trip per step.

Latency deserves a mention too. Each tool call is a network hop plus a model turn, so a five-call task can easily add several seconds. The answer is a coarser tool that does more per call, not a faster server. The same trade-offs we walk through in choosing an LLM API apply here, one layer up.

Where MCP genuinely earns its keep: internal knowledge that changes weekly, systems with real auth, and anything you want reachable from more than one host. We built a documentation server over the Canvas component library for exactly that reason. One server, three clients, and nobody pastes stale markup into a project any more.

A short shipping checklist

  1. Business logic lives in a library with its own tests. The MCP layer is an adapter and nothing else.
  2. Count your tool definition tokens. If the total is over about 8,000, split the server.
  3. Every description names at least one case where the tool should not be used.
  4. Errors return as isError results with a suggested fix in plain language.
  5. All logging goes to stderr. Verified by running the binary and piping stdout to a JSON validator.
  6. Remote servers validate token audience and never forward client tokens upstream.
  7. You have 20 to 30 recorded real queries you replay after every tool change. Without this, you are guessing.

That last one is the difference between a server that demos well and one that survives contact with users. The same discipline we apply to prompt patterns that survive model updates applies to tool surfaces: write the evaluation set before you need it.

Frequently Asked Questions

Is MCP just function calling with extra steps?

No, though the confusion is fair. Function calling is how a model requests an action within one provider’s API. MCP is how your application discovers and connects to capabilities that live outside it, from any vendor. The model still emits a tool call at the end. MCP standardises where that tool came from, how it’s authenticated and how it’s transported.

How many MCP servers can I connect at once?

Technically as many as the host allows, practically two to four before tool selection degrades noticeably. The limit is context and naming collisions, not the protocol. Namespace your tool names and enable only the servers a given task needs.

Do I need OAuth for a local stdio server?

No. stdio servers run as a subprocess of the host with the user’s own permissions, so credentials come from environment variables or a local config file. OAuth 2.1 and Resource Indicators only apply to remote servers over Streamable HTTP, where an untrusted party could otherwise reach your endpoint.

What language should I write an MCP server in?

Whichever one already holds your logic. Official SDKs cover TypeScript, Python, Java, Kotlin, C#, Go, Rust, Swift, PHP and Ruby, and they’re close enough in shape that porting is mechanical. TypeScript has the widest example coverage and the best Inspector integration, so it’s the easiest first server.

Can an MCP server read files it shouldn’t?

Yes, if you let it. A server runs with the permissions of the process that spawned it, and the protocol has no sandbox. Constrain paths inside your own handler code, run untrusted servers in a container, and read the source of any community server before connecting it to a machine with credentials on it.

If you’re deciding this week: don’t start by writing a server. Take the internal API your team already reaches for most often, wrap the three or four operations that matter in tools, and run 20 real queries against it. You’ll learn more from that afternoon than from any amount of architecture diagramming, and you’ll find out quickly whether the problem you have is an integration problem or a tool design problem. It’s usually the second one.

how to build an mcp server mcp oauth resource indicators mcp server tool design mcp stdio stdout logging error mcp vs function calling model context protocol model context protocol explained streamable http mcp transport