The Model Context Protocol (MCP) is the most consequential AI standard since OpenAI shipped tool use. By 2026 it’s everywhere — Claude Code, Cursor, Zed, Windsurf, and a thousand internal LLM apps all speak it. If you build software the AI uses, you should ship an MCP server.

This post is the explanation that should exist. What MCP is, why it works, what’s different from “just call my API,” and a concrete walkthrough of building one.

What MCP is, in one paragraph

MCP is a JSON-RPC protocol that lets an LLM client (Claude, an IDE, an agent) discover and use tools, resources, and prompts from a server. The server exposes capabilities; the client invokes them. The protocol is small, transport-agnostic, and designed for the reality that LLMs need to call functions and read data on behalf of users.

Think of it as USB-C for AI: a single plug that connects any client to any tool.

Why a new protocol?

Couldn’t you just call HTTP APIs? Yes. But:

  • OpenAPI specs are huge. A 50-endpoint OpenAPI doc is too much context for a model to reason over per call.
  • Auth is bespoke. Every API has its own auth flow.
  • Discovery is missing. “What tools are available?” has no standard answer.
  • No common shape for resources. Files, DB rows, search results all look different.

MCP standardizes this. Servers describe their capabilities; clients consume them uniformly. Auth is delegated to the transport. Models see a small, consistent interface.

The three primitives

1. Tools — functions the model can call

{
  name: "search_orders",
  description: "Search orders by customer email or order ID",
  inputSchema: {
    type: "object",
    properties: {
      query: { type: "string" },
      limit: { type: "integer", default: 10 }
    },
    required: ["query"]
  }
}

Same shape as OpenAI/Anthropic tool definitions. The model decides when to call.

2. Resources — data the model can read

{
  uri: "file:///orders/2026-04-28.csv",
  name: "April 28 orders",
  mimeType: "text/csv"
}

Resources are addressable. The client lists them; the model can request the contents. Files, database rows, API responses — all uniform.

3. Prompts — reusable templates the user can invoke

{
  name: "summarize_meeting",
  description: "Summarize a meeting transcript",
  arguments: [{ name: "transcript", required: true }]
}

Surfaces in the client UI as slash commands or quick actions. The user picks; the prompt is sent to the model with the arguments filled in.

Transports

MCP runs over two transports primarily:

  • stdio — server is a subprocess; protocol is line-delimited JSON. Used by Claude Code, Cursor, Zed for local tools.
  • HTTP + SSE — server is a remote process; the client connects over HTTP. Used for hosted MCP servers (GitHub, Linear, Slack, etc.).

The transport doesn’t change the protocol. Same messages, different pipe.

A real Python MCP server

uv add mcp
# server.py
import asyncio
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

import asyncpg

server = Server("orders-mcp")
pool: asyncpg.Pool | None = None


@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="search_orders",
            description="Search orders by customer email or order ID. Returns up to 10 matches.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Email or order ID to search for"},
                    "limit": {"type": "integer", "default": 10, "minimum": 1, "maximum": 50},
                },
                "required": ["query"],
            },
        ),
    ]


@server.call_tool()
async def call_tool(name: str, args: dict) -> list[TextContent]:
    if name != "search_orders":
        raise ValueError(f"unknown tool {name}")

    query = args["query"]
    limit = args.get("limit", 10)

    rows = await pool.fetch(
        """
        SELECT id, email, total_cents, created_at
        FROM orders
        WHERE email = $1 OR id::text = $1
        ORDER BY created_at DESC
        LIMIT $2
        """,
        query, limit,
    )

    if not rows:
        return [TextContent(type="text", text="No orders found.")]

    out = "\n".join(
        f"#{r['id']} {r['email']}  ${r['total_cents']/100:.2f}  {r['created_at'].isoformat()}"
        for r in rows
    )
    return [TextContent(type="text", text=out)]


async def main():
    global pool
    pool = await asyncpg.create_pool(dsn="postgres://...")
    async with stdio_server() as (read, write):
        await server.run(
            read, write,
            InitializationOptions(
                server_name="orders-mcp",
                server_version="0.1.0",
                capabilities=server.get_capabilities(NotificationOptions()),
            ),
        )


if __name__ == "__main__":
    asyncio.run(main())

That’s a complete MCP server. ~50 lines. Wire it to Claude Code:

// ~/.claude/mcp_settings.json
{
  "mcpServers": {
    "orders": {
      "command": "uv",
      "args": ["run", "python", "/path/to/server.py"]
    }
  }
}

Now Claude Code can call search_orders("[email protected]"). Try it: ask Claude “find orders for [email protected] ” — it’ll call your tool, format the result, and answer.

A TypeScript MCP server

bun add @modelcontextprotocol/sdk
// server.ts
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: "orders-mcp", version: "0.1.0" });

