- 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.
config/policies.json
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 bemcp-capability-filter-inbound.handler.export<string>- The name of the exported type. Value should beMcpCapabilityFilterInboundPolicy.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 areallPublic,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:
| Request | Answer |
|---|---|
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
Code
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. Settingroles/groups/publicon a capability in this mode is a configuration error (it would silently do nothing).rolesAndGroups: match the caller's roles and groups — read fromrequest.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:
Code
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:
Code
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:
Code
Code
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.
Code
Code
Requests and responses the filter cannot read
The policy fails closed. It never forwards something it could not check:
| Case | Result |
|---|---|
Body is not JSON (Content-Type other than JSON), on any HTTP method | 415 problem+json, not forwarded |
| Body over 10 MiB | 413 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 message | 400, -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 batch | 400, 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 case | 400, -32600, not forwarded |
A message key in another case (Method), or params.Name/URI/Ref or ref.Name/… on a direct call | 400, -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 request | Member dropped (see below) |
SSE list reply: an event whose data is not JSON, or whose message is any of the -32603 cases above | Event dropped |
SSE list reply: an event with an unknown field, an event other than message, or a malformed id/retry | Event dropped |
| SSE list reply: an event over 10 MiB, or a final event without a closing blank line | Event dropped |
| SSE list reply: a comment with text | Text removed (: kept) |
| A list item without a string name or URI | Item 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:
Code
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:
Code
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.
| Case | Result |
|---|---|
MCP-Protocol-Version, Mcp-Method or Mcp-Name disagrees with the body | HTTP 400, JSON-RPC -32020, not forwarded |
tools/call, prompts/get, resources/read or completion/complete for a hidden target | The 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 accessControl | cacheScope set to "private"; ttlMs unchanged |
A resources/read result on a route whose accessControl covers resources or resourceTemplates | cacheScope set to "private"; ttlMs unchanged |
notifications/resources/updated for a URI resources/read would refuse, on a stream the policy filters | Event dropped |
resultType: "input_required" or "task", and any structuredContent, on a tools/call or prompts/get | Passed through unchanged |
resultType: "input_required" or "task" on a resources/read | Passed 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:
| Case | Result |
|---|---|
resourceSubscriptions names a hidden URI | Hidden URIs removed before the request is forwarded |
notifications/subscriptions/acknowledged lists a hidden URI | Hidden URIs removed |
notifications/resources/updated for a hidden URI | Event 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 twice | HTTP 400, JSON-RPC -32600, not forwarded |
The accessControl resolver throws | JSON-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:
| Request | Answer |
|---|---|
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 |
Code
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