You shipped an MCP server for your internal APIs on a Wednesday afternoon. The protocol part took about three hours. You installed the SDK, defined a handful of tools that wrapped your existing endpoints, connected Claude Desktop, and watched the model call them by dinner. Two weeks later you're in a room with security explaining why an agent conversation about billing invoked delete_customer on a real customer, and why the audit trail says the request was authorised by anthropic-sdk/1.2.3.

Nobody made a mistake in the SDK. The SDK did what it was told. You wired the customer-database tools into the MCP server without thinking about whose credentials would flow through, because the tutorials that got you to a working server in an afternoon skipped the part where a real user's identity has to reach the tool call. The model is not a user. The model doesn't have a session. The model will happily invoke a tool with whatever auth context you configured the server with, and if that context had delete scope, the model had delete scope.

This issue is the production layer of MCP that the tutorials skip. How to authorise the user, never the model. How to design tools that survive real traffic (few tools, narrow scopes, typed recoverable errors). And how to fit per-tool timeouts inside the agent's wall-clock cap from Issue 013. The protocol itself is an afternoon. The rest of what follows takes a quarter.

Why the tutorials leave you exposed

Look at any "Build your first MCP server" post from 2025 or early 2026. You'll see the same shape: install the SDK, define a tool with a name and a JSON schema, run the server, connect Claude Desktop or Cursor, ask it a question, watch the tool fire. It's a great tutorial. It's a terrible starting point for production, because the four things that actually matter never get mentioned.

Authentication is missing. The tutorial has you run the server locally with no credential model at all, or with a static token baked into an environment variable. Both are fine for a demo. Neither is fine for anything a real user can reach.

Tool design is missing. The tutorial wraps one endpoint per tool, and the tool arguments mirror the endpoint's parameters. If your endpoint takes an SQL string, the tool takes an SQL string, and now the model can run any query it can compose. This isn't a design; this is the endpoint with extra steps.

Error handling is missing. The tutorial returns whatever exception the endpoint raised as a stack trace in the tool result. The model reads it, tries something else, fails again, keeps going, and the retry-budget failure mode from Issue 013 shows up on your bill.

Timeouts are missing. The tutorial doesn't set any, so a slow database query or a stuck third-party API can hold the tool call open for as long as the transport allows. The agent's wall-clock cap trips first, the tool call never returns, and the state of whatever the tool was doing is unknown.

Each of these is fixable in the same afternoon you shipped v1. Doing them all is what turns v1 into something you can leave running.

Authorise the user, never the model

This is the sentence that carries the article: the server authorises the user, never the model. Every other rule follows from it.

The model doesn't have credentials, and it shouldn't. The client that hosts the model (Claude Desktop, Cursor, your own agent runtime) has an authenticated session with the user. That session's token, whatever it is (OAuth access token, session JWT, API key with the user's identity in it), rides on the MCP transport. The server verifies the token on every tool call, resolves it to a specific user, and passes that user's identity through to the tool implementation. The tool implementation then scopes what it does to that user's permissions, using whatever authorisation model your existing APIs already have. If the model asks for something outside those permissions, the tool returns a typed permission_deniederror and the model finds out it can't do that.

The order matters. The server verifies on every call, not just at connect time. The tool implementation checks scope for the specific action, not just "is this a valid user". And the auth context follows the user through the whole call, not the server's own service account.

The diagram shows the auth chain. The user's token flows from the client to the server to the tool. The server verifies on every call. The tool implementation checks per-action scope. There is no service account and no static token doing the authorising, because a static token can't tell you which user is asking.

Two production things follow. Log every tool call with the user id, the tool name, and the arguments, not just the server-side request id. Your security team wants "who did what" and the model isn't the who. And rotate the tokens the way you'd rotate any other user credential; the fact that a model is the caller doesn't change the credential's lifecycle.

Tool design rules

The MCP protocol lets you expose as many tools as you want. That doesn't mean you should. Three rules make the difference between a server that composes well with the agent and one that surfaces every quirk of your internal APIs.

Few tools. If your MCP server exposes fifty tools, the model has to reason about fifty options every turn. Its context is bigger, its planning is slower, and the failure mode where the model picks the wrong tool becomes routine. Group related actions into semantically meaningful tools: manage_customer with a verb argument that takes read, update, or archive beats three separate tools. Aim for under a dozen top-level tools for a mid-sized server; if you're pushing twenty, the surface is probably wrong.

Narrow scopes. Don't expose query_database. Don't expose call_internal_api. Every tool should describe one specific thing it can do, in verbs the model can reason about. get_customer_by_id(customer_id) is fine. run_sql(query) is a foot-gun with a JSON schema. The narrow tool is easier for the model to use correctly and easier for you to audit later.

Typed, recoverable errors. When a tool fails, return a structured error the model can act on. {"error": "customer_not_found", "message": "no customer with that id", "retryable": false} beats a stack trace. Categorise errors into what the model can do about them: retry (transient), ask the user (missing input, ambiguous request), give up (permission, quota), escalate (server bug). The model will make better decisions when the error tells it what class of thing went wrong. And your logs get cleaner too.

Idempotency where you can afford it. Tool calls will be retried, by the queue architecture from Issue 012, by the agent loop from Issue 013, by the user hitting refresh. Where a tool has real side effects (creates a record, sends an email, charges a card), accept an idempotency key from the client and dedupe on it. This is boring backend hygiene, but it's the specific boring hygiene that stops the "I got charged twice" support ticket.