server.tool(
  "search_orders",
  "Search orders by customer email or order ID",
  {
    query: z.string(),
    limit: z.number().int().min(1).max(50).default(10),
  },
  async ({ query, limit }) => {
    const rows = await db.query(
      "SELECT id, email, total_cents, created_at FROM orders WHERE email=$1 OR id::text=$1 LIMIT $2",
      [query, limit]
    );
    if (rows.length === 0) {
      return { content: [{ type: "text", text: "No orders found." }] };
    }
    const text = rows
      .map(r => `#${r.id} ${r.email}  $${(r.total_cents / 100).toFixed(2)}  ${r.created_at}`)
      .join("\n");
    return { content: [{ type: "text", text }] };
  }
);

await server.connect(new StdioServerTransport());

McpServer from the official SDK is significantly cleaner than the older API. Zod schemas become tool input schemas; validation is automatic.

What makes a good tool

After shipping a few MCP servers, the patterns are clear:

1. Names + descriptions are prompts

The model picks tools based on description. Treat descriptions like prompts:

  • Verb-led: “Search orders…”, not “Order search.”
  • Specific: “by email or order ID” beats “by customer info.”
  • Honest about limits: “Returns up to 10 matches” prevents the model from expecting more.

2. Small, composable tools beat one big tool

A search_orders + get_order_details + update_order_status trio works better than a manage_orders Swiss army knife. Models reason better over small focused tools.

3. Return text, not JSON, when you can

The model is going to read the response and synthesize an answer for the user. Pre-format. A line of human-readable text per row is better than a JSON dump for the model to re-parse.

4. Include hints

If the user might want to take an action next, suggest it:

Found 3 matching orders. Use get_order_details with the order ID for more.

The model picks up these hints and offers them.

5. Error well

Tool errors propagate as isError: true content blocks. Be specific: “Order #42 not found” not “error.” The model uses these to give the user useful feedback.

Resources — when to use them

Resources are appropriate when:

  • The data is listable: “here are all 50 logs”; the model picks one to read.
  • The data is addressable: a stable URI you can re-read.
  • The data is large: the model loads only what’s needed, not all of it.

Tools and resources together are the pattern: a list_logs tool returns URIs; the model picks one and the client fetches the resource.

Auth and security

Two layers:

Transport-level

  • stdio — local process, run with the user’s permissions. Trust boundary is the user.
  • HTTP + SSE — bearer tokens, OAuth flows. The server enforces what the user can do.

Tool-level

  • Validate inputs (Zod, Pydantic).
  • Audit sensitive actions. Log who, what, when.
  • For destructive operations, require explicit confirmation in the description: “This will permanently delete the order.”

The MCP spec includes a tools/elicitation flow (forthcoming, sometimes implemented) that lets a server ask the user for confirmation before acting. Useful for high-risk tools.

Real MCP servers worth knowing

In 2026 the MCP ecosystem is mature:

  • GitHub MCP — Claude can browse, comment, manage PRs.
  • Linear MCP — issue tracking integration.
  • Slack MCP — read and post messages with permission scopes.
  • Postgres MCP — schema introspection, query execution (read-only by default).
  • Filesystem MCP — bounded file operations.
  • Browser MCP (via Playwright) — agent can navigate, screenshot, click.

You can install these in any MCP client. They all follow the same protocol. You don’t need to integrate each one yourself.

Building MCP for your product

If you have an SaaS product, an MCP server is now table stakes:

  1. List your top tools. What 5–10 actions would a user want an AI to do on their data?
  2. Map them to MCP tools. Each one ~10 lines.
  3. Auth via your existing OAuth. Reuse it.
  4. Publish on GitHub. The community installs it; your product gets distribution inside every AI client.

The companies winning at AI distribution in 2026 are the ones whose products have first-class MCP servers. Notion, Linear, Stripe, GitHub — all there. Your CRM, your wiki, your DB tooling should be too.

Common mistakes

1. Returning the entire database

A list_orders that returns 100k rows breaks the model context. Paginate. Filter. Cap.

2. Inventing complex schemas

The simpler the inputSchema, the better the model uses it. Fancy unions, anyOf, recursive types — all hurt.

3. Not handling errors

A tool that throws on bad input crashes the conversation. Wrap in try/except; return isError: true with a helpful message.

4. Stateless when state matters

Some tools want a session. Use a server-side store keyed by session ID. Don’t try to pass state through tool arguments.

5. No observability

Log every tool call: name, args, latency, status. When a user reports “the AI did the wrong thing,” you need to see what happened.

What’s coming

  • MCP authorization flow standardization is moving. Expect cleaner OAuth in 2026.
  • Streaming tools for long-running operations.
  • Multi-modal results — audio, images, video as first-class.
  • Server-to-server MCP so agents can compose other agents.

The protocol is moving fast in a good way. The core shape is stable.

Read this next

If you want a working MCP server example with Postgres + auth + observability, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .