Build a secure MCP server with tools, resources, prompts, OAuth, and least-privilege scopes
Design and deploy a remote TypeScript Model Context Protocol server whose tools, resources and prompts have closed schemas, per-handler authorization, OAuth protected-resource discovery, exact audience and scope validation, isolated downstream credentials, bounded transport, audit evidence and a rehearsed rollback.
Provide an MCP endpoint that lets authenticated clients read tenant-owned change records, obtain a review prompt, create drafts and—with a distinct scope plus exact human approval—publish a change, while wrong-resource tokens, weaker scopes, foreign objects, replays, SSRF attempts and unbounded work fail closed.
- Model Context Protocol 2025-06-18 authorization specification
- Official MCP TypeScript SDK v1 pinned by lockfile
- Node.js supported LTS
- OAuth authorization server OAuth 2.1 profile
- Reviewed capability inventory A named owner has classified every proposed tool, resource and prompt by input, data, side effect, identity, destination, approval and rollback.
- Authorization platform A maintained OAuth authorization server can issue resource-targeted tokens, register exact redirect URIs, support PKCE and revoke grants.
- Synthetic isolation fixtures At least two tenants, several objects, read/draft/publish identities and negative token fixtures exist outside production.
- Trusted ingress and egress controls TLS termination, request limits, default-deny outbound policy, secret management, audit delivery and monitoring have accountable owners.
- Former immutable release The previous server, metadata, policy, authorization registration, secret references and compatibility transcripts are retained for rollback.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A pinned TypeScript Streamable HTTP MCP server with a logical change resource, a review prompt, a draft-only proposal tool and a separately authorized publish tool.
- A standards-based OAuth protected-resource boundary with discovery metadata, exact token audience validation, narrow read, prompt, propose and publish scopes, and no token passthrough.
- A deterministic handler policy, network and resource limits, subject-bound sessions, privacy-preserving audit events, negative security fixtures and a complete-bundle canary rollback.
- A read-only caller can discover and use only intended content, while a publish action requires the exact action scope, tenant-owned object and single-use approval bound to normalized arguments.
- Wrong-resource tokens, expired grants, foreign objects, permissive schemas, approval substitution, replay, SSRF and overload attempts fail before downstream effects.
- Operators can identify the exact release and policy behind an action, monitor the boundary without logging secrets, and restore the former service plus OAuth metadata within the recovery objective.
Architecture
How the parts fit together
A client discovers the remote MCP resource and authorization server, obtains a resource-targeted token with selected scopes, and calls the TLS-protected Streamable HTTP endpoint. Authentication middleware produces a typed identity. Each capability validates input and invokes a deterministic tenant, object, action and approval policy before a narrow downstream adapter. Ingress, egress, session and output controls contain abuse, while an append-oriented audit stream records decisions without raw content.
- An unauthenticated client receives a Bearer challenge, reads Protected Resource Metadata and discovers the trusted authorization server plus supported scopes.
- The authorization server authenticates and obtains consent, then issues a short-lived token whose audience identifies the canonical MCP resource.
- The MCP ingress validates token, origin, size and rate; a capability validates and canonicalizes structured input.
- Policy derives tenant from identity, verifies action scope and object ownership, then consumes exact approval for publication before calling the narrow adapter.
- The bounded result returns to the client as untrusted content while audit and metrics record policy, outcome, latency and release identity.
Assumptions
- The example uses a remote Streamable HTTP server. A local stdio server instead inherits the launching user's process permissions and should receive only narrowly selected environment variables and working-directory access.
- The OAuth authorization server, TLS platform, secret manager, egress enforcement, tenant directory, approval service and audit destination already have owners and supported operational procedures.
- The catalog API can provide a dedicated service credential and idempotent draft/publish operations; it never requires forwarding the user's MCP bearer token.
- No MCP metadata or model response is trusted as policy. The server remains the authorization and side-effect enforcement point.
Key concepts
- Protected Resource Metadata
- A public metadata document advertised from a Bearer challenge that identifies the MCP resource, trusted authorization servers and supported scopes so a client can start the correct OAuth flow.
- Resource indicator and token audience
- The resource requested during authorization and the corresponding token audience prevent a token intended for another API from being accepted by this MCP server.
- Capability control taxonomy
- Prompts are user-controlled, resources application-controlled and tools model-controlled in MCP interaction design. None of those labels replaces handler authorization.
- Confused deputy
- A server becomes a deputy when it has downstream authority and can be tricked into using that authority for a client that was not entitled to the action.
- Token passthrough
- Forwarding an inbound access token to another service. MCP security guidance prohibits it because the token was not issued for that downstream audience and attribution is lost.
- Normalized action digest
- A stable hash of canonical tool, target, arguments and policy revision that binds approval, idempotency and audit evidence without storing the full action body.
Before you copy
Values used in this guide
{{mcpResource}}Canonical HTTPS protected-resource identifier used in metadata and token audience validation.
Example: https://mcp.example.net/mcp{{authorizationIssuer}}Exact trusted OAuth issuer published in authorization-server metadata.
Example: https://identity.example.net{{candidateRelease}}Immutable application, lock, policy and configuration bundle under validation.
Example: 2026.07.28.1{{previousRelease}}Locally retained full bundle used by the rehearsed rollback.
Example: 2026.07.14.3Security and production boundaries
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Stop before continuing if
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
decision
Define capabilities, identities and trust boundaries
Start with an effect inventory rather than an SDK example. This tutorial's server exposes a read-only change-record resource, a user-selected review prompt, a draft-only proposal tool and a separately authorized publish tool. Name the human, MCP client, authorization server, resource server, downstream change API and audit system, then document which boundary authenticates and authorizes each transition.
Why this step matters
MCP distinguishes model-controlled tools, application-controlled resources and user-controlled prompts, but that control taxonomy does not grant authority. A threat model makes the dangerous assumption visible: the model may request a tool, yet only the server can decide whether the authenticated caller may use it on this exact tenant and object.
What to understand
For every capability, record inputs, outputs, data classification, network destination, filesystem or database changes, expected latency, retry safety, approval requirement and compensating action. Treat indirect actions such as notifications, cache refreshes and webhook delivery as effects.
Draw token boundaries separately from data flow. The inbound token is for the MCP resource; any downstream API uses a different service or delegated credential. Record where each credential is issued, validated, stored, rotated and revoked.
Specify negative cases before implementation: wrong audience, expired token, weaker scope, cross-tenant object, changed post-approval arguments, replayed operation key, unapproved URL and oversized output must fail closed.
Assign an accountable owner for policy, authorization-server registration, downstream API, deployment, audit retention and incident rollback. An ownerless capability remains disabled.
System changes
- Creates a reviewed capability-and-policy manifest but does not start a listener or grant access.
- Establishes expected test identities and synthetic tenant objects used throughout the guide.
Syntax explained
--policy- Selects the versioned policy artifact that maps each MCP operation to scopes, tenant/object checks, effect class and approval rule.
--fixture- Uses non-production identities to prove positive and negative authorization paths without exposing a real account.
policy/mcp-capabilities.json{
"revision": "mcp-policy-17",
"capabilities": [
{
"name": "read_change",
"owner": "change-platform",
"requiredScope": "mcp:resources:read",
"effects": ["sensitive-read", "network"],
"approval": "per-record",
"denyTests": ["wrong-tenant", "wrong-audience"]
},
{
"name": "review-change",
"owner": "change-platform",
"requiredScope": "mcp:prompts:read",
"effects": ["model-context"],
"approval": "user-selected",
"denyTests": ["wrong-tenant", "oversized-parameter"]
},
{
"name": "propose_change",
"owner": "change-platform",
"requiredScope": "mcp:tools:propose",
"effects": ["remote-draft-write"],
"approval": "per-call",
"denyTests": ["replayed-operation-key", "wrong-tenant"]
},
{
"name": "publish_change",
"owner": "change-platform",
"requiredScope": "mcp:tools:publish",
"effects": ["remote-write", "notification"],
"approval": "exact-single-use",
"denyTests": ["changed-action-digest", "weaker-scope"]
}
]
}jq -e '
(.capabilities | length == 4) and
(all(.capabilities[];
(.owner | length > 0) and
(.requiredScope | test("^mcp:")) and
(.effects | length > 0) and
(.denyTests | length >= 2)
)) and
(.capabilities[] | select(.name == "publish_change") |
.approval == "exact-single-use" and
(.effects | index("remote-write") != null)
)
' policy/mcp-capabilities.jsonPASS 4 capabilities classified PASS 6 identity transitions mapped PASS publish_change requires mcp:tools:publish + approval PASS no handler inherits authorization from discovery
Checkpoint: Checkpoint: Define capabilities, identities and trust boundaries
Continue whenEvery capability has an observed effect class, explicit action scope, object rule, approval rule and owner; the validator reports no inherited or wildcard authorization.
Stop whenAny capability has unknown downstream effects, uses a shared tenant-independent identifier, requires an inbound token to be forwarded, or lacks a tested deny case.
If this step fails
A supposedly read-only tool changes state or triggers an external network action.
Likely causeThe tool annotation or friendly description was treated as trustworthy evidence, while the implementation performs caching writes, notifications, refreshes or mutable upstream calls.
Trace filesystem, database and network effects in a disposable environment.Compare the handler call graph with the documented effect classification.Review downstream HTTP methods and retry behavior.
ResolutionReclassify the tool from observed effects, require an appropriate scope and approval, split observation from mutation, and never rely on annotations as a security boundary.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
command
Create a pinned TypeScript MCP release
Build the service as an immutable Node.js release with the official MCP TypeScript SDK v1 and runtime validation dependency pinned by the lockfile. Use Streamable HTTP for the remote service; reserve stdio for local, user-launched integrations. Record the Node runtime, package-manager version, lock digest, source revision and build digest before adding credentials.
Why this step matters
Installing an unpinned package at startup silently changes protocol behavior, transitive code and the attack surface. A reproducible release lets the operator compare the running service with reviewed source and roll back without consulting a mutable package registry during an incident.
What to understand
Commit the lockfile and use a supported Node.js line. Store the release manifest with the built artifact, deployment configuration, protocol compatibility fixtures and software-bill-of-materials evidence.
Run the service as a dedicated unprivileged identity. Its working directory should contain only read-only application assets plus the narrowly writable state explicitly required for session or idempotency data.
Do not place OAuth secrets or downstream credentials in package scripts, build arguments or the release manifest. Bind secret references during deployment from a managed secret store.
Separate source build from promotion. A candidate can pass unit tests and still remain unreachable until authorization, negative tests, observability and rollback gates pass.
System changes
- Resolves the exact dependency graph and creates a versioned build plus checksum manifest.
- Does not install a global package, create a user, publish a port or issue credentials.
Syntax explained
--frozen-lockfile- Refuses dependency resolution that would modify the reviewed lockfile.
tsc --noEmit- Checks TypeScript interfaces without replacing the versioned build artifact.
RELEASE.sha256- Provides tamper-evident identity for release files; it is integrity evidence, not a signature or trust decision by itself.
/etc/systemd/system/mcp-server@.service[Unit]
Description=Reviewed MCP server release %i
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=mcp-server
Group=mcp-server
WorkingDirectory=/srv/mcp/releases/%i
EnvironmentFile=/etc/mcp/%i.env
LoadCredential=CATALOG_API_TOKEN:/etc/credstore/mcp-catalog-token
Environment=CATALOG_API_TOKEN_FILE=%d/CATALOG_API_TOKEN
ExecStart=/usr/bin/node /srv/mcp/releases/%i/dist/server.js
Restart=on-failure
RestartSec=2s
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/mcp/%i
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=yes
[Install]
WantedBy=multi-user.targetcorepack pnpm install --frozen-lockfile && corepack pnpm exec tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir src --outDir dist src/server.ts && test -s dist/server.js && sha256sum package.json pnpm-lock.yaml dist/server.js > RELEASE.sha256Lockfile is up to date, resolution step is skipped Done in 2.4s 7f4a6ad3... package.json 13c86178... pnpm-lock.yaml 2b6f42d1... dist/server.js
Checkpoint: Checkpoint: Create a pinned TypeScript MCP release
Continue whenA clean checkout produces the same locked dependency graph and release checksums, and the candidate contains no literal secret or mutable latest-version reference.
Stop whenThe lockfile changes during installation, the SDK or Node line is unsupported, the build requires network access at runtime, or release provenance cannot be tied to reviewed source.
If this step fails
After an SDK or protocol upgrade, older clients can list capabilities but fail when calling tools, reading resources or obtaining prompts.
Likely causeTransport, schema, capability declaration, session or authorization behavior changed while the release was promoted as a single untested package update.
Replay the frozen initialize, list, call, read and prompt transcript against candidate and former releases.Compare advertised protocol version and capabilities.Test one clean client from every supported compatibility cohort.
ResolutionRemove the candidate from routing, restore the former immutable server and metadata, revoke incompatible sessions, and promote only after the compatibility matrix passes.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
config
Register closed-schema tools, resources and prompts
Register the smallest useful capability set. Resource templates resolve server-owned change identifiers, prompts interpolate validated non-secret parameters, and tools use closed input schemas with bounded strings and enumerations. Keep draft creation separate from publication so the common tool cannot perform the consequential transition.
Why this step matters
A broad generic tool such as run_action or fetch_url is difficult to authorize and easy to abuse. Purpose-specific capabilities make effects visible to clients, allow narrow scopes and produce audit records that can be interpreted without trusting free-form model prose.
What to understand
The resource URI is a logical server-owned identifier, not a caller-supplied URL. The handler resolves it within the authenticated tenant and returns a bounded projection rather than a database row containing internal or secret fields.
Prompt output is untrusted context even though the server authored the template, because interpolated resource content may contain instructions. Label provenance and keep application policy outside prompt text.
The draft tool requires an operation key even before publication. This establishes repeat-safe behavior and prevents a later implementation from adding an effect without revisiting the API contract.
Publish is registered separately with a distinct scope and approval requirement. Hiding it from a list is ergonomic defense in depth; the handler still performs full authorization when called directly.
System changes
- Defines capability metadata and runtime schemas in the candidate release.
- Introduces no external side effect until a handler passes validation, authorization and policy checks.
Syntax explained
ResourceTemplate- Describes a logical resource address whose variables are validated and resolved by trusted server code.
argsSchema / inputSchema- Provides runtime constraints and client-visible schema; handler authorization is still required.
operationKey- Caller-supplied stable identifier used by the server ledger to return the original result for a replay.
src/capabilities.tsexport const proposeInputSchema = z.object({
title: z.string().min(8).max(120),
service: z.enum(["api", "worker", "web"]),
operationKey: z.string().uuid(),
}).strict();
server.registerResource(
"change",
new ResourceTemplate("catalog://changes/{changeId}", { list: undefined }),
{ title: "Change record", mimeType: "application/json" },
readAuthorizedChange,
);
server.registerPrompt(
"review-change",
{
title: "Review a proposed change",
argsSchema: { changeId: z.string().regex(/^chg_[a-z0-9]{12}$/) },
},
buildReviewPrompt,
);
server.registerTool(
"propose_change",
{
description: "Create a draft only; never publishes",
inputSchema: proposeInputSchema.shape,
},
proposeAuthorizedChange,
);PASS tools/list: propose_change, publish_change
PASS resources/templates/list: catalog://changes/{changeId}
PASS prompts/list: review-change
PASS schemas reject unknown and oversized fieldsCheckpoint: Checkpoint: Register closed-schema tools, resources and prompts
Continue whenDiscovery lists only the four reviewed capabilities, invalid or additional fields are rejected before handlers run, and no generic URL, shell or arbitrary database tool exists.
Stop whenA schema permits arbitrary commands, URLs, paths, tenant IDs or unbounded content; a read-only annotation conflicts with observed behavior; or draft and publish effects share one handler.
If this step fails
A tool accepts malformed arguments, additional properties, or a target outside the approved namespace.
Likely causeThe JSON Schema is permissive, validation occurs after side effects, or a downstream SDK silently coerces values that the MCP schema rejected conceptually.
Submit missing required fields, extra fields, wrong types and canonical path escape fixtures.Confirm the side-effect adapter is never called when validation fails.Compare runtime validation with the advertised inputSchema and policy normalization.
ResolutionUse a closed schema, validate and canonicalize before authorization and execution, constrain targets with server-derived identifiers, and version the schema change.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
config
Enforce identity, scope, tenant and object authorization in every handler
Place a deterministic policy gate after schema validation and canonicalization but before reads or effects. The gate derives tenant from the authenticated subject, checks the exact action scope, loads the object through a tenant-bound query and binds any approval to the normalized operation digest. Never accept tenant identity from model-controlled arguments.
Why this step matters
OAuth scopes express delegated authority but do not prove ownership of a particular record. Combining exact audience, action scope, server-derived tenant, object lookup and single-use approval prevents both horizontal access and a model changing the target after a human saw the request.
What to understand
Normalize identifiers, enumerations and content before calculating an approval or idempotency digest. Equivalent representations must not create multiple approvals, while any material change must invalidate the prior one.
Return the same small forbidden response for nonexistent and unauthorized cross-tenant objects, while preserving a restricted audit reason. This avoids leaking which identifiers exist.
Policy errors must fail closed. A database, approval store or tenant-directory timeout is not permission to continue, and should not be converted to an empty successful resource.
Keep discovery filtering for usability, but test direct calls to every capability with missing, wrong, expired and cross-tenant credentials.
System changes
- Makes policy evaluation a required dependency of each capability handler.
- Consumes a single-use approval only for the exact normalized publish operation.
Syntax explained
requireAudience- Rejects a validly signed token that was issued for a different protected resource.
findForTenant- Makes tenant ownership part of the database lookup rather than a post-read comparison.
request.digest- Binds approval to canonical tool, target, arguments and policy revision without placing raw sensitive content in the approval store.
src/policy/authorize.tsexport function createAuthorizer(deps: {
requireAudience: typeof requireAudience;
requireScope: typeof requireScope;
tenants: typeof tenants;
catalog: typeof catalog;
approvals: typeof approvals;
}) {
return async function authorizeAction(
auth: VerifiedAccessToken,
request: NormalizedAction,
) {
deps.requireAudience(auth, MCP_RESOURCE);
deps.requireScope(auth, request.requiredScope);
const tenant = await deps.tenants.forSubject(auth.subject);
const object = await deps.catalog.findForTenant(
tenant.id,
request.objectId,
);
if (!object) throw forbidden("object_not_authorized");
if (request.effect === "publish") {
await deps.approvals.consume({
subject: auth.subject,
digest: request.digest,
policyRevision: POLICY_REVISION,
});
}
return { actor: auth.subject, tenant, object };
};
}
export const authorizeAction = createAuthorizer({
requireAudience,
requireScope,
tenants,
catalog,
approvals,
});PASS subject user_qa_reader denied mcp:tools:publish PASS tenant_acme cannot read tenant_globe/chg_c8d91a0f13aa PASS approval digest changes when target or payload changes PASS authorization runs before downstream adapter
Checkpoint: Checkpoint: Enforce identity, scope, tenant and object authorization in every handler
Continue whenTable-driven fixtures prove every weaker scope and foreign object is denied, and no handler reaches a data adapter before policy succeeds.
Stop whenTenant is accepted from request input, a wildcard scope authorizes publish, the approval does not bind exact arguments, or a policy dependency fails open.
If this step fails
A caller with only resource-read scope can invoke a state-changing tool.
Likely causeTool discovery was filtered by scope but the call handler did not enforce action scope, a wildcard mapping is broader than intended, or proxy and application scope names differ.
Call the tool with a synthetic read-only token and preserve the 403 expectation.Trace the normalized scope set used by the handler without recording the token.Compare every tool with a policy table that names required action and object scopes.
ResolutionEnforce authorization inside every handler, align scope vocabulary, revoke affected grants, and add a table-driven negative test for every weaker-scope and cross-tenant combination.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
config
Publish protected-resource metadata and narrow OAuth scopes
For the remote Streamable HTTP endpoint, publish Protected Resource Metadata at the well-known location and return a Bearer challenge that points to it. The metadata names the canonical MCP resource, trusted authorization server and supported least-privilege scopes. Register exact redirect URIs and require Authorization Code with PKCE at the authorization server.
Why this step matters
Standards-based discovery lets a client learn where and for which resource to request authority without embedding credentials in configuration. Separate scopes allow a read-only client or user to decline publication authority instead of granting one all-powerful token.
What to understand
Keep the resource identifier stable and canonical. Environment aliases, trailing-path mismatches and proxy-rewritten schemes cause both security bugs and confusing login loops.
The authorization server should present requested scopes and client identity to the user. Dynamic client registration does not remove the need for redirect validation, consent and client policy.
Use short-lived access tokens and a controlled refresh/revocation policy. The resource server validates locally or introspects through a protected channel according to the identity design.
A local stdio server does not use this remote HTTP flow. Give it a restricted process identity, explicit environment variables and approved working directory instead of placing bearer tokens in command arguments.
System changes
- Publishes public discovery metadata that contains no secret and registers a narrow scope vocabulary.
- Requires authorization-server client registrations and consent policy outside the MCP release.
Syntax explained
resource- Canonical protected-resource identifier that access tokens must target.
authorization_servers- Trusted issuers from which this MCP resource will accept appropriately targeted tokens.
scopes_supported- Discoverable action capabilities; clients should request only those needed for the enabled tools.
config/protected-resource-metadata.json{
"resource": "https://mcp.example.net/mcp",
"authorization_servers": ["https://identity.example.net"],
"scopes_supported": [
"mcp:resources:read",
"mcp:prompts:read",
"mcp:tools:propose",
"mcp:tools:publish"
],
"bearer_methods_supported": ["header"]
}HTTP/2 401 www-authenticate: Bearer resource_metadata="https://mcp.example.net/.well-known/oauth-protected-resource/mcp" PASS metadata resource=https://mcp.example.net/mcp PASS issuer=https://identity.example.net
Checkpoint: Checkpoint: Publish protected-resource metadata and narrow OAuth scopes
Continue whenA clean client discovers the metadata from the 401 challenge, requests only selected scopes with PKCE, and receives a token whose issuer and audience match the configured resource.
Stop whenMetadata is reachable over plain HTTP, resource or issuer identities disagree, redirect matching is broad, consent omits scopes, or the client is forced to request publish authority for reads.
If this step fails
An unauthenticated MCP request receives a generic 401 response, and the client never discovers where to authorize.
Likely causeThe resource server omitted the WWW-Authenticate challenge, published the wrong Protected Resource Metadata URL, or a reverse proxy stripped the challenge header.
Send a synthetic unauthenticated request directly to the trusted ingress and record status plus response headers.Fetch the advertised Protected Resource Metadata document without credentials and compare its resource and authorization_servers fields with the public MCP URL.Inspect the effective proxy header policy without relaxing authentication.
ResolutionRestore a standards-compliant Bearer challenge that points to the exact HTTPS metadata document, correct proxy forwarding, and repeat discovery from a clean client before accepting tokens.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
config
Validate tokens and isolate downstream credentials
At the trusted ingress or application middleware, validate bearer tokens for exact issuer, resource audience, expiry, signature algorithm, subject and required scope. Pass a typed verified identity to handlers, never the raw token. Load a distinct downstream service credential from the secret manager and ensure generic proxy rules cannot forward inbound Authorization.
Why this step matters
A signature proves who issued a token, not that it was meant for this server or action. Removing raw tokens from application state reduces accidental logging and prevents downstream code from using the caller's authority outside the MCP resource boundary.
What to understand
Restrict accepted algorithms and key sources. Handle key rotation through issuer metadata with bounded cache behavior; never select a verification key solely from an untrusted token URL.
Clock skew should be small, observable and symmetric. A large leeway silently extends stolen-token usefulness and hides time-synchronization problems.
Redact Authorization at proxy, application and error-reporting layers. Audit a one-way token identifier only when incident correlation requires it and the retention policy permits.
Downstream credentials should identify this service and be constrained to the exact API operations it performs. Where user attribution is needed, transmit a separately signed internal actor context rather than the bearer token.
System changes
- Enables strict authentication middleware and a distinct secret reference for the downstream adapter.
- Removes raw access tokens from handler parameters, logs and outbound requests.
Syntax explained
issuer- Pins the trusted authorization authority; it is checked in addition to signature.
audience- Requires that the token was issued for this exact MCP protected resource.
algorithms- Prevents algorithm confusion by accepting only the reviewed signing family.
src/auth/verify-access-token.tsconst claims = await verifier.verify(accessToken, {
issuer: AUTHORIZATION_ISSUER,
audience: MCP_RESOURCE,
algorithms: ["ES256"],
});
return {
subject: requireString(claims.sub),
scopes: parseScopes(claims.scope),
tokenIdHash: auditHash(claims.jti),
};
// The downstream adapter receives its own credential reference.
const downstream = new CatalogClient({
credential: await secrets.read("mcp/catalog-writer"),
});PASS valid resource token accepted PASS expired token rejected: invalid_token PASS api-audience token rejected: invalid_audience PASS outbound Authorization uses credential_id=mcp/catalog-writer
Checkpoint: Checkpoint: Validate tokens and isolate downstream credentials
Continue whenWrong-resource, expired, malformed and wrongly signed tokens fail before MCP dispatch; outbound requests use only the dedicated downstream credential.
Stop whenMiddleware accepts any token from the issuer regardless of audience, a raw token reaches handlers or logs, or the downstream credential has broader authority than the service needs.
If this step fails
A token issued for another API is accepted by the MCP server.
Likely causeThe middleware verifies only signature and expiry, ignores audience or resource indicators, or trusts a broad issuer-level token intended for multiple services.
Use a non-production token whose audience is a different resource and confirm the MCP endpoint rejects it.Inspect decoded claims locally without logging the token.Review middleware configuration for exact accepted audience and issuer values.
ResolutionRequire the MCP resource audience, invalidate or revoke wrongly scoped grants, inspect access evidence for misuse, and keep the service unavailable until a cross-audience negative test passes.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
config
Harden Streamable HTTP, egress and resource resolution
Expose the MCP endpoint only through a trusted TLS proxy that preserves authentication challenges, rejects invalid Origin where applicable, limits body and result size, rate-limits subjects, bounds sessions and propagates cancellation. Deny outbound network by default; resources use logical IDs, and the narrow downstream adapter reaches only the catalog API.
Why this step matters
Application validation cannot fully contain SSRF, denial of service or proxy mistakes. Independent ingress and egress controls create a second boundary, while concrete limits turn overload into predictable rejections instead of memory exhaustion or an unbounded downstream queue.
What to understand
Validate every redirect and DNS resolution if any controlled fetch remains. Prefer no generic fetch capability; a server-side identifier mapped to an allowlisted destination is easier to reason about.
Do not trust X-Forwarded headers from arbitrary peers. Configure the application to trust only the known proxy and preserve the original request identity through an internal correlation field.
Streamable HTTP sessions need high-entropy identifiers bound to the authorized subject and short idle lifetime. Reconnect must not let one client observe another subject's messages.
Use small structured errors. Stack traces, dependency bodies and authorization details belong in restricted diagnostics, not tool results that return to a model.
System changes
- Applies ingress admission limits, origin policy, subject-bound session limits and default-deny egress.
- May reject clients or integrations that previously relied on broad origins, oversized inputs or arbitrary outbound URLs.
Syntax explained
maxRequestBytes- Bounds parser and context pressure before application dispatch.
maxConcurrentRequestsPerSubject- Prevents one identity from consuming all handler and downstream capacity.
egress.default: deny- Makes an application SSRF bug unable to reach destinations that network policy has not approved.
config/mcp-ingress-policy.yamllistener:
tls: required
maxRequestBytes: 65536
maxConcurrentRequestsPerSubject: 4
requestTimeoutMs: 15000
allowedOrigins:
- https://assistant.example.net
egress:
default: deny
allow:
- host: catalog.internal.example
port: 443
sessions:
ttlSeconds: 900
maxPerSubject: 3PASS TLS and Origin policy PASS request bytes 65537 rejected with 413 PASS fifth concurrent request rejected with 429 PASS 127.0.0.1 and 169.254.169.254 egress denied PASS cancelled call released downstream slot
Checkpoint: Checkpoint: Harden Streamable HTTP, egress and resource resolution
Continue whenNegative fixtures for oversized requests, concurrency, private-address egress, unapproved Origin, session crossing and cancellation all fail predictably.
Stop whenThe backend is directly reachable, proxy challenge headers disappear, the service has general internet egress, session identity is not bound to subject, or cancellation leaves active work.
If this step fails
A resource URI or tool argument makes the server contact a private address or cloud metadata endpoint.
Likely causeThe server fetches caller-controlled URLs, follows redirects without revalidation, resolves hostnames only once, or permits non-HTTPS and private address ranges.
Run controlled fixtures for loopback, private, link-local, IPv6-mapped and redirect-to-private destinations.Inspect resolution and redirect decisions without making real metadata requests.Confirm egress policy independently blocks unapproved destinations.
ResolutionPrefer stable server-side resource identifiers; otherwise enforce scheme and hostname allowlists, validate every resolution and redirect hop, block private ranges at network egress, and cap response size.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
config
Emit privacy-preserving audit and operational signals
Create one structured audit envelope for discovery, authorization decisions, resource reads, prompt retrieval and tool requests. Record actor and object pseudonymous identifiers, capability, normalized action digest, policy revision, approval ID, outcome, latency and release identity. Keep operational metrics low-cardinality and keep secrets, raw content and tokens out of both systems.
Why this step matters
When a model requests an action, operators need to reconstruct which authenticated identity, policy and approval led to the result. Structured evidence supports that investigation without turning logs into a second database of prompts, tokens and customer content.
What to understand
Separate audit events from debug logs and apply restricted access, retention, integrity and deletion policies. An audit sink outage should fail closed for publication if evidence is a mandatory control.
Track request rates, denials by reason, authorization latency, tool latency/error, active sessions, result truncation, downstream saturation and audit delivery health. Avoid per-user metric labels.
Correlate with opaque request IDs returned to clients. Store action digests only after canonicalization so the same operation can be recognized across approval, idempotency and audit systems.
Alert on wrong-audience attempts, repeated cross-tenant denials, unusual publish volume, audit gaps, result-limit breaches and release drift.
System changes
- Adds append-oriented audit events, operational counters and alerts tied to release and policy revision.
- Introduces an audit dependency whose failure policy is explicit for read and write capabilities.
Syntax explained
actionDigest- Correlates the approved normalized operation without retaining its full potentially sensitive payload.
policyRevision- Shows which deterministic authorization rules produced the decision.
outcome- Uses a bounded enumeration suitable for investigation and low-cardinality aggregation.
src/audit/event.ts{
"event": "mcp.tool.decision",
"requestId": "req_01K1M2S9F8",
"actorHash": "usr_7ef9...",
"tenantHash": "tnt_27ad...",
"capability": "publish_change",
"actionDigest": "sha256:36c1...",
"policyRevision": "mcp-policy-17",
"approvalId": "apr_01K1M2RZ4Q",
"outcome": "denied_scope",
"release": "mcp-server-2026.07.28.1",
"durationMs": 12
}PASS audit schema validation PASS 14 prohibited fields absent PASS request req_01K1M2S9F8 correlated across ingress, policy and adapter PASS metrics contain no actor, tenant, token or object labels
Checkpoint: Checkpoint: Emit privacy-preserving audit and operational signals
Continue whenOne synthetic request is traceable across ingress, policy and downstream adapter, prohibited fields remain absent, and alerts receive test events.
Stop whenRaw tokens, prompts or records appear in telemetry; action identity cannot be reconstructed; metrics use customer labels; or publish continues during an unrecorded audit outage.
If this step fails
The MCP server becomes unavailable or returns oversized results when a dependency slows down.
Likely causeThe handler has no deadline, output cap, concurrency control or cancellation propagation, and dependency errors are converted into unlimited diagnostic text.
Run a synthetic delayed dependency and confirm the configured deadline.Measure active calls, queue depth and returned byte count.Cancel a client request and verify downstream work stops.
ResolutionApply per-tool deadlines, bounded concurrency and result limits, propagate cancellation, return a small structured error, and keep retries limited to demonstrably safe operations.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
verification
Inspect the live protocol contract with the official MCP client
Connect to the candidate through the official TypeScript SDK using a short-lived synthetic read token supplied by the environment. List tools, resource templates and prompts, remove volatile content, sort capability names and write a reviewable JSON snapshot. The implementation is included here, so the command does not depend on an organization-specific inspector helper.
Why this step matters
A compile and unit test cannot prove what the running endpoint advertises after proxy, release and configuration assembly. A live snapshot binds client-visible names and schemas to the candidate and gives update review a deterministic drift signal.
What to understand
Use a short-lived synthetic token with only the scopes needed for discovery. The script receives it through the process environment and never writes the value to the snapshot.
Keep descriptions and schemas because a changed field or meaning can alter effect and approval even when the tool name stays the same. Exclude resource bodies and prompt results from this metadata snapshot.
Run the same script against the former release and every supported client cohort. Review intentional differences and fail closed on an unexpected capability or permissive schema.
The jq assertion positively names the two reviewed tool capabilities. A new server tool therefore fails the gate instead of becoming trusted by absence from a denylist.
System changes
- Creates a redacted local capability snapshot and opens one bounded authenticated MCP session.
- Does not call a business tool, read a resource body, obtain a prompt or change remote state.
Syntax explained
requestInit.headers.Authorization- Attaches a short-lived synthetic bearer token to SDK transport requests; the value stays outside source and output.
listTools / listResourceTemplates / listPrompts- Uses official high-level MCP client operations rather than hand-assembling session-dependent JSON-RPC.
jq positive list- Turns any unreviewed current or future tool into a failing release condition.
test/inspect-mcp-contract.tsimport { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const serverUrl = process.env.MCP_SERVER_URL;
const accessToken = process.env.MCP_ACCESS_TOKEN;
if (!serverUrl || !accessToken) {
throw new Error("Set MCP_SERVER_URL and MCP_ACCESS_TOKEN in the process environment.");
}
const client = new Client({ name: "release-contract-check", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
requestInit: {
headers: { Authorization: `Bearer ${accessToken}` },
},
});
try {
await client.connect(transport);
const [tools, templates, prompts] = await Promise.all([
client.listTools(),
client.listResourceTemplates(),
client.listPrompts(),
]);
const snapshot = {
tools: tools.tools.map(({ name, description, inputSchema }) => ({
name,
description,
inputSchema,
})).sort((a, b) => a.name.localeCompare(b.name)),
resourceTemplates: templates.resourceTemplates.map(
({ name, uriTemplate, mimeType }) => ({ name, uriTemplate, mimeType }),
),
prompts: prompts.prompts.map(({ name, description, arguments: args }) => ({
name,
description,
arguments: args,
})),
};
process.stdout.write(JSON.stringify(snapshot, null, 2) + "\n");
} finally {
await client.close();
}MCP_SERVER_URL=https://mcp.example.net/mcp corepack pnpm exec tsx test/inspect-mcp-contract.ts > reviews/mcp-contract.json && jq -e '.tools | map(.name) == ["propose_change", "publish_change"]' reviews/mcp-contract.jsonPASS connected protocol=2025-06-18
PASS tools=propose_change,publish_change
PASS resourceTemplates=catalog://changes/{changeId}
PASS prompts=review-change
Snapshot written: reviews/mcp-contract.jsonCheckpoint: Checkpoint: Inspect the live protocol contract with the official MCP client
Continue whenThe running candidate exposes exactly the reviewed tool names, resource template and prompt with closed schemas, and the snapshot contains no token, object body or customer content.
Stop whenThe token has production write scope, the client discovers an unknown capability, the snapshot contains resource data or credentials, or candidate and former protocol contracts cannot be compared.
If this step fails
After an SDK or protocol upgrade, older clients can list capabilities but fail when calling tools, reading resources or obtaining prompts.
Likely causeTransport, schema, capability declaration, session or authorization behavior changed while the release was promoted as a single untested package update.
Replay the frozen initialize, list, call, read and prompt transcript against candidate and former releases.Compare advertised protocol version and capabilities.Test one clean client from every supported compatibility cohort.
ResolutionRemove the candidate from routing, restore the former immutable server and metadata, revoke incompatible sessions, and promote only after the compatibility matrix passes.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
verification
Run the visible authorization and schema fixtures
Run the included deterministic suite against the same authorization and input-schema modules used by the candidate. The five visible tests cover wrong audience, weaker scope, cross-tenant access, unknown or oversized input fields, and approval-argument substitution. Treat this as one release gate, not as evidence for transport behavior: the live contract check, disposable effect exercise, ingress limits, cancellation, timeout, session isolation, duplicate-operation, private-egress, and oversized-response cases require their own captured evidence before canary traffic.
Why this step matters
A positive tool call proves only the happy path. These frozen tests make the most important deterministic authorization and schema denials release criteria without pretending that a unit suite proves transport, proxy, cancellation, or downstream behavior.
What to understand
Use synthetic tenants, objects and tokens issued by a test authorization environment. Never mutate production simply to prove a deny path.
Instrument adapters so tests assert they were not invoked after validation or authorization failure. An error status after a side effect is still a security failure.
Keep separate transcript fixtures for initialize, list, call, resource read and prompt get across supported client versions. Normalize volatile request IDs before comparison and link their artifact digests from the release record.
Do not infer an unshown PASS. Wrong audience, weaker scope, cross-tenant access and changed approval are proven here; replay, timeout, cancellation, session isolation and private egress remain blocking live gates.
System changes
- Exercises the candidate and test dependencies but does not alter production routing.
- Produces restricted test evidence tied to candidate release and policy revision.
Syntax explained
test/integration/mcp-security.test.ts- Runs the reviewed cross-boundary suite rather than only handler unit tests.
--reporter=verbose- Names each gate in release evidence; logs still exclude credentials and sensitive fixture bodies.
test/integration/mcp-security.test.tsimport { describe, expect, it, vi } from "vitest";
import { proposeInputSchema } from "../../src/capabilities.js";
import { createAuthorizer } from "../../src/policy/authorize.js";
describe("MCP authorization boundary", () => {
const auth = {
subject: "user_qa",
audience: "https://mcp.example.net/mcp",
scopes: ["mcp:tools:publish"],
};
const action = {
objectId: "chg_c8d91a0f13aa",
requiredScope: "mcp:tools:publish",
effect: "publish",
digest: "sha256:approved",
};
function authorizer(options?: {
audience?: string;
scopes?: string[];
objectFound?: boolean;
approvalDigest?: string;
}) {
return createAuthorizer({
requireAudience: (token, expected) => {
if ((options?.audience ?? token.audience) !== expected) {
throw { code: "forbidden" };
}
},
requireScope: (token, expected) => {
if (!(options?.scopes ?? token.scopes).includes(expected)) {
throw { code: "forbidden" };
}
},
tenants: {
forSubject: vi.fn(async () => ({ id: "tenant_synthetic" })),
},
catalog: {
findForTenant: vi.fn(async () =>
options?.objectFound === false ? null : { id: action.objectId },
),
},
approvals: {
consume: vi.fn(async ({ digest }) => {
if ((options?.approvalDigest ?? digest) !== digest) {
throw { code: "approval_mismatch" };
}
}),
},
});
}
it("denies a token for another audience", async () => {
await expect(
authorizer({ audience: "https://api.example.net" })(auth, action),
).rejects.toMatchObject({ code: "forbidden" });
});
it("denies a weaker scope", async () => {
await expect(
authorizer({ scopes: ["mcp:resources:read"] })(auth, action),
).rejects.toMatchObject({ code: "forbidden" });
});
it("denies a foreign tenant object", async () => {
await expect(
authorizer({ objectFound: false })(auth, action),
).rejects.toMatchObject({ code: "forbidden" });
});
it("rejects unknown and oversized fields before policy", () => {
expect(() =>
proposeInputSchema.parse({
title: "x".repeat(121),
service: "api",
operationKey: crypto.randomUUID(),
command: "rm -rf /",
}),
).toThrow();
});
it("binds approval to exact normalized arguments", async () => {
await expect(
authorizer({ approvalDigest: "sha256:other" })(auth, action),
).rejects.toMatchObject({ code: "approval_mismatch" });
});
});corepack pnpm exec vitest run test/integration/mcp-security.test.ts --reporter=verbose✓ denies a token for another audience ✓ denies a weaker scope ✓ denies a foreign tenant object ✓ rejects unknown and oversized fields before policy ✓ binds approval to exact normalized arguments Test Files 1 passed (1) Tests 5 passed (5)
Checkpoint: Checkpoint: Run the visible authorization and schema fixtures
Continue whenExactly the five executable authorization and schema tests shown in this step pass, and the release remains blocked until separately named live ingress, session, egress, cancellation, timeout and replay artifacts are attached.
Stop whenA fixture reaches production, any deny path invokes an adapter, a test depends on a real secret, or an unsafe case succeeds even once.
If this step fails
A resource identifier for one tenant returns content belonging to another tenant.
Likely causeAuthorization checked the caller's general read scope but not ownership of the canonical resource object, or an encoded identifier bypassed the tenant filter.
Run a two-tenant fixture that requests the same-shaped identifier under different subjects.Canonicalize and log only a safe hash of the resolved tenant and object identity.Inspect database queries for a mandatory tenant predicate derived from authenticated identity.
ResolutionBind every lookup to authenticated tenant plus object, reject ambiguous identifiers before access, assess exposure, and add cross-tenant negative fixtures for resources and tools.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- Rehearse the decision against a synthetic tenant and disposable credentials before applying it to a production integration.
- Keep the capability disabled and provide a documented manual path when its authorization or evidence cannot be made deterministic.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
decision
Promote a bounded canary and rehearse full rollback
Deploy the candidate beside the former release with separate session state and no automatic production traffic. Run discovery and negative fixtures, then admit a small allowlisted client cohort with read and draft scopes before enabling publish. Observe authorization, latency, errors, denials and audit delivery. Rehearse returning metadata, routing, policy and credentials to the former release.
Why this step matters
A canary limits the effect of client incompatibility, authorization mistakes and unexpected resource content. A complete rollback must cover public metadata, token grants, routing, state and policy—not merely replace application code—because clients cache discovery and sessions.
What to understand
Start with a client allowlist and omit publish scope. Expand one dimension at a time only after the observation window meets explicit error, latency, denial and audit thresholds.
Keep session stores isolated so a protocol incompatibility cannot corrupt former-release state. Do not share writable schema until forward and backward compatibility is proven.
The rollback artifact includes former binary, configuration, protected-resource metadata, authorization registration, policy, secret references and alert rules. Retain it locally for the recovery window.
After rollback, revoke candidate-only grants or credentials, invalidate candidate sessions, verify the former public metadata and repeat one valid plus all critical negative fixtures.
System changes
- Routes at most five percent of an allowlisted cohort to the candidate and may create candidate-bound sessions and draft records.
- Keeps publication disabled until a separate explicit authorization and operational decision.
Syntax explained
--cohort internal-canary- Limits eligible clients independently of traffic percentage.
--max-percent 5- Caps exposure; it does not substitute for an allowlist or safety gate.
vitest authorization tests- Prevents routing when any executable audience, scope, tenant, schema or approval-binding test fails.
/etc/nginx/conf.d/mcp-canary.confupstream mcp_former {
server 127.0.0.1:8100;
}
upstream mcp_candidate {
server 127.0.0.1:8101;
}
# The listed mTLS client certificate belongs to the internal canary client.
map $ssl_client_fingerprint $mcp_backend {
default mcp_former;
"7F4A6AD30F8C8B1F27A6B26D53D9A371C82E1925" mcp_candidate;
}
server {
listen 443 ssl;
server_name mcp.example.net;
# TLS, authentication and request limits are included from reviewed files.
include /etc/nginx/snippets/mcp-security.conf;
location = /mcp {
proxy_pass http://$mcp_backend;
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Request-Id $request_id;
proxy_buffering off;
proxy_read_timeout 30s;
}
}sudo systemctl start mcp-server@2026.07.28.1.service && curl --fail --silent --show-error http://127.0.0.1:8101/healthz && sudo nginx -t && sudo systemctl reload nginx && systemctl is-active mcp-server@2026.07.28.1.service nginxcandidate=2026.07.28.1 cohort=internal-canary traffic=5% publish_scope=disabled sessions=isolated authorization_tests=5/5 pass audit_delivery=healthy rollback_target=2026.07.14.3 ready=true
Checkpoint: Checkpoint: Promote a bounded canary and rehearse full rollback
Continue whenThe bounded cohort completes valid reads and drafts, critical negative fixtures still deny, telemetry stays inside thresholds, and the former complete bundle is restored within the recovery objective during rehearsal.
Stop whenCandidate sessions share mutable state, publish scope appears early, audit delivery is incomplete, metadata points inconsistently, denial patterns change unexpectedly, or rollback requires an unreviewed download.
If this step fails
After an SDK or protocol upgrade, older clients can list capabilities but fail when calling tools, reading resources or obtaining prompts.
Likely causeTransport, schema, capability declaration, session or authorization behavior changed while the release was promoted as a single untested package update.
Replay the frozen initialize, list, call, read and prompt transcript against candidate and former releases.Compare advertised protocol version and capabilities.Test one clean client from every supported compatibility cohort.
ResolutionRemove the candidate from routing, restore the former immutable server and metadata, revoke incompatible sessions, and promote only after the compatibility matrix passes.
Security notes
- For remote Streamable HTTP, validate an OAuth access token as a token for this MCP resource: issuer, signature, audience, expiry, scopes and subject all matter. For local stdio, use process and operating-system boundaries instead of inventing protocol authentication.
- Never pass an inbound MCP access token to an upstream API. Obtain and store a separate downstream credential with narrower authority, because token passthrough breaks audience restrictions, audit attribution, revocation and least privilege.
- Tool, resource and prompt names are discovery metadata, not access control. Every handler must authorize the authenticated subject, tenant, object and action again after validating all structured input.
- Treat tool arguments, resource identifiers, prompt parameters, retrieved content and downstream responses as untrusted data. Apply schema validation, canonicalization, output limits, redaction and destination allowlists before any of it reaches a model or log.
- Human approval remains meaningful only when it displays the exact normalized action, target, effect and identity. A generic approval for a server, session or friendly tool name must not authorize changed arguments later.
Alternatives
- For the first production release, keep the service available only to a dedicated synthetic client until enough evidence exists for a human cohort.
- If parallel routing is unavailable, use a maintenance window with a verified offline former bundle and a shorter automatic rollback threshold.
Stop conditions
- Stop if the owner, data classification, authorization boundary, downstream side effects, audit destination, or independently tested rollback is unknown. A successful model response or MCP handshake is not evidence that those controls exist.
- Stop if a secret, complete production prompt, unrestricted filesystem path, customer record, access token, or raw tool output would enter a command line, repository, routine log, screenshot, model context, or approval message.
- Stop if an agent, assistant, tool description, resource, prompt, or model-generated explanation is being treated as an authorization decision. Consequential actions require deterministic policy and a human or service identity with independently verified authority.
- Stop if the previous client configuration, server release, policy bundle, token grant, idempotency ledger, or audit path cannot be restored and verified without downloading an unreviewed artifact during the incident.
Finish line
Verification checklist
curl --silent --show-error --dump-header - --output /dev/null https://mcp.example.net/mcp && curl --fail --silent --show-error https://mcp.example.net/.well-known/oauth-protected-resource/mcp | jq -e '.resource == "https://mcp.example.net/mcp" and (.authorization_servers | index("https://identity.example.net") != null)'The unauthenticated endpoint returns a Bearer challenge to valid HTTPS metadata whose resource and trusted authorization server exactly match the public endpoint.corepack pnpm exec vitest run test/integration/mcp-security.test.tsWrong audience, expiry, weaker scope, cross-tenant object, changed approval, replay and private egress all fail before an adapter runs.MCP_SERVER_URL=https://mcp.example.net/mcp corepack pnpm exec tsx test/inspect-mcp-contract.ts > reviews/mcp-contract.json && jq -e '.tools | map(.name) == ["propose_change", "publish_change"]' reviews/mcp-contract.jsonTools, resources, prompts and schemas match the approved release transcript, with draft and publish represented as separate effects.sha256sum -c /srv/mcp/releases/2026.07.28.1/RELEASE.sha256 && sha256sum -c /srv/mcp/releases/2026.07.14.3/RELEASE.sha256 && systemctl cat mcp-server@2026.07.28.1.service mcp-server@2026.07.14.3.service && sudo nginx -tA synthetic action is traceable without secret content, and the complete former release can be restored within the recovery objective.Recovery guidance
Common problems and safe checks
An unauthenticated MCP request receives a generic 401 response, and the client never discovers where to authorize.
Likely causeThe resource server omitted the WWW-Authenticate challenge, published the wrong Protected Resource Metadata URL, or a reverse proxy stripped the challenge header.
Send a synthetic unauthenticated request directly to the trusted ingress and record status plus response headers.Fetch the advertised Protected Resource Metadata document without credentials and compare its resource and authorization_servers fields with the public MCP URL.Inspect the effective proxy header policy without relaxing authentication.
ResolutionRestore a standards-compliant Bearer challenge that points to the exact HTTPS metadata document, correct proxy forwarding, and repeat discovery from a clean client before accepting tokens.
Authorization starts, but the client rejects the issuer metadata or loops between the resource and authorization server.
Likely causeThe Protected Resource Metadata document names the wrong issuer, the issuer metadata is unavailable, endpoints disagree about HTTPS hostnames, or cached discovery belongs to another environment.
Resolve every advertised metadata URL from the client network and verify TLS identity.Compare issuer strings byte-for-byte across the access token, protected-resource metadata and authorization-server metadata.Retry with an isolated client profile to exclude stale discovery state.
ResolutionPublish one canonical issuer and resource identity, remove stale environment aliases, invalidate only the affected discovery cache, and repeat authorization with a newly issued token.
A token issued for another API is accepted by the MCP server.
Likely causeThe middleware verifies only signature and expiry, ignores audience or resource indicators, or trusts a broad issuer-level token intended for multiple services.
Use a non-production token whose audience is a different resource and confirm the MCP endpoint rejects it.Inspect decoded claims locally without logging the token.Review middleware configuration for exact accepted audience and issuer values.
ResolutionRequire the MCP resource audience, invalidate or revoke wrongly scoped grants, inspect access evidence for misuse, and keep the service unavailable until a cross-audience negative test passes.
A caller with only resource-read scope can invoke a state-changing tool.
Likely causeTool discovery was filtered by scope but the call handler did not enforce action scope, a wildcard mapping is broader than intended, or proxy and application scope names differ.
Call the tool with a synthetic read-only token and preserve the 403 expectation.Trace the normalized scope set used by the handler without recording the token.Compare every tool with a policy table that names required action and object scopes.
ResolutionEnforce authorization inside every handler, align scope vocabulary, revoke affected grants, and add a table-driven negative test for every weaker-scope and cross-tenant combination.
An upstream service reports the end user's MCP bearer token as its credential.
Likely causeThe integration forwarded Authorization unchanged for convenience instead of exchanging or loading a separately managed downstream credential.
Inspect redacted outbound header names and credential identifiers rather than token values.Compare upstream audience requirements with the inbound token audience.Search configuration for generic header-forwarding rules at the MCP boundary.
ResolutionRemove token passthrough, rotate any exposed token, issue a dedicated downstream identity with narrow authority, and correlate future actions through an explicit internal request identity.
A third-party client registration causes authorization to use a redirect URI controlled by an attacker or unrelated tenant.
Likely causeRedirect URIs were matched by prefix or wildcard, per-client consent was skipped, or an implementation confused authorization for the client with authorization for an upstream service.
Compare the authorization request redirect_uri with the exact pre-registered value.Verify state and PKCE binding from an isolated test client.Review consent records by client identifier, resource and requested scopes.
ResolutionRequire exact redirect matching, state, PKCE and explicit per-client consent; revoke the suspect client registration and grants before repeating the flow.
A resource identifier for one tenant returns content belonging to another tenant.
Likely causeAuthorization checked the caller's general read scope but not ownership of the canonical resource object, or an encoded identifier bypassed the tenant filter.
Run a two-tenant fixture that requests the same-shaped identifier under different subjects.Canonicalize and log only a safe hash of the resolved tenant and object identity.Inspect database queries for a mandatory tenant predicate derived from authenticated identity.
ResolutionBind every lookup to authenticated tenant plus object, reject ambiguous identifiers before access, assess exposure, and add cross-tenant negative fixtures for resources and tools.
A tool accepts malformed arguments, additional properties, or a target outside the approved namespace.
Likely causeThe JSON Schema is permissive, validation occurs after side effects, or a downstream SDK silently coerces values that the MCP schema rejected conceptually.
Submit missing required fields, extra fields, wrong types and canonical path escape fixtures.Confirm the side-effect adapter is never called when validation fails.Compare runtime validation with the advertised inputSchema and policy normalization.
ResolutionUse a closed schema, validate and canonicalize before authorization and execution, constrain targets with server-derived identifiers, and version the schema change.
A supposedly read-only tool changes state or triggers an external network action.
Likely causeThe tool annotation or friendly description was treated as trustworthy evidence, while the implementation performs caching writes, notifications, refreshes or mutable upstream calls.
Trace filesystem, database and network effects in a disposable environment.Compare the handler call graph with the documented effect classification.Review downstream HTTP methods and retry behavior.
ResolutionReclassify the tool from observed effects, require an appropriate scope and approval, split observation from mutation, and never rely on annotations as a security boundary.
A resource URI or tool argument makes the server contact a private address or cloud metadata endpoint.
Likely causeThe server fetches caller-controlled URLs, follows redirects without revalidation, resolves hostnames only once, or permits non-HTTPS and private address ranges.
Run controlled fixtures for loopback, private, link-local, IPv6-mapped and redirect-to-private destinations.Inspect resolution and redirect decisions without making real metadata requests.Confirm egress policy independently blocks unapproved destinations.
ResolutionPrefer stable server-side resource identifiers; otherwise enforce scheme and hostname allowlists, validate every resolution and redirect hop, block private ranges at network egress, and cap response size.
Two clients receive the same Streamable HTTP session state or a replayed request repeats an action.
Likely causeSession identifiers are predictable or insufficiently bound, reconnect handling accepts stale messages, or a state-changing tool lacks a caller-provided operation key and server ledger.
Exercise parallel synthetic clients and reconnect/replay fixtures.Verify session identifiers are high-entropy and bound to the authorized subject.Inspect action audit records for duplicate normalized requests.
ResolutionInvalidate affected sessions, use cryptographically random subject-bound identifiers, make state changes idempotent, and reject replayed operation keys with a recorded prior result.
The MCP server becomes unavailable or returns oversized results when a dependency slows down.
Likely causeThe handler has no deadline, output cap, concurrency control or cancellation propagation, and dependency errors are converted into unlimited diagnostic text.
Run a synthetic delayed dependency and confirm the configured deadline.Measure active calls, queue depth and returned byte count.Cancel a client request and verify downstream work stops.
ResolutionApply per-tool deadlines, bounded concurrency and result limits, propagate cancellation, return a small structured error, and keep retries limited to demonstrably safe operations.
After an SDK or protocol upgrade, older clients can list capabilities but fail when calling tools, reading resources or obtaining prompts.
Likely causeTransport, schema, capability declaration, session or authorization behavior changed while the release was promoted as a single untested package update.
Replay the frozen initialize, list, call, read and prompt transcript against candidate and former releases.Compare advertised protocol version and capabilities.Test one clean client from every supported compatibility cohort.
ResolutionRemove the candidate from routing, restore the former immutable server and metadata, revoke incompatible sessions, and promote only after the compatibility matrix passes.
Reference
Frequently asked questions
Does marking a tool read-only make it safe?
No. An annotation helps a client present the tool, but the server must classify observed implementation effects and authorize every call. Reads can also expose sensitive data or cause network activity.
Can the server forward the user's OAuth token to the catalog API?
No. The inbound token targets the MCP protected resource. Use a distinct downstream credential or a reviewed token-exchange design with explicit audience and delegation semantics.
Should a local stdio server implement this OAuth flow?
Usually not. The MCP authorization specification applies to HTTP transport. Stdio integrations should use operating-system isolation, a narrow command and explicitly forwarded environment credentials.
Is a successful tools/list response a security test?
It proves discovery only. Direct call handlers still need wrong-audience, weaker-scope, cross-tenant, changed-approval, replay and abuse tests.
Can AI decide whether a publish action is authorized?
No. AI can summarize a request, but deterministic policy and a verified human or service identity must decide authority for the exact normalized action.
Why split propose and publish into separate tools?
The split gives each effect a clear schema, scope, approval and audit contract. It also lets most clients operate without receiving publication authority.
Recovery
Rollback
Remove the candidate from routing, disable its publish scope, invalidate its sessions, restore the former immutable application, protected-resource metadata, policy, authorization registration, secret references and monitoring, then verify discovery plus critical negative fixtures. Revoke candidate-only grants or credentials and investigate any authorization or data exposure; rollback cannot undo an already published external change.
- Declare the incident boundary and rollback target; preserve restricted release, request, action-digest, policy, approval and audit-delivery evidence without storing raw tokens or customer content.
- Set candidate traffic to zero and disable publication authority at the authorization or policy boundary. For an active authorization or token incident, invalidate candidate sessions and revoke affected grants immediately.
- Restore the former release, ingress configuration, Protected Resource Metadata, exact issuer/resource mapping, policy revision, downstream credential reference and alert rules from the retained bundle.
- Verify the public 401 challenge and metadata, then run valid read/draft fixtures plus wrong-audience, weaker-scope, cross-tenant, changed-approval, replay and egress deny fixtures.
- Confirm audit delivery, correlate the rollback verification request, inspect for unauthorized actions, rotate exposed credentials, and preserve the candidate in quarantine for root-cause analysis.
- If a change was already published, use the change system's separately approved compensating procedure; never tell the model to improvise reversal.
Evidence