How-to › Expose your API to agents

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.

Audience
API producer
Level
intermediate
Topic
Build an MCP server
Languages
TypeScript and JavaScript
Verified

Read first: Generate an MCP server from an OpenAPI document

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-written facade in front of the generated serverNear duplicates to merge, arguments to rename, and /admin paths to hide, with the generated server kept as the HTTP layerA second process and a mapping table to maintain, and a regeneration that can rename an upstream tool under youThe cut is a plain subset and no tool needs reshaping
openapi-mcp-generator include and exclude filtersThe cut is a subset of operations, marked in the document and regenerated in one commandNo merging, so four list endpoints stay four tools or go, and a mark the next export of the document can dropAgents need tools shaped like tasks rather than like endpoints
Speakeasy Gram tag-based tool filteringA hosted server whose tools are grouped by OpenAPI tags and filtered per install URLA platform account, curation that lives outside your repository, and a filter by tag rather than by taskYou 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.

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

  1. 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

  2. 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-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. ↩︎ Back to text

  3. The protocol’s schema 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. ↩︎ Back to text

  4. The 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.