# Curate the tools an upstream exposes (in code)

<CurateToolsPicker
  mode="local"
  alternateLink="/mcp-gateway/how-to/curate-tools"
/>

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, including what the policy
filters, the omit-versus-empty-array rule, and how projections are merged, see
[Capability filtering](../capability-filtering.mdx).

Prefer the Portal? The [Portal version](./curate-tools.mdx) reaches the same
result from the MCP Gateway Virtual Server UI.

## 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, so fields you specify win and
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, with 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.

:::

## 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

- [Curate tools in the Portal](./curate-tools.mdx): do the same from the Virtual
  Server UI.
- [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.
