@data-fair/openapi-mcp
v0.6.0
Published
MCP tools for AI agents from OpenAPI documents annotated with x-agent
Readme
openapi-mcp
Turns an OpenAPI document annotated with a custom x-agent extension into MCP tools for AI agents. Use it as a library to build a tool set from a spec and wire it into any MCP server, or run the bundled standalone binary to serve a spec directly over stdio or HTTP.
- Annotation reference:
docs/x-agent.md— every key of the vocabulary. - Form tools:
docs/editor-tool-groups.md— turn a write operation into eight json-layout editing tools.
Install
npm install @data-fair/openapi-mcpLibrary
import { load } from '@data-fair/openapi-mcp'
import { createMcpServer } from '@data-fair/openapi-mcp/adapters/mcp'
const toolSet = await load('https://example.com/openapi.json', { profiles: ['explore'] })
const server = createMcpServer(toolSet, { name: 'my-api', version: '1.0.0' })
// server.connect(transport) with any @modelcontextprotocol/sdk transportload returns { profiles, instructions, tools, skills } — provider-agnostic tools, each with an
input schema, a description, MCP annotations and an execute(params) that returns
{ text, structuredContent?, resourceLink?, isError? } and never throws. toMcpServer(toolSet, server)
registers them on an existing SDK Server if you already have one.
Standalone server
The package also installs an openapi-mcp binary that reads its configuration from the environment:
| Env var | Required | Default | Description |
| ------------------- | -------- | ------- | -------------------------------------------------------------------------------- |
| INDEX_URL | one of | | URL of a deployment index listing service-level OpenAPI documents |
| OPENAPI_URL | one of | | URL of a single OpenAPI document |
| PROFILES | no | | Comma-separated profile set; the default profile otherwise |
| REFRESH_INTERVAL | no | 300 | Seconds between conditional re-fetches of the index and documents; 0 disables |
| LINT | no | error | error, warn or off — see Annotation lint |
| LOCALE | no | en | Locale used to resolve localized text |
| BASE_URL | no | | Overrides servers[0].url from the document |
| TRANSPORT | no | stdio | stdio or http |
| PORT | no | 8080 | Port to listen on when TRANSPORT=http |
| API_KEY_HEADER | no | | Header name added to every upstream request |
| API_KEY | no | | Value sent in API_KEY_HEADER |
| STRUCTURED_CONTENT| no | false | Set to true to also return structuredContent from tool calls |
| FILE_URL_PREFIXES | no | | Comma-separated URL prefixes of the file store whose presigned URLs file inputs take; unset: file inputs off |
| FILE_MAX_SIZE | no | 104857600 | Largest file a file input accepts, in bytes |
| FILE_TIMEOUT | no | 300 | Seconds to fetch a file input, first request to last byte |
In HTTP mode a request's ?profiles=explore,edit selects the set for that request. HTTP
mode here is single-identity and stateless: it is for development. A deployed server that
serves several callers forwards each caller's identity per call through the adapter's
context hook — that is data-fair/mcp v2, not this binary.
Coding agents behind nhi-proxy
Identity comes from the environment; the binary implements nothing for it:
NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://127.0.0.1:7331 \
NODE_EXTRA_CA_CERTS=~/.config/nhi-proxy/koumoul.com/ca.crt \
INDEX_URL=https://koumoul.com/data-fair/api/v1/agents/index.json PROFILES=explore,edit openapi-mcpAPI_KEY_HEADER + API_KEY are a single-credential convenience for standalone use. Anything richer (OAuth, multiple headers, per-request auth) is done in code with the library's fetch option.
Example MCP client configuration:
{
"mcpServers": {
"my-api": {
"command": "npx",
"args": ["-y", "@data-fair/openapi-mcp"],
"env": { "OPENAPI_URL": "https://example.com/openapi.json" }
}
}
}Composing a deployment
An index lists a deployment's service-level documents; createComposer merges them into
one tool set per profile set, over one document cache:
import { createComposer } from '@data-fair/openapi-mcp'
import { createMcpHttpHandler } from '@data-fair/openapi-mcp/adapters/mcp'
const composer = await createComposer('https://host/data-fair/api/v1/agents/index.json', { locale: 'fr' })
composer.profiles() // every declared profile, expanded, with the index's wording
const c = await composer.compose(['explore', 'edit'])
c.toolSet.tools // each operation once, index order then document order
c.services // [{ id, status: 'ok' | 'skipped' | 'error', tools, reason? }]
setInterval(() => composer.refresh(), 300_000) // conditional GETs; the library runs no timer
const handler = createMcpHttpHandler(composer, { name: 'my-server', version: '1.0.0' }, {
context: (request) => ({ headers: { cookie: request?.headers.get('cookie') ?? '' }, identity: request?.headers.get('cookie') ?? undefined })
})The index is the one document this library dictates:
{
"version": 1,
"services": [{ "id": "data-fair", "openapi": "https://host/data-fair/api/v1/api-docs.json" }],
"profiles": { "full": { "title": "Full", "includes": ["explore", "edit"] } },
"skills": [{ "name": "publishing-workflow", "description": "…", "profiles": ["edit"] }]
}A failing service is excluded and reported; only a bad index throws. Tool names must be
unique across services (namePrefix is how); on a collision the later service is excluded.
A request naming a profile no service declares is an error, not an empty set.
The index's profiles are the deployment's vocabulary. A service declaring a profile the index
does not declare keeps serving, and its status carries a warning
(warnings: ['declares profiles absent from the index: …']) — a typo in a profile name would
otherwise create a profile no consumer can present.
A skill body that cannot be read is reported the same way: warnings: ['skill <name>: HTTP 404 (<url>)'].
A compatibility route composes a subset of services under an overridden prefix, e.g.
composer.compose(['explore'], { services: ['data-fair'], namePrefix: '' }) to serve the
names a caller already has. createMcpHttpHandler also takes a source chosen per request —
createMcpHttpHandler((request) => …, info) — for one handler serving several such routes;
publish change notifications yourself with handler.notify.toolsChanged(), since there is no
single live source to subscribe to.
Identity per call
Tool.execute(params, ctx) takes a CallContext — fetch, headers, signal,
identity. The library never reads a cookie or holds a credential: a server derives the
context from the request it serves, a page passes its own fetch, the binary relies on the
environment. identity partitions json-layout editor sessions per caller.
Skills
Skills follow the Agent Skills disclosure levels. The MCP instructions list each selected skill
by name and description, with the resource to read; bodies never go there. A body is served as
skill://<service-id>/<name>/SKILL.md through resources/read, and through the skills extension
(io.modelcontextprotocol/skills: skills/list, skills/get, with a SHA-256 manifest).
A client that declares the skills extension in its initialize
(capabilities.extensions['io.modelcontextprotocol/skills']) lists skills itself with skills/list,
so its instructions carry the guides only, without the skill entries. A skill that names tools
carries them in its frontmatter as metadata.tools (space-separated), so a host can hide a skill
whose tools it filtered out without reading its body.
A body is inline (body) or linked (href, a markdown file next to the document). load() reads
linked bodies with its fetch; a composer caches them with conditional requests and revalidates
them on refresh(). A body that cannot be read keeps the skill listed: its resource answers the
error, the service status carries a warning, and the skills manifest leaves it out.
Upgrading to 0.4.0
A skill's description is now at most 1024 characters and is never split into paragraphs. Move a
long text to body (or to a file served next to the document, referenced by href) and write a
one- or two-sentence description. The instructions now list skills instead of embedding them,
and golden snapshots gain each skill's digest.
Per-service golden
toolSetSnapshot(toolSet) is the agent-facing surface as plain data. A service's CI loads
its own document with lint: 'error' for every declared profile and diffs the snapshot
against a committed golden, so a change to a tool's name, description or schema is a
reviewable diff in the pull request that caused it.
Editor tool groups
Annotate a write operation with editor and it becomes eight json-layout form tools —
describeState, getData, setData, setFieldValue, getFieldSuggestions,
editArray, saveForm, reloadForm — instead of one opaque body-submitting tool. A
session per record loads the current document, validates every edit, and saves the whole
document when it is valid.
put:
operationId: updateLine
x-agent:
profiles: [write]
name: dataset_line
editor:
schemaOperation: readSchema
schemaParams: { mimeType: application/schema+json }
readOperation: readLine@json-layout/agents and @json-layout/core are optional peers, imported lazily and
needed only when an editor annotation is present. See
docs/editor-tool-groups.md for the annotation, the tool
surface, schema preprocessing and limitations.
Large request bodies
A write operation's request body schema can be far too large to put in a tool definition —
data-fair's dataset body is 28 KB, roughly twice its entire six-tool read set. Annotate the
operation with body: compact and the tool exposes a single body property described by a
compact listing (name, required, type, allowed values, a short description) instead of
merging the schema's properties at top level.
The real schema still validates the body before a request is sent, so a caller that
misreads the listing gets a JSON path and a keyword locally rather than a round trip.
body: flat, the default, keeps the previous behaviour.
Files
Upload operations take a file as a URL, and response: { file: true } operations return one as
an MCP resource_link: file bytes never pass through the model or the MCP protocol. File inputs
are indirect uploads. They are detected from the OpenAPI document (multipart format: binary /
contentMediaType parts, raw text/csv-style bodies) and marked x-mcp-file. The server
fetches only the presigned URLs of the file store named by FILE_URL_PREFIXES (unset: operations
that need a file — a required file part, raw body or list of files — are not offered, and optional
file parts disappear from their tool), without the caller's credentials, and streams them upstream with an exact
Content-Length. Behind a credential-injecting proxy (NODE_USE_ENV_PROXY), keep the file store in
NO_PROXY: see docs/x-agent.md#files.
An optional file part that a tool cannot do without is made required for that tool with the
view key bodyRequired, so the tool disappears with the file store rather than lose its file.
Annotation lint
load checks that a description an annotation authored still agrees with the schema it
describes — a description promising a scalar for a parameter whose schema is an array gets
the agent rejected before any request is sent. Descriptions inherited from the OpenAPI
document are not linted: they belong upstream.
A disagreement refuses the tool set by default, listing every one at once. Pass
lint: 'warn' to print and continue, or lint: 'off' to skip the check. Under warn and off,
an operation whose x-agent this version does not accept is left out (the rest loads): see
Forward compatibility in docs/x-agent.md.
Coding agent skill
A skill that teaches coding agents to write and review x-agent annotations lives in
skills/openapi-mcp. Install it into a project with:
npx skills add data-fair/openapi-mcpContributing
Development commands, the evaluation harness and the release process are in
CONTRIBUTING.md.
License
AGPL-3.0-only
