Adam Innes · Blog

MCP or Just Your API? What the Protocol Actually Adds

· 7 min · mcp, ai agents, apis, security

Every team that starts wiring an LLM into its own systems ends up in the same meeting. You already have a perfectly good REST API, and somebody says you should build an MCP server. Is that a second API to maintain, a wrapper, or just the thing everyone is talking about this year? The short answer is that MCP doesn’t replace your API at all. It changes who the consumer is, and that has real consequences for discovery, auth and security.

Where MCP came from

Anthropic open sourced the Model Context Protocol on November 25, 2024, pitching it as an open, shared way to hook AI assistants up to the places your data actually sits. The problem it targets will be familiar to anyone who has built integrations for a living: each new data source ends up needing a bespoke connector. The launch shipped the spec and SDKs, local MCP server support in the Claude Desktop apps, and a repository of prebuilt servers for systems like Google Drive, Slack, GitHub and Postgres.

The spec has been revised since then, and the current revision as I write this is 2025-03-26. That’s the version everything below describes. Messages are JSON-RPC 2.0, connections are stateful, and the spec says it takes some inspiration from the Language Server Protocol, which solved the same kind of problem for editors and programming languages.

An API is written for a developer, MCP is written for a host

A REST API assumes a person on the other end. Someone reads the docs, decides which endpoints matter, writes client code and ships it. If you want a model to use that API through function calling, you do that work inside each application. You hand write a tool definition for every endpoint you care about, you write the code that turns the model’s tool call into an HTTP request, and then you do it all again in the next app that wants the same data.

Diagram comparing per app API connectors with MCP clients and servers

MCP moves that work to the side that owns the system. You build one server that describes itself, and any application that speaks the protocol can pick it up at runtime. A connection opens with an initialize request in which the client sends the protocol version it supports and its capabilities, and the server replies with its own. A server that offers tools has to declare a tools capability, and if it sets listChanged it’s saying it will send a notification when its tool list changes. From there the client calls tools/list to get a name, a description and a JSON Schema for each tool’s input, and calls tools/call with a name and arguments when the model picks one.

That runtime discovery is the real difference. A hand written integration is frozen at the moment someone wrote it, while an MCP server tells whatever client connects what it can do right now. You can see the consequence in the OpenAI Agents SDK, whose MCP docs at v0.0.17 say it calls list_tools() on each MCP server every time an agent runs. There’s a cache_tools_list option for when you’re sure the list won’t change, because repeating that call against a remote server adds latency. The same page covers stdio, HTTP over SSE and Streamable HTTP servers, which is a nice illustration of the point: a server written against the spec isn’t tied to the vendor whose app it was first built for.

More than a list of endpoints

Tools are only one of three things a server can offer, and the spec is clear about who is in charge of each. Tools are model controlled, so the model decides when to call them. Resources are application controlled: data identified by a URI, such as file contents or a database schema, that the host decides how to bring into context. Prompts are user controlled templates, the kind of thing a client might surface as a slash command. The official Python SDK README frames it in API terms, comparing resources to GET endpoints that load information and tools to POST endpoints that run code or cause side effects.

The conversation can also run in the other direction, which a plain request and response API doesn’t give you. A client that supports sampling lets a server ask the host’s model for a completion, so the server doesn’t need model API keys of its own, and the spec says a human should be able to deny those requests. A client can also offer roots, which tell a server the filesystem boundaries it’s allowed to work within.

Transports and auth

The spec defines two standard transports. With stdio, the client launches the server as a subprocess and they exchange newline delimited JSON-RPC messages over stdin and stdout. With Streamable HTTP, which replaced the older HTTP plus SSE transport in this revision, the server exposes a single endpoint that accepts POST and GET. It can answer a request with plain JSON or open a Server-Sent Events stream, and it can assign an Mcp-Session-Id header that the client then sends on every later request.

Auth is where MCP differs most from how you’d secure a normal API, and the authorization section is worth reading slowly. Authorization is optional. Servers on the stdio transport aren’t supposed to use this flow at all and should pull credentials from the environment instead. HTTP servers that do support it follow a flow based on OAuth 2.1. A request without valid authorization gets a 401, PKCE is required for every client, clients must implement authorization server metadata discovery, and dynamic client registration is strongly recommended.

The reason for all that machinery makes sense once you compare it to a normal API. With a normal API you register your app with the provider ahead of time and paste a client ID into your config. An MCP client can’t know every server it will ever meet, so it needs a way to find the auth endpoints and register itself without anyone copying credentials around by hand. Once it has a token, the token goes in an Authorization: Bearer header on every request and never in the query string.

What wrapping an API looks like

The simplest useful MCP server is a thin adapter over an API you already have. With the official Python SDK at v1.9.4, the FastMCP helper builds each tool’s input schema from your type hints and uses the docstring as its description:

import httpx
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations

mcp = FastMCP("Orders")

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
async def get_order(order_id: str) -> str:
    """Look up one order by ID and return its status and total."""
    async with httpx.AsyncClient() as client:
        r = await client.get(f"https://api.example.com/orders/{order_id}")
        r.raise_for_status()
        return r.text

if __name__ == "__main__":
    mcp.run()

Calling mcp.run() with no arguments uses the stdio transport, and mcp.run(transport="streamable-http") serves the same tool over HTTP. If the request raises, the SDK doesn’t crash the session. It sends the error message back as a tool result with isError set to true, which is how the spec says execution failures like a broken upstream API should be reported, as opposed to standard JSON-RPC errors for protocol problems.

Sequence diagram of an MCP session in front of an existing REST API

Notice what didn’t move, though. Your business logic, your API’s own auth and your rate limits all still live in the API. The MCP server is a translator that sits in front of it.

Security is the part to slow down on

Putting an MCP server in front of an API changes who is deciding which calls to make. It’s no longer code you reviewed. It’s a model acting on text you don’t fully control. The spec is direct about this. It treats tools as arbitrary code execution, says hosts must get explicit user consent before invoking any tool, and says there should always be a human in the loop who can deny a call. Servers must validate tool inputs, implement access controls, rate limit tool invocations and sanitize outputs. Clients should show users the inputs before calling a server, so data doesn’t leak out by accident or by design.

Tool annotations need a special mention. The 2025-03-26 revision added hints like readOnlyHint and destructiveHint, which are handy for deciding when a client should ask for confirmation. But the spec says clients must treat annotations as untrusted unless they come from a trusted server. Nothing stops a malicious server from labeling a delete tool as read only, so treat annotations as a UX hint and never as a security control.

My own rule, and this is opinion rather than spec, is to scope the credentials an MCP server uses down to exactly what its tools need. If the server holds an admin token for your API, every model that connects to it effectively has admin too, no matter how tidy the tool list looks.

So which one do you build?

If you have one application talking to one API and you control both, plain function calling straight against the API is fine. You write a few tool definitions and skip a moving part. MCP starts paying for itself when the same system needs to be reachable from several AI clients (Claude Desktop, an agent built on the OpenAI Agents SDK, an internal tool), or when you own the API and want to publish one standard way in. Then you write the adapter once, next to the API, and let each client discover it.

Either way, keep your API. MCP is the layer that describes it to models, negotiates what each side supports and standardizes auth for clients that have never met your server. The API still does the actual work, and it’s still where your real security boundaries belong.

← all posts