MCP Server Security: What We Actually Changed After Running One in Production
A first-party account of hardening mcp.o3.security: the cross-tenant bug we fixed, the query tool we locked down, and the gaps we haven't closed yet.

- O3 runs a production MCP server at mcp.o3.security serving both its own TARS agent and third-party clients like Claude Code and Cursor.
- A cross-tenant authorization bug was fixed by adding a fail-closed check that compares the API key's resolved org against the org asserted by the gateway, rejecting any mismatch with a 403.
- The API-key validation cache TTL was deliberately cut from 24 hours to 5 minutes so a revoked key stops working in minutes, not up to a day.
- The one generic database-query tool exposed to the agent is locked down with a collection deny-list, a MongoDB operator allow-list, and a maximum filter-nesting depth of 6, not input validation alone.
- OWASP's MCP Top 10 project is real but still in beta as of this writing; MCP server security content today is mostly checklists, not accounts of what changed after a real incident.
- The server still has no MCP-layer rate limiting or structured per-tool-call audit log. That's an honest gap, not a hidden one.
Every AI agent platform is standing up an MCP server right now, and every security vendor has a listicle ready for it: validate your inputs, scope your tools, rotate your keys. We read a dozen of them while researching this piece and none of them said what actually broke, or what got fixed after it did. So instead of another checklist, here's ours: mcp.o3.security, in production, the bug we found, and the design decisions that came out of fixing it.
An MCP server is a network endpoint that exposes a defined set of tools to an AI agent over a standard protocol, the same idea as a REST API, except the client on the other end is a language model deciding for itself which tool to call and with what arguments. That difference is the whole security problem. A REST API trusts its caller's intent because a human or a fixed script wrote the call. An MCP server has to assume the caller might construct a call it never anticipated, because an LLM strings arguments together from whatever text it was just shown.
What mcp.o3.security actually does
O3's MCP server sits in front of the same data our own AI analyst, TARS, uses to answer security questions: vulnerability and reachability data, malicious-package findings, cryptographic and post-quantum readiness data, and live infrastructure security events. Third-party MCP clients (Claude Code, Cursor, Copilot) talk to the same server TARS does, over the Model Context Protocol's streamable-HTTP transport, authenticated with an API key.
- Vulnerability and reachability lookups: CVE detail cross-referenced with whether the vulnerable code path is actually reachable in your codebase, plus EPSS exploitability scores.
- Package intelligence: live public package metadata and malicious-package checks.
- Threat intelligence: deliberately org-agnostic public advisory data (OSV, EPSS), no tenant context needed.
- Graph and entity queries: relationship data (which service calls which dependency, which finding traces to which asset) served from a security graph.
- BOM and security-context tools: SBOM/CBOM/AIBOM-style outputs and summarized security posture for a given asset.
That's a wide surface. Each category above is served by its own tool module, and the count keeps moving as new tools ship. We're not publishing an exact number here on purpose: a fixed count in a blog post goes stale the next time someone ships a tool, and a stale number is worse than a category list that stays true.
The bug: a cross-tenant read through a mismatched org
The specific incident we're describing here won't name the customers involved. What matters is the mechanism, because the mechanism is what a reader can actually learn from. The fix shipped in one commit, in June 2026, touching 10 files and adding 528 lines against 103 removed, mostly new tenant-isolation logic rather than a patch to existing code.
O3 is multi-tenant. Every API key resolves to an organization, and every query the MCP server runs is supposed to be scoped to that organization's data. The request path runs through a gateway that independently verifies the caller's session and forwards the org it resolved as a header. Under one specific condition, the org an API key resolved to and the org the gateway's session asserted could disagree, and the server used the API key's org without checking the two agreed. That's a cross-tenant read waiting to happen: right key, wrong context, and nothing in the middle noticed.
The fix wasn't better input validation. The tool calls involved were completely valid. The fix was adding a check that didn't exist: does the org this key resolves to match the org this session says it's acting as.
The fix is a function called assertOrgMatch, and it's short on purpose. It compares the API key's resolved org against the org header the gateway asserts. If they don't match, the request is rejected with a 403 before any tool executes, logged with both org values so it's traceable. If the header is absent entirely (an older or direct caller that predates this check), the request proceeds and the key's own org scopes the data, same as before. The code comment above the function states plainly what this guardrail is for: stopping cross-tenant reads when an API key's org disagrees with the authenticated session.
function assertOrgMatch(req, res, user):
asserted = header('x-o3-org-id')
if asserted is empty: return true // legacy/direct caller, allow
resolved = user.orgId
if asserted != resolved:
log('Cross-org assertion failed', { assertedOrg, resolvedOrg })
respond 403 ORG_MISMATCH
return false
return trueFail-closed matters here. The easy version of this check logs a warning and lets the request through, because breaking a legitimate caller is scarier than a rare mismatch. We went the other way: any mismatch is a 403, no exceptions, because a wrong guess in the permissive direction is a data leak and a wrong guess in the strict direction is a support ticket.
Shortening the blast radius of a leaked key
Separately from the org-mismatch fix, we cut how long a validated API key stays trusted without being re-checked. API key validation used to be cached for 24 hours, which is a reasonable performance tradeoff and a bad security one: revoke a key, and it can keep working for up to a day anywhere that cached the old validation. We shortened that cache to 5 minutes, configurable per deployment via an environment variable, so a revoked or rotated key stops working within minutes instead of hours.
| Setting | Before | After | Why |
|---|---|---|---|
| API key validation cache | 24 hours | 5 minutes (env-configurable) | A revoked key stays trusted for up to a day otherwise |
| Org scoping on API-key requests | Trusted the key's resolved org alone | Key's org must match the gateway-asserted session org, or 403 | Closes a cross-tenant read path that existed with zero invalid input |
Neither change shows up in a generic MCP security checklist, because neither is generic. They're specific to how this particular server authenticates and how its particular gateway forwards trust. That's most of what's wrong with checklist content: it can tell you to have a cache TTL, but it can't tell you 24 hours is too long for your threat model, because it doesn't know your threat model.
The tool that could read almost anything, on purpose
Most of our MCP tools do one narrow thing: look up a CVE, resolve an entity, fetch a package's advisory history. One tool doesn't. It's a general-purpose read against our internal database, the deliberate escape hatch for questions the narrow tools can't answer. That's also the most dangerous shape a tool can have, because "read almost anything" is exactly the primitive an over-eager or manipulated agent would reach for.
We didn't handle that by asking the model nicely. Four independent restrictions apply to every call, and all four have to hold at once:
- A collection deny-list, currently 16 collections wide. Internal infrastructure state, the API keys and sessions collections themselves, admin settings, and the agent's own audit and approval logs are explicitly walled off. Everything else is readable by default, which only works because the deny-list, not an allow-list, is the boundary that's kept current.
- An operator allow-list of 13 MongoDB operators (equality, comparison, existence, basic regex, and/or). A separate list of 6 operators that can execute arbitrary logic inside the database, like $where, $function, $expr, and $accumulator, is explicitly blocked, not just left off an allow-list that might have gaps.
- A depth cap. Filter objects can nest at most 6 levels deep, closing off resource-exhaustion attempts built from deeply nested queries. Results are capped at 100 rows per call, 25 by default.
- A server-injected org id. The organization scoping this query is never a parameter the agent supplies. It's attached server-side from the authenticated session, so no combination of arguments can point the query at another tenant's data.
Input validation checks that an argument has the right shape. It doesn't check that the argument, correctly shaped, still can't do something you don't want. That second check is what the deny-list, operator allow-list, and depth cap are actually for.
The rest of the graph and relationship tools take a different, arguably simpler approach: they don't expose a query language at all. Each one wraps exactly one fixed query template with safely-formatted parameters, no free-text query string ever reaches the underlying graph database. That's a stricter rule than the general-purpose tool follows, and it's stricter because it can afford to be: a fixed template for "show me what depends on this package" doesn't need the flexibility a general escape hatch needs, so it doesn't get the attack surface either.
Why this matters more for MCP than for a normal API
OWASP's MCP Top 10 project names the risk category this defends against directly: prompt injection and context manipulation that gets an agent to call a tool, or call it with arguments, that the human operator never intended. The project is real and it's useful, currently in beta as of this writing, and it's the closest thing MCP security has to a shared vocabulary right now. But a top-10 list can tell you the category of risk exists. It can't tell you whether your own deny-list is actually complete, because it's never seen your deny-list.
The Model Context Protocol's own specification has moved the same direction: its current authorization framework defines an OAuth-based flow for HTTP-transport MCP servers specifically because API-key-only auth, the simplest thing to ship first, doesn't have a clean answer for token scoping or delegation once an agent is calling tools on a user's behalf rather than a human typing a request directly. We're still on API-key auth today. Migrating to the spec's OAuth flow is on our list, not because API keys are broken, but because delegated, scoped tokens are a better match for what an autonomous agent should be allowed to do on someone's behalf.
“A checklist tells you what category of thing could go wrong. It can't tell you whether the thing you actually built is one of them.”
What's still open
None of this is a claim that the server is finished. Two gaps are real and worth naming plainly, because a case study that only lists fixes reads exactly like the marketing content it's trying not to be.
- No MCP-layer rate limiting. The only rate limiting in the request path is a general one at the platform's edge, not specific to the MCP endpoint or to individual tools. A client that discovers a valid key can call any tool as fast as the network allows.
- No structured per-call audit log. Tool invocations are logged as they happen, but there's no persisted, queryable record of which key called which tool with which arguments over time. Debugging an incident today means grepping logs, not querying an audit trail.
Both are on the roadmap. Neither is hidden here, because the honest version of a hardening story includes the parts that aren't hardened yet.
What this means if you're building your own MCP server
The specific fixes above are ours, tied to our gateway and our tool shapes. The pattern underneath them travels:
- If any tool crosses a tenant boundary, check the boundary explicitly at the point the tool executes, not just at login. A valid key from the right tenant, used in the wrong context, is a valid-looking request that's still wrong.
- Cache validation results if you need to, but keep the cache window short enough that revocation actually means something. 24 hours of trust after a key is revoked isn't a caching decision, it's a policy decision wearing a caching decision's clothes.
- For any tool with a wide read surface, layer independent restrictions, a deny-list, an operator or capability allow-list, a depth or size cap, rather than relying on one validation pass to catch everything.
- Say what's not done yet. A hardening story that claims to be complete is either finished with an unusually simple system, or it's not being fully honest about what's left.
Frequently asked questions
Frequently asked questions
What is an MCP server?
+
Why is MCP server security different from normal API security?
+
What is the OWASP MCP Top 10?
+
Does input validation alone secure an MCP tool?
+
How long should an MCP server cache API key validation?
+
What does the current MCP specification say about authentication?
+
Sources
- https://owasp.org/www-project-mcp-top-10/
- https://github.com/OWASP/www-project-mcp-top-10
- https://blog.cloudflare.com/enterprise-mcp/
- https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- https://snyk.io/news/snyk-acquires-invariant-labs-to-accelerate-agentic-ai-security-innovation/