Zuplo
Model Context Protocol

Expose Only the MCP Tools You Choose

Martyn DaviesMartyn Davies
June 25, 2026
8 min read

Point an agent at an MCP server and its destructive tools sit one bad inference away from a read task. Front the server with a gateway, allowlist the tools you trust, and everything else stops existing on that route.

Connect an agent to the Stripe MCP server and it can read your customers. It can also issue refunds. The server hands a client its whole tool surface at once, so create_refund sits in the same list as fetch_stripe_resources with nothing between them but the model’s judgment. One bad inference, one prompt-injected instruction in a support ticket, and a read task becomes a write you did not authorize.

The full tool surface is the default. You opt out of the dangerous half, not into the safe one, and most teams never opt out.

Use this approach if you're:
  • Pointing agents at a third-party MCP server like Stripe, Linear, or GitHub
  • Fronting an upstream that ships write and delete tools beside its read tools
  • Building a "read-only" connection you can actually prove is read-only
  • Happier blocking a destructive tool than trusting a model not to call it

The default tool surface is the blast radius

An MCP server advertises its tools through tools/list, and a client takes the whole list. The protocol leaves consent to the client and has no notion of a safe subset.

The Stripe MCP server is a clean example. Alongside read tools like stripe_api_read, search_stripe_resources, and get_stripe_account_info it exposes create_refund and a general-purpose stripe_api_write that covers every POST, PATCH, PUT, and DELETE the Stripe API offers.

Hand that server to an agent unmodified and the agent’s blast radius is the upstream’s entire capability set. The model decides which tool to call, and the model is the part of the system you control least. Narrowing what it can reach is not a tuning knob, it is the boundary.

Filter capabilities with an allowlist, not a denylist

The Zuplo MCP Gateway fronts an upstream MCP server with a virtual server of your own, and the mcp-capability-filter-inbound policy decides which of the upstream’s capabilities survive the crossing. It works as an allowlist:

  • Omit a capability type entirely and everything of that type passes through unchanged.
  • Name specific entries and only those are exposed.
  • Pass an empty array and every capability of that type is hidden.

The filter covers tools, prompts, resources, and resource templates, matched by exact name or URI. No regex, no glob: the set you write is the set you get, with no pattern quietly widening the allowlist later.

Common mistake:

A denylist is the trap here. List the dangerous tools to block and every tool the upstream adds tomorrow is exposed by default, including the next destructive one. An allowlist fails closed: a new upstream tool stays hidden until you add it on purpose.

Build a read-only Stripe MCP server

In the portal, add a route and pick MCP Gateway Virtual Server, then point it at the Stripe MCP server. The wizard wires the inbound OAuth and a Stripe token exchange for you, one-time setup covered in fronting a third-party server and binding MCP tokens to one server.

At the wizard’s Tools step, choose Curate instead of Passthrough, sign in to Stripe, and check the Read-only group, leaving every write tool unselected. That is the whole curation: a column of checkboxes, nothing typed by hand.

The Zuplo MCP Gateway wizard on the Tools step, set to Curate, showing the Stripe upstream catalog with the Read-only group of seven tools all checked and the write tools left unselected, seven of eleven tools selected.

The Stripe sign-in is its own enforcement layer, and it comes before the gateway sees a single tool. Stripe’s OAuth consent screen presents the scopes you are granting and lets you narrow them there, so you can hand the gateway a connection that is already restricted at the source. The allowlist then narrows again on top of that: the upstream token is scoped at Stripe, and the tool surface is scoped at the gateway. Two independent cuts, neither relying on the other.

The gateway writes your choice to git-backed config as an mcp-capability-filter-inbound policy and attaches it to the route. For a security reader the generated artifact matters more than the clicks, because it is the thing you review in a pull request:

JSONjsonc
// policies.json
{
  "name": "filter-stripe-readonly",
  "policyType": "mcp-capability-filter-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpCapabilityFilterInboundPolicy",
    "options": {
      "tools": [
        "stripe_api_read",
        "stripe_api_search",
        "stripe_api_details",
        "search_stripe_resources",
        "fetch_stripe_resources",
        "get_stripe_account_info",
        "search_stripe_documentation",
      ],
    },
  },
}

The Read-only group holds seven tools. Check that group and leave the rest, and the agent gets exactly those seven. create_refund and stripe_api_write are among the four you left unchecked, so they are gone: not hidden in the UI, not discouraged in a description, gone from tools/list and unreachable by name.

