# How to prune a generated MCP server to ten tools an agent can use

> Cut a generated MCP server from forty-one tools to ten, merging near duplicates behind an enum, and prove the same agent tasks still complete.

Source: https://voxgig.com/howto/prune-a-generated-mcp-server-to-ten-tools

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Decide which tasks agents must complete, then cut the generated server to the tools those tasks need. Filter operations with x-mcp marks and --default-include false in openapi-mcp-generator. Or put a hand-written facade in front of the generated server, merging near duplicates behind one tool with an enum argument and leaving the /admin paths and deprecated operations out. Replay the same task list against both servers and keep the cut only if completion did not fall.

---
## You will need

A generated MCP server with dozens of tools, a list of the tasks agents are asked to complete, and
Node 20 or later. Verified 2026-10-21 against Node 22.22.0, openapi-mcp-generator 4.1.0,
@modelcontextprotocol/sdk 1.32.1, @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client
2.3.1, and Zod 3.25.76. The sample generates a forty-one tool server from `meterco.json`, prunes it
two ways, and replays twelve tasks against each result over stdio, with a mock standing in for the
API. The task list is the input that matters.[^1] Without one, every pruning decision is a guess
about what agents do.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-written facade in front of the generated server](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server) | Near duplicates to merge, arguments to rename, and `/admin` paths to hide, with the generated server kept as the HTTP layer | A second process and a mapping table to maintain, and a regeneration that can rename an upstream tool under you | The cut is a plain subset and no tool needs reshaping |
| [openapi-mcp-generator include and exclude filters](https://github.com/harsha-iiiv/openapi-mcp-generator) | The cut is a subset of operations, marked in the document and regenerated in one command | No merging, so four list endpoints stay four tools or go, and a mark the next export of the document can drop | Agents need tools shaped like tasks rather than like endpoints |
| [Speakeasy Gram tag-based tool filtering](https://www.speakeasy.com/docs/ai-control-plane/mcp-gateway/building-servers/tool-filtering) | A hosted server whose tools are grouped by OpenAPI tags and filtered per install URL | A platform account, curation that lives outside your repository, and a filter by tag rather than by task | You want the pruned server as code you can diff and test |

The filters are the cheapest cut and the bluntest. An operation is in or out, so a document with
four ways to list meters keeps four tools or loses three. Speakeasy's gateway filters by tag at the
URL and edits names and descriptions in a dashboard, which is curation you cannot run in CI. The
facade costs a process and a mapping, and it is the only one of the three that can turn four
endpoints into one tool.

## Rank the cut by tasks, not by traffic

The obvious way to choose ten tools is to keep the ten busiest operations, and the sample does that
first so the replay can measure it. `traffic.json` holds a month of request counts per operation.
The script picks the top ten and marks each one in a copy of the document.

```js title="prune-spec.mjs"
// The generator's include filter. x-mcp: true marks an operation to keep; with
// --default-include false, every operation without the mark is left out of the server.
export function markIncluded(doc, ids) {
  const marked = structuredClone(doc)
  for (const ops of Object.values(marked.paths)) {
    for (const method of METHODS) {
      if (ops[method] && ids.includes(ops[method].operationId)) ops[method]['x-mcp'] = true
    }
  }
  return marked
}
```

```bash
node prune-spec.mjs
```

```text output
the 10 busiest operations over 30 days
  listMeters            48210 requests
  getMeter              30112 requests
  listMeterReadings     22870 requests
  createReading         19930 requests
  listInvoices          15402 requests
  getInvoice            12210 requests
  listSites              9870 requests
  listSiteMeters         8120 requests
  createMeter            6610 requests
  updateMeter            5120 requests
wrote generated/importance.json: x-mcp true on 10 of 41 operations
```

`x-mcp` is the generator's own vendor extension,[^2] read at the root, the path, or the operation,
with the most specific setting winning. The
[README](https://github.com/harsha-iiiv/openapi-mcp-generator) documents the three levels and the
`--default-include` flag that sets the rule for everything unmarked. Generate from the marked copy
with the default flipped to exclude.

```bash
npx openapi-mcp-generator -i generated/importance.json -o generated/importance --default-include false --force 2>&1
```

```text output
Creating project directory structure at: generated/importance
 -> Created generated/importance/src/index.ts
 -> Created generated/importance/package.json
 -> Created generated/importance/tsconfig.json
 -> Created generated/importance/.gitignore
 -> Created generated/importance/.eslintrc.json
 -> Created generated/importance/.prettierrc
 -> Created generated/importance/jest.config.js
 -> Created generated/importance/.env.example
 -> No OAuth2 security schemes found, skipping documentation

---
MCP server project 'meterco-api' successfully generated at: generated/importance
```

The replay in the next section measures what these ten are worth.

## Put ten task-shaped tools in front of the generated server

The facade is a second MCP server. It connects to the generated one as a client, exposes ten tools
of its own, and forwards each call upstream, so the generated code still owns every HTTP request.

```js title="facade/server.mjs"
const GENERATED = fileURLToPath(new URL('../generated/build/index.js', import.meta.url))
const upstream = new Client({ name: 'meterco-facade', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } })
await upstream.connect(new StdioClientTransport({ command: process.execPath, args: [GENERATED], env: process.env, stderr: 'ignore' }))

const forward = (name, args = {}) => upstream.callTool({ name, arguments: args })
```

The merge is an enum. Four list endpoints become one tool whose `state` argument picks the upstream
operation, and two lookup operations become one `find_meter`.

```js title="facade/server.mjs"
  // Four list endpoints become one tool. The enum is the merge.
  tool('list_meters', 'List meters. state narrows the list to installed, retired, or faulty meters.',
    { state: z.enum(['all', 'installed', 'retired', 'faulty']).default('all') },
    ({ state }) => forward({ all: 'listMeters', installed: 'listInstalledMeters', retired: 'listRetiredMeters', faulty: 'listFaultyMeters' }[state]))

  tool('find_meter', 'Find one meter by its id or by its serial number.',
    { by: z.enum(['id', 'serial']), value: pathId },
    ({ by, value }) => (by === 'id' ? forward('getMeter', { meterId: value }) : forward('getMeterBySerial', { serial: value })))
```

`pathId` refuses an empty id, `.`, and `..` for every id the facade forwards. The generated server
escapes `/` and `?` in a path parameter but resolves those three, so `void_invoice` with an id of
`..` would otherwise send `POST /void`.

```bash
node list-tools.mjs facade/server.mjs
```

```text output
10 tools from facade/server.mjs
  list_meters        List meters. state narrows the list to installed, retired, or faulty meters.  {"readOnlyHint":true}
  find_meter         Find one meter by its id or by its serial number.  {"readOnlyHint":true}
  meter_readings     Readings for a meter: recent readings, or monthly totals.  {"readOnlyHint":true}
  record_reading     Record a kWh reading against a meter. Writes data.  {"readOnlyHint":false,"destructiveHint":false}
  list_invoices      List invoices. status narrows the list to open or overdue invoices.  {"readOnlyHint":true}
  get_invoice        Read one invoice by id.  {"readOnlyHint":true}
  void_invoice       Void an invoice, giving a reason. Cannot be undone.  {"readOnlyHint":false,"destructiveHint":true}
  list_sites         List sites.  {"readOnlyHint":true}
  site_meters        List the meters installed at one site.  {"readOnlyHint":true}
  alerts             Open alerts, all of them or only the critical ones.  {"readOnlyHint":true}
```

Ten tools reach eighteen of the forty-one operations. The other twenty-three are gone from the
agent's view: every `/admin` path, both deprecated operations, and the tariffs, users, exports,
and webhooks. So is every way to create, update, replace, or delete a meter or a site, along with
reading one site and acknowledging an alert. Each is a task an agent can no longer do through this
server: the price of the cut, written down. Every tool also
carries the
[annotations](https://modelcontextprotocol.io/specification/2026-07-28/server/tools) the generated
ones lack, so a client can tell `void_invoice` from `get_invoice` without reading prose.[^3]

## Replay the tasks against every server

`tasks.json` holds twelve things agents are asked to do, each with the HTTP requests that fulfil
it and a plan in the facade's vocabulary. On a one-tool-per-operation server the plan follows from
the operations themselves.

```json title="tasks.json"
{
  "id": "t01",
  "goal": "Which meters are retired",
  "steps": [{ "op": "listRetiredMeters", "args": {} }],
  "facade": [{ "tool": "list_meters", "args": { "state": "retired" } }],
  "footprint": ["GET /meters/retired"]
},
```

The replay connects to a server, lists its tools, and runs each plan. A task is complete when the
requests that reached the mock API are exactly the ones the task needs. A plan naming a tool the
server lacks is reported as such, since no other tool can produce that request.

```js title="coverage.mjs"
  for (const task of TASKS) {
    const plan = label === 'facade' ? task.facade : task.steps.map((s) => ({ tool: s.op, args: s.args }))
    const missing = plan.filter((p) => !tools.has(p.tool)).map((p) => p.tool)
    if (missing.length) {
      results.push({ id: task.id, status: `no ${missing.join(', ')}` })
      continue
    }
    mock.seen.length = 0
    for (const p of plan) await client.callTool({ name: p.tool, arguments: p.args })
    const got = [...mock.seen].sort()
    const want = [...task.footprint].sort()
    results.push({ id: task.id, status: JSON.stringify(got) === JSON.stringify(want) ? 'ok' : `wrong requests: ${got.join(' ')}` })
  }
```

```bash
node coverage.mjs
```

```text output
task  goal                                 full                         importance                   facade
t01   Which meters are retired             ok                           no listRetiredMeters         ok
t02   Look up meter SN-40199 by serial     ok                           no getMeterBySerial          ok
t03   Recent readings for mtr_8f2          ok                           ok                           ok
t04   Record 1301 kWh for mtr_8f2          ok                           ok                           ok
t05   List overdue invoices                ok                           no listOverdueInvoices       ok
t06   Void invoice inv_77 as a duplicate   ok                           no voidInvoice               ok
t07   Which meters are at site st_3        ok                           ok                           ok
t08   Open critical alerts                 ok                           no listCriticalAlerts        ok
t09   Read invoice inv_77                  ok                           ok                           ok
t10   Monthly totals for mtr_8f2           ok                           no listMeterReadingsMonthly  ok
t11   List the sites                       ok                           ok                           ok
t12   List all meters, then read mtr_8f2   ok                           ok                           ok
tools exposed                              41                           10                           10
tasks complete                             12/12                        6/12                         12/12
```

Both pruned servers expose ten tools. The one cut by traffic completes six tasks, and the one cut
by tasks completes all twelve, which is the measurement this page exists to make. The six
failures are not obscure. Voiding an invoice, finding a meter by serial, and listing the overdue
invoices are everyday support work that generates little traffic. People do them hundreds of
times a month, while a dashboard lists meters forty-eight thousand times.

## Check it worked

Run the tests. One of them replays all three servers and asserts on the counts.

```bash
node --test prune.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 13151.30795
```

`# pass 6` covers the ten annotated facade tools, a merged tool reaching `GET /meters/retired`
through the generated server, and the filter keeping exactly the ten busiest operations. The fourth
test asserts that completion stays at twelve for the facade and falls for the traffic cut. The
fifth sends ids such as `..` and `inv_77?x=1` and checks the paths the mock received. The sixth
checks that the replay exits 1 when the facade completes fewer tasks than the full server.

## When it goes wrong

The pruned server looks tidy but completes only half of the tasks agents are asked to do. The cut
followed the endpoint list, so it kept the operations dashboards hammer and lost the ones agents
need.

```bash
node list-tools.mjs generated/importance/src/index.ts
```

```text output
10 tools from generated/importance/src/index.ts
  listMeters         List meters
  createMeter        Create a meter
  getMeter           Read one meter
  updateMeter        Update a meter
  listMeterReadings  List recent readings for a meter
  createReading      Record a reading
  listInvoices       List invoices
  getInvoice         Read one invoice
  listSites          List sites
  listSiteMeters     List the meters at a site
```

Nothing on that list is wrong, but half the task list is impossible with it. Start from the tasks.
If you have no task list, the agent's own transcripts are one, and a week of them beats a year of
request counts.

A regeneration renames an upstream tool. The facade forwards to `listRetiredMeters` by name. A
document that renames the operation leaves the facade calling a tool that no longer exists. The
generated server answers with the text `Error: Unknown tool requested` and leaves `isError` unset,
so the agent receives it as an ordinary result instead of meters.

Add a startup check that lists the upstream tools and refuses to serve while any forwarded name is
missing, and run the replay in CI after every regeneration. `coverage.mjs` exits 1 when the facade
completes fewer tasks than the full server, so a lost tool fails that build.

A model picks badly from tools the replay approved. The replay is scripted. It proves the tools can
complete the tasks, not that a model will choose them, and a model chooses on names and
descriptions. Run the same task list through a real agent before and after the cut, which is a
measurement with its own page.

## When not to do this

Do not prune a server whose agents you have not watched. Without a task list, the traffic table is
the only evidence you have, and it measures what people and dashboards call, not what agents
attempt.

Do not cut to a round number. Ten is this page's number because twelve tasks needed ten tools. A
different task list needs a different count, and OpenAI's
[function calling guide](https://developers.openai.com/api/docs/guides/function-calling) puts its
own figure at fewer than twenty, calling it a soft suggestion.

Do not filter by tag and call the result a task list. Tags group operations for documentation,[^4]
and a tag that happens to match a task is luck rather than design.

Do not put a facade in front of a server with eight tools. The second process and the mapping cost
more than they save.

Do not treat the facade as a security boundary. The generated server still exists, anyone who can
start it has all forty-one tools, and the API's own permissions are what limit the damage.

Do not delete operations from the OpenAPI document to prune the server. The document describes the
API. The server describes what agents may use, and the two have different readers.

## Related how-tos

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

- [Compare agent task success rates across model providers](/howto/measure-task-success-across-model-providers)

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Measure the token cost of an agent task against your API](/howto/measure-token-cost-per-agent-task)

- [Generate an MCP server from an OpenAPI document](/howto/generate-an-mcp-server-from-openapi)

## Last verified

Verified 2026-10-21 against Node 22.22.0, openapi-mcp-generator 4.1.0, @modelcontextprotocol/sdk
1.32.1, @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client 2.3.1, Zod 3.25.76 in the
generated servers, and Zod 4.6.7 in `facade/`. Every output block is what the command preceding it
printed. The three servers were run over stdio against a mock API. The task replay is scripted: it
proves which tools can complete each task, not which tools a model chooses. Speakeasy's filtering
was read from its documentation and not run.

[^1]: OpenAI's [function calling guide](https://developers.openai.com/api/docs/guides/function-calling)
says to keep the number of initially available functions small for higher accuracy, and to aim for
fewer than twenty at the start of a turn. It calls that number a soft suggestion. The same page
says to combine functions that are always called in sequence and to use enums to prevent invalid
states. That describes the facade on this page, from a vendor who has never seen it.

[^2]: OpenAPI's extension mechanism is one sentence of design.
[Specification extensions](https://spec.openapis.org/oas/v3.1.0.html#specification-extensions)
are patterned fields that are always prefixed by `x-`, and the specification says nothing about
what goes in them. `x-mcp` is one generator's vocabulary. Another tool reading the same document
steps over it, which is the point of the prefix, and also why a mark in the document is a promise
that only one tool keeps.

[^3]: The protocol's
[schema](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations) gives
the hints the defaults a cautious reader would choose: `readOnlyHint` false, `destructiveHint`
true, `idempotentHint` false, and `openWorldHint` true. A tool that says nothing about itself is
therefore presumed to write, to destroy, to repeat its effects, and to reach the open world. Every
one of the forty-one generated tools arrives under that presumption.

[^4]: The [Operation Object](https://spec.openapis.org/oas/v3.1.0.html#operation-object) offers
`tags` as a list for API documentation control, for logical grouping of operations by resources
or any other qualifier. Filtering a tool server by them, as Speakeasy's gateway does, turns a
documentation aid into an access list, which is more than the field was asked to carry when it
was written.