Skip to content

MCP security best practices: what we implement, and what we don't

Written 2026-09-06 against the MCP 2026-07-28 Security Best Practices page, which complements the MCP Authorization specification. Implementation claims reconciled on 2026-10-08 for the unified product. The OWASP MCP Top 10 assessment maps controls, deployment prerequisites, evidence, and remaining work across the agent system.

This is a conformance map, not a summary of the spec. Each section names the attack the spec describes, states whether it applies to this server, and cites the code that mitigates it. Where we fall short of a SHOULD, the section says so plainly and says why — a gap an operator can read is worth more than a claim they cannot check.

The audience is whoever is answering a security questionnaire about this server, and whoever changes the auth boundary next and needs to know which properties are load-bearing.

Spec section Applies Status
Evaluation boundary (additional evidence, not a normative MCP requirement) Test harness only Pinned SDK loop with one authenticated gateway, no provider key, inherited settings/plugins or built-ins; bounded process group and versioned digest-only manifests/metadata terminal evidence. Linux requires a root-owned, runner-group namespace helper with scoped AppArmor permission; host restrictions stay enabled, and SDK qualification remains pending. A runtime error alone cannot prove refusal; incomplete/cancelled trials have no negative verdict (runtime_tests::runtime_refusal_requires_terminal_gateway_and_destination_evidence, runtime_tests::cancelled_trial_has_a_manifest_and_no_negative_attack_result). Local scripted provider, not live-model resistance (runtime_tests::runtime_routes_both_paths_under_one_owner_with_isolated_credentials_and_tools, runtime::tests::cancellation_timeout_and_leader_exit_kill_owned_descendants); limits
Confused deputy No — not an OAuth proxy N/A by architecture
Token passthrough (MUST NOT) Yes Met, enforced by type shape
SSRF Yes (control-plane fetches, downloads, Git cloning, and the stdio-only browser tools) CI status polling retries typed transport failures within its existing schedule, re-authorizes each poll, and stops on policy refusals; vendor error text cannot enable transport retries (ci_wait_tests, ci_wait::tests). Partial; HTTP and Git destinations are bound before dispatch, browser URLs are restricted to http/https, network restrictions are deployment controls, and Git’s and the browser’s own network stacks are outside the HTTP policy boundary
Optional local model guard When deployed Explicit ONNX CPU/CUDA selection and CPU-node fallback policy; no provider-loss downgrade or execution retry. Mutually exclusive hash-locked ONNX wheels; qualify actual CUDA placement and numerical/performance behavior with the opt-in harness before deployment (test_onnx_gpu.py::test_session_provider_and_fallback_contract, test_onnx_gpu.py::test_unavailable_cuda_refuses_before_artifact_fetch). GPU drivers/libraries/device confinement and VRAM remain operator responsibilities. Liveness-aware readiness, idle heartbeats, capped backoff and lifetime restart budget; permanent configuration/artifact failures stay unavailable. Process-group cancellation/shutdown cleanup; use the dedicated-account cgroup deployment example and qualify supervisor SIGKILL/OOM containment in staging. test_supervision.py::test_idle_hang_heartbeat_and_inflight_timeout_recover, test_supervision.py::test_cancellation_during_spawn_joins_and_reaps_child, inference_guard_tests::supervised_local_guard_failures_preserve_gateway_policy. Pinned artifacts and runtime; authenticated loopback; complete subjects; bounded downloads/windows and fail-closed readiness. GPU required for Safetensors; The explicitly selected trained ONNX CPU profile has curated validation, with all artifacts SHA-256 pinned and policy actions locked by inference_guard_tests::trained_local_model_gateway_preserves_policy_actions_and_failures; test_real_model.py::test_trained_cold_download_http_and_offline_reuse covers real provisioning/offline checks. Production model quality and native-runtime containment remain deployment responsibilities (local model guards); test_local_guard.py::test_no_gpu_never_downloads_non_onnx, test_local_guard.py::test_http_auth_complete_subject_and_content_not_retained, test_artifacts.py::test_dns_resolution_refuses_private_addresses. CUDA qualification now binds warmup placement to the actual worker PID and checks no replay under owned-worker faults; host selection and partial-result retention are synthetic-tested (test_onnx_gpu.py::test_qualification_harness_on_synthetic_cpu_graph, test_qualification.py::test_failed_fault_phase_preserves_completed_evidence). NVIDIA hardware and disruptive staging tests remain unverified; see qualification record.
State handle hijacking (MUST, MUST NOT) Yes Met; durable Tasks additionally bind every read/mutation to the existing owner, with indistinguishable unknown/inaccessible IDs; pending linked status reconciles against the verified journal; terminal receipts remain owner-bound; cancellation journals a distinct requester withdrawal first (task_wire_tests, unified_call_approvals_tests::approval_tasks)
Local MCP server compromise Yes Explicit native keychain stores, encrypted Linux Secret Service sessions and unavailable-store refusal (devtools-identity keychain tests); met for stdio; Tasks uses private storage and a lifetime lock, direct CLI cannot recover server tasks, and only pristine first-boot state is initialized (tasks::sqlite::tests, task_lifecycle_tests); MCP_AUTH_MODE=off over HTTP is a documented deviation
OAuth authorization URL validation Yes (console) Met
stdio transport in proxy scenarios No — no proxy architecture N/A
Mix-up attacks Not today (single issuer) Defended anyway (RFC 9207 iss)
Localhost redirect URI impersonation No — console redirect is the public URL N/A
CIMD trust policies No — no client-ID metadata documents N/A
Scope minimization Yes Met; served skills grant no permission and ship no allowed-tools (skills_wire_tests); schema 3 content rules narrow what an allowed write may carry (decoded write arguments inspected before dispatch; deny, hold with the findings digest in the hold key, or label; on_error deny by default; content_guard_tests); Jira binary downloads authorize the canonical GET and keep unclassified enforcing calls denied (jira_download_tests); Jira attachment uploads reuse canonical POST authorization, owner-bound artifact reads and bounded file preparation (jira_upload_tests, multipart_transport_tests)

Qualifications appear beside the affected controls and in Deviations and residual risk.

Tool descriptions are compiled and pinned by the tool-surface tests. bitbucket_pr_update_guidance_uses_put additionally locks the corrected Bitbucket PR-update guidance against accidentally restoring PATCH examples. This strengthens MCP03 regression coverage; it does not change authorization or validate the intent of an agent’s subsequent calls.

Routine Rust CI validates Linux and Windows on Reacher. Hosted macOS is opt-in through repository variable MCP_CI_MACOS=true; leaving it unset reduces platform regression coverage while account billing blocks hosted jobs. Release builds still require both macOS architectures. This MCP04 validation limit is recorded in the build threat model and CI runner instructions.

