mcp server
MCP is not a REST replacement. Decision table, real code from both, side-by-side comparison across 10 dimensions, and the hybrid pattern that ships in production.
TL;DR — MCP is not a REST replacement. It's a contract layer for AI agents that sits on top of the same kind of backend code you'd write anyway. Use REST for humans and traditional app clients. Use MCP when an AI agent needs to discover and invoke tools on your system. Most production systems end up with both — the MCP server calls the REST handler underneath.
I run both in production. My MCP servers (AppHandoff, MCP Beast) and my REST APIs (the blog, the admin dashboard, the contact pipeline) live in the same codebase. They share auth, they share the database, and half the time the MCP tool handler is a 10-line wrapper over an existing REST function. This post is the honest answer to "should this be an MCP server or a REST API?" — no buzzwords, no product pitch.
| Need | Pick | Why |
|---|---|---|
| Web app, mobile app, or CLI calling your backend | REST / GraphQL | Mature tooling, cacheable, every language has a client |
| AI agent needs to do something in your system | MCP | Tool discovery, structured schemas, audit per-call |
| AI agent needs to read public or semi-public data | REST (+ retrieval) | Don't add MCP just to expose a GET |
| Multiple AI clients (Claude, Cursor, custom) need the same tools | MCP | One contract, many clients |
| You need fine-grained per-tool access control for agents | MCP | Built into the model |
| You need sub-100ms latency for user-facing UI | REST | Fewer hops |
| You haven't shipped the REST version yet | REST first | Build once, wrap with MCP later |
That's the whole post. The rest is the receipts.
Model Context Protocol is an open standard from Anthropic. At the wire level it's JSON-RPC 2.0 over stdio or HTTP. The real product is what it standardises:
tools/list — "what can this server do?" (agent-readable)tools/call — invoke a named tool with structured argumentsresources/list + resources/read — expose content by URIprompts/list + prompts/get — reusable prompt templatesAn MCP server is valuable for one specific reason: the agent can discover your tools and understand their schemas without you writing a custom client. Claude, Cursor, Claude Code, Claude Desktop, and any MCP-compatible agent all speak the same protocol. Build the tool once.
If your answer to "why MCP?" doesn't involve an LLM as the primary consumer, you don't need MCP.
REST / GraphQL are not going anywhere. They win at:
stale-while-revalidate, edge caching, browser HTTP cache. None of this applies to MCP tool calls — every tools/call is effectively uncacheable.s-maxage=60, stale-while-revalidate=300. Wrapping it in MCP would lose the caching and be slower.Here's the actual REST handler powering inspiredbyfrustration.com/blog — 20 lines, edge-cached, human-readable:
// inspired-api/dashboard/app/api/blog/route.ts
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin')
const { searchParams } = new URL(request.url)
const siteId = searchParams.get('siteId') || (await getTenantIdFromOrigin(origin))
const limitParam = searchParams.get('limit')
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 100) : undefined
const posts = await getPublishedPosts(limit, siteId)
return NextResponse.json(
{ data: posts },
{
headers: {
...corsHeaders(origin),
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
},
}
)
}
This would be worse as an MCP server. You can't cache it on Cloudflare, and nothing in the call pattern benefits from tool discovery. Leave it alone.
| Dimension | REST | MCP |
|---|---|---|
| Protocol | HTTP verbs + JSON/GraphQL | JSON-RPC 2.0 over stdio or HTTP |
| Discovery | OpenAPI (optional, often stale) | tools/list (required, live, schema-typed) |
| Auth | Bearer tokens, OAuth, cookies | Bearer tokens in headers (HTTP) or process env (stdio) |
| Caching | Full HTTP cache ecosystem | None — every call executes |
| Streaming | SSE / WebSockets (custom) | SSE transport is part of the spec |
| Client ecosystem | Every language, every framework | MCP SDKs (TS, Python, others growing) |
| Rate limiting | You build it | You build it (same tools) |
| Primary consumer | Humans + app clients | AI agents |
| Error format | HTTP status + JSON body | JSON-RPC error object |
| Versioning | URL path or header | Capability negotiation at handshake |
Neither is a strict superset of the other. Picking one is about who's calling you, not which is "newer."
The question on a real codebase isn't "MCP or REST?" It's "do I need an MCP surface on top of the REST API I already have?"
In AppHandoff this is exactly how it works. The tool handler is 15 lines:
// handler for mcp tool "list_handoff_tickets"
async function listHandoffTickets({ projectId, status }) {
// reuse the same service function the REST /api/handoffs endpoint uses
const tickets = await getHandoffTickets({ projectId, status })
return { content: [{ type: 'text', text: JSON.stringify(tickets) }] }
}
The REST endpoint exists for the dashboard. The MCP tool exists for Claude, Cursor, and anyone's custom agent. Both call getHandoffTickets() underneath. One source of truth, two front doors.
If you're starting from scratch, build the REST layer first. Then wrap the verbs that agents actually need as MCP tools. Don't MCP the whole surface — agents rarely need your admin endpoints.
If you're federating many MCP servers (AppHandoff actually does this — users can register their own MCP endpoints), you need to proxy tool calls over JSON-RPC. The full implementation is 40 lines:
// apphandoff/apps/web/lib/mcp-foreign.ts
async function execCustomMcp(tool, args, secrets, dryRun) {
const serverUrl = tool.config.mcp_server_url
const headers = {
'Content-Type': 'application/json',
...(secrets.MCP_AUTH_TOKEN ? { Authorization: `Bearer ${secrets.MCP_AUTH_TOKEN}` } : {}),
}
if (dryRun) {
const res = await fetchWithTimeout(serverUrl, {
method: 'POST', headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
}, 10_000)
const json = await res.json()
return { data: { tools_count: (json?.result?.tools ?? []).length }, status: 'success' }
}
const res = await fetchWithTimeout(serverUrl, {
method: 'POST', headers,
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: tool.config.remote_tool_name ?? tool.name, arguments: args },
}),
}, 10_000)
const json = await res.json()
if (json.error) return { error: json.error.message, status: 'error' }
return { data: json.result, status: 'success' }
}
Every call is wrapped in a 10-second timeout. The response goes through a 2 MB size cap. And a rate limiter + 3-state circuit breaker sit in front of the whole thing — details in MCP Server Architecture if you want the full stack.
A REST 429 is a number. An MCP error is a structured payload the agent can act on:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32003,
"message": "rate_limited",
"data": { "retry_after_ms": 30000, "limit_per_min": 60 }
}
}
When I made AppHandoff return structured errors instead of plain 429s, Claude stopped giving up and started waiting 30 seconds and retrying. No prompt change, no model change — just better error payloads. Support tickets dropped roughly in half.
You can do the same thing in REST. Nothing stops you from returning a JSON body with retry_after_ms. Most people don't. MCP bakes it into the protocol, so it's the default.
If you already have a REST API and you're considering MCP:
create_*, update_*, search_*, and list_* for one or two entities.tool_name, status, duration_ms, caller_id. You need this for the error-rate dashboard; you really need it when a customer says "the agent did something weird."send_email isn't idempotent, say so in the tool description.Build REST for humans. Build MCP for agents. If you're only ever going to have one AI feature in one product, skip MCP and expose a tight REST endpoint. If you expect Claude + Cursor + a custom agent to all hit your system, MCP pays back the setup cost fast.
And if you already have a REST API — great, you're halfway done. Keep it, call it from your MCP tools, and let both serve their own audience.
Need help deciding, or need someone to actually ship the MCP layer? I've built production MCP servers and maintain the REST APIs they sit on top of. Describe the system you want to make AI-native and I'll tell you what the first version should look like.
// keep reading
mcp server · 8 min
Four layers every production MCP server needs — transport, JSON-RPC shape, tool definitions, and guardrails. With real code for rate limiting, circuit breakers, and structured errors.
mcp server · 7 min
The actual working config for ~/.cursor/mcp.json — two transports, env-var secrets, the 5-minute setup flow, and the debugging loop for the red dot.