The agent does worse with the generated server than it did without one. Forty-one tools arrive in every prompt. Six of them list meters in slightly different ways, and the agent keeps choosing the deprecated one. Cutting tools is the obvious fix, but every tool you cut is a task somebody can no longer finish.
What you get
You will end up with ten tools in front of a generated forty-one, chosen from the tasks agents are asked to do, and a replay proving every task still completes. This is for you if agents face more tools than they can choose between.
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 | 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 | 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 | 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.
// 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
}
node prune-spec.mjs
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 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.
npx openapi-mcp-generator -i generated/importance.json -o generated/importance --default-include false --force 2>&1
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.
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.
// 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.
node list-tools.mjs facade/server.mjs
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 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.
{
"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.
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(' ')}` })
}
node coverage.mjs
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.
node --test prune.test.mjs
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.
node list-tools.mjs generated/importance/src/index.ts
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 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
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.
Footnotes
-
OpenAI’s function calling guide 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. ↩︎ Back to text
-
OpenAPI’s extension mechanism is one sentence of design. Specification extensions are patterned fields that are always prefixed by
x-, and the specification says nothing about what goes in them.x-mcpis 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. ↩︎ Back to text -
The protocol’s schema gives the hints the defaults a cautious reader would choose:
readOnlyHintfalse,destructiveHinttrue,idempotentHintfalse, andopenWorldHinttrue. 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. ↩︎ Back to text -
The Operation Object offers
tagsas 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. ↩︎ Back to text