Policy/audit rotation uses the operator workflow. It re-signs only a verified baseline, preserves policy and revocation bytes, advances generations, and refuses drift or rollback to a compromised key. Owner-only staging holds the replacement private key; the recovery record binds the service environment file by digest and never copies it. Policy activation refuses while that key is still in the rotation directory, so the gateway’s configured paths never sit beside the key that signs them. Only redacted status and public references cross the browser boundary. The console handoff uses existing admin authorization and escaped templates, with no browser mutation or service-execution endpoint. Activation runs locally under the systemd service owner’s authority, uses no shell, and accepts only a loopback health origin. Journal history and old audit public keys are retained.

Tests: crates/control/tests/rotation.rs, configuration_admin_tests, and unified_console_tests::rotation_console_is_an_authenticated_operator_handoff_without_private_keys. A same-account systemd user service does not isolate the operator’s files from that service identity; separate policy-signing custody requires deployment of only the public signed artifacts. Multi-host orchestration and browser-triggered activation remain outside this adapter. Local transition records do not replace the gateway’s signed audit journal or end-to-end client authentication checks.

N/A by architecture. The spec’s attack needs all four of: a static client ID against a third-party authorization server, dynamic client registration by MCP clients, a third-party consent cookie, and no per-client consent at the proxy.

This server is not an OAuth proxy. It does not register clients, does not mint authorization codes, does not hold a static client ID on behalf of MCP clients, and does not sit between an MCP client and a third-party authorization server. Clients obtain their own tokens from the operator’s identity provider and present them here; the RFC 9728 metadata document at /.well-known/oauth-protected-resource names that provider (src/server/auth.rs, protected_resource_metadata) and the flow ends there.

The console (crates/console/src/console/) is a first-party public client with its own client ID, signing its own administrator in. It proxies nothing on behalf of a third party, so there is no consent to be confused about.

If this changes, the whole “Required Protections” block of the spec — per-client consent storage, a consent UI with CSRF protection and frame-ancestors, __Host- consent cookies bound to client_id, exact-match redirect_uri validation, and state stored only after consent approval — becomes mandatory. It is not partially satisfied today; it is simply not reached.

Client-credentials support is an optional advertisement through authenticated HTTP discovery (MCP_CLIENT_CREDENTIALS_ENABLED), not a new OAuth proxy or a grant restriction. Legacy initialization and ordinary client admission are unchanged. oauth_tasks_compatibility_tests locks discovery/EMA compatibility; admin_principal_kind_tests locks validation, scopes, revocation, policy and machine-admin restrictions with the flag absent and enabled. The HTTP adapter records its authentication gate explicitly; discovery reads live configuration and disables the advertisement after an invalid reload. HTTP startup validates the opt-in, while stdio and direct CLIs ignore it. Both EMA and client-credentials opt-ins reject conflicting vendor settings. The service-account setup keeps secrets/assertions at the client–AS boundary and documents least privilege.

MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.

Met. Two independent halves, both enforced.

Audience validation. The JWT validator pins the audience to the configured resource identifier and the issuer to the configured issuer, accepting both trailing-slash spellings because providers disagree about it (crates/identity/src/auth/oidc.rs). exp, iss and aud are required spec claims, nbf is validated when present, and clock skew is bounded by configuration. The signing algorithm is pinned to RS256 on both the token header (crates/identity/src/auth/oidc.rs) and the JWK itself, so neither alg: none nor an HMAC-for-RSA key confusion is representable. A token minted for another resource fails aud and never reaches a tool.

No passthrough. The stronger property is that there is nothing to pass through. The inbound bearer is read from the header, handed to the validator, and dropped. What survives is Principal (crates/contracts/src/policy/mod.rs: tenant, subject, groups, scopes, authority) and TokenFacts (crates/contracts/src/ports/token_validator.rs: iat, jti, validated actors) — claims, never the credential. No vendor client can forward the caller’s token because no vendor client can obtain it. Vendor credentials are resolved independently from configuration or the OS keychain (crates/identity/src/auth/secrets.rs).

This is guiding constraint 4 in the enterprise product plan §0 (retired Enterprise repository), and it is enforced by shape rather than by discipline — the failure mode the spec warns about is unreachable rather than merely avoided.

