Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

MCP server security checklist

Audience:securityengineerarchitectMCP spec 2026-07-28

A printable pre-deploy checklist for operators and reviewers of MCP servers on Vercel. Every item is a single, testable statement. Items default to the safer recommendation; if an item does not apply, record the justification alongside the deployment. The section anchors below are deep-linked from every pattern page in this repo, so keep reading them in context: the pattern tells you why, this page tells you what to verify.

Primary threat to keep in mind: prompt injection. Tool arguments are generated by an LLM and tool outputs are returned to an LLM. Both are untrusted input, even when the user is trusted, because an attacker who controls any upstream content the model has read can attempt to steer subsequent tool calls. The Input validation, Output trust, and Consent sections are the primary defenses; read them with this threat in mind.

Stack note. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path. Items below that reference legacy behavior (protocol sessions, server-initiated sampling) apply while pre-2026-07-28 peers remain in the wild during the deprecation window.

Authentication

The end-to-end OAuth 2.1 flow behind these items (discovery, registration, PKCE, token use, audience binding) is documented in Authorization flows.

  • All remote MCP endpoints require a verified bearer token before any request is processed: withMcpAuth wraps every exported method with required: true. Why: the wrapper’s default is required: false, which passes unauthenticated requests straight to your tools; the protocol makes HTTP auth a SHOULD, and this checklist adopts mandatory auth on every non-local deployment as the conservative default.
  • verifyToken validates signature, issuer, and expiry, and validates the token’s audience against the server’s canonical URL; tokens issued for any other resource are rejected. Why: mcp-handler performs no token validation itself, and audience validation is the spec MUST that stops a token stolen from one service being replayed against yours.
  • The server publishes OAuth Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource (protectedResourceHandler for GET, metadataCorsOptionsRequestHandler for OPTIONS), with authServerUrls matching the authorization server’s issuer exactly.
  • The client’s token is never forwarded upstream; upstream calls use a separate credential held by the server (no token passthrough).
  • No long-lived credentials, API keys, or tokens are committed to source control, example configs, or vercel.json. Why: secret material in version control is permanently leaked the moment the repo is published or forked.
  • All upstream credentials load from Vercel environment variables marked sensitive, injected at runtime, never hardcoded or written to build output.
  • Credential rotation is documented, automated where possible, and exercised at least quarterly for every credential the server holds.
  • Access tokens accepted by the server have an explicit expiry of one hour or less, enforced via AuthInfo.expiresAt, with refresh handled through a documented flow.

Authorization & scoping

  • The set of tools, resources, and prompts exposed is the minimum required for the server’s stated purpose, with each capability justified in writing. Why: every additional exposed capability widens the blast radius of a compromised client or a prompt-injection attack.
  • Route-level requiredScopes gates the whole surface, and each privileged tool re-checks its own required scopes against ctx.http.authInfo inside the handler.
  • Destructive or privileged tools are gated behind an authorization check distinct from the request’s authentication.
  • The principal is derived from the verified token (AuthInfo), never from a client-supplied argument or header; see Where the principal comes from.
  • Capability listings from tools/list, resources/list, and prompts/list are filtered per the authenticated principal, so callers only see what they may invoke; list results that vary per principal declare cacheScope: "private" so a shared intermediary never serves one principal’s listing to another.
  • Each tool declares the upstream scopes it requires, and startup configuration validation rejects credentials that grant less or more than the declared set.
  • Default deny: any tool invocation whose authorization decision is indeterminate is rejected, not allowed.
  • Every tool that writes, deletes, sends, pays, or otherwise produces a side effect is annotated as such (destructiveHint, readOnlyHint: false) and requires explicit user approval before invocation. Why: the MCP trust model places the human in the loop for consequential actions; annotations are untrusted hints, so the host enforces approval and the server marks honestly.
  • Approval prompts surfaced to the host include the tool name, the resolved arguments, and the target system in human-readable form.
  • Bulk-approval or “always allow” modes are opt-in, time-bounded, and revocable from the host UI.
  • Resources containing personal or sensitive data require explicit user selection before being attached to model context.
  • Server requests for more input are gated on host-side approval and never satisfied silently: under 2026-07-28 a resultType: "input_required" result is the server asking for more, and the host approves before retrying (the MRTR retry is where the fail-closed gate now lives); on legacy connections, sampling requests (deprecated, SEP-2577) are gated the same way.
  • Incremental scope consent is supported: when a tool needs a scope the current token lacks, the server returns 403 with an insufficient_scope challenge so the client can request step-up consent, rather than over-requesting scopes up front.

