# MCP Security

This page covers the authorization model, how upstream credentials are sealed and never pass
through, request guardrails, rate limits, and what the audit record contains. The guiding rule is
fail-closed: anything ambiguous denies rather than opens.

## Principal authorization

Authorization is independent of tool publish state. Two principal kinds may be granted allow or
deny policies:

| Principal kind | `principalRef`              |
| -------------- | --------------------------- |
| `user`         | An individual user id       |
| `api_key`      | A project-scoped API key id |

Actions are `read` (discover) and `invoke` (call a tool). There is deliberately no `manage` tool
action: mutating the catalog and policies is a management-surface capability (`mcp:manage`), not a
principal-grantable action, so it never appears in the access-rule editor.

### Adjudication rules

1. **Collect matches.** Gather policies matching this principal kind and reference, the requested
   action, and overlapping resources, ordered by priority (highest first).
2. **Deny overrides.** If any matched policy denies, deny immediately regardless of priority.
3. **Allow on match.** Otherwise, if at least one allow matches, allow, narrowed to the requested
   resources.
4. **Default deny.** Anything else denies.

### Narrowing only

A request may never widen what an allow grants. If the caller names a server or tool that no allow
policy covers, the request is rejected even though some other resource was allowed. A policy with
`serverIds: null` or `toolNames: null` means "all" within scope; an explicit array narrows it.

Deny reasons surface as `no_matching_policy` (default deny), `explicit_deny` (a deny matched), or
`request_outside_allow` (the request tried to widen).

### Hosted tools are pre-approved

The five shipped hosted tools (`web_search` and the trace and Finding readers) carry a default-allow
flag. When adjudication reaches default-deny, those tools are still listed and invocable by an
`mcp:invoke` caller; they need no policy of their own. A matched explicit deny still blocks them,
and federation tools stay fully default-deny until an allow policy admits them.

Hosted tools act as the project identity: there is no upstream, no sealed secret, and no credential
entry. The only key a caller supplies is the project API key used when connecting an agent; it
authenticates ingress and is never an upstream credential.

### Upstream authentication posture

Each federation server carries a persisted authentication posture written by the connection probe
and credential lifecycle. Reachability, per-credential enabled state, and this posture are
orthogonal facts the call path combines; posture is not recomputed from the credential list at read
time.

| Posture        | Meaning                                                                                   | Callable?                          |
| -------------- | ----------------------------------------------------------------------------------------- | ---------------------------------- |
| `not_required` | An anonymous probe reached the upstream and it accepts the call; no credential configured | Yes, if reachable                  |
| `missing`      | The upstream answers `401`; authentication is required but no credential is configured    | No; the catalog remains manageable |
| `active`       | The primary credential verified successfully, or an authenticated call succeeded          | Yes                                |
| `invalid`      | A configured credential was rejected by the upstream (`401`)                              | No; the catalog remains manageable |
| `revoked`      | The last primary credential was revoked, leaving no identity to attach                    | No; the catalog remains manageable |

A connection that is reachable but not callable (`missing`, `invalid`, or `revoked`) still renders
and remains fully manageable: edit, discover, sync, publish, and add, test, pause, resume, or
promote credentials. Its tools simply cannot be invoked until an active primary exists.

Two upstream failures stay distinct: a `401` means the credential itself is bad (test and promote a
fresh secret); a `403` means an upstream authorization decision, treated as a policy or capability
problem rather than a bad credential. A `403`, network error, or unsupported probe classification
is diagnostics-only and does not overwrite the persisted posture; only a successful probe or a
`401` probe does.

### Upstream error codes on tool calls

Upstream HTTP status maps to a JSON-RPC code and differs slightly by transport:

| Upstream condition                                       | Native MCP, both runtimes  | OpenAPI on self-hosted Node        | OpenAPI on hosted edge    |
| -------------------------------------------------------- | -------------------------- | ---------------------------------- | ------------------------- |
| `401` / `403`                                            | `-32001` permission denied | `-32001` permission denied         | `-32003` upstream failure |
| `404`                                                    | `-32601` method not found  | `-32001` permission denied         | `-32003` upstream failure |
| `429`                                                    | `-32002` rate limited      | `-32001` permission denied         | `-32003` upstream failure |
| Other non-2xx                                            | `-32003` upstream failure  | `-32001` permission denied         | `-32003` upstream failure |
| Transport error, timeout, blocked host, or oversize body | `-32003` upstream failure  | `-32000` session expired / re-auth | `-32003` upstream failure |

The full reserved range is `-32000` session expired, `-32001` permission denied, `-32002` rate
limited, `-32003` upstream failure, `-32004` gateway paused, `-32601` method not found, and
`-32603` internal error. On the HTTP ingress, a paused project gateway returns `503 MCP_PAUSED`
before the JSON-RPC body; `-32004` appears on bypass paths such as the Workbench playground.

