# Curate the tools an upstream exposes

When an upstream MCP server exposes more capabilities than belong in front of an
AI client, attach the `mcp-capability-filter-inbound` policy to the route to
allow-list the subset that should pass through, override descriptions or
annotations, and block direct calls to anything outside the list.

For the conceptual model behind capability filtering — what the policy filters,
the omit-versus-empty-array rule, and how projections are merged — see
[Capability filtering](../capability-filtering.mdx).

## Add the capability filter policy

1. Declare the policy in `config/policies.json`. List the upstream identifiers
   you want to expose for each capability type — `name` for tools and prompts,
   `uri` for resources, `uriTemplate` for resource templates:

   ```jsonc title="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"],
       },
     },
   }
   ```

2. Attach the policy to the route in `config/routes.oas.json`, **after**
   `mcp-token-exchange-inbound` so the filter operates on the final upstream
   response:

   ```jsonc title="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"
           ]
         }
       }
     }
   }
   ```

Because `prompts`, `resources`, and `resourceTemplates` are omitted from the
options, the upstream's prompts and resources flow through unmodified. Only the
tool list is restricted.

## Override a tool description

To rewrite the description or annotations a client sees while keeping the
upstream identifier as the match key, replace the string entry with a projection
object:

```jsonc
{
  "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.

## Override tool annotations

[Tool annotations](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
are deep-merged with the upstream's annotations — fields you specify win, fields
you don't specify pass through. The same applies to `_meta`:

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

## Project a resource

Resources use `uri` as the match key. A resource projection can rewrite the
downstream-facing `name`, `description`, or `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.",
    },
  ],
}
```

## Block everything from a capability type

Provide an empty array to expose nothing of that type. The list response becomes
empty and every direct call returns `MethodNotFound`:

```jsonc
{
  "options": {
    "tools": ["safe_tool_a", "safe_tool_b"],
    "prompts": [],
    "resources": [],
    "resourceTemplates": [],
  },
}
```

To turn a route into a temporary kill switch — all capability types disabled
without removing the route from configuration — set every type to `[]`:

```jsonc
{
  "options": {
    "tools": [],
    "prompts": [],
    "resources": [],
    "resourceTemplates": [],
  },
}
```

:::caution

Omitting an option behaves like a pass-through; an empty array (`"tools": []`)
hides every capability of that type. Confusing the two is the most common source
of "why can the client still see that tool?" reports.

:::

## Worked example: read-only Linear

Suppose the corp Linear upstream exposes more than two dozen tools and only the
read-only subset belongs in front of the team's AI assistant. Allow-list the
read tools, override descriptions for clarity, and hide all prompts and
resources:

```jsonc title="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": [],
    },
  },
}
```

Attach the policy to a dedicated route in `config/routes.oas.json`:

```jsonc title="config/routes.oas.json"
"/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.

## Verify the filter

After deploying (or restarting `zuplo dev`), confirm the filter is active:

1. Connect a test client (the [MCP Inspector](../test-clients.mdx#mcp-inspector)
   is the fastest option) to the filtered route.
2. Call `tools/list`. Only the allow-listed tools should appear.
3. Call `tools/call` with a tool name that isn't on the list. The gateway
   returns a JSON-RPC `MethodNotFound` error before the request reaches the
   upstream.

If a tool you expected to see doesn't appear, check the upstream's `tools/list`
response directly — the match is case-sensitive and exact, so a typo or
capitalization difference makes the entry not match.

## Related

- [Capability filtering](../capability-filtering.mdx) — the conceptual model
  behind the policy.
- [`McpProxyHandler` reference](../code-config/mcp-proxy-handler.mdx) — the
  route handler the filter runs in front of.
- [Connect a gateway to an upstream OAuth provider](./connect-upstream-oauth.mdx)
  — pair the filter with per-user upstream OAuth.
