# MCP in production: a checklist

> I check seven things before an MCP server touches real data: hints, token audience, scopes, OAuth discovery, local servers, state handles, logs.

Published: 2026-10-24
Canonical: https://gianlucamazza.it/en/blog/mcp-production-checklist
Tags: MCP, Security, Agents, Production

## Why this matters

"I want to connect my ERP to the AI assistant I already use. If it goes through MCP, is it safe?"

MCP (Model Context Protocol) is the standard an AI assistant uses to reach outside tools and data, but the protocol does not answer that for you. The MCP specification says it plainly: [MCP itself cannot enforce these security principles at the protocol level](https://modelcontextprotocol.io/specification/2026-07-28). It defines how a client and a server talk. Whether a given server is safe to point at your data depends on how the server, the client and the login were built.

The last few weeks show where that goes wrong. Between 28 September and 5 October 2026 the official MCP Python SDK (the library used to build servers and clients) published advisories about [OAuth credentials sent to a server-chosen authorization server](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-qx49-fqc8-xw99) and [tokens accepted when they were issued for a different service](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-w4fh-qvv9-3v23). Neither needs a prompt injection (instructions hidden in content the model reads). Both are about login and permissions.

Below are seven checks I make before an MCP server touches real data. For each one I give what the [current spec revision, 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/changelog), requires and how I check it. Every requirement links to its source.

## 1. A tool hint is not a permission

A tool can declare `annotations` such as `readOnlyHint`. In the schema, [`readOnlyHint` means "the tool does not modify its environment"](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations). The same page says that all annotations are hints, that they are not guaranteed to describe the tool faithfully, and that clients should never base tool-use decisions on annotations from untrusted servers. The tools page goes further: [clients MUST treat annotations as untrusted unless they come from trusted servers](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool).

So a server can label a tool read-only and still write. The label is a description, written by the party you are trying to limit.

```python
# Anti-pattern: the server's own label decides
if tool.annotations.get("readOnlyHint"):
    auto_approve(call)

# Better: my policy decides, keyed on server, tool and arguments
if policy.allows(server.id, tool.name, call.arguments):
    run(call)
else:
    ask_person(call)
```

The real permission lives in two places. On the server, which [MUST implement proper access controls](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#security-considerations). In the client, where [a person SHOULD be able to deny a tool call](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#user-interaction-model).

**How I check it:** I list every tool that writes, sends or deletes. For each one I ask what stops it on the server when the hint is wrong.

## 2. The token must be issued for your server

A remote MCP server is an OAuth resource server. The spec requires it to [validate that each access token was issued specifically for it](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling), as the intended audience, and to answer 401 when it was not. It [MUST NOT accept or transit any other tokens](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling). When the server calls an upstream API, it uses a separate token from that API's authorization server and [MUST NOT pass through the token it received from the client](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#access-token-privilege-restriction). The security docs call the opposite [token passthrough and list why it is forbidden](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#token-passthrough).

The Python SDK advisory of 5 October 2026 is exactly this case. Its server-side bearer authentication [had no way to check the token's audience](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-w4fh-qvv9-3v23), so a valid token for another service on the same authorization server was accepted. The fix is in `mcp` 1.30.0 and 2.2.0, and the advisory adds that upgrading alone changes nothing: the check stays off until you set `validate_token_resource=True` and return the token's audience from your verifier.

**How I check it:** I take a valid token for a different service from the same authorization server and send it to the MCP server. The answer must be 401.

## 3. Start with the smallest scopes

Clients [SHOULD request only the scopes](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#scope-selection-strategy) (the permissions a token grants) they need. The server's `scopes_supported` is meant to be the minimal set for basic use, with more scopes requested later through a step-up challenge when a tool needs them.

There is a catch on the server side. When the first 401 response names no `scope`, the client falls back to requesting everything in `scopes_supported`. So whatever the server publishes there is what users get asked for. The security docs list the [common mistakes](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#scope-minimization): publishing every possible scope, wildcard scopes such as `*` or `full-access`, and treating the scopes in a token as enough without authorization logic on the server.

**How I check it:** I read the Protected Resource Metadata and the first 401 response. Then I compare the scopes with what the first workflow actually needs.

## 4. What the server sends during login is untrusted

During OAuth discovery the MCP server tells the client where its authorization server is. That makes every field in those documents input from the party being authenticated.

The spec asks the client to [record the expected issuer and validate the `iss` in the authorization response](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#authorization-response-validation) before sending the code to any token endpoint. It requires [PKCE with `S256` and HTTPS or `localhost` redirect URIs](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#authorization-code-protection). The high-severity Python SDK advisory of 28 September 2026 shows what happens without the issuer check: [a malicious MCP server could receive the client secret, the authorization code and the PKCE verifier meant for the real authorization server](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-qx49-fqc8-xw99). Fixed in 1.30.0 and 2.2.0. For providers that run without a user present, the fix only takes effect once you pass `issuer=`.

The authorization URL is the other half. The security docs say clients [MUST only allow `http://` and `https://` and MUST NOT open URLs through a shell](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#oauth-authorization-url-validation). Of the two, the second is the one that actually protects you. [Pillar Security described a case in opencode](https://www.pillar.security/blog/open-and-shut-remote-code-execution-in-opencode-via-mcp-oauth) on Windows and WSL where an ordinary `https://` URL from a remote server could reach PowerShell and run code during login. They list versions 1.0.137 to 1.18.32 as affected and 1.18.33 as fixed, [released on 28 September 2026](https://github.com/anomalyco/opencode/releases/tag/v1.18.33). A scheme check passes that URL. What stops it is never building a shell command from that URL.

**How I check it:** I note the client and SDK versions, check whether the issuer is pinned, and look at how the client opens the login URL.

## 5. Local servers: localhost, Origin, isolation

A server that runs on your machine has different risks. Over Streamable HTTP, servers [MUST validate the `Origin` header and answer 403 when it is invalid](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#security-%26-endpoint), to block DNS rebinding (a trick that lets a web page reach services on your own computer), and SHOULD bind to 127.0.0.1 rather than 0.0.0.0. In the Python SDK this protection [was off by default before 1.23.0](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-9h52-p55h-vw2f). Over stdio, the spec says not to use the OAuth flow and to [take credentials from the environment](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#protocol-requirements).

The official [local server checklist](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/local-server-security#checklist) covers the rest: an identifiable publisher and a pinned version, isolation in a container, sandbox or VM, narrowly scoped credentials per server, filesystem access limited to specific directories, and a known list of network destinations.

**How I check it:** I read the startup command in full, check the bind address, and list which credentials the process can reach.

## 6. A state handle is not an identity

Revision 2026-07-28 [removed protocol-level sessions](https://modelcontextprotocol.io/specification/2026-07-28/changelog). A server that needs state across calls returns a handle, such as a cart or workflow ID, and gets it back as an ordinary tool argument. The tools page says [a handle is a name, not a capability](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#stateful-tools): the server should check the caller's authorization against it on every call. The security docs add that servers [MUST NOT treat possession of a handle as authentication](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#state-handle-hijacking) and SHOULD bind it to the user taken from the verified token.

Older revisions had the same problem with session IDs. The Python SDK [routed requests to a session by ID alone, without checking the authenticated principal](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-jpw9-pfvf-9f58), until 1.27.2.

**How I check it:** I create state as user A and call it as user B with the same handle. The call must fail.

## 7. A person in the loop, and a log

The model decides when to call a tool. The spec still says there [SHOULD always be a human in the loop who can deny a tool call](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#user-interaction-model). Its security list asks servers to [validate inputs, enforce access controls, rate limit and sanitize outputs](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#security-considerations), and clients to show tool inputs before the call, validate results, set timeouts and log tool usage for audit.

Tool descriptions are input too. The model reads them as instructions. The local server guide suggests [reading the tool list before relying on a server, and preferring clients that ask again when a tool definition changes](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/local-server-security#treat-tool-definitions-as-untrusted-input). The [MCP Inspector](https://modelcontextprotocol.io/docs/2026-07-28/tools/inspector) lists a server's tools without a model in between.

**How I check it:** I pick one write action and look for its trace. Who asked, which arguments went out, who approved, what came back.

## The checklist on one page

If you are not technical, you can hand this list to whoever sets up the server and ask for an answer on each line.

- No tool is approved because of its `readOnlyHint` or other annotations.
- The server answers 401 to a token issued for another service.
- The server never forwards the client's token upstream.
- `scopes_supported` holds only what the first workflow needs.
- The client pins the expected issuer and uses PKCE with `S256`.
- The client opens login URLs without a shell.
- Local HTTP servers check `Origin` and bind to localhost; stdio servers get their own narrow credentials.
- Every state handle is checked against the caller on every call.
- Write actions need a person's confirmation and leave a log entry.
- SDK and client versions are pinned and include the fixes above.

## Before you connect it

I am Gianluca Mazza, an independent AI consultant. If your problem is the one at the top, "I want to connect my ERP to the assistant I already use, and I do not know if it is safe", write to me at [info@gianlucamazza.it](mailto:info@gianlucamazza.it) with the server and the client you plan to use, or use the [written second opinion](/en/services/second-opinion) if you already have a quote for the integration.

## FAQ

### Does readOnlyHint stop an MCP tool from writing data?

No. The spec defines all tool annotations as hints that may not describe the tool faithfully. Clients must treat them as untrusted unless the server is trusted. What stops a write is access control on the server and a person who can deny the call.

### Is OAuth required for every MCP server?

No. Authorization is optional in the spec. When it is used over HTTP, the authorization spec applies. Over stdio, the spec says to take credentials from the environment instead of running the OAuth flow.

### Is validating the URL scheme enough during MCP login?

No. The MCP security guidance requires both accepting only `http` and `https` and never opening the URL through a shell. In the opencode case Pillar Security described, the URL was ordinary `https`; the shell was the problem.

### Which spec revision does this checklist follow?

The MCP specification revision 2026-07-28, its authorization and tools pages, and the official security docs for that revision. The Python SDK advisories cited are on the SDK's GitHub security page.