Per-tool timeout budget

Every tool needs a hard timeout, and the sum of the tool timeouts the agent might call has to fit inside the agent's wall-clock cap from Issue 013.

Here's the rule of thumb. If your agent runtime has a 60-second wall-clock cap and can take up to 10 steps, each individual tool call should time out at 5 to 6 seconds. That leaves headroom for model calls, network jitter, and the fact that you'd rather kill one slow tool than blow the agent's whole budget. Set it explicitly per tool, don't rely on the transport default.

For tools that legitimately need longer (a report generation, a large query, a third-party API that's slow), don't just crank the timeout. Refactor the tool into two: one that kicks off the work and returns a job_id, and one that polls status. The agent can then do useful things between polls, and if the job outlives the agent session, you haven't lost the work.

Wrap every tool implementation in a timeout that raises a typed tool_timeout error when it fires. Don't leak an asyncio cancellation or a raw exception into the tool result; the model reads that and does something unpredictable. The typed error lets the model try a different tool, ask the user, or give up cleanly. And the timeout has to actually cancel the work, not just return the error while the underlying request keeps running; a tool call that appears to time out but is still holding a database connection is worse than one that timed out cleanly.

Blast radius: classify before you expose

Not every tool has the same cost when it goes wrong. Sort them into three buckets before you decide what to expose.

Read-only tools have low blast radius. get_customer_by_id, list_recent_orders, search_docs. These can be shipped freely once the auth story is right, because the worst case is the model reads something it wasn't quite meant to read, and your existing per-user permissions should catch that.

Write tools have medium blast radius. update_customer_email, create_order, send_notification. These need the auth chain from the section above (user identity flowing through, per-action scope check), plus a log entry for every call so you can reconstruct who asked for what.

Destructive or externally-visible tools have high blast radius. delete_customer, send_email_to_user, charge_card, deploy_to_production. These should not be invocable by the model on its own judgement. Use MCP's elicitation flow (or your client's confirmation UI) to require the user to say yes before the action fires. The model can propose; the user has to accept. Skipping this is how the story in the intro happened.

The other way to say this: the model is allowed to compose actions the user is authorised to take. It's not allowed to take irreversible actions on the user's behalf without the user in the loop. The delete_customer in the intro violated both halves of that.

Common mistakes

Configuring the server with a service-account token. The server starts, holds a static token with god-mode scope, and every tool call gets that token's permissions. Every user gets the same access, which means the least-privileged user in your system can do things they should not be able to do. Fix: the token comes from the user's session, not the server's config.

Exposing raw database or API tools. The tutorial had you wrap one endpoint per tool and pass through the arguments. In production this means the model is composing SQL, or hitting internal APIs that were never designed to be called by a language model. Fix: design the tools around what the agent needs to do (read this customer, update that field), not around what the underlying API happens to expose.

No timeout, or a timeout longer than the agent's wall-clock cap. A tool that hangs for 90 seconds inside an agent with a 60-second wall-clock cap has cost you the whole request. The agent gives up, the tool call is still running somewhere, and the state of whatever it was doing is undefined. Fix: per-tool timeout, always shorter than the agent cap, and the timeout has to actually cancel the underlying work.

Returning stack traces as errors. The tool crashes, the server serialises the exception, the model reads it. Now the model is trying to reason about a Python traceback, which it will do, and the result will be creative. Fix: catch the exception, classify it, return a typed error the model can act on.

The takeaway

MCP the protocol is an afternoon of work. MCP as a production surface is the auth chain, the tool design, the timeouts, and the blast-radius classification. The rule that carries all four is that the server authorises the user, never the model, and every tool call carries the user's identity through to the implementation. Few tools with narrow scopes and typed recoverable errors, a per-tool timeout that fits inside the agent's wall-clock cap, and explicit user confirmation on high-blast-radius actions. Do all of that and your MCP server behaves like an ordinary internal API with an unusual client. Skip any of it and you get the story in the intro.

Production checklist

  • Pass the user's authenticated token through the MCP transport. Verify it on every tool call, not only at connect. Resolve it to a user id inside the server before dispatching to the tool.

  • Never run the server with a static service-account token that grants broader access than the user's own permissions.

  • Scope every tool implementation to the resolved user's permissions using your existing API's authorisation model.

  • Keep the total tool count small (under a dozen for a mid-sized server). Bundle related actions into semantically meaningful tools rather than one-per-endpoint wrappers.

  • Design tool arguments around the agent's task, not the underlying API's parameters. Do not expose run_sql or call_api.

  • Return typed structured errors with a category (retry, ask user, permission, escalate) and a human-readable message. Do not return stack traces.

  • Set an explicit timeout on every tool, sized so timeout × max_steps fits inside the agent's wall-clock cap from Issue 013. The timeout must actually cancel the underlying work.

  • Classify every tool by blast radius. Read-only ships freely. Write tools log every call. Destructive or externally-visible tools require elicitation or a client-side confirmation before firing.

  • Accept idempotency keys on write tools and dedupe on them. Retries will happen.

  • Log every tool call with the resolved user id, tool name, and arguments to your observability stack from Issue 004.