Stripe MCP tool What it does On this server
stripe_api_read Read data from any Stripe GET Exposed
stripe_api_search Find Stripe API operations by intent Exposed
stripe_api_details Get parameters for an API operation Exposed
search_stripe_resources Search Stripe resources with a query Exposed
fetch_stripe_resources Fetch a specific Stripe object by ID Exposed
get_stripe_account_info Read the connected account’s details Exposed
search_stripe_documentation Search the Stripe documentation Exposed
stripe_api_write Any POST, PATCH, PUT, or DELETE Blocked
create_refund Issue a refund Blocked

An agent connecting to /mcp/stripe-readonly sees the seven exposed tools and no way to move money.

The filter is exactly as granular as the tools the upstream defines. Stripe splits reads and writes into separate tools, stripe_api_read against stripe_api_write, so a clean read-only cut is possible. An upstream that bundles both into one tool forces a coarser call: expose it or don’t. Check how an upstream draws those lines before you promise anyone read-only.

MCP capability filtering

The full model: how the allowlist matches tools, prompts, resources, and resource templates, and how projections override what the client sees.

Prove the destructive tool is unreachable

A read-only claim you cannot demonstrate is a comment, not a control. Connect the route in an MCP inspector: set the transport to Streamable HTTP, paste the gateway URL, and connect through the OAuth flow. The screenshots here use MCPJam; the official MCP Inspector behaves the same. tools/list returns exactly the seven allowlisted tools, and create_refund is not among them.

MCPJam showing the tools/list response from the read-only virtual server, returning the seven allowlisted Stripe read tools with their descriptions and annotations.

Then bypass the list and call the filtered tool directly by name, the move a confused or steered agent would make:

JSONjson
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "tools/call",
  "params": {
    "name": "create_refund",
    "arguments": { "charge": "ch_123", "amount": 5000 }
  }
}

The gateway rejects it before anything reaches Stripe:

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

That is JSON-RPC error -32601, the same response the client would get for a tool that never existed. The filtered tool is indistinguishable from a nonexistent one, so a cached or guessed name buys an attacker nothing. The refund call dies at the gateway, and the Stripe API never sees it.

Raw JSON-RPC response showing error code -32601 Method not found for a create_refund call.

Narrow the descriptions agents see

The Curate checkboxes get you the allowlist. The one thing they cannot do is change how a tool describes itself, so this is the step where you open the generated policy and edit it directly. Replace a plain string entry with an object and you override the description and annotations the upstream ships, while the upstream name stays the match key:

JSONjsonc
{
  "tools": [
    {
      "name": "stripe_api_read",
      "description": "Read Stripe objects with GET only: customers, invoices, charges, balances.",
      "annotations": { "readOnlyHint": true },
    },
    "search_stripe_resources",
    "fetch_stripe_resources",
    "get_stripe_account_info",
    "search_stripe_documentation",
  ],
}

The override is deep-merged, so the fields you set win and everything else passes through from upstream. A tighter description steers the model toward the narrow use you intend, and readOnlyHint advertises that the tool does not mutate state. That hint is advisory, a client is free to ignore it, so neither is a security control on its own: the allowlist enforces the boundary. But a tool the agent understands narrowly is a tool it misuses less.

A blocked call is a signal, not silence

The rejection is not just a dead end for the caller, it is information for you. Because the gateway returns the -32601, the attempt to call create_refund is a request it sees and logs like any other, attributed to the authenticated subject through the OAuth flow. The gateway emits structured mcp_* events carrying the route, the upstream, and the subjectId of the caller, surfaced in the gateway’s logs and analytics.

So a blocked call is a question worth asking: which identity tried to refund a charge through a connection you scoped to reads only? On most setups that signal is lost, because the agent talks to the upstream directly with no choke point to record the attempt. Routing through the gateway turns a quiet failure into an audit trail, the same per-call visibility we covered in exposing internal APIs as governed MCP tools.

Blast radius is a design choice

The capability filter is small, but the property it buys is not. A virtual server scoped to seven read tools cannot issue a refund no matter how the model is prompted, because the tool that issues refunds does not exist on that route. That is a deterministic guarantee, not a detection rate, and it sits beside the other boundaries the gateway draws: tokens bound to one server so a leaked credential cannot roam, and governance over shadow MCP so the connections exist where you can see them.

We run our own agents’ access to third-party servers through virtual servers scoped this way, so an agent reaching for a tool we never exposed finds a -32601 instead of a live endpoint. Decide the blast radius when you stand up the server, not after an agent has found the edge of it.

Public beta

Put a read-only virtual server in front of your first upstream

The Zuplo MCP Gateway fronts any upstream MCP server, allowlists the tools you trust, and returns Method not found for everything else. No code to write the boundary.

  • Allowlist tools, prompts, and resources
  • Git-backed, reviewable policy
  • Per-call logs and attribution