@ttoss/http-server-mcp-openapi
Generate Model Context Protocol (MCP) tools
from an OpenAPI specification, ready for
@ttoss/http-server-mcp's
registerTools.
Point it at your existing OpenAPI document and every operation becomes an MCP tool whose handler resolves the incoming arguments into an HTTP request against your REST API. The OpenAPI spec stays the single source of truth for both your REST surface and your MCP tool surface.
Installation
pnpm add @ttoss/http-server-mcp-openapi @ttoss/http-server-mcp
Quick Start
import { App, bodyParser } from '@ttoss/http-server';
import {
createMcpRouter,
McpServer,
registerTools,
} from '@ttoss/http-server-mcp';
import { openApiToTools } from '@ttoss/http-server-mcp-openapi';
import openApiDocument from './openapi.json' with { type: 'json' };
const server = new McpServer({ name: 'my-api', version: '1.0.0' });
const tools = openApiToTools({
spec: openApiDocument,
// You own how the request is executed — base URL, auth, fetch impl.
// `headers` is what createMcpRouter's `getApiHeaders` produced for this
// MCP request — typically the caller's credentials.
callApi: async ({ method, url, body, headers }) => {
const res = await fetch(`https://api.example.com${url}`, {
method,
headers: { ...headers, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return undefined;
return res.json();
},
});
registerTools({ server, tools });
const app = new App();
app.use(bodyParser());
app.use(createMcpRouter(server).routes());
app.listen(3000);
How Operations Map to Tools
Each OpenAPI operation with an operationId and a supported HTTP method
(GET, POST, PUT, PATCH, DELETE) becomes one tool:
| OpenAPI | MCP tool |
|---|---|
operationId: listAgents | tool name list-agents (kebab-case) |
| path/query/body params | a single inputSchema object |
$ref, oneOf, anyOf | dereferenced and merged into a flat schema |
| snake_case names | camelCase tool inputs, mapped back on call |
operation description | tool description, newlines flattened |
operation tags | tool tags, for the deferred search filter |
Path params are always required strings, carrying their own description; a
call missing one is refused before callApi runs, whatever
validateArguments says. Query and body params carry their
declared type and required flag. Array params keep their items schema. A
body property declared as a single-entry allOf (usually allOf: [{ $ref }]
beside its own description) takes type, nullable and items from the
referenced schema; a multi-entry allOf is forwarded verbatim, and a property
with no declared type is advertised untyped so it accepts any value.
OpenAPI's nullable becomes JSON Schema at every depth — inside items,
properties, additionalProperties and oneOf / anyOf / allOf
alternatives: nullable: true adds 'null' to the type, and the keyword is
dropped.
Parameters declared at the path-item level (shared by every operation on a
path) are merged into each operation; an operation-level parameter overrides a
path-item one with the same name+in.
By default tool arguments are camelCase (agentId, projectId) and the
generated request path, query string, and body use the spec's original names.
Set argumentNames: 'verbatim' to use the spec's names as the arguments too,
so the MCP contract matches the REST contract exactly.
Query params honour their declared style and explode. form (the default)
repeats array values, spaceDelimited/pipeDelimited join them, and
deepObject emits bracketed keys — including nested objects and arrays, so
{ documentId: { $eq: 'doc_1' } } becomes filters[documentId][$eq]=doc_1.
openApiToTools
| Field | Description |
|---|---|
spec | One OpenAPI document, or an array of them (tools are flattened). |
callApi | Runs the resolved { method, url, body, tool, headers } request and returns the raw data. |
toText? | Serialises the raw data into the tool's text payload. Defaults to pretty JSON; strings pass through verbatim. |
serverParameters? | Supplies server-managed path/query parameter values. See Server-managed values. |
toStructuredContent? | ({ data, tool }) → the result's structuredContent, sent beside the text. undefined keeps it text-only. |
toolMeta? | ({ tool }) → the tool's _meta on tools/list. See MCP Apps views. |
validateArguments? | Enforce each generated inputSchema before callApi runs. Default false; see the caveat below. |
options? | See Options. |
The default toText answers NO_CONTENT_TEXT (Succeeded. The operation returned no content.) when callApi resolves undefined or '', so a 204
reaches the client as a success.
Returns one OpenApiTool per operation: a Tool with the operation's tags,
a summary of METHOD /path — description, and the definition it was
derived from. Enable validateArguments only when the spec describes every
value the API accepts: a generated schema is easily narrower than the API, and
validating against it rejects calls the API would have taken.
Deferring the tools of a large API
Every tool definition ships to the model on every turn, so a large API
overflows the client's context. Defer
the same tools behind search / describe / call, for example on a second
endpoint next to the full one:
registerTools({ server: fullServer, tools });
registerTools({ server: deferredServer, tools, defer: true });
search filters by the operations' tags and shows each tool's route; tools
linked to an MCP Apps view stay registered directly.
Gating generated tools
A generated tool is an ordinary Tool, so a gate wraps it like any other.
definition carries what the operation declares, such as a custom extension
naming the scope:
const gate = createToolGate({ gates: [subscriptionGate] });
registerTools({
server,
tools: tools.map((tool) => {
return gate({
tool,
requiredScope: tool.definition.extensions['x-required-scope'] as string,
});
}),
});
MCP Apps views
A generated tool links to a view through toolMeta, and the view reads the
result from structuredContent:
import { registerAppResource, registerTools } from '@ttoss/http-server-mcp';
const agentCard = registerAppResource({
server,
name: 'agent_card',
uri: 'ui://agents/card',
html: agentCardHtml,
});
const tools = openApiToTools({
spec,
callApi,
toolMeta: ({ tool }) => {
return tool.name === 'get-agent' ? agentCard.toolMeta() : undefined;
},
toStructuredContent: ({ data, tool }) => {
return tool.name === 'get-agent'
? (data as Record<string, unknown>)
: undefined;
},
});
registerTools({ server, tools });
Keep the text payload: a host without MCP Apps support renders only that.
Calling the API In-Process
When the REST API runs in the same process as the MCP server,
createInProcessCallApi dispatches each tool call through the app's own
middleware chain with no socket (see dispatchInProcess in
@ttoss/http-server), so
validation, authorization and error handling exist once, in the routes. The MCP
request's headers — what createMcpRouter's getApiHeaders produced — are
forwarded onto the dispatched request.
import {
createInProcessCallApi,
openApiToTools,
} from '@ttoss/http-server-mcp-openapi';
const tools = openApiToTools({
spec,
callApi: createInProcessCallApi({
app, // or () => app, when the app is built after the tools
headers: () => ({ 'x-via': 'mcp' }), // optional, added to every call
}),
});
registerTools({ server, tools });
const router = createMcpRouter(server, {
getApiHeaders: (ctx) => ({ authorization: ctx.headers.authorization ?? '' }),
});
app.use(router.routes());
A 2xx answers its body. Anything else throws, so the client sees a tool error
rather than an error body rendered as a result: the message is read from a
string body, { error: '…' }, { error: { code, message } } (as
code: message) or { message: '…' } (exported as errorMessageOf), falling
back to HTTP <status>. Pass toError to build the error yourself.
openApiToToolDefinitions
Use the lower-level function when you want the tool definitions without handlers — to inspect, filter, or wire handlers yourself:
import { openApiToToolDefinitions } from '@ttoss/http-server-mcp-openapi';
const tools = openApiToToolDefinitions({ spec: openApiDocument });
for (const tool of tools) {
// tool.name, tool.method, tool.inputSchema, tool.extensions, ...
const url = tool.path(args) + (tool.query ? tool.query(args) : '');
const body = tool.body?.(args);
}
Each ToolDefinition exposes name, description, inputSchema, method,
pathTemplate, operationId, tags, the path/query/body builders,
acceptedBodyFields, extensions, and serverManagedParameters.
Options
openApiToTools({
spec,
callApi,
options: {
excludeExtension: 'x-mcp-exclude', // operations flagged truthy are skipped
serverManagedExtension: 'x-mcp-server-managed', // or several: ['x-a', 'x-b']
argumentNames: 'camelCase', // or 'verbatim'
documents: { './tags.yaml': tagsDocument }, // targets of cross-file $refs
schemaDetail: 'full', // or 'compact' (default)
describe: ({ operation, method, pathTemplate }) => operation.summary ?? '',
},
});
-
excludeExtension(defaultx-mcp-exclude) — an operation with this extension set truthy is omitted from the tool surface. -
serverManagedExtension(defaultx-mcp-server-managed) — one name or an array of names; see Server-managed values. -
argumentNames(defaultcamelCase) —verbatimkeeps the spec's parameter and property names as tool argument names. -
documents— sibling documents for$refs with a file part, keyed by that part as the spec writes it (a leading./is optional). In./tags.yaml#/components/schemas/Tagthe key is./tags.yaml; refs inside a sibling resolve against that sibling. A ref to a file missing from the map resolves to an empty schema, which accepts any value. -
schemaDetail(defaultcompact) — see Schema detail. -
describe— builds each tool's description from{ operation, method, pathTemplate }(method uppercase). The default is the operation'sdescriptionflattened to one line.
Schema detail
compact gives each top-level argument its type, items and description,
with descriptions flattened to one line. It is the smallest surface, and the
model learns nothing about which values are allowed.
full gives each argument its whole schema: enum, format, pattern,
minimum/maximum, default, nested properties and required, oneOf,
additionalProperties, and descriptions verbatim. It changes only what JSON
Schema cannot express:
allOfis merged — the properties andrequiredof an object composition, or a single referenced scalar with the wrapper's owndescriptionwinning;nullable: trueadds'null'to thetype, andnullto anenum;- OpenAPI-only keywords (
example,discriminator,xml,externalDocs) andx-extensions are dropped; - a path or query parameter's own
descriptionwins over its schema's.
properties is always present in full, even when empty. The same
transformation is exported as toToolSchema, for a schema you derive yourself.
Server-managed values
A value flagged with serverManagedExtension is never offered to the model:
- A request-body property is hidden from
inputSchemaand, unless pinned, never sent (the API sets it itself). It still appears inacceptedBodyFields. - A path or query parameter is hidden from
inputSchemaand listed intool.serverManagedParameters.openApiToToolsdiscards anything the model sent for it and fills it fromserverParameters, keyed by spec name:
openApiToTools({
spec,
callApi,
serverParameters: ({ tool, headers }) => ({
project_id: projectIdFromToken(headers.Authorization),
}),
});
With openApiToToolDefinitions, set each entry's argName in the args before
calling tool.path / tool.query.
A string extension value pins the parameter: wait declared with
x-mcp-server-managed: 'true' is always sent as wait=true. tool.path and
tool.query apply pinned values themselves, over anything in the args or
serverParameters, and the entry in serverManagedParameters carries it as
value.
A pinned request-body property is sent by tool.body, over anything in the
args, as the JSON type its schema declares: 'true' on a boolean is true,
'3' on an integer is 3. A pin its type cannot hold ('yes' on a
boolean, any pin on an object, array or untyped property) throws while
the tools are generated, naming the operation and property.
Reading custom extensions
Every x- prefixed extension on an operation is forwarded verbatim on
tool.extensions, so you can attach and read your own metadata without this
package needing to know about it:
const tools = openApiToToolDefinitions({ spec: openApiDocument });
const iamAction = tools[0].extensions['x-iam-action'];
Migrations
Breaking changes and what they require of consumers are listed in MIGRATIONS.md, newest first.
Related Packages
- @ttoss/http-server-mcp - MCP server integration for @ttoss/http-server
- @ttoss/http-server - HTTP server foundation
- @modelcontextprotocol/sdk - MCP SDK