Security & Validation
Applies to
Gatewayruntime for API, AI, and MCP gateways
Hosting
Managed EdgeManaged DedicatedSelf-Hosted

MCP Capability Filter Policy

MCP Gateway Policy

This policy is for use with the MCP Gateway. See the MCP Gateway documentation to learn how to proxy and secure MCP servers with Zuplo.

Curate the tools, prompts, resources, and resource templates an upstream MCP server exposes through the gateway.

Use this after McpTokenExchangeInboundPolicy to enforce a per-route allow-list on the upstream MCP capabilities. Each entry can be a name (or URI) string or a projection object that overrides the downstream-facing description, annotations, and _meta while keeping the upstream identity intact. Omit a capability option to pass that capability type through unchanged; use an empty array to expose none.

Configuration

The configuration shows how to configure the policy in the 'policies.json' document.

JSONconfig/policies.json
{ "name": "my-mcp-capability-filter-inbound-policy", "policyType": "mcp-capability-filter-inbound", "handler": { "export": "McpCapabilityFilterInboundPolicy", "module": "$import(@zuplo/runtime/mcp-gateway)", "options": { "accessControl": { "groupClaim": "groups", "identifier": {}, "mode": "allPublic", "roleClaim": "roles" }, "prompts": [], "resourceTemplates": [], "resources": [], "tools": [] } } }

Policy Configuration

  • name <string> - The name of your policy instance. This is used as a reference in your routes.
  • policyType <string> - The identifier of the policy. This is used by the Zuplo UI. Value should be mcp-capability-filter-inbound.
  • handler.export <string> - The name of the exported type. Value should be McpCapabilityFilterInboundPolicy.
  • handler.module <string> - The module containing the policy. Value should be $import(@zuplo/runtime/mcp-gateway).
  • handler.options <object> - The options for this policy. See Policy Options below.

Policy Options

The options for this policy are specified below. All properties are optional unless specifically marked as required.

  • tools <undefined[]> - Tools to expose. Use a string for name-only filtering, or an object to expose and project tool description, annotations, and _meta. Omit to pass through all upstream tools; use an empty array to expose no tools.
  • prompts <undefined[]> - Prompts to expose. Use a string for name-only filtering, or an object to expose and project prompt description and _meta. Omit to pass through all upstream prompts; use an empty array to expose no prompts.
  • resources <undefined[]> - Resources to expose. Use a string for URI-only filtering, or an object to expose and project resource name, description, MIME type, and _meta. Omit to pass through all upstream resources; use an empty array to expose no resources.
  • resourceTemplates <undefined[]> - Resource templates to expose. Use a string for URI-template-only filtering, or an object to expose and project template name, description, MIME type, and _meta. Omit to pass through all upstream resource templates; use an empty array to expose no resource templates.
  • accessControl <object> - Optional per-caller access control. Defaults to mode 'allPublic' (no access control). Set mode to 'rolesAndGroups' to match the caller's role/group claims against each capability's markup (a capability with no roles/groups/public markup is available to no one), or 'function' to delegate to a custom resolver.
    • mode <string> - Access-control strategy. 'allPublic' (default): no access control; roles/groups/public markup is not allowed. 'rolesAndGroups': match the caller's role and group claims against each capability's markup. 'function': delegate to the resolver named by 'identifier'. Allowed values are allPublic, rolesAndGroups, function. Defaults to "allPublic".
    • roleClaim <string> - Property on request.user.data holding the caller's roles (commonly a JWT 'roles' claim, but request.user.data is set by whichever auth policy ran — JWT, API key, etc.), matched against each capability's 'roles'. Supports a dot-path for nested values, e.g. 'realm_access.roles'. Used in mode 'rolesAndGroups'. Defaults to 'roles'. Defaults to "roles".
    • groupClaim <string> - Property on request.user.data holding the caller's groups (commonly a JWT 'groups' claim, but request.user.data is set by whichever auth policy ran — JWT, API key, etc.), matched against each capability's 'groups'. Supports a dot-path for nested values, e.g. 'cognito'. Used in mode 'rolesAndGroups'. Defaults to 'groups'. Defaults to "groups".
    • identifier <object> - Custom capability resolver, required when mode is 'function'. It fully replaces built-in roles/groups matching.
      • module (required) <string> - Module to load the resolver from, e.g. $import(./modules/mcp-access-control).
      • export (required) <string> - Named export of the resolver function, e.g. 'default'.

Using the Policy

Overview