### Route-level gating

| Caller            | Management read         | Management write                | Tool gateway invoke                   |
| ----------------- | ----------------------- | ------------------------------- | ------------------------------------- |
| Project API key   | Capability `mcp:manage` | Capability `mcp:manage`         | Capability `mcp:invoke`               |
| Signed-in session | Project read permission | `project:mcp:manage` permission | Not accepted; ingress is API-key only |

An API key needs `mcp:manage` for both management reads and writes; there is no separate read
capability. Both `mcp:manage` and `mcp:invoke` are absent from the default key capability set, so
they are default-deny until granted explicitly. The streamable-HTTP tool gateway accepts only a
project API key; a session or any other actor receives `401 UNAUTHORIZED`, and a principal the
project cannot place receives `403 FORBIDDEN`.

## Policy and catalog revisions

At `initialize`, the allowed set is resolved together with the current policy and catalog
revisions. Any change to access policies advances the policy revision; any publish or sync advances
the catalog revision. Authorization is re-adjudicated on every exchange, so a policy or catalog
change takes effect immediately rather than waiting for a long-lived session to expire. A cached
session snapshot is invalidated when its revision no longer matches.

## Sealed credentials, not pass-through

- Upstream secrets are sealed at rest and unsealed only on the outbound call path.
- Listing credentials returns only the id, server id, label, kind, outbound auth method, role
  (`primary` or `backup`), enabled state, last-verified time, and timestamps — never the secret.
  Exactly one credential is the active primary; every other credential is a backup held for
  cutover. There is no numeric selection priority.
- The inbound Bearer token presented by the caller is never forwarded upstream. The only outbound
  auth channel is the sealed upstream secret, injected under the whitelisted outbound auth method:
  `Authorization: Bearer <secret>` or `X-Api-Key: <secret>`. Caller-supplied header names are not
  accepted.
- The secret never appears in spans, logs, error messages, or returned bodies.

### Guardrail scans on every call

A deterministic, transport-neutral guardrail runs before and after each federated call and never
mutates the payload:

- **Input:** arguments must be JSON-serializable and within 64 KiB.
- **No smuggled credentials:** arguments cannot contain credential-shaped keys such as
  `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `password`, `secret`, `token`,
  `api-key`, or `private-key`, matched case-insensitively. A hit fails closed; the sealed outbound
  credential remains the only auth channel.
- **Output:** tool output must be JSON-serializable and within 256 KiB. Over budget, the output is
  withheld and replaced with an error.

Fail-closed on purpose: a false positive that denies a benign call is recoverable by the caller;
silently forwarding a leaked secret to an upstream is not.

## Rate limiting and concurrency

The bucket dimension differs by runtime, and both runtimes return JSON-RPC `-32002` when exceeded:

- **Self-hosted Node federation:** a fixed-window limiter per `(server, tool)` at 120 calls per
  minute, plus a concurrency limit of four in-flight calls per server. These gates live in one
  process and are not distributed across instances.
- **Self-hosted Node hosted tools:** a separate per-process limiter per `(project, principal,
tool)` at 60 calls per minute.
- **Hosted edge:** one fixed-window bucket per `(project, principal, tool)` at 120 calls per
  minute, shared across the project's requests; hosted and federation calls share the same bucket.

Upstream `429` responses are surfaced independently of these local limiters.

## Transport hardening

Every outbound target — a native MCP HTTP server and an OpenAPI-federated REST call — must point at
a permitted public host:

- The upstream URL must use `http` or `https` and carry no embedded credentials.
- Blocked hostnames include `localhost`, `*.localhost`, `internal`, `*.internal`, `local`,
  `*.local`, `metadata`, `*.metadata`, and `metadata.google.internal`.
- Blocked address ranges include loopback and link-local, RFC 1918 private ranges, CGNAT,
  documentation and benchmark blocks, and multicast or reserved ranges (IPv4 and the IPv6 ULA,
  link-local, multicast, and documentation prefixes).
- DNS resolution is re-checked: every resolved address must be public, which blocks DNS rebinding
  to an internal address.
- Redirects are not followed; the response body is bounded to 1 MB and the call to 30 seconds.

## Audit telemetry

Each outbound federated call produces a bounded client span named `MCP <method>`. It carries only
non-sensitive attributes:

- `mcp.method`, `mcp.tool.name`, `mcp.server.id`, `mcp.server.sourceKind`
- `mcp.principal.kind`, `mcp.principal.ref`, `mcp.project.id`
- `mcp.policy.version`, `mcp.catalog.version`
- `mcp.outcome`, `mcp.rpcResponseStatusCode`

Tool arguments, content bodies, upstream URLs, and secrets are never attached. Outcome categories
are `ok`, `capability_unavailable`, `permission_denied`, `session_expired`, `rate_limited`,
`upstream_error`, and `internal`.