Input validation

  • Every tool input is described by a Zod schema that round-trips into the declared inputSchema, and arguments are validated against it before the handler runs. Why: model-generated arguments are untrusted input; schema validation is the first defense against malformed or hostile payloads.
  • Maximum sizes and bounds are declared in the schema for every string, array, and numeric argument (z.string().max(), z.number().int().min().max()), with rejection, not truncation, on overflow.
  • Path, URL, and identifier arguments are canonicalized and checked against an allowlist before reaching filesystem, network, or subprocess operations; outbound hosts are never constructed from model-supplied input.
  • Arguments interpolated into SQL, shell, HTTP, or templating contexts use parameterized APIs; string concatenation into those contexts is prohibited.
  • Unicode normalization and homoglyph defenses are applied to any argument used in authorization decisions or identity comparison.
  • Structured argument content (JSON, XML, YAML) is parsed with safe loaders that disable external entity resolution and code execution.
  • Prompt-injection-style payloads in arguments (instructions, role markers, hidden Unicode) are rejected or neutralized before the handler acts. Why: an upstream injection usually surfaces as the model calling a tool with adversarial arguments; argument validation is the last line of defense before the tool acts.

Output trust

  • All tool outputs are treated as untrusted data when re-injected into model context, regardless of which upstream system produced them. Why: tool outputs are the primary prompt-injection vector in MCP systems; a compromised upstream can steer the model into tool calls the user never intended.
  • Tool results return the minimum the tool contract promises: internal-only fields are dropped and identifiers minimized before the model sees them.
  • Outputs containing HTML, scripts, or terminal escape sequences are sanitized or escaped before display in the host UI.
  • Secrets, tokens, and internal identifiers are filtered out of tool outputs before they reach the model or the user.
  • Upstream exception messages are never forwarded to the client: an unexpected backend fault is logged server-side under a correlation id, and the tool result carries a fixed message plus that id and nothing derived from the exception. Why: a tool result is re-injected into the model’s context, so a driver error, hostname, or query fragment in an exception message is handed to the model and, through it, to the user; the id lets an operator join the caller’s report to the server-side record without leaking the detail. See examples/facade-server (in the repository).
  • Large outputs are paginated or truncated with an explicit marker, never silently dropped.
  • For each tool, the server documents whether output content can be controlled by an external party and is therefore higher risk.

Session handling

2026-07-28 removed protocol-level sessions from Streamable HTTP (SEP-2567): there is no Mcp-Session-Id header, so there is no protocol session to hijack, fixate, or steal. Cross-call state travels as explicit server-minted handles passed as ordinary tool arguments, and this section now scopes handles the way it once scoped session ids. See Serverless sessions.

  • Every request re-verifies the bearer token; nothing is ever trusted because an earlier request was authenticated. Why: with protocol sessions removed, authentication is per-request by construction, and on serverless any instance may serve any request, so continuity proves nothing about the caller.
  • Server-minted handles handed to clients (job ids, cursors, state tokens) are unguessable (CSPRNG, at least 128 bits of entropy) or integrity-protected (signed), validated on return, and checked against the authenticated principal’s ownership; a handle is a claim check, not a capability. Why: handles are now the spec’s cross-call state mechanism, so handle theft replaces session hijacking as the attack; possession must never substitute for authorization.
  • Each handle is scoped to the principal and purpose it was minted for and expires on a TTL; an expired or foreign handle is rejected with an explicit error, never silently honored or recreated.
  • Externalized cross-call state (Redis, Blob) is keyed by the handle with a TTL; expiry is the cleanup policy, and after expiry the client starts over with a fresh call, not a resumed session.
  • No request state lives only in module scope; instance memory is treated as a cache, and the server answers correctly with instance reuse disabled entirely. Why: Fluid compute reuses instances as an optimization, not a guarantee, and in-function concurrency makes module-scope request state a cross-principal leak.
  • Where the server still speaks pre-2026-07-28 revisions during the deprecation window, legacy session identifiers are unguessable (CSPRNG, at least 128 bits of entropy), not reused across reconnects, and never used for authentication (the earlier spec already forbade session-based auth).
  • Cancellation is honored (a legacy notifications/cancelled, or the client closing the request’s response stream): in-flight work stops, partial state is rolled back where feasible, and no result is returned for a cancelled call.
  • Per-invocation resource use is bounded: maxDuration is set explicitly per route, and outbound connections and memory are capped in code.