The mcp-capability-filter-inbound policy curates the MCP capabilities exposed by a proxied upstream server. It filters successful JSON-RPC list responses, whether the upstream answers with JSON or with a text/event-stream, and blocks direct JSON-RPC access to hidden tools, prompts, and resources before the request is forwarded upstream.

Direct access is checked on tools/call and prompts/get (by params.name), on resources/read, resources/subscribe and resources/unsubscribe (by params.uri), and on completion/complete: a ref/prompt reference's name against prompts, a ref/resource reference's uri against resources and resourceTemplates (either configured list may allow it, since the URI can be a concrete resource or a template). A URI with a template expression ({name}) is never a concrete resource, so it passes while resourceTemplates is omitted, whatever resources lists. A completion whose ref has any other shape (an unknown or missing type, no ref) is refused while prompts, resources or resourceTemplates is configured.

A call to a hidden capability is not forwarded. It is answered exactly as MCP answers a capability that does not exist, so a client cannot tell the two apart:

RequestAnswer
tools/call of a hidden tool-32602, Tool "<name>" not found
prompts/get of a hidden prompt-32602, Prompt "<name>" not found
resources/read, subscribe, unsubscribe-32002, Resource not found, with data.uri
completion/complete of a hidden reference-32602, Prompt "<name>" not found or Resource template "<uri>" not found
Any of these without a string name or uri-32602, for example Invalid params for tools/call: params.name must be a string

A method the policy checks or filters spelled another way, in another case or with surrounding spaces (Tools/Call, tools/list), is answered -32601 (Method not found): it is no MCP method, and an upstream that matched it leniently would run it unchecked.

A resource URI is allowed when resources lists it, or when it matches a template that resourceTemplates allows, as the MCP SDK matches templates. With the configuration below, stripe://customers/cus_1 is readable from the stripe://customers/{id} template, and a URI from any other template, such as stripe://invoices/in_1, is refused. When you omit resources, a URI passes unless it matches only templates hidden from the caller: the policy cannot tell a concrete resource from a URI of a template it does not know, so configure resources too when reads of unlisted templates must be refused.

Omit a capability option to pass that capability type through unchanged. Set an option to an empty array to expose none of that capability type. Matching is case-sensitive and exact.

Each entry can be either a string identifier or a projection object. Projection objects still use the name/URI as the stable upstream identity, but can override the downstream-facing description, merge annotations for tools, and merge _meta for future metadata such as role hints. Keep input and output schemas out of this policy config; schemas are supplied by the upstream list response.

Configuration

JSONCode
{ "name": "mcp-filter-stripe", "policyType": "mcp-capability-filter-inbound", "handler": { "module": "$import(@zuplo/runtime/mcp-gateway)", "export": "McpCapabilityFilterInboundPolicy", "options": { "tools": [ { "name": "create_invoice", "description": "Create an invoice for accounting users.", "annotations": { "destructiveHint": false }, "_meta": { "roles": ["accounting"] } } ], "prompts": ["summarize_customer"], "resources": ["stripe://customers"], "resourceTemplates": ["stripe://customers/{id}"] } } }

Place this policy after mcp-token-exchange-inbound when the route uses gateway-managed upstream OAuth credentials. That order lets the token exchange policy retry or replace a 401 response first; this policy then filters the final upstream JSON-RPC response.

Access control

accessControl narrows the curated catalog per caller. Its mode selects the strategy:

  • allPublic (default): no access control — every curated capability is available to all callers. Setting roles/groups/public on a capability in this mode is a configuration error (it would silently do nothing).
  • rolesAndGroups: match the caller's roles and groups — read from request.user.data (see below) — against each capability's markup.
  • function: delegate to a custom resolver.

Mode rolesAndGroups

Mark each capability with roles and/or groups, or public: true. Nothing is ever exposed by omission — a capability with no markup stays in the catalog but is available to no one until you classify it (so you can list it now and assign roles later). Combining public: true with roles/groups is contradictory and rejected at load:

JSONCode
{ "options": { "tools": [ { "name": "search_invoices", "roles": ["billing", "admin"] }, { "name": "create_invoice", "roles": ["admin"], "groups": ["finance"] }, { "name": "list_products", "public": true } ], "accessControl": { "mode": "rolesAndGroups" } } }

A caller may use a capability if it is public, or if the caller matches any listed role or any listed group (so create_invoice above is available to admins or to the finance team). Anonymous callers get only public capabilities. roles/groups/public are enforcement markup and are stripped from downstream responses — use _meta for hints you want clients to see.

