Capability Filtering
The Model Context Protocol lets a server advertise tools, prompts, resources, and resource templates. When the Zuplo MCP Gateway proxies an upstream server, every one of those capabilities flows through to the client by default. That's the right behavior when the upstream server is small and trusted. It's the wrong behavior when the upstream exposes dozens of operations, only a few of which belong in front of an AI client.
The mcp-capability-filter-inbound policy curates that surface area.
Configure an allow-list per capability type, optionally rewrite the description
or annotations the client sees, and the gateway:
- Filters successful JSON-RPC list responses so the client only sees what's on the allow-list.
- Blocks direct calls to anything not on the allow-list with a JSON-RPC
MethodNotFounderror, before the request reaches the upstream.
Capability filtering is configured in routes.oas.json alongside the auth and
token-exchange policies. See Virtual MCP Servers
for where this policy fits in the route definition.
What it filters
The policy operates on four MCP capability types, each matched by the upstream identifier the protocol uses:
| Capability | Matched by | List method | Invocation method |
|---|---|---|---|
tools | name | tools/list | tools/call |
prompts | name | prompts/list | prompts/get |
resources | uri | resources/list | resources/read |
resourceTemplates | uriTemplate | resources/templates/list | resources/read |
Matching is case-sensitive and exact. There's no regex, glob, or category
matching in this iteration — if the upstream returns a tool named createUser
and the policy lists create_user, the tool stays hidden.
The most important rule
The behavior of each option depends on whether it's present at all:
- Omit the option — every capability of that type passes through unchanged. Useful when filtering tools but leaving prompts and resources alone.
- Provide an empty array — expose nothing of that type. The list response
becomes empty and every direct call returns
MethodNotFound. - Provide entries — expose only the listed items. Everything else is filtered or blocked.
Omitting an option is the default and behaves like a pass-through. An empty
array ("tools": []) is the opposite: it hides every capability of that type.
Confusing the two is the most common source of "why can the client still see
that tool?" reports.
Minimum example: allow-list tools only
The shortest useful configuration. The policy is named in policies.json and
the route opts in by adding the policy name to its inbound chain.
Code
Because prompts, resources, and resourceTemplates are omitted, the
upstream's prompts and resources flow through unmodified. Only the tool list is
restricted.
Attach the policy to the route after mcp-token-exchange-inbound. Order
matters — see Policy order below.
Code
Projecting a capability
Each entry can be a plain string (name only) or a projection object that keeps the upstream identifier but overrides what the client sees. Use projections to:
- Rename a description for clarity.
- Add or override tool annotations (
destructiveHint,readOnlyHint, etc.). - Attach
_metafields that downstream clients or middleware understand. - Rewrite a resource's
nameormimeType.
The upstream identifier (name, uri, or uriTemplate) is always required
and serves as the stable match key. The override fields are optional.
Example: rewrite a description
The upstream describes a tool as "Create a Linear issue with title, description, team, assignee, priority, labels, project, parent issue, and estimate." That's fine for an integrator and overwhelming for an AI client. Override it:
Code
The string entries ("list_issues", "get_issue") pass through with the
upstream's own descriptions. The projection object overrides create_issue's
description while keeping the upstream's input schema, output schema, and name
untouched.
Example: merge tool annotations
Tool annotations are hints to the client about how a tool behaves (read-only, destructive, idempotent, etc.). Annotations on the projection object are deep-merged with the upstream's annotations — fields you specify win, fields you don't specify pass through.
Code
The _meta object is also deep-merged. Use it for vendor-specific metadata your
client or another middleware layer reads.
Example: project a resource
Resources use uri as the match key. A resource projection can also rewrite the
downstream-facing name and mimeType.
Code
Behavior on */list responses
When the gateway sees a successful response to tools/list, prompts/list,
resources/list, or resources/templates/list:
- The policy reads the list from the upstream response.
- It keeps only items whose identifier appears in the corresponding allow-list.
- For projection entries, it merges the overrides into the kept item.
- It returns the filtered list to the client.
Items the upstream returns that aren't on the allow-list are silently dropped. The client never learns they exist.
If the option is omitted entirely, the list passes through with no filtering or projection.
Behavior on direct invocation
When the gateway sees tools/call, prompts/get, or resources/read:
-
The policy reads the target identifier from the request —
params.namefor tools and prompts,params.urifor resources. -
If the identifier isn't on the matching allow-list, the gateway returns a JSON-RPC error before forwarding upstream:
Code -
If the identifier is on the allow-list, the request forwards normally.
This is what makes the filter a real boundary instead of cosmetic curation. A
client that already knows the hidden tool's name (from a cached tools/list, a
different gateway, or guesswork) still can't invoke it.
The block also fires when the option is set to an empty array — every direct
call of that capability type returns MethodNotFound.
Batch requests
The policy handles JSON-RPC batch requests:
- List responses inside a batch are filtered per item. The policy matches each response item to its originating list request by ID and applies the same filtering and projection rules as for a single response.
- Hidden invocations inside a batch block the whole batch with a single
MethodNotFounderror. The gateway does not split, partially filter, or forward sibling items.
Policy order
Place mcp-capability-filter-inbound after mcp-token-exchange-inbound in
the route's inbound policy list:
Code
Why this order:
mcp-token-exchange-inboundinstalls a response hook that retries the upstream call on a 401 — for example, after refreshing a stale upstream OAuth token. That retry may replace the response the client eventually sees.- The capability filter also runs as a response hook. Placing it later in the inbound list means it runs last on the way out, so it filters the final upstream response, not an intermediate one.
If you don't use mcp-token-exchange-inbound — for example, your upstream uses
an API key set by set-headers-inbound or
set-upstream-api-key-inbound —
order doesn't matter for retry semantics. Keep the capability filter at the end
of the chain anyway, so any future inbound policies that produce or replace
responses run before it.
What the policy does not do
A few capabilities are intentionally out of scope:
- No schema overrides.
inputSchemaandoutputSchemaalways come from the upstream list response. The policy can't rewrite parameter shapes or enforce additional validation. Use a separate policy on the route if you need that. - No regex, glob, or category matching. Allow-lists are exact, by identifier. If the upstream renames a tool, the policy entry must be updated to match.
- No non-JSON filtering. The response hook only runs when the response's
content-typeheader containsjson. Streamed or binary responses pass through untouched. - No effect on capability metadata in
initialize. The protocol-levelserverCapabilitiesblock in theinitializeresponse advertises which capability types the server supports (tools, prompts, resources). The filter doesn't strip those flags. A client sees that the gateway supports tools even when the tool allow-list is empty; only the list and call responses change. - No quota or rate limit. Capability filtering trims the surface area the
gateway exposes but doesn't bound how often clients can call what remains.
Pair it with the gateway's standard
rate-limit-inboundpolicy when you need usage controls. Per-user RBAC is a near-term roadmap item; see Teams.
Strictness modes (when you want only some capabilities)
The omit-vs-empty-array rule lets you compose three useful patterns:
Code
Pattern 3 effectively turns the route into a JSON-RPC initialize echo that
exposes nothing — sometimes useful as a temporary kill switch on a route without
removing the route from the configuration.
Worked example: curating a multi-tool upstream
The corp Linear upstream exposes more than two dozen tools. Suppose only the read-only subset belongs in front of the team's AI assistant. Configure the policy to allow the read tools, rewrite descriptions for clarity, and hide all prompts and resources.
Code
The route in routes.oas.json adds this policy to the existing inbound chain:
Code
The same upstream Linear MCP server is now reachable at two routes — the
full-featured /mcp/linear-v1 and the curated /mcp/linear-readonly — each
with its own surface area.
Reference
- Policy source: see the
mcp-capability-filter-inboundreference for the full schema. - Project the policy alongside an upstream that uses
mcp-token-exchange-inboundfor per-user OAuth, or alongsideset-headers-inboundfor API-key upstreams. See Origin MCP Servers for the upstream side of the picture. - The route handler is
McpProxyHandlerand the full route shape is documented under Virtual MCP Servers. - MCP capability semantics live in the MCP specification.