Securing MCP Resources and Prompts: The Primitives Nobody Reviews

Most MCP hardening work stops at tools. Tools are where the write operations are, so that is where the design reviews, the scope tables, and the adversarial tests go. Meanwhile the same server exposes two other primitives that read customer data and shape model behaviour: resources (with resources/list, resources/read, resources/templates/list, and optional subscriptions) and prompts (prompts/list, prompts/get).

In audits these are the primitives we most often find unauthorized, unlogged, or both. The pattern is consistent: the tool handlers were written last and carefully, the resource handlers were written first, during the prototype, and never revisited. This post is the checklist we apply to them.

Why reviewers eventually get here

A security reviewer reading your specification-conformance claims will ask which methods the server implements. If the answer includes resources/read, the next question is how a read is authorized and what is logged. "Resources are read-only" is not an answer. A read-only method that returns another tenant's document is a data breach with a shorter incident report.

Two properties make resources awkward:

  1. The address space is client-supplied. A tool call has a typed schema you control. A resource read takes a URI, and the client can ask for any URI, not only the ones your resources/list returned.
  2. Listings are themselves disclosure. A resources/list response that enumerates file names, customer names, or project titles leaks information even when every subsequent read is denied.

Authorize every read, not the listing

The rule is the same one that governs tools: identity comes from the validated access token, never from the request. Apply it per call.

  • Resolve the subject (user, tenant, client application) from token claims in the handler, before the URI is parsed.
  • Filter resources/list and resources/templates/list by that subject. A user should never see a URI they cannot read. Treat the list as data they are entitled to, not a catalogue of everything.
  • Re-check authorization inside resources/read. Do not treat presence in a previous listing as permission. Listings are cached by clients, entitlements change, and the read may arrive minutes after an offboarding.
  • Scope matters as well as identity. If your scheme has a read scope for documents, resources/read on a document URI must require it. A server that gates tools on scopes but serves resources to any valid token has an inconsistency a reviewer will find by reading the code.

Treat the URI as hostile input

Resource URIs are the injection surface of this primitive. Your handler should parse, validate, and re-derive, in that order.

  • Allowlist schemes. If your server serves myapp:// resources, reject everything else outright. Never pass a client-supplied URI into an HTTP client, a file API, or a database driver.
  • Resolve path traversal before authorization, not after. Normalize the path, then check. myapp://tenant/42/../7/report must not become tenant 7's report.
  • Do not let the URI carry the tenant. If the URI contains a tenant or user segment, validate it against the token's claims and fail closed on mismatch. The safest design derives the tenant from the token and uses the URI only for the object identifier within that tenant.
  • Bound expansion of resource templates. A template like myapp://logs/{date} invites a client to walk the space. Validate parameter format, restrict ranges, and rate limit reads the same way you rate limit tool calls.
  • Return the same error for denied and non-existent. Otherwise the error code becomes an enumeration oracle. Log the distinction internally; do not expose it.

File-backed resources deserve a separate look. If any resource maps to local disk on the server, decide whether it should exist at all in a remote, multi-tenant deployment. Usually it should not, and the URI should resolve through the same data access layer that enforces per-user access for the rest of the product.

Subscriptions are long-lived authorization

If the server declares the subscribe capability, resources/subscribe creates a channel that keeps notifying a client after the request that created it ends. That makes it a standing grant, and standing grants need a lifecycle.

  • Record the subject and the token identifier that created each subscription alongside the URI.
  • Re-check authorization when a notification is about to be sent, not only at subscribe time. Entitlements go away; subscriptions should go away with them.
  • Terminate subscriptions on token expiry, token revocation, session end, and user deactivation. This is the same offboarding path your tools use, and the subscription table is the part teams forget.
  • Cap subscriptions per session and per user. An unbounded resources/subscribe loop is both a memory exhaustion vector and a way to keep a change feed open cheaply.
  • Send change notifications, not payloads, where the data is sensitive. Let the client come back through resources/read, which re-authorizes.

Prompts are code you ship

Prompts returned by prompts/get are instructions that a model will follow. Two consequences.

First, if a prompt interpolates customer data — a ticket body, a document excerpt, a field a user typed — that data is untrusted content being placed into an instruction position. The mitigations are the ones from tool results: keep interpolated content in clearly delimited data sections, bound its length, prefer structured values over free prose, and never let interpolated content reach a place where it can add or redefine a rule.

Second, prompt arguments are client-supplied. Validate them against the declared schema, reject unknown arguments, and do not let an argument select an arbitrary template by name or path.

Also treat the prompt catalogue as a change-controlled artifact. Prompt text influences which tools an agent calls and in what order, so it belongs in version control, in code review, and in the change log you show reviewers — the same handling as tool definitions. A prompt edited in a database by anyone with production access is an ungoverned instruction channel into your customers' agents.

Audit the quiet methods

The audit schema that covers tool calls should cover these methods with the same fields: timestamp, correlation and session identifiers, subject, client application, method, target, outcome class, and duration. For resources, the target is the normalized URI; do not log the returned content, and redact any identifiers your data-handling policy treats as sensitive.

Emit an event for every resources/read, every resources/subscribe and unsubscribe, every notification delivery if volume allows, and every prompts/get. Denials especially. A customer's SIEM should be able to answer "what did this user's agent read last Tuesday" without distinguishing between primitives, and if resource reads are missing from the export, the answer is incomplete in a way that is hard to explain during an incident.

Add these to the test plan

Before handover, run the same adversarial pass you run on tools:

  • Read a URI belonging to another tenant, directly and via traversal, with a valid token.
  • Read a URI that was never returned by resources/list.
  • Expand a resource template with out-of-range and malformed parameters.
  • Subscribe, then disable the user, and confirm notifications stop.
  • Subscribe with an expiring token and confirm the channel closes at expiry.
  • Request a prompt with unknown arguments, oversized arguments, and injected instructions inside argument values.
  • Confirm every one of the above produced an audit event with the right subject.

Write down what happened. That record belongs in the evidence pack next to the threat model, and it is the difference between claiming the read path is authorized and showing it.

Scope note

If your server implements only tools, none of this applies yet, and adding resources or prompts for their own sake is not a security improvement. The point is narrower: whatever primitives you do declare in your capabilities must be authorized, bounded, and logged to the same standard, because a reviewer reads the capability list and works down it.

We check these methods on every MCP security audit, and they are part of the design document on every server build. If you are unsure which primitives your server currently exposes, that is a short conversation: get in touch.