The caller's roles and groups come from request.user.data — the object an upstream authentication policy sets for the authenticated caller. What lands there depends on the policy: a JWT auth policy stores the token's claims, an API key policy stores the key's metadata, and so on. This policy reads the roles and groups properties by default. For example, behind a JWT auth policy a token with these claims:

JSONCode
{ "sub": "user_8f3a", "roles": ["billing"], "groups": ["sales"], "iss": "https://acme.us.auth0.com/", "aud": "https://mcp.acme.com", "exp": 1893456000 }

request.user.data.roles is ["billing"] and request.user.data.groups is ["sales"]. Against the config above, this caller may use search_invoices (matches the billing role) and the public list_products, but not create_invoice (which needs the admin role or the finance group). A request with no authenticated caller (no request.user) has no roles or groups, so it sees only public capabilities.

Use roleClaim/groupClaim to read different request.user.data properties, including a dot-path for nested values or a literal namespaced key:

JSONCode
{ "accessControl": { "mode": "rolesAndGroups", "groupClaim": "cognito:groups" } }
JSONCode
{ "accessControl": { "mode": "rolesAndGroups", "roleClaim": "realm_access.roles" } }

Mode function

For any other mapping — looking up entitlements from an external API, requiring both a role AND a group, blocklists, etc. — set mode: "function" and point identifier at a resolver (same { module, export } shape as rate-limit). It fully replaces the built-in roles/groups matching. It receives (request, context, options) and returns the allowed identifiers; the result is always clamped to the configured catalog, so a resolver can only narrow access. A capability type left as passthrough (no static list) is not narrowed. A resolver that throws or returns no value fails closed.

JSONCode
{ "accessControl": { "mode": "function", "identifier": { "module": "$import(./modules/mcp-access-control)", "export": "default" } } }
TypeScriptCode
// modules/mcp-access-control.ts import { ZuploRequest, ZuploContext, environment } from "@zuplo/runtime"; import type { McpCapabilityFilterInboundPolicyOptions, AllowedCapabilities, } from "@zuplo/runtime/mcp-gateway"; export default async function resolveCapabilities( request: ZuploRequest, context: ZuploContext, options: McpCapabilityFilterInboundPolicyOptions, ): Promise<AllowedCapabilities> { const subject = request.user?.sub; if (!subject) return { tools: [] }; // fail closed for anonymous const res = await fetch( `https://entitlements.example.com/users/${encodeURIComponent(subject)}`, { headers: { authorization: `Bearer ${environment.ENTITLEMENTS_API_KEY}` }, }, ); if (!res.ok) { context.log.warn(`entitlements lookup failed: ${res.status}`); return { tools: [] }; } const { allowedTools } = (await res.json()) as { allowedTools: string[] }; return { tools: allowedTools }; }

Requests and responses the filter cannot read

The policy fails closed. It never forwards something it could not check:

CaseResult
Body is not JSON (Content-Type other than JSON), on any HTTP method415 problem+json, not forwarded
Body over 10 MiB413 problem+json, not forwarded
Body is JSON-typed but does not parse, or cannot be read (the upload failed)400, -32700, not forwarded
Body is JSON but not a JSON-RPC message400, -32600 (its id when valid)
Batch with a member that is not a JSON-RPC 2.0 object (no or another jsonrpc, a nested array, a scalar)400, one -32600, not forwarded
A method that is not a string (an array, number, null or object), alone or in a batch400, one -32600, not forwarded
A non-ASCII key in a message, its params or params.ref (Go folds paramſ into params)400, -32600, not forwarded
jsonrpc/method/id/params, params.name/uri/ref or ref.type/name/uri twice, in any case400, -32600, not forwarded
A message key in another case (Method), or params.Name/URI/Ref or ref.Name/… on a direct call400, -32600, not forwarded
A direct call whose params is not an object, or has no string name or URI-32602, not forwarded
A method the policy checks or filters, in another case or with spaces (Tools/Call, tools/list)-32601, not forwarded
A refused call sent as a notification (no id)400, -32600, no id, not forwarded
A refused call whose id is not a string or an integer (1.5, null)400, -32600, no id, not forwarded
Access-control resolver throws or returns no value-32603 for each request that needed it
List reply the policy cannot parse, or with no or unknown media type-32603 instead of the reply
List reply in a content-coding the runtime does not decode (deflate, zstd)-32603 instead of the reply
List reply that is not a JSON-RPC result, error, notification or request-32603 instead of the reply
List result whose result is not an object, or whose list (tools, prompts, …) is missing or not an array-32603 instead of the reply
List result whose nextCursor is not a string (or null)-32603 instead of the reply
A JSON-RPC array answering a single (non-batch) request-32603 instead of the reply
Result holding a filtered list (tools, prompts, …) that its request did not list-32603 instead of the reply
Batch reply array: a member that is any of the -32603 cases above, or whose id matches no requestMember dropped (see below)
SSE list reply: an event whose data is not JSON, or whose message is any of the -32603 cases aboveEvent dropped
SSE list reply: an event with an unknown field, an event other than message, or a malformed id/retryEvent dropped
SSE list reply: an event over 10 MiB, or a final event without a closing blank lineEvent dropped
SSE list reply: a comment with textText removed (: kept)
A list item without a string name or URIItem dropped

