# 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
  `MethodNotFound` error, 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](./virtual-mcp-servers.mdx)
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.

:::caution

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.

```jsonc
// config/policies.json
{
  "name": "filter-linear-tools",
  "policyType": "mcp-capability-filter-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpCapabilityFilterInboundPolicy",
    "options": {
      "tools": ["list_issues", "get_issue", "create_issue"],
    },
  },
}
```

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](#policy-order) below.

```jsonc
// config/routes.oas.json
"/mcp/linear-v1": {
  "get,post": {
    "operationId": "linear-mcp-server",
    "x-zuplo-route": {
      "corsPolicy": "none",
      "handler": {
        "module": "$import(@zuplo/runtime/mcp-gateway)",
        "export": "McpProxyHandler",
        "options": { "rewritePattern": "https://mcp.linear.app/mcp" }
      },
      "policies": {
        "inbound": [
          "auth0-managed-oauth",
          "mcp-token-exchange-linear",
          "filter-linear-tools"
        ]
      }
    }
  }
}
```

## 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 `_meta` fields that downstream clients or middleware understand.
- Rewrite a resource's `name` or `mimeType`.

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:

```jsonc
{
  "name": "filter-linear-issue-tools",
  "policyType": "mcp-capability-filter-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpCapabilityFilterInboundPolicy",
    "options": {
      "tools": [
        {
          "name": "create_issue",
          "description": "Create a Linear issue. Provide a title and team; everything else is optional.",
        },
        "list_issues",
        "get_issue",
      ],
    },
  },
}
```

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](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
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.

```jsonc
{
  "tools": [
    {
      "name": "delete_issue",
      "description": "Delete a Linear issue. This is irreversible.",
      "annotations": {
        "destructiveHint": true,
        "readOnlyHint": false,
      },
      "_meta": {
        "io.example.audit": "high",
      },
    },
  ],
}
```

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`.

```jsonc
{
  "resources": [
    {
      "uri": "stripe://customers",
      "name": "Customers",
      "description": "All Stripe customers visible to this account.",
      "mimeType": "application/json",
    },
  ],
  "resourceTemplates": [
    {
      "uriTemplate": "stripe://customers/{id}",
      "name": "Customer detail",
      "description": "A single Stripe customer keyed by ID.",
    },
  ],
}
```

## Behavior on `*/list` responses

When the gateway sees a successful response to `tools/list`, `prompts/list`,
`resources/list`, or `resources/templates/list`:

1. The policy reads the list from the upstream response.
2. It keeps only items whose identifier appears in the corresponding allow-list.
3. For projection entries, it merges the overrides into the kept item.
4. 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`:

1. The policy reads the target identifier from the request — `params.name` for
   tools and prompts, `params.uri` for resources.
2. If the identifier isn't on the matching allow-list, the gateway returns a
   JSON-RPC error **before forwarding upstream**:

   ```json
   {
     "jsonrpc": "2.0",
     "id": "1",
     "error": {
       "code": -32601,
       "message": "Method not found"
     }
   }
   ```

3. 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
  `MethodNotFound` error. The gateway does not split, partially filter, or
  forward sibling items.

## Policy order

Place `mcp-capability-filter-inbound` **after** `mcp-token-exchange-inbound` (or
any other policy that may transform the upstream response) in the route's
inbound policy list, so the filter operates on the final response:

```jsonc
"policies": {
  "inbound": [
    "auth0-managed-oauth",
    "mcp-token-exchange-linear",
    "filter-linear-tools"
  ]
}
```

Keep the capability filter last in the chain even when there's no
`mcp-token-exchange-inbound` policy (for example, an API-key upstream via
`set-headers-inbound` or
[`set-upstream-api-key-inbound`](../policies/set-upstream-api-key-inbound.mdx)),
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.** `inputSchema` and `outputSchema` always 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.** Filtering applies only to JSON responses. Streamed
  or binary responses pass through untouched.
- **No effect on capability metadata in `initialize`.** The protocol-level
  `serverCapabilities` block in the `initialize` response 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-inbound`](../policies/rate-limit-inbound.mdx) policy when you
  need usage controls. Per-user RBAC is a near-term roadmap item; see
  [Teams](./teams.mdx).

## Strictness modes (when you want only some capabilities)

The omit-vs-empty-array rule lets you compose three useful patterns:

```jsonc
// 1. Filter tools, leave prompts and resources alone.
{
  "tools": ["safe_tool_a", "safe_tool_b"]
}

// 2. Filter tools and hide all prompts and resources.
{
  "tools": ["safe_tool_a", "safe_tool_b"],
  "prompts": [],
  "resources": [],
  "resourceTemplates": []
}

// 3. Hide every capability the upstream exposes.
{
  "tools": [],
  "prompts": [],
  "resources": [],
  "resourceTemplates": []
}
```

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.

```jsonc
// config/policies.json
{
  "name": "filter-linear-read-only",
  "policyType": "mcp-capability-filter-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpCapabilityFilterInboundPolicy",
    "options": {
      "tools": [
        {
          "name": "list_issues",
          "description": "List Linear issues. Filter by team, state, assignee, or label.",
        },
        {
          "name": "get_issue",
          "description": "Get a single Linear issue by ID or identifier (e.g. ENG-123).",
        },
        {
          "name": "list_teams",
          "description": "List the teams in the current Linear workspace.",
        },
        {
          "name": "list_projects",
          "description": "List the projects in the current Linear workspace.",
          "annotations": {
            "readOnlyHint": true,
          },
        },
      ],
      "prompts": [],
      "resources": [],
      "resourceTemplates": [],
    },
  },
}
```

The route in `routes.oas.json` adds this policy to the existing inbound chain:

```jsonc
"/mcp/linear-readonly": {
  "get,post": {
    "operationId": "linear-readonly-mcp-server",
    "x-zuplo-route": {
      "corsPolicy": "none",
      "handler": {
        "module": "$import(@zuplo/runtime/mcp-gateway)",
        "export": "McpProxyHandler",
        "options": { "rewritePattern": "https://mcp.linear.app/mcp" }
      },
      "policies": {
        "inbound": [
          "auth0-managed-oauth",
          "mcp-token-exchange-linear",
          "filter-linear-read-only"
        ]
      }
    }
  }
}
```

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-inbound` reference for the full
  schema.
- Project the policy alongside an upstream that uses
  `mcp-token-exchange-inbound` for per-user OAuth, or alongside
  [`set-headers-inbound`](../policies/set-headers-inbound.mdx) for API-key
  upstreams. See [Origin MCP Servers](./origin-mcp-servers.mdx) for the upstream
  side of the picture.
- The route handler is
  [`McpProxyHandler`](./virtual-mcp-servers.mdx#configure-with-code) and the
  full route shape is documented under
  [Virtual MCP Servers](./virtual-mcp-servers.mdx).
- MCP capability semantics live in the
  [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
