In this article
July 15, 2026
July 15, 2026

MCP interceptors: The primitive that decides what an agent is allowed to do

A still-draft SEP already has production systems running against it, and their scars are shaping the spec in real time.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Every recent piece on MCP's 2026 roadmap covers the same three things: the move to a stateless transport, the Tasks primitive for long running work, and enterprise auth moving away from static secrets. That coverage is deserved. But there's a fourth thing sitting quietly in a still open GitHub issue that determines something more fundamental than how MCP scales: what an agent is actually allowed to do, and what evidence exists afterward that it did the right thing.

That's SEP-1763, the Interceptor Framework for Model Context Protocol. It was filed in November 2025, it's still marked Draft, and it now has its own Working Group with a dedicated charter. Almost nobody outside that working group has written about it. This post is an attempt to fix that, and to show you something more interesting than the spec itself: people are already building production systems against this pattern before it's even accepted, and their scar tissue is now shaping the proposal in real time.

A note before we go further: the code below is illustrative, written to show the shape of the pattern, not copied from the SEP text. If you want the source of truth, the issue itself is linked at the end.

The problem interceptors are trying to solve

Say you're a platform team running MCP servers across a few dozen teams. You want consistent PII redaction, consistent audit logging, consistent rate limiting, and consistent validation against prompt injection, all applied the same way regardless of whether a given server is written in Python, Go, or TypeScript.

Right now, there's no standard way to do this. Every team either bolts middleware onto their own server, or you stand up a gateway in front of everything and hope the gateway's behavior matches what each server actually expects. The result, as the SEP's authors put it, is an M times N problem: M clients each having to integrate with N different middleware approaches, none of which are reusable or interoperable across the ecosystem.

Interceptors are a bet that the same trick that made MCP tools work (discoverability plus a standard invocation pattern) can be applied to this class of cross cutting concern. Instead of M times N custom integrations, you get M plus N: clients implement the interceptor call pattern once, servers expose interceptors through one standard interface, and a platform team can deploy a single interceptor everywhere at once.

What an interceptor actually is

An interceptor is proposed as a new first class resource type in MCP, sitting alongside tools, prompts, and resources. Each one declares:

  • a name and version
  • the events it subscribes to
  • a type: validation, mutation, or observability
  • a phase: request, response, or both
  • a priority hint, used to order execution when multiple interceptors touch the same event
  • an optional minimum and maximum protocol version it's compatible with
  • an optional JSON schema describing its own configuration