The inference path keeps the shape. The provider routes (/v1/*, runtime security plan §6) forward the client’s body under a credential that is a server setting (MCP_INFERENCE_CREDENTIAL), resolved like a vendor credential. The handler copies four request headers upstream (content-type, accept, anthropic-version, anthropic-beta) and nothing else, so the caller’s Authorization, x-api-key and cookie never leave the gateway (inference_role_tests::client_bearer_never_reaches_upstream). The routes require the mcp:inference scope on the same validator as /mcp.

Partially mitigated, with different controls at each outbound boundary. The MCP guidance discusses clients following OAuth discovery URLs. This server also consumes discovery documents, download locations, and clone URLs supplied by upstream services; each needs its own trust boundary.

Canonical API targets. CanonicalTarget accepts a path and query, joined to the configured vendor base URL; absolute URLs and user information cannot be supplied through that target. This prevents host substitution on that API path, but does not cover every outbound operation. See crates/contracts/src/policy/canonical.rs and its canonicalization tests.

Control-plane URLs. OIDC issuer, discovery endpoints and JWKS use require_https with PlainHttp::Loopback (crates/identity/src/auth/oidc.rs). The public URL, admin client, Vault and SIEM enforce their own scheme checks. Discovery must identify the configured issuer, advertised endpoints are validated before use, and document bodies are bounded. These clients do not follow redirects. Cloud metadata secret adapters have a separate, intentional link-local boundary; that exception does not apply to OIDC URLs.

Downloads and redirects. The shared HTTP adapter disables automatic redirects (crates/http/src/lib.rs). Explicit download handling may follow a bounded redirect chain. Cross-origin destinations require MCP_DOWNLOAD_ALLOWED_ORIGINS; API credentials are stripped when crossing origins, and redirect origin and path are authorized before dispatch. This is not a claim that redirects never occur. The regression evidence is tests/integration_download_tests.rs, including github_log_redirect_strips_credentials_and_returns_artifact and redirects_are_authorized_by_origin_and_path_before_dispatch.

Browser automation. The six playwright_* tools (opt-in playwright feature) are stdio by default. HTTP sessions omit them and refuse direct calls before any launch unless the operator sets PLAYWRIGHT_HTTP_ENABLED and declares the browser’s network containment (PLAYWRIGHT_CONTAINMENT: an allowlist forward proxy the browser is pointed at with Chromium’s loopback bypass removed and WebRTC held to proxied transports, so only Chromium-based engines are accepted; a pod network policy alone is refused, since it cannot block the pod’s own loopback); the opt-in refuses to turn on otherwise. Locked by http_transport_tests::http_refuses_playwright_tools_before_launching_anything, playwright_http_tests::the_opt_in_refuses_to_turn_on_without_containment, tool_surface_parity_tests::stdio_advertises_playwright_tools_when_selected, and proven outside the process by the playwright-containment CI job (scripts/ci/playwright-containment-smoke.sh: link-local, loopback, RFC 1918 and the metadata address refused directly, via redirect, via WebSocket and as subresources). Each page is bound to the bearer subject the transport validated, on every call (playwright_http_tests::handles_are_bound_to_the_bearer_subject_without_a_policy_scope), closed on that subject’s revocation, capped per owner and per process before launch under concurrent load with a flat gateway resident set (::opens_past_the_cap_are_refused_before_spawn_with_flat_memory), closed when idle on the handle’s own clock, limited by a per-child cgroup memory ceiling where Linux lets the gateway write one, and driven through a line client that refuses oversized child output before parsing it (src/vendor/playwright/rpc.rs, playwright_bridge_tests). A browser the kernel kills is a dropped handle. Under a policy file, clicks and fills are act_on_page writes a require_approval rule holds at admission (::click_and_fill_are_held_for_approval_when_policy_requires). Page text is bounded and scrubbed before the model sees it (devtools_contracts::browser::snapshot, fuzzed). The deployment shape is docs/playwright-http-deployment.md; the gateway runs as one replica.

Git cloning. The MCP bb_clone tool is available over stdio only. HTTP sessions omit it from discovery and reject direct calls before admission, filesystem writes or subprocess execution, including loopback HTTP and both stateless and initialized sessions. Direct CLI cloning remains a local operator command. Locked by http_transport_tests::modern_tools_list_is_stateless_deterministic_and_cacheable, ::http_refuses_bb_clone_before_creating_a_destination and tool_surface_parity_tests::stdio_advertises_bb_clone_when_bitbucket_is_enabled. bb_clone takes its clone URL from the repository metadata Bitbucket returns, so that URL is vendor data and is bound to the request before Git sees it (src/controllers/clone.rs). The href must be one of the three shapes Bitbucket emits (https://host/ws/slug.git, ssh://git@host/ws/slug.git, git@host:ws/slug.git); its host must be the Bitbucket host the vendor base URL implies (bitbucket.org in production); and its path must name exactly the workspace and repository the call asked for. Userinfo, queries, fragments, extra path, and any host or segment that begins with - are refused as malformed, and a refused href is never echoed. Git then runs without a shell, with -- before the URL, with its transport pinned to SSH and HTTPS (protocol.allow=never plus explicit allows, so ext::, file:// and git:// cannot be used even by a URL that passed), with HTTP redirects refused (http.followRedirects=false; Git’s default follows one on its first request, which would let the upstream move the clone off the bound host), with hooks pointed at a non-existent directory, and in a cleared environment that keeps only what a clone needs: PATH, HOME, the SSH agent socket, an operator-set GIT_SSH_COMMAND, trust-root and proxy variables, locale and temp space. GIT_CONFIG_PARAMETERS, GIT_SSH, GIT_PROXY_COMMAND, GIT_DIR and the rest of git’s environment-driven configuration do not survive, and GIT_CONFIG_NOSYSTEM=1 drops the system config file. The user’s own ~/.gitconfig and ~/.ssh are deliberately honoured: that is where credential helpers and keys live.

What this does not do: Git’s network traffic is Git’s, not the Rust HTTP adapter’s, so it passes no egress chokepoint and produces no egress record (the metadata fetch that produced the URL does). The call’s outcome record lists it under subprocesses with the URL Git was handed and how Git ended, so the journal shows where the clone went (cli_audited_tests::bb_clone_journals_the_git_destination_on_its_outcome). BITBUCKET_CLONE_ROOT confines the target directory when configured. Process-level containment (seccomp, network namespaces) is a deployment control. Locked by clone_tests: a_clone_url_on_a_foreign_host_is_refused_before_git_runs, a_clone_url_for_a_different_workspace_or_slug_is_refused, malformed_and_option_shaped_clone_urls_are_refused, git_runs_with_a_pinned_transport_and_a_cleared_environment, and git_refuses_a_redirect_on_its_first_request (the real git binary against a redirecting origin); the adapter’s environment handling by shell_tests::system_command_runner_applies_an_allowlisted_environment. The grammar itself (devtools_contracts::clone_url) is fuzzed by fuzz/fuzz_targets/clone_url.rs, and clone_url_invariants_tests runs the same invariants over a seeded corpus on stable: an accepted href names exactly the requested repository on the expected host and nothing else.

Network restrictions. The application does not provide universal DNS/IP range filtering. Operators must constrain destinations at the network boundary; see deviation 2.

MCP servers that implement authorization MUST verify all inbound requests. MCP servers MUST NOT treat possession of a state handle as authentication.

Met. Every handle this server mints is bound server-side to the principal that created it, and a handle presented by anyone else is indistinguishable from one that does not exist.

MCP session IDs. enforce_session_owner (src/server/session.rs) runs inside the bearer middleware, so the validated Principal is already in the request extensions. A request naming a session another principal owns — or one with no binding — gets the same 404 rmcp gives an unknown session (session_not_found, src/server/session.rs), so a session ID is not a capability and probing does not distinguish “not yours” from “not there”. A request with no session ID passes through; if its response creates a session, the session is bound to the requester before the response leaves.

Artifact IDs. Identical treatment: only the principal that created an artifact may download it or read it with artifact_read, and a cross-owner request is the same 404 (or not-found tool result) as an unknown ID (download_artifact, src/server/http.rs). Ownership is taken from the validated principal on every admitted call, with or without an audit journal: a journal-less call runs under an owner-only task-local (with_acting_owner, crates/policy/src/policy/scope.rs) rather than the enforcing call scope, so bb_upload reads only the caller’s artifacts and the response cache is partitioned the same way (principal_partitioning_tests::without_a_journal_a_tool_created_artifact_belongs_to_its_creator, ::without_a_journal_upload_reads_only_the_callers_artifacts, ::without_a_journal_the_response_cache_is_partitioned_by_principal). Without inbound auth the requester is local, which is also what every locally created artifact is owned by, so community mode is unchanged (::stdio_calls_stay_local).

Console session and login IDs. 256 bits from a CSPRNG, base64url (random_id, crates/console/src/console/session.rs) — the spec’s “secure, non-deterministic handles … avoid predictable or sequential identifiers”. Both maps are bounded and expiring; a login in flight is consumed exactly once (Bounded::take removes before checking expiry, crates/console/src/console/session.rs) and lives at most five minutes, which is inside the spec’s suggested ten.

Revocation. When the revocation list changes, sessions belonging to newly revoked subjects are closed and the validated-token cache is cleared (watch_revocations, src/server/http.rs), so nothing derived from a revoked token outlives it. See docs/revocation-runbook.md.

The spec’s guidance for servers intended to run locally: use stdio to limit access to just the MCP client, or if using an HTTP transport, require an authorization token or use a Unix domain socket.

stdio: met. src/server/stdio.rs is the default local transport and is reachable only by the process that spawned it.

HTTP with MCP_AUTH_MODE=oidc: met. Every tool call needs a validated bearer.

HTTP with MCP_AUTH_MODE=off: a documented deviation. See Deviations.

DNS rebinding — listed by the spec as the third local-server attack — is blocked for browsers by an Origin allowlist applied globally to the transport’s routes (origin_allowlist, src/server/http.rs; OriginAllowlist, src/server/origin.rs). It admits loopback origins, the origin of MCP_PUBLIC_URL, and the exact origins the operator lists in MCP_ALLOWED_ORIGINS (each https, or http on loopback; a malformed entry refuses startup). An attacker page at evil.example that resolves to 127.0.0.1 still sends Origin: https://evil.example and is refused with 403. A missing Origin is allowed, because non-browser MCP clients do not send one and the header is exactly what distinguishes them; browsers send it on every cross-origin fetch and form post, which is the case that matters. Until the 2026-09-15 assessment (S11) the list was loopback-only in every auth mode, which erred safe but refused browser-hosted clients behind a public URL.

A fail-closed startup gate refuses to boot with a non-loopback bind while no inbound authentication exists (validate_startup_security, src/server/http.rs). The loopback bind is the community trust boundary; removing it without authentication would expose every configured vendor credential to the network, so that combination is not a warning, it is a refusal.

Met. The spec requires clients to allow only http/https (loopback-only for http), reject javascript:, data:, file:, vbscript:, prefer allowlists over blocklists, and never open URLs through a shell.

The console is the only component that sends a browser to a provider-supplied URL. require_https with PlainHttp::Loopback (crates/identity/src/auth/oidc.rs) is allowlist-based — it admits https, and http only on loopback, rejecting everything else — and it is applied to authorization_endpoint and token_endpoint at discovery time (crates/identity/src/auth/oidc.rs), before either is used. A javascript: or data: endpoint therefore cannot reach a Location header. No shell is used to open a URL anywhere in this codebase.

Content Security Policy. The console serves default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none' with no inline script or style, plus Referrer-Policy: same-origin, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and no-store on everything but static assets (CSP and security_headers, crates/console/src/console/mod.rs). This is stricter than the spec’s script-src 'self' suggestion and covers the clickjacking requirement the confused-deputy section states for consent pages.

Cookies. HttpOnly; Secure; SameSite=...; Path=...; Max-Age=... on both console cookies (set_cookie, crates/console/src/console/mod.rs). The session cookie is SameSite=Strict and scoped to /console; the login cookie is SameSite=Lax — it must survive the provider’s cross-site redirect back to the callback, which Strict would drop — and is scoped to the callback path alone, for five minutes. The __Host- prefix is not used: it requires Path=/, and path scoping is the more valuable of the two properties here. The spec mandates __Host- for consent cookies in an OAuth proxy, which we do not have.

Policy proposal review. Pending administrative candidate reads retain admin scope and the proposer’s tenant boundary. The API returns the exact stored signed generation only after its digest check; the console escapes all policy, manifest and revocation text. Downloads use fixed attachment filenames, application/octet-stream, nosniff and Cache-Control: no-store. Reads do not approve or apply anything; decision forms retain the origin checks below. Locked by unified_console_tests::proposal_review_shows_stored_policy_and_downloads_exact_signed_bytes and unified_approvals_tests::pending_proposals_survive_a_restart_and_a_tampered_journal_is_refused. This exposes signed operator documents to same-tenant administrators; it is not a redaction mechanism and does not protect files after an administrator explicitly saves them.

CSRF. Every non-GET console request must carry the console’s own Origin, or Sec-Fetch-Site: same-origin when no Origin is present (same_origin, crates/console/src/console/mod.rs). Locked by tests/unified_console_tests.rs::non_get_requests_need_the_public_origin, which exercises nine refused origin/fetch-site combinations and three accepted ones.

PKCE and state. Authorization-code flow with PKCE S256 (crates/console/src/console/login.rs); the console is a public client and holds no client secret. A 256-bit state is stored server-side, keyed by the login cookie, and compared on callback; a mismatch, an empty value, or a second use of the same login is refused (callback_handler, crates/console/src/console/mod.rs).

N/A. The spec is explicit that this “only applies to MCP implementations that use a proxy architecture, not to direct stdio transport usage”. This server is not a proxy: it never spawns MCP servers as child processes, and there is no local proxy authentication token to steal. This does not imply process isolation: bb_clone spawns Git, whose separate boundary is described in Server-side request forgery.

Not applicable today; defended anyway. The attack needs a client talking to several authorization servers, one of them hostile. The console has exactly one configured issuer, and the JWT validator pins iss to it (crates/identity/src/auth/oidc.rs), so an authorization response from a different provider cannot yield a usable token here.

The spec’s mitigation is RFC 9207 authorization-response validation: bind the response to the authorization server recorded before the redirect. The console validates the iss parameter when the provider returns one, comparing it to the configured issuer and refusing a mismatch (callback_handler, crates/console/src/console/mod.rs). An absent iss is accepted, because the spec notes the mitigation “depends on honest authorization servers emitting iss” and not all providers do. The check costs nothing today and stops the configuration from silently becoming unsafe if a second issuer is ever added.

N/A. The console’s redirect_uri is derived from MCP_PUBLIC_URL, which must be https (or http on loopback for development) and is validated at startup (src/server/auth.rs). It is a registered server URL, not a localhost port any local process could claim. We also do not use Client ID Metadata Documents, which is the precondition for the spec’s attack.

N/A. This server does not act as an authorization server and does not accept URL-based client IDs, so there is no client-metadata document to fetch and no trust policy to apply.

Met.

  • Two scopes, no wildcards. mcp:tools for the data plane (MCP_REQUIRED_SCOPE, default in src/server/auth.rs) and mcp:admin for the control plane, which is a separate boundary with its own rate limiter (InboundAuth::for_admin). No *, all, or full-access scope exists — the spec’s first “common mistake”.
  • Scope is not the only gate on the control plane. mcp:admin admits a kind of principal as well as a scope: a token the provider positively marks as a machine’s (per profile, on validated claims — never on subject naming) is refused at /admin/* under the default MCP_ADMIN_PRINCIPALS, because the two-person approval rule compares subjects and two service accounts would satisfy it. Locked by tests/admin_principal_kind_tests.rs; CF-42 carries the per-tenant evidence that a real provider emits the marker.
  • The catalog is not published. scopes_supported in the RFC 9728 document carries only the required scope, not every scope the server knows (src/server/auth.rs) — the spec’s second “common mistake”. Locked by tests/inbound_auth_tests.rs.
  • Precise challenges. A token that validates but lacks the scope gets 403 with WWW-Authenticate: ... error="insufficient_scope", scope="<required>" naming exactly the scope needed (insufficient_scope, src/server/auth.rs) — the spec’s “emit precise scope challenges; avoid returning the full catalog”.
  • Scopes are not the authorization decision. The spec’s last common mistake is “treating claimed scopes in token as sufficient without server-side authorization logic”. A valid, correctly scoped token only gets a request as far as the policy engine, which decides per action on the canonical outbound request and journals the decision (crates/policy/src/policy/, plan §1.3). Scope is admission; policy is authorization.

Four transport and identity qualifications follow. The Git boundary is covered above; prompt injection, downstream sharing and shadow servers are covered in the OWASP assessment.

1. MCP_AUTH_MODE=off over loopback HTTP has no inbound token

Section titled “1. MCP_AUTH_MODE=off over loopback HTTP has no inbound token”

Spec: local servers using an HTTP transport SHOULD require an authorization token or use a Unix domain socket.

What we do: with MCP_AUTH_MODE=off there is no inbound authentication. The loopback bind is the trust boundary, and startup refuses any non-loopback bind in this mode (validate_startup_security).

Residual risk: any process running as any user on that host can call every enabled tool within the configured local policy and vendor credential authority. Without a policy, that authority is the local credential set. The Origin allowlist stops a browser, not a local process.

Mitigation available to operators: use the stdio transport for local use — it is the default and it is reachable only by the client that spawned it — or run with MCP_AUTH_MODE=oidc. This is a deliberate posture (plan §1.2), not an oversight, but it is weaker than the spec’s SHOULD and should be stated as such in any assessment. Whether to close it — a static local token, or a Unix domain socket — is tracked as CF-44.

2. No private-IP range blocking on control-plane fetches

Section titled “2. No private-IP range blocking on control-plane fetches”

Spec: clients SHOULD block requests to private and reserved ranges (10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, fc00::/7, fe80::/10), while also warning “avoid implementing IP validation manually” because of encoding tricks, and recommending egress proxies.

What we do: https-or-loopback only. We do not resolve and range-check.

Residual risk: these control-plane URLs come from operator configuration or discovery documents served by the configured issuer; they are not canonical tool targets. This does not cover download or Git destinations described above. One case is a compromised or misconfigured identity provider naming, say, https://169.254.169.254/... as its jwks_uri. Redirect following is already off, which removes the redirect-chain variant, and the TOCTOU DNS-rebinding variant the spec describes would additionally need the attacker to control DNS for a name with a valid certificate.

Why not fixed in code: the spec itself advises against hand-rolled IP validation, and a correct implementation must pin DNS between check and use. An egress proxy enforcing network policy — the spec’s own recommendation — is the right control, and it belongs in the deployment, not in this binary.

Outbound posture: restrict egress from the gateway and control pods to the identity provider, the SIEM, the configured vendor hosts, and the cluster resolver; block the instance metadata endpoint (169.254.169.254) and, where nothing the pods need lives there, the private ranges. The Helm chart ships an optional policy enabled by setting networkPolicy.enabled: true; the shipped default is false (deploy/helm/mcp-devtools, one NetworkPolicy per role: ingress from the WAF pods (or, with the WAF disabled, the ingress controller) on the HTTP port only, egress to DNS plus HTTPS anywhere except link-local by default, narrowed by networkPolicy.egress.allow / except; and one for the WAF, which may open only DNS and the roles’ HTTP port). It is opt-in because a CNI that does not enforce NetworkPolicy accepts the object and ignores it. The cloud templates enforce the same posture with the platform’s own objects, on by default: deploy/aws/fargate.yaml gives the task a security group whose egress is TCP 443, NFS to the file system and the SIEM port, with no all-traffic rule (a Fargate task has no instance metadata endpoint to block); deploy/azure/main.bicep gives the environment subnet a network security group that denies 169.254.169.254/32 before any allow and denies everything not listed. operations_tests::aws_fargate_template_preserves_the_deployment_constraints and ::azure_container_apps_template_preserves_the_deployment_constraints lock both.

Inbound edge: every shipped deployment path puts a WAF in front of the server by default (ModSecurity with the OWASP CRS, blocking, rules in deploy/waf/). It is defense in depth for the console, the admin API and the MCP envelope, not a substitute for any control on this page: tool-call arguments and authored policy content are exempt from its injection rules, and it makes no authorization decision. Keep server, ingress, nginx and inspection caps aligned: Helm validates waf.clientMaxBodySize against waf.requestBodyLimitBytes; Compose/k8s use deploy/waf/body-limit.conf. Kubernetes resolves fixed Service destinations using pod DNS at request time, so recreation needs no restart; client headers cannot choose an upstream. Keep demo credentials outside the checkout; the exact local token filename is also ignored as a staging backstop. These controls are locked by the operations tests and WAF body/DNS smoke scripts. See the OWASP assessment. Network egress restrictions are distinct from per-request authorization in crates/policy/src/policy/egress.rs; both are needed for shared deployments. CF-43 records the history.

3. openid is requested but no ID token is validated

Section titled “3. openid is requested but no ID token is validated”

What we do: the console requests openid mcp:admin by default and reads only the access_token from the exchange (crates/console/src/console/login.rs). It does not request or validate an ID token; the administrator’s identity comes from the access token, which the admin API validates at its own boundary.

Residual risk: none identified. Security comes from PKCE, state, and the audience-validated bearer. openid remains in the default scopes because several providers expect it on an OIDC authorization request and operators have configured against it.

Previously: a nonce was generated and sent but never verified, because verifying it requires an ID token nobody validated. That parameter has been removed rather than left as a check that appears to exist and does not.

4. Console routes sit outside the transport’s Origin allowlist

Section titled “4. Console routes sit outside the transport’s Origin allowlist”

What we do: the console router is merged after .layer(origin_allowlist) and .layer(cors) in build_app_inner (src/server/http.rs), so neither applies to it. This is deliberate: the console is served at the deployment’s real public origin and carries its own origin check, and it predates the allowlist admitting the MCP_PUBLIC_URL origin. Moving it inside the allowlist would now be possible; it has not been done, so the test below still locks the layer ordering.

Compensating controls: the console carries its own origin check on every non-GET request (same_origin), SameSite cookies, and a CSP with form-action 'self' and frame-ancestors 'none'.

Why it is called out: the correctness of this depends on axum’s layer ordering — .layer() applies to routes registered before it — which a refactor could silently invert. It is locked by tests/unified_console_tests.rs::non_get_requests_need_the_public_origin: that test runs through the composed router and checks the console origin rules. The transport also admits the configured public origin, so acceptance of that origin alone does not prove middleware ordering. A separate assertion checks that the console’s 403 does not leak into the server’s fallback for unknown paths.

Re-read this page against the spec whenever:

  • the MCP specification revision this server targets changes (currently 2026-07-28, see with_stateless_protocol_metadata_required in src/server/http.rs);
  • a new inbound authentication mode, issuer, or client is added — in particular, anything that would make this server an OAuth proxy, which activates the entire confused-deputy section;
  • a tool gains a parameter that reaches an outbound URL, which would end the structural SSRF argument in Server-side request forgery; bb_clone is the standing exception, and its URL is bound to the request by validation rather than by shape, so a change to that validation needs the same review;
  • a new handle type is returned to callers, which needs the owner binding described in State handle hijacking.

Authentication troubleshooting diagnostics

Section titled “Authentication troubleshooting diagnostics”

Inbound HTTP diagnostics (src/server/diagnostics.rs) record arrival and response-header completion, including public discovery and failed auth, with a server-generated ID also returned as x-request-id. Authentication warnings share that ID. Fields are restricted to standard methods, route templates, bearer presence, status and duration; queries, literal path parameters, cookies, token values, bodies and claims are excluded. Unknown paths and custom methods use fixed markers, and caller correlation headers are ignored. inbound_auth_tests::http_diagnostics_correlate_discovery_and_refusals_without_request_secrets locks correlation, discovery/refusal coverage and exclusion without relying on the log redactor. These retained, filtered diagnostics supplement the durable journal; they cannot prove client or IdP steps outside this server.

PostgreSQL connection failures for NinjaOne and WRDS produce warnings with the resolved host, port, database, TLS mode and transport cause chain in the process log (~/.mcp/data/mcp-server-devtools.<session>.log). TLS setup failures also warn. Connection configuration, SQL and credential fields are not logged; client-side causes mask the selected username/password. Server ErrorResponse messages, details and hints are omitted in favour of SQLSTATE. Host and database names remain visible to operators. postgres_connection_tests locks these MCP01/MCP08 controls using loopback TCP and PostgreSQL protocol fixtures. Tool error envelopes and audit payloads remain unchanged. Existing RUST_LOG filters apply; no debug setting is required for these warnings.

NinjaOne database queries can return a plan instead of rows (explain on ninjaone_db_query_central, _division and _named). This is an MCP05 surface, so the control is structural: the caller’s sql still passes the unchanged single-SELECT guard, and the server renders the EXPLAIN prefix from four typed fields (analyze, buffers, verbose, format) in a closed, deny_unknown_fields schema, so caller text never becomes an option and EXPLAIN of INSERT, UPDATE, DELETE or a data-modifying CTE is refused before any connection. analyze executes the SELECT once, so it runs in the same read-only session and transaction under the 10-second statement timeout; the server refuses a writer function or write statement under it (checked against PostgreSQL 17: cannot execute INSERT in a read-only transaction, zero rows written). rowLimit does not bound a plan, so the statement timeout is the only cap on analyze; plan text can include literals from the query the caller supplied, which the audit digests already treat as argument content. explain_is_still_guarded_before_connecting and explain_statement_renders_only_typed_flags (library tests), explain_arguments_refuse_unknown_options and central_explain_reaches_the_port_as_typed_flags_without_a_row_cap (application_boundary_tests) lock it. Policy classifies the three tools, not the explain field, so a rule that allows a query tool allows its plan (MCP02: no new authority, since analyze runs a query the rule already permits).

Container builds for amd64 and arm64 share Cargo artifacts, but certify source fingerprints separately for each target. Keep the Dockerfile’s --cache-scope argument when changing the build pipeline: a successful build for one target must not certify cached output for the other. tests/build_cache_test.py locks this against source changes with old timestamps and against failed builds.

Nested credentials and acknowledged audit forwarding

Section titled “Nested credentials and acknowledged audit forwarding”

Legacy NinjaOne server-entry passwords and TOTP seeds use the same startup/refresh secret snapshot as top-level credentials. Calls read that snapshot without fetching a provider; unresolved references refuse login before vendor I/O. Destination URLs, prefixes and commands remain literal administrator configuration. The discovery boundary is locked by config_tests::ninjaone_nested_reference_discovery_is_limited_to_credential_fields; login, rotation, TOTP and unresolved-reference behavior are locked in ninjaone_session_tests (OWASP MCP01/MCP05).

For an acknowledged audit copy, select relp+tls:// and configure the receiver to persist its queue before replying. The adapter validates the TLS peer and optional client certificate, negotiates syslog, and requires a matching 200 response for every transaction before advancing a batch cursor. Missing, malformed or rejected responses discard the connection and retain the cursor. Responses are bounded and peer prose is never logged. An acknowledged prefix can be duplicated on retry; deduplicate by origin and sequence. TCP syslog retains its transport-only acknowledgement gap. audit_forwarding_tests RELP conformance/cursor tests and relp_invariants_tests lock these controls (OWASP MCP08). See the forwarding runbook.

RELP oversized-record notices preserve forwarding progress without silently skipping evidence: the SIEM receives the original sequence, JSON byte count and SHA-256 digest, and the full line remains in the journal. Alert on audit_forwarding.reason = relp_message_too_large, retrieve by origin/sequence and verify the digest. Every notice requires acknowledgement before cursor advance. relp_oversized_notice_is_acknowledged_before_cursor_progress locks this MCP08 control; relp_batch_can_outlast_each_exchange_budget and relp_reconnects_an_idle_closed_session_in_the_next_delivery lock exchange budgets and idle connection recovery. Shared TLS receiver URL validation rejects user-info, paths, queries and fragments without echoing URL contents (MCP01/MCP05), locked by the configuration rejection tests.

The local import helper is an operator workflow outside MCP dispatch. Explicit --admin-token-file or --admin-recovery-file authorizes bootstrap using that credential; it is never inferred from the runtime config. A unique temporary policy grants only create/update on planned KV v2 data paths, and a separate in-memory token performs the CAS-zero imports. It is non-renewable with a ten-minute maximum lifetime; both revocation and policy deletion are attempted after success or failure. TLS/origin checks, redirect refusal and sanitized provider errors apply to all administration calls. The runtime token is unchanged.

Regression evidence is in scripts/operations/test_import_configs_vault.py: test_operator_import_over_http, test_operator_mode_scopes_writer_and_cleans_up_without_logging_tokens, test_operator_preview_never_opens_admin_credentials_or_contacts_vault, and test_operator_failure_revokes_writer_and_deletes_policy. The administrator credential is trusted local input. Interruptions and cleanup failures may leave a policy behind; the token TTL bounds access and warnings identify cleanup needed. This workflow uses Vault auditing when configured, not the MCP journal.

Tasks reuse the validated issuer/tenant/subject owner and HTTP middleware on every operation. Capability metadata only selects protocol compatibility; legacy initialize cannot enable creation. The SQLite schema has no token, vendor credential or original-argument fields. Input updates are discarded, not interpreted as administrative approval. Results are bounded to 16 KiB. Approval results are minimized receipts; background results are the tool’s own output, held at rest under the owner binding, with larger results moved to the owner-bound artifact store. Tests in task_wire_tests, task_lifecycle_tests and background_task_tests lock these boundaries.

Background execution is limited to a fixed allow-list of long-running, non-mutating or idempotent tools (tools::tasks::BACKGROUND_TOOLS). It starts only after full admission and a committed task, never turns a refusal or approval hold into work, and is bounded by MCP_TASKS_MAX_RUNNING with a synchronous fallback (background_task_tests).

Persistence uses verified WAL/FULL, one owning process and a bounded worker queue. Corruption or uncertain writes refuse service; a backup restore cannot create authority or undo approval consumption. Private local storage, reliable sync and platform ACLs remain operator obligations. There is no general prompt-injection filtering or multi-replica support. See tasks deployment for recovery and evidence limits.

PR 4 connects held calls only after explicit request negotiation. Proposal/task association commits before a handle is returned. The verified approval journal controls pending-task reconciliation, including after restart. Terminal results are retained historical receipts, bound to the authenticated owner and containing the proposal ID and execution deadline; they remain readable when approvals are disabled. They are not independent audit evidence. Updates cannot decide; requester withdrawal uses call_cancellation, never a free-text rejection reason. Hold indexes include issuer ownership. Capacity/availability failures preserve the ordinary approval response without executing work. Fresh admission and single consumption still govern execution, even if retained history is stale. unified_call_approvals_tests::approval_tasks covers races/partial commits and unified_jira_create_approval_tests::approval_tasks covers stale tokens, revocation and changed upstream identity; tasks::sqlite::tests covers v1-to-v2 migration and retained associations. This extends the state-handle and approval controls above without introducing saved execution context.

Tasks availability controls additionally isolate direct CLI bootstrap from the store (direct_cli_neither_locks_nor_recovers_the_server_task_store). Admitted requests await durable replies; queue delay never disables Tasks (slow_reads_and_writes_do_not_poison_the_store). SQLite FULL refuses capacity while preserving reads/cleanup, expired rows do not consume admission capacity, and lowered limits preserve startup (tasks::sqlite::tests). Empty first-boot files resume only after pristine-schema validation under the lifetime lock; nonempty unversioned databases remain refused. Legacy Tasks methods return method-not-found, and supported later protocol versions pass the version gate.

jira_download_attachment reuses the bounded streaming and owner-bound artifact controls: numeric attachment IDs, generated paths, byte/disk limits, canonical egress authorization, explicit redirect allowlisting and credential stripping. It returns metadata without archive previews and never extracts or executes attachments. Clients retrieve bytes through owner-checked artifact_read or the authenticated HTTP artifact endpoint. jira_download_tests covers these tool-specific boundaries; background_task_tests covers negotiated task and synchronous retrieval. The built-in extractor leaves this native tool unclassified, preserving denial under enforcing file policies.

The identity adapter uses keyring-core with explicit macOS Keychain, Windows Credential Manager and Linux dbus Secret Service stores. It does not install or consume a mutable global default store and cannot select a mock as a fallback. The Linux store negotiates encrypted DH sessions; errors disclose fixed categories, excluding daemon payloads and secret bytes. Current/legacy lookup and removal preserve saved credentials. These MCP01 controls are locked by auth::keychain::backend::tests, crates/identity/tests/keychain_tests.rs, and the isolated old-to-new compatibility runner scripts/ci/keychain-compatibility.sh.

For MCP04, the deprecated zbus chain is absent from the product lockfile and the advisory gate has no exceptions. Vendored libdbus adds C/FFI to desktop Linux; its embedded revision needs monitoring beyond RustSec and binary rebuilds for updates. Native stores still depend on OS access controls and an available, unlocked service. See backend and packaging evidence.

The runtime security plan’s data map (PR 5) answers which principals of one tenant touched which classes through which vendors, tools and models. It is served three ways, and each is bound to one tenant: the admin API’s reports/data-map takes a window and nothing else, binding the tenant to the bearer’s verified identity, and the generic usage route refuses a data_map query so no body can name a tenant; the console page sends the window and renders what the boundary answers; audit report --data-map opens the rollup file and takes --tenant explicitly, as an auditor with the file is outside the boundary. The rows it summarises are class ids with where they went, never content, and they are telemetry: the journal’s marked, labels and owner_classes export columns are the record. admin_api_tests::data_map_is_bound_to_the_callers_tenant and unified_console_tests::data_map_is_tenant_scoped lock the binding; operations_tests::compose_front_end_never_forwards_metrics locks that the overlay’s unauthenticated metrics page stays on the compose network.

Runtime security plan PR 1. A schema 3 policy bundle can carry content rules over the decoded arguments of non-read tool calls. The application asks the ContentGuard port after admission and before the approval gate and the intent; the built-in detectors are the logger’s credential patterns, shared through devtools_contracts::guard::secrets, fuzzed (fuzz/fuzz_targets/secret_detector.rs) and bounded by MCP_GUARD_MAX_SUBJECT_BYTES. The best-practice mapping:

  • Least privilege beyond authorization (MCP02). A write the policy allows is still refused, or held for a second person, when its arguments carry a credential. The hold key binds the findings digest, so an approval covers exactly the content that was reviewed (content_guard_tests, hold_key_tests::content_findings_bind_the_key_and_their_absence_keeps_the_v1_key).

  • Fail closed (decision 4). A guard that cannot inspect follows the rule’s on_error, deny unless the rule says label; a rule this build cannot enforce is a load error, not a silent no-op.

  • Decode before matching (decision 15). JSON escapes are resolved by the parser before inspection; object keys are inspected too (content_guard_tests::json_escaped_secret_in_write_argument_is_denied, secret_detector_invariants_tests).

  • Evidence without content (MCP08, MCP10). content_finding records name rule, detector, class, count and action; refusals name the rule and detector; neither carries the value, and the caller-facing decision omits the rule list.

  • What an owner has read follows the owner (PR 4, decision 7). Flow rules deny a vendor call or a prompt once the verified owner’s taint classes satisfy when.owner_has; the marks are keyed by issuer, tenant and subject, never by session, token or run id, expire by TTL, and are read before admission on both paths in the one gateway process (flow_policy_tests, policy_engine_tests::flow_rule_denies_prompt_after_prod_read).

  • Budgets by reservation, not by hindsight (PR 4, decision 17). A request’s estimate is reserved under one lock before anything is forwarded, all or nothing across the applicable budgets, so concurrent requests cannot each pass a nearly exhausted limit; a request that cannot report usage follows each budget’s unknown_usage, deny by default; an exceeded budget denies or holds for a second person (run_budget_tests).

  • A held prompt is the same bytes or nothing (PR 4). The inference hold key digests the exact request body and the findings, and the same gate, proposal records and admin approval as a tool call decide it (unified_call_approvals_tests::inference_hold_round_trip).

  • One kill switch for both paths (PR 4). The signed revocation list refuses a subject’s prompts and tool calls alike and leaves its taint in place (revocation_tests::revoked_subject_is_refused_on_both_paths_and_keeps_its_taint).

  • Tested as an attacker chains it (governance plan WP6). Five multi-step workflows run against one deployment: prod logs copied out from a second session and then a prompt, a planted credential carried into an allowed write, a secret sent base64 encoded after a labelled read, a retry loop past a held budget, and a revoked owner with an open session and a re-minted token. Each refusal leaves the target upstream untouched and the journal without the adversarial text, and each case fails when its control is disabled (adversarial_workflow_tests::prod_logs_read_in_one_session_cannot_leave_through_another, adversarial_workflow_tests::confluence_injection_cannot_carry_its_planted_credential_into_a_write, adversarial_workflow_tests::tool_result_secret_cannot_reach_a_model_even_encoded, adversarial_workflow_tests::runaway_tool_loop_is_held_at_its_budget_not_run, adversarial_workflow_tests::revoked_owner_is_stopped_mid_workflow_on_both_paths).

  • Every representation, before any copy (decision 16, PR 3). A tool_result rule inspects a result’s text blocks and structuredContent together at the result boundary, before the structured value is bounded and before a task record or an overflow artifact holds it; redact rewrites both, deny leaves no structuredContent (content_guard_tests::oversized_structured_result_is_inspected_before_the_overflow_artifact_is_written, background_task_tests::oversized_background_result_is_inspected_before_spill).

  • In flight on the inference path (PR 3). prompt rules review every decoded string of the body before forwarding; completion rules review streamed deltas over a lookback window before relay and terminate on a finding, or hold the whole response in buffer mode and release all or nothing (inference_guard_tests). An event past the 1 MiB event bound is never held and is refused under the rule’s on_error (inference_guard_tests::an_event_past_the_event_bound_is_refused_not_relayed_uninspected). Prompts and completions are never persisted (inference_guard_tests::sensitive_values_never_reach_storage_or_logs).

  • External engines on the rule’s terms (PR 3). The webhook adapter is bounded in time, size and subject, fails under the rule’s on_error, and applies the engine’s answer under the rule’s own action (inference_guard_tests::webhook_timeout_follows_rule_on_error).

Not met, by design: base64 block payloads and binary artifacts are not decoded, the built-in patterns detect credential shapes only, and bytes a stream relayed before a finding are gone. The request-side policy remains the provable boundary; content rules are enforcement with stated detection limits.

MCP04: keep the strict Cargo Deny gate unsuppressed. The lockfile uses patched ctutils, rustls-webpki, webpki-root-certs, zstd, zstd-safe and zstd-sys. Askama keeps derive/std/urlencode but disables config parsing to remove unmaintained basic-toml; console templates live in crates/console/templates/. The source cache fingerprints this default directory (tests/build_cache_test.py). Template contents and HTML escaping are unchanged; unified_console_tests::pages_render_the_api_answers_and_never_the_token checks rendering and escaping.

The self-rooted fuzz crate has its own lock, which remediation of the root lock does not reach. Regenerate fuzz/Cargo.lock from the root lock after every root change (fuzz/README.md); binary_tests::fuzz_lock_resolves_shared_crates_to_root_versions fails on drift, and the quality job audits the fuzz-only crates against fuzz/deny.toml. Dependabot (.github/dependabot.yml) proposes weekly cargo updates with a seven-day cooldown, and its PRs need the same regeneration.

With MCP_SKILLS_ENABLED=true the server publishes embedded, reviewed workflows through the MCP Skills extension (MCP Skills). Every Skills method uses one gate: the live opt-in, a 2026-07-28 request, and the validated principal; client _meta is never identity. A skill exists for a session only when every tool it names is on that session’s surface, and knowing its URI changes nothing. URIs and cursors are parsed by a fuzzed canonical grammar and resolved by exact in-memory lookup, so no request reaches a filesystem or the network. Content is an explicit allowlist compiled into the binary, validated as a whole at startup (no allowed-tools, no scripts, bounded sizes, digests over the exact bytes), identical for every principal and tenant, and returned verbatim with cacheScope: private.

Following a skill grants nothing: each tool call it suggests passes the same admission, credential selection, policy and approvals as any other call. The specification’s host duties (activation consent, origin binding, digest/frontmatter verification, never honoring permission-widening frontmatter) are not something this server can enforce; digests show consistency with the manifest, not trustworthiness. skills_wire_tests, skills_packaging_tests and skill_uri_invariants_tests lock these boundaries.

MCP04 also covers the excluded eval/ test consumer: exact direct pins, a self-rooted lock held to root versions by binary_tests::eval_lock_resolves_shared_crates_to_root_versions, and its own CI advisory gate. It introduces no eval-exclusive Rust registry versions; the direct process-control pin follows the root lock. The reference-runtime adapter additionally has a hashed Python lock, exact SDK/CLI/Python pins and an advisory audit. Linux CI requires loopback-only namespace execution (runner qualification pending); see the compatibility record. The scripted workflow provides additional containment evidence via workflow_tests::paired_runbook_workflow_proves_containment_and_benign_completion; it does not establish how a live agent behaves or alter production controls.