Trust boundaries

  • Each MCP server is its own Vercel project with its own environment variables; no two servers share a credential or writable state. Why: the MCP model assumes servers are mutually distrustful; co-locating them in one project collapses that boundary.
  • Untrusted or model-generated code runs inside Vercel Sandbox with a deny-by-default egress networkPolicy, never inside the serving function.
  • The server receives only the conversation context strictly required for the current request; it does not receive the full host transcript.
  • The server does not read, log, or persist context that originated from other servers attached to the same host.
  • Cross-server tool chaining is mediated by the host; there are no direct server-to-server calls.
  • Outbound network access is restricted to the upstream systems the server integrates with; there is no platform egress firewall (Static IPs on Pro and Enterprise give backends a fixed source address to allowlist, Secure Compute on Enterprise adds private connectivity, and neither filters outbound calls), so the code’s fixed set of upstream hosts is the allowlist, and it is reviewed as such.
  • Internal-only services called by the server are protected by Deployment Protection with OIDC Trusted Sources or a scoped bypass, not by obscurity of their URLs.

Inventory & supply chain

  • A current inventory lists every MCP server deployed, its version, its source repository, and its responsible owner.
  • Dependencies are pinned: mcp-handler and the SDK at versions inside its peer range (2.1.1 peers @modelcontextprotocol/server ^2.0.0), a committed lockfile, and npm ls @modelcontextprotocol/server asserting a single SDK copy in CI. Why: an unpinned dependency can be silently replaced between deployments, and two SDK copies mean your auth types and the handler’s disagree.
  • Production deploys come from a reviewed Git branch through the Vercel Git integration, not from ad-hoc CLI deploys off arbitrary machines.
  • Third-party MCP servers are reviewed for provenance (known publisher, public repository, recent maintenance) before a host composes them.
  • Dependency manifests are scanned for known vulnerabilities on every build, and builds fail on findings above an agreed severity.
  • A documented process exists for removing or replacing a server whose publisher disappears, is compromised, or stops maintaining it.

Monitoring & audit

  • Every tool invocation is logged with timestamp, authenticated principal, tool name, argument hash, outcome, and latency.
  • Logs are structured (JSON) and shipped via Drains to a central system with integrity protection; runtime logs alone are treated as a debugging view, not the audit trail. Why: Vercel runtime logs are retention-limited; an audit trail that expires is not an audit trail.
  • Authentication failures, authorization denials, and schema-validation rejections are logged at a level that triggers alerting on volume anomalies.
  • Alerts cover: spikes in tool error rates, invocations outside expected hours, repeated denials for a single principal, and new tool names appearing in traffic.
  • Sensitive argument values are redacted or hashed in logs; raw secrets, bearer tokens, and personal data are never written to log storage.
  • Log retention is defined, documented, and enforced to the operating environment’s compliance requirements.

Deployment posture

  • Preview deployments have Deployment Protection enabled; an unprotected preview is a public URL serving your real tools. Why: every preview deployment gets a working public URL by default, and previews often run with real credentials while carrying unreviewed code.
  • Agent and CI access to protected previews uses the x-vercel-protection-bypass secret, scoped to automation, stored as a secret, and rotated; it is never committed or shared in prompts.
  • Environment variables are scoped per environment: production credentials exist only in production, and previews get lower-privilege or dummy credentials.
  • No secret is exposed under a NEXT_PUBLIC_ prefix. Why: NEXT_PUBLIC_ variables are inlined into the client JavaScript bundle at build time; they are public by construction.
  • Secrets are provided at runtime as sensitive environment variables, not baked into build artifacts or echoed in build logs.
  • Streamable HTTP servers validate the Origin header and respond 403 Forbidden when it is present and not allowlisted, defending local development against DNS rebinding; requests without an Origin header come from non-browser clients and are governed by bearer-token authentication, not Origin checks. The reference implementation is examples/secure-tools-server/src/origin.ts (in the repository): withOriginCheck wraps every example’s route, and the allowlist comes from MCP_ALLOWED_ORIGINS.
  • Vercel Firewall rules and rate limits sit in front of the MCP endpoint paths, with limits keyed on principal or client where possible.
  • Per-tool edge rules key on the standard request headers: 2026-07-28 requires Mcp-Method on every Streamable HTTP POST and Mcp-Name on tools/call, resources/read, and prompts/get, so WAF rules and rate limits can match a specific method or tool without body inspection; rules stay in log-only mode until the client population actually sends the headers. Why: header-based rules are the first edge control that can distinguish a cheap read tool from an expensive destructive one, but current-generation clients negotiating 2025-11-25 do not send these headers yet, and a blocking rule would reject their traffic.
  • Resource limits are explicit: maxDuration per route, Fluid concurrency understood, and spend or usage alerts configured so an abuse spike surfaces as an alert, not an invoice.
  • A documented incident-response runbook covers credential revocation, instant rollback to a prior deployment, server takedown, and user notification paths.
  • Pre-deploy review of this checklist is recorded with reviewer name, date, and the version of the server being deployed.

A control you do not assert against is a control you do not have; a checklist you did not record is a checklist you did not run.

Bibliography