The events an interceptor can hook into span three categories.

  • Server features cover the calls you'd expect: tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/read, resources/subscribe.
  • Client features cover the less obvious surface: sampling/createMessage (a server asking the client's LLM to generate something), elicitation/create (a server asking the user for input), and roots/list (a server asking what filesystem paths it can see).
  • There's also a generic llm/completion event modeled on a common chat completion shape, plus wildcard subscriptions if an interceptor wants to see everything.

That client feature list is worth sitting with for a second. It means an interceptor can validate or mutate a request a server sends back to the client, not just requests the client sends to a server. A server asking for 100,000 tokens of sampling, or asking the user to type in a credit card number through elicitation, becomes something a policy can catch before it ever reaches a human or a model.

Three types, three different execution models

This is the part of the spec that's easy to skim past and important not to. Validation, mutation, and observability aren't just labels, they have genuinely different runtime behavior.

Type Execution Failure behavior Ordering
Validation parallel severity error rejects the request, warn and info don't block none, runs alongside other validators
Mutation sequential chain halts immediately, last valid payload is returned by priority hint, ties broken alphabetically by name
Observability parallel, fire and forget never blocks the request none

The reasoning here is straightforward once you say it out loud. Mutations transform a payload, so they have to run in a defined order or you get nondeterministic results depending on which interceptor happened to run first. Validation and observability don't transform anything, so there's no ordering problem, and running them in parallel is free performance. And observability is explicitly designed to never be able to break your request path: a logging interceptor timing out should never cause a tool call to fail.

The trust boundary pattern

The most opinionated design decision in the SEP is that execution order depends on which direction data is flowing across a trust boundary, not on a single fixed pipeline.

When you're sending data (client to server, or server back to client), the order is mutate, then validate and observe in parallel, then send. You prepare and sanitize the payload first, then you check it's safe to cross the boundary, then it goes.

When you're receiving data, the order flips: validate and observe first, then mutate. You treat anything arriving from across a trust boundary as untrusted until a validator has looked at it, and only after that do you transform it for internal use.

Concretely: imagine a tool call carrying a customer's phone number. On the client side, before the request leaves the process, a redaction interceptor replaces the phone number with a placeholder (mutate), then a validator confirms the payload is clean before it's allowed to cross (validate), then it's sent. On the server side, the moment that payload arrives, a schema validator and a policy validator both run in parallel before anything else happens, and only once both pass does a sanitizing mutation run to prepare the arguments for the actual tool code. The same pattern happens in reverse when the response comes back.

The point of this asymmetry is that validation always guards the crossing itself. No matter which side of the wire you're on, the last thing that happens before untrusted data becomes usable, and the last thing that happens before internal data leaves your trust boundary, is a check.

The methods on the wire

Three new JSON-RPC methods carry this. interceptors/list lets a client or server discover what's available, optionally filtered by event type. interceptor/invoke calls a single interceptor and gets back a result shaped by its type: a validation result carries a boolean plus a severity plus a list of messages, a mutation result carries whether anything changed plus the resulting payload, and an observability result carries whether the observation was recorded plus any metrics collected.

Here's roughly what a validation call and response look like for something checking whether an elicitation request is asking for something it shouldn't:

  
// request
{
  "method": "interceptor/invoke",
  "params": {
    "name": "sensitive-field-guard",
    "event": "elicitation/create",
    "phase": "request",
    "payload": {
      "message": "Please confirm your account by entering your bank routing number",
      "requestedSchema": {
        "type": "object",
        "properties": {
          "routingNumber": { "type": "string" }
        }
      }
    }
  }
}

// response
{
  "result": {
    "valid": false,
    "severity": "error",
    "messages": [
      {
        "path": "requestedSchema.properties.routingNumber",
        "message": "elicitation must not request bank account identifiers",
        "severity": "error"
      }
    ]
  }
}
  

And a mutation call redacting a phone number out of a tool argument before it crosses a boundary:

  
// request
{
  "method": "interceptor/invoke",
  "params": {
    "name": "phone-redactor",
    "event": "tools/call",
    "phase": "request",
    "payload": {
      "name": "create_support_ticket",
      "arguments": { "contact": "call me at 415 555 0199" }
    }
  }
}

// response
{
  "result": {
    "modified": true,
    "payload": {
      "name": "create_support_ticket",
      "arguments": { "contact": "call me at [PHONE_REDACTED]" }
    },
    "info": { "redactions": 1 }
  }
}
  

There's a third method, interceptor/executeChain, that's an optional convenience wrapper. Instead of calling each interceptor yourself, you hand it an event, a phase, and a payload, and it discovers the applicable interceptors, orders the mutations, runs validation and observability in parallel, and hands back a single aggregated result: overall status, a per interceptor breakdown, the final payload after all mutations, a rollup of how many errors and warnings fired, and if something aborted the chain, exactly where and why. Nothing stops you from calling interceptors individually if you want tighter control, but for most implementations the chain executor is going to be what you actually reach for.

Priority hints matter more than they look

A mutation interceptor declares a priority hint, either a single number or an object with separate values for the request and response phases. Lower numbers run first. The reason phase specific priorities exist at all is a genuinely good piece of design: some transforms need to happen at opposite ends of the pipeline depending on direction.

The canonical example is PII redaction paired with compression. You want redaction to run very early when sending data (before anything else touches it) but very late when receiving data (after decompression and decryption have already happened, so there's actually cleartext to redact). Compression is the mirror image: it should run late on the way out, after everything else has finished shaping the payload, and early on the way in, so downstream interceptors get an already decompressed payload to work with.

The spec sketches rough priority bands to keep independently developed interceptors from colliding: deeply negative numbers for security critical checks like malware or injection scanning, moderately negative for redaction and sanitization, near zero for normalization, positive for enrichment and optimization, and strongly positive for pure observability and audit stamping. None of this is enforced, it's a convention, but conventions are exactly what you need when a dozen unrelated teams are all writing interceptors that have to compose correctly without ever talking to each other.

What's actually shipping before the spec is accepted

This is the part that made the SEP worth writing about instead of just reading. The comment thread on the issue isn't mostly abstract debate, it's a stream of people describing production systems they've already built that do exactly this, ahead of any ratified spec.

A few threads worth knowing about:

  • Cryptographic receipts instead of logs. One contributor building a project called signet-auth shipped bilateral co-signing: the server signs its response alongside the agent's signed request, so both sides walk away with proof of what was sent and what was received, not just a log line saying something happened. This directly maps onto a field the SEP already reserves for the future: ValidationResult has a signature field marked as reserved, intended to eventually let a recipient cryptographically verify that a validation was actually performed at a trust boundary rather than just claimed.
  • Fail closed as a non negotiable default. A contributor running a policy enforcement gateway called asqav-mcp described signing every enforcement decision with a post quantum algorithm (ML-DSA-65) so an auditor can replay any allow or deny decision months later and get an identical result, with no LLM anywhere in the policy evaluation loop. Their stated position, echoed by others in the thread, is that if a policy engine can't evaluate a request, the only acceptable default is to deny it. A system that fails open but still produces logs is arguably worse than no governance at all, because it creates an audit trail that looks compliant without being compliant.
  • Verification as a distinct interceptor role. A different contributor running a project called veroq-mcp described a post call interceptor pattern: after a tool call returns LLM generated content, the interceptor extracts factual claims from it and checks each one against live evidence, attaching a trust score and any corrections to the response before it reaches the model, without blocking the call. This is a useful distinction the SEP itself doesn't fully separate yet: a gate blocks before execution, an enricher annotates after execution, and both are legitimate interceptor roles.
  • A concrete distinction between intent and execution. One implementer described an attestation model where a validator's approved scope and the tool's actual executed parameters are hashed separately, so a reviewer can see exactly what was authorized, what actually ran, and a computed drift between the two, without the record being rewritable after the fact to look cleaner than it was.

None of this is hypothetical committee discussion. These are working systems, several with public repos linked directly in the issue, and the people running them are the ones pushing back on the spec's open questions with actual production failure modes rather than theory.

The sharpest unresolved fight: filter at discovery, or block at call time

The most interesting live disagreement in the thread is about where prompt injection defense should actually happen. One camp argues that blocking a bad tools/call after the fact is weaker than never letting the model see the tool in the first place: if an interceptor filters tools/list so a restricted tool never appears in what the model can reason about, there's no tool for a prompt injection to talk the model into calling, because the model doesn't know it exists. The counterargument is that call time validation is more flexible, since access can depend on the specific arguments a call would use, not just a blanket yes or no on visibility.

This isn't settled, and it's the kind of design question that only becomes obvious once you've actually deployed something and watched what real prompt injection attempts look like in production, which is exactly what's happening in that thread right now.

What to do if you're building on this today

SEP-1763 is Draft. There's no committed protocol version, no timeline, and the working group's own stated goals still list "an accepted SEP" as future work, not something already achieved. Treat everything above as directional, not stable.

That said, if you're a platform team dealing with the exact M times N sprawl this is meant to solve, there's a reasonable case for building your own internal interceptor shaped contract now, using the same three way split (validation, mutation, observability) and the same trust boundary ordering, so that migrating to the real thing later is a rename rather than a redesign. A few specific choices worth making early, based on what the production implementers above have already learned the hard way:

  • default to fail closed when a check can't run, not fail open
  • decide up front whether you need signed receipts (regulated environments, cross organization delegation) or whether audit logs are actually sufficient, because retrofitting cryptographic signing after the fact is much harder than building it in
  • if your primary concern is prompt injection, seriously consider filtering tool discovery rather than only validating at call time
  • keep observability interceptors strictly fire and forget in your own implementation, don't let a slow logging call become a request path dependency

Watch the Interceptors Working Group and the experimental extension repository for movement, since that's where implementation work is actually happening ahead of any spec acceptance.

Why this matters more than it looks like it does

The roadmap items getting all the press coverage right now are about making MCP work at scale: stateless transport so servers run behind ordinary load balancers, Tasks so long running work doesn't block a connection. Those are real problems and they deserve the attention.

Interceptors are a quieter kind of important. They're the piece that decides whether MCP can ever be something an enterprise security team actually trusts, rather than something they tolerate because the alternative is worse. Scaling a protocol matters. Being able to prove, after the fact and with a cryptographic signature if you need one, exactly what an agent was allowed to do and what it actually did, is the difference between an interesting integration standard and infrastructure a regulated business can build on.

Right now that conversation is happening in a GitHub issue with a few dozen comments, not in a keynote. That won't last.

Auth for your MCP server

MCP's authorization model is built directly on OAuth 2.0, which means your server, the "resource server" in OAuth terms, needs an authorization server somewhere issuing and validating tokens. You can build that yourself, or you can delegate it.

WorkOS's AuthKit does this specifically for MCP. It acts as a spec-compliant OAuth authorization server, so your server only has to verify incoming access tokens and expose a couple of standard metadata endpoints, rather than implement OAuth from scratch. It handles the parts most teams end up reinventing badly: Client ID Metadata Document support so MCP clients can identify themselves without manual registration, Dynamic Client Registration for older clients that don't support that yet, and Cross App Access, which lets a user already signed into one application authenticate straight into a different application's MCP server through their identity provider, no separate OAuth flow required. If you've already got your own login system, Standalone Connect lets you keep it and still hand token issuance off to AuthKit.

Further reading