The key checks guard against an upstream that reads JSON keys case-insensitively (as Go's encoding/json does) or keeps the first of two duplicates. Non-ASCII keys and the repeated keys above are refused on every message, notifications included, whatever its method: {"params":{"name":1,"Name":2}} is a 400 even on notifications/initialized. A single case variant in params or params.ref (Name, URI, Ref) is refused only on a direct call; keys nested deeper, such as params.arguments.Name, are not checked.

A request without a body, or with an empty one, passes through whatever its HTTP method (GET, a DELETE that ends a session, an empty POST). The policy reads a body it cannot parse only as far as its answer needs: one that is not JSON up to its first non-blank character, a JSON one up to the 10 MiB limit. A non-2xx reply passes through with its status and headers (a problem details or OAuth error body, an empty body, one that is not JSON or is still compressed), unless it is a JSON reply with a result or a batch, which is filtered.

A list reply the policy cannot read is replaced by a JSON-RPC -32603 error for the list request, with its id (an array of errors for a batch), sent as application/json with HTTP 200; the error carries none of the reply. Every reply the policy does not forward (a replaced reply, a dropped event or batch member) is logged as a warning, without any of its content.

A request with a list method the policy filters is sent upstream with Accept-Encoding: gzip, br, replacing the client's value, so the upstream answers in a content-coding the runtime decodes. Other requests keep the client's Accept-Encoding.

When the policy drops an SSE list reply, the client is not left waiting. Each list request whose reply was dropped gets a JSON-RPC error event instead, with none of the dropped content:

JSONCode
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32603, "message": "The MCP capability filter could not read the upstream's list response, so it was not forwarded." } }

The error is sent at once when the dropped reply's id shows which request it answered, and otherwise when the upstream stream ends, for each request still unanswered. A request that is not a list request gets a different message (see Batch Requests). Once no request is waiting, the stream ends. Other events in the stream (progress notifications, server requests, error replies, events with only an id of letters, digits and ._~:/+=-, retry or event: message) pass through unchanged. So does an event without a payload (no data, or an empty one), such as the priming event a 2025-11-25 server sends first to make its stream resumable: its id is kept, and nothing is logged.

The policy adds an error only after it dropped something. When the upstream stream ends cleanly without answering a request, and nothing was dropped, the policy adds nothing: answering that request is the handler's job. McpProxyHandler sends a -32603 error for each unanswered request itself (before this policy sees the stream), so behind it the client still gets an answer.

Batch Requests

For JSON-RPC batch requests, list responses are filtered per response item, matched to the original list request by id. A reply id matches a request of the same type first (1 to 1), and otherwise a request with the same text ("1" for 1). A batch may hold ids that differ only in type (1 and "1"): a reply goes to the request whose id it matches exactly. A result whose id matches no request in the batch fails closed, including one whose id is that of a client result or error in the batch: that reply is dropped. Ids can be re-typed in more ways than the policy can tell apart (1.0, "01", -0, numbers past 2^53), so a result that holds a list the policy filters (tools, prompts, resources, resourceTemplates) fails closed the same way unless it answers the request that listed it.

A batch reply array, as a JSON body or in an SSE event, is checked member by member, so one bad member does not lose its siblings' replies (a tool call in the batch may already have run). Each member that is safe is sent. A member the policy cannot read is replaced by a JSON-RPC -32603 error for the request it answers, or dropped when its id matches no request still waiting. When a member was dropped, every request left without a reply gets the error too: in a JSON reply at once, appended to the array; on a stream when it ends. A JSON batch reply that is not an array gets a -32603 for every request.

When the stream drops an event that might have held a reply, every request still waiting at the end of the stream gets a JSON-RPC -32603 error. For a request that is not a list request, the message says the filter did not forward the reply and the request may already have run, so the client does not assume a retry is safe:

