MCP Server vs API: When to Use Each for LLM Tooling
I run FastAPI services that power LLM tool calling in production. The short answer: use MCP when the model drives the workflow and needs dynamic tool discovery. Use REST when you control the client and need stable contracts. This post breaks down the mcp server vs api decision across transport, authentication, schemas, and observability so you can stop guessing.
When to Choose MCP Over REST for LLM Tooling
Choose MCP when the LLM decides which tools to call at runtime. The protocol exposes a dynamic tools/list endpoint that the model queries before every turn. This lets you add, remove, or rename tools without redeploying the client. I learned this the hard way after versioning a REST API for six months while the prompt engineering team changed function signatures weekly.
Choose REST when you own the client code. If your backend calls a fixed set of endpoints, OpenAPI gives you compile-time validation, generated SDKs, and cacheable GET semantics. MCP adds latency for no benefit here. Don’t force MCP onto a synchronous request-response flow just because it is trendy.
Transport Differences: Stdio vs HTTP/SSE and Deployment Impact
MCP speaks JSON-RPC 2.0. The spec defines two transports: stdio for local processes and Server-Sent Events (SSE) over HTTP for remote servers. Stdio is trivial to spawn from a CLI agent like Cursor or Claude Desktop. You launch the server as a subprocess, pipe JSON over stdin/stdout, and the OS handles lifecycle. Zero network config. Zero TLS termination.
SSE changes the deployment model. You now run a long-lived HTTP server that holds open connections. That means sticky sessions, connection draining on deploy, and websocket-style heartbeats. I run MCP over SSE behind nginx with proxy_read_timeout 3600s and proxy_send_timeout 3600s. If you forget those, idle connections drop mid-tool-call and the model hallucinates a timeout error.
REST over HTTP/2 is simpler to operate. Every request is stateless. Load balancers, CDNs, and autoscalers work out of the box. If your team lacks SSE experience, the operational tax of MCP-over-HTTP is real. For a deeper look at the component model, see MCP Server Architecture: Components, Transport, and Patterns.
Authentication Patterns: OAuth for MCP vs API Keys for REST
REST APIs typically use API keys or Bearer tokens validated at the edge. Middleware checks the header, looks up the tenant, and attaches request.state.user. Straightforward.
MCP authentication is weird. The spec punts on auth for stdio because the process inherits the user’s environment. For SSE, the current best practice is OAuth 2.1 with Dynamic Client Registration. The MCP client registers itself, gets a client ID, and runs the authorization code flow. The server issues an access token scoped to mcp:tools:call. You validate that token on every JSON-RPC request.
I implemented this recently using authlib and a custom FastAPI dependency. The token introspection endpoint becomes a hot path. Cache the JWKS. Cache the introspection result for 30 seconds. If you skip caching, your auth service becomes the bottleneck.
# FastAPI dependency for MCP OAuth validationasync def get_mcp_principal( authorization: str = Header(None), http_client: AsyncClient = Depends(get_http_client),) -> Principal: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(401, "Missing bearer token") token = authorization.split(" ")[1] # Cache introspection in Redis with 30s TTL cached = await redis.get(f"mcp_token:{token}") if cached: return Principal.parse_raw(cached) resp = await http_client.post( settings.INTROSPECTION_URL, data={"token": token}, auth=(settings.CLIENT_ID, settings.CLIENT_SECRET), ) resp.raise_for_status() data = resp.json() if not data.get("active"): raise HTTPException(401, "Token inactive") principal = Principal(sub=data["sub"], scopes=data["scope"].split()) await redis.setex(f"mcp_token:{token}", 30, principal.json()) return principalSchema Discovery: Dynamic Tool Listing vs Static OpenAPI Specs
This is the biggest architectural difference. REST publishes a static OpenAPI document. Clients generate types at build time. Breaking changes require versioned paths (/v1/, /v2/).
MCP publishes tools/list at runtime. The schema lives in code as Pydantic models. When the model asks “what can you do?”, the server returns current tool definitions including JSON Schema for arguments. You change a parameter description? The model sees it next turn. No client redeploy.
The risk: silent drift. If you rename a tool but the prompt still references the old name, the model fails at inference time. I write contract tests that snapshot tools/list output and fail CI on unexpected changes. Treat tool names like public API surface. For a working example of this pattern with FastAPI, check Model Context Protocol Examples: How to Run MCP with FastAPI.
Error Handling and Retries Across Protocol Boundaries
REST maps errors to HTTP status codes. 4xx is client fault. 5xx is server fault. Retry logic is standard: exponential backoff on 5xx and 429.
MCP wraps everything in JSON-RPC 2.0 responses. The HTTP layer always returns 200 OK (for SSE) or streams chunks. Errors live in the error field with a code and message. The spec defines -32600 to -32603 for parse, invalid request, method not found, and internal error. You define application codes above -32000.
Retry logic must inspect the JSON-RPC error code, not the HTTP status. A -32603 internal error might be transient. A -32601 method not found means the tool disappeared. Do not retry that. I implement a retry policy in the MCP client wrapper:
async def call_tool_with_retry( self, name: str, args: dict, max_attempts: int = 3) -> Any: for attempt in range(max_attempts): result = await self.session.call_tool(name, args) if result.error: code = result.error.code if code == -32603 and attempt < max_attempts - 1: await asyncio.sleep(2 ** attempt) continue raise MCPToolError(result.error) return result.result raise MCPToolError("Max retries exceeded")Production Checklist: Observability, Rate Limiting, and Versioning
You cannot run MCP in production without these three.
Observability: Log every tools/call request and response with correlation IDs. Track latency percentiles per tool. The model calls tools in bursts. A single user turn triggers five parallel tools/call requests. Your traces must link them. I use OpenTelemetry with a custom span processor that injects mcp.tool.name and mcp.request.id attributes.
Rate Limiting: REST rate limits by IP or API key. MCP rate limits by session and by tool. A malicious prompt can spin a loop calling delete_database until quota exhausts. Implement a token bucket per session per tool. Store state in Redis with a sliding window. Return a JSON-RPC error with code -32001 (rate limited) and a retry_after_ms field in the data payload.
Versioning: MCP has no built-in versioning. The initialize negotiation exchanges protocol version only. Tool versioning is on you. I prefix tool names with a major version: v1_query_users, v2_query_users. When v1 usage drops to zero, I delete it. The tools/list response becomes your version manifest. Document the deprecation schedule in the tool description field so the model sees it.
FAQ
Can I run MCP and REST on the same FastAPI app?
Yes. Mount the MCP SSE endpoint at /mcp and your REST routers at /api. Share dependencies like database pools and auth validators. Keep the MCP router lightweight. It only handles initialize, tools/list, and tools/call.
Does MCP replace my internal service APIs? No. MCP is an interface for LLM agents. Your internal services still need REST or gRPC for service-to-service communication, webhooks, and dashboards. MCP servers usually act as a thin adapter layer over existing service clients.
How do I test MCP tools locally?
Use the MCP Inspector (npx @modelcontextprotocol/inspector) against your stdio server. For SSE, point it at http://localhost:8000/mcp. It renders tools/list, lets you invoke tools, and shows raw JSON-RPC traffic. Add it to your make dev target.
What happens if the SSE connection drops mid-call?
The client must retry the tools/call request on a new connection. Design tools to be idempotent or use idempotency keys passed in arguments. The server should deduplicate based on that key. This is not automatic.
Key Takeaways
- Use MCP for model-driven workflows with dynamic tool sets. Use REST for fixed contracts you control.
- SSE transport adds operational complexity. Stdio is simpler but only works for local agents.
- Auth for remote MCP requires OAuth 2.1. Cache token introspection or your auth service will melt.
- Schema discovery is dynamic in MCP. Snapshot
tools/listin CI to catch breaking changes. - Observability, per-tool rate limiting, and explicit versioning are non-negotiable for production MCP.
Working on something similar?
If you're building backend or AI systems and want a second set of senior eyes, let's talk.