JSONCode
{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32603, "message": "The MCP capability filter did not forward the upstream's reply to this request. The request may already have run." } }

If any batch item directly calls a hidden tool, prompt, or resource, the policy refuses the whole batch and forwards none of it. It answers with an array that has one error per request in the batch: the not-found error for the hidden call, and for each other request a -32603 that says it was not forwarded because another request in its batch was refused. Notifications in the batch get nothing. On protocol version 2025-06-18 and later, which have no batches, the answer is one 400 with -32600 and no id.

MCP 2026-07-28 requests

With the mcpProtocolVersion20260728 compatibility flag on, the policy handles a 2026-07-28 request as that revision requires. A request is a 2026-07-28 request when its MCP-Protocol-Version header or its params._meta["io.modelcontextprotocol/protocolVersion"] names 2026-07-28 or later; a batch never is. Requests from earlier clients keep the behavior above, byte for byte, with the flag on or off. With the flag off, a 2026-07-28 request gets the behavior above too.

CaseResult
MCP-Protocol-Version, Mcp-Method or Mcp-Name disagrees with the bodyHTTP 400, JSON-RPC -32020, not forwarded
tools/call, prompts/get, resources/read or completion/complete for a hidden targetThe MCP SDK server's not-found error (-32602), below
A method the policy filters, spelled in another case or with spaces (Tools/Call)HTTP 404, JSON-RPC -32601, not forwarded
A list on a route with accessControlcacheScope set to "private"; ttlMs unchanged
A resources/read result on a route whose accessControl covers resources or resourceTemplatescacheScope set to "private"; ttlMs unchanged
notifications/resources/updated for a URI resources/read would refuse, on a stream the policy filtersEvent dropped
resultType: "input_required" or "task", and any structuredContent, on a tools/call or prompts/getPassed through unchanged
resultType: "input_required" or "task" on a resources/readPassed through unchanged (no cacheScope added)

A list or resources/read result is marked "private" whenever the route has accessControl (mode rolesAndGroups or function), even for a caller the resolver leaves unrestricted, so a shared cache never serves one caller's results to another. With only the static allowlist, every caller sees the same results, so the upstream's cacheScope is kept.

The SDK server's not-found words, below, are used only when the MCP-Protocol-Version header names 2026-07-28 or later. A request that names 2026-07-28 only in _meta can reach a server that reads only the header, which answers it as an earlier request, so a hidden target gets the earlier answer (see Overview) and looks the same as an absent one.

The policy filters the replies to list requests, subscriptions/listen and, with accessControl covering resources, resources/read. Other replies, such as a tools/call stream, are relayed unchanged.

subscriptions/listen

subscriptions/listen exists only in 2026-07-28, so with the flag on the policy handles every subscriptions/listen, even one that names an earlier version or none, while resources or resourceTemplates is configured:

CaseResult
resourceSubscriptions names a hidden URIHidden URIs removed before the request is forwarded
notifications/subscriptions/acknowledged lists a hidden URIHidden URIs removed
notifications/resources/updated for a hidden URIEvent dropped
The method spelled in another case or with spaces (Subscriptions/Listen)JSON-RPC -32601, not forwarded
params spells notifications, or params.notifications spells resourceSubscriptions, in another case or twiceHTTP 400, JSON-RPC -32600, not forwarded
The accessControl resolver throwsJSON-RPC -32603, not forwarded

The -32601 is HTTP 404 on a 2026-07-28 request and 200 otherwise. With the flag off, or with neither resources nor resourceTemplates configured, subscriptions/listen is forwarded and its stream relayed unchanged.

Only headers that are present are compared; the upstream decides whether a missing one is an error. A hidden target with a mismatched header gets the -32020 error, as it would from the upstream.

A hidden target is answered as the TypeScript MCP SDK server answers a target it does not have. The name or URI is echoed as sent:

RequestAnswer
tools/call of a hidden tool-32602, Tool <name> not found
prompts/get of a hidden prompt-32602, Prompt <name> not found
resources/read, subscribe, unsubscribe-32602, Resource not found: <uri>, with data.uri
completion/complete of a hidden reference-32602, Prompt <name> not found or Resource template <uri> not found
JSONCode
{ "jsonrpc": "2.0", "id": 2, "error": { "code": -32602, "message": "Tool hidden_tool not found" } }

A retry after input_required is a new tools/call with the same name, so a hidden tool stays unreachable through it.

Read more about how policies work

Last modified on