openapi-swagger-mcp
by nhan7777
README.md
# openapi-swagger-mcp
A local MCP server that lets an AI agent read Swagger 2.0, OpenAPI 3.0 and OpenAPI 3.1 specs, find operations, inspect their contracts and send real requests to the dev/UAT services you configure.
You describe your APIs and how to authenticate in one YAML or JSON file. The agent works through **ten fixed tools**, so the tool count does not grow with the number of endpoints.
## Before you start
You need:
- **Node.js 22.19 or newer** and npm.
- An API you can reach, and its Swagger/OpenAPI spec (a URL or a file).
- An MCP client that can launch a local stdio server.
> **Use this only against dev/UAT services.** The tools send real POST, PUT, PATCH and DELETE requests. There is no rollback and no server-side write gate. A confirmation prompt in your MCP client is the client's feature, not something this server enforces.
>
> **Think about where your data goes.** Tool arguments and results can be stored in your AI provider's conversation history. Responses are not scrubbed of tokens or personal data. Read secrets from environment variables or files instead of pasting them into the conversation.
## Quick start
### 1. Create a config file
Save this as `config.dev.yaml`:
```yaml
services:
orders:
baseUrl: http://localhost:8080/api/v1
spec: http://localhost:8080/v3/api-docs
```
- `orders` is the service name the agent uses in tool calls.
- `baseUrl` is where requests are really sent, **including any prefix** such as `/api/v1`.
- `spec` is the Swagger/OpenAPI URL, or a file path such as `./openapi.json`.
- With no `auth` block, requests are anonymous.
The URLs above are examples. They only work if your own API is running there, so replace them with your dev/UAT addresses.
### 2. Check the config
```bash
npx -y @nhan7777/openapi-swagger-mcp@0.1.0 --config /absolute/path/config.dev.yaml --check
```
`ok: true` means the static checks passed. It does **not** mean the remote spec was fetched or any endpoint was verified: `--check` makes no network calls, does not log in, and does not create the artifact directory. See [Troubleshooting](#troubleshooting) for the report format and exit codes.
### 3. Add the server to your MCP client
For clients that use an `mcpServers` configuration, add this to the **client's MCP config file** (not to the YAML file above):
```json
{
"mcpServers": {
"api-dev": {
"command": "npx",
"args": [
"-y",
"@nhan7777/openapi-swagger-mcp@0.1.0",
"--config",
"/absolute/path/config.dev.yaml"
]
}
}
}
```
The file name and location depend on your client. If your client has a form for command, arguments and environment variables, enter the same values there. Not every client uses the `mcpServers` format, so adapt the snippet if yours differs. Use absolute paths so the server does not depend on the directory the client starts in.
This server uses **stdio**: the client starts the process and talks to it over stdin/stdout. There is no web UI and no HTTP port. stdout is reserved for MCP traffic and logs go to stderr. Do not add `--check` to the client entry, because that mode exits after checking.
### 4. Try it
Once the client is connected, ask:
> List the configured APIs. Find the operation that returns order details in the orders service, read its contract, and propose a request. Do not send anything until I give you an order ID.
## Configure your APIs
A fuller example with two services:
```yaml
# yaml-language-server: $schema=/absolute/path/to/config.schema.json
version: 1 # optional
services:
orders:
baseUrl: https://orders.dev.example.test/api/v1
spec: https://orders.dev.example.test/v3/api-docs
auth:
bearer: { token: { fromEnv: ORDERS_TOKEN } }
catalog:
baseUrl: http://localhost:8081
spec: ./specs/catalog.yaml
```
YAML (`.yaml`, `.yml`) and JSON are both supported. For JSON, add `"$schema": "/absolute/path/to/config.schema.json"` so your editor can suggest keys. See the [complete example config](examples/config.example.yaml) and the [JSON Schema](config.schema.json).
Key rules:
- Relative paths in the config resolve from the **directory that contains the config file**, not from where you run the command.
- **The URL comes from `baseUrl`.** The operation `GET /orders/{id}` with `baseUrl: https://host/api/v1` calls `https://host/api/v1/orders/{id}`. The server ignores `servers`, `host` and `basePath` in the spec. `baseUrl` must not contain a username, password, query or fragment.
- **One environment per process.** For dev and UAT, create two config files and two MCP entries (for example `api-dev` and `api-uat`). Each process has its own credentials, cookies and artifacts. There is no `--env` flag and no tool for switching environments.
- A config change needs a restart of the MCP process. `refresh_specs` only reloads specs.
### Pass a token from the environment
```yaml
services:
orders:
baseUrl: https://orders.dev.example.test/api/v1
spec: https://orders.dev.example.test/v3/api-docs
auth:
bearer: { token: { fromEnv: ORDERS_TOKEN } }
```
The server reads `ORDERS_TOKEN` from **its own process environment**. That is not the same as the environment of the shell where you tested the command: set it in the `env` field of the client entry, or wherever your client launches the process.
```json
{
"mcpServers": {
"api-dev": {
"command": "npx",
"args": ["-y", "@nhan7777/openapi-swagger-mcp@0.1.0", "--config", "/absolute/path/config.dev.yaml"],
"env": { "ORDERS_TOKEN": "replace-with-your-dev-token" }
}
}
}
```
The server does not load `.env` files. Changing the environment of another process does not change the environment of a running MCP server; restart it with the new values.
### Other ways to install
The `npx` commands above download the pinned version on first use. Alternatives:
```bash
# Global install; the command is then `openapi-swagger-mcp`
npm install -g @nhan7777/openapi-swagger-mcp@0.1.0
# From a source checkout (see Development)
npm ci && npm run build
node /absolute/path/to/openapi-swagger-mcp/dist/cli.js --config /absolute/path/config.dev.yaml
```
Do not install the unscoped npm package `openapi-swagger-mcp`. It belongs to someone else and is a different project. This package is `@nhan7777/openapi-swagger-mcp`.
## Discover and call an operation
### The normal flow
| Step | Tool | Example arguments |
|---|---|---|
| See services and revisions | `list_apis` | `{}` |
| Find an operation | `search_operations` | `{"service":"orders","query":"order detail","limit":5}` |
| Read its contract | `get_operation_schema` | `{"service":"orders","operation":"GET /orders/{id}","revision":"<revision-you-just-received>"}` |
| Send the request | `invoke_operation` | See below |
A complete `invoke_operation` call:
```json
{
"service": "orders",
"operation": "GET /orders/{id}",
"revision": "<revision-you-just-received>",
"input": {
"path": { "id": "demo-123" },
"query": { "includeItems": true },
"headers": { "X-Request-ID": "demo-request" }
}
}
```
`operation` is the key returned by search, usually `METHOD /path`. It is not the `operationId`. `revision` is the spec revision, which stops you calling an operation based on an outdated contract. When the spec changes, call `refresh_specs` with `{"service":"orders"}`, then search or read the contract again to get the new revision. A failed refresh keeps the last valid snapshot.
`search_operations` accepts `service`, `method`, `tag`, `includeDeprecated`, `limit` and `cursor`. Pass `nextCursor` with the same query to get the next page. Changing the query or the revision invalidates an old cursor.
### Request bodies
The snippets below are **fragments** that go inside `input`. They are not complete tool calls. A request has at most one body type.
Note the difference from auth recipes: `invoke_operation.input.body` uses the `json.value` and `form.fields` wrappers, while a recipe `body` uses plain `json` and `form` (see [Login and refresh](#login-and-refresh)).
JSON:
```json
{ "body": { "json": { "value": { "name": "demo", "quantity": 2 } } } }
```
Form (`application/x-www-form-urlencoded`):
```json
{ "body": { "form": { "fields": { "name": "demo", "tags": ["a", "b"] } } } }
```
You can set `contentType` inside `json` or `form` to pick one of the media types the operation declares.
For multipart and binary uploads, first allow directories in the config and create them:
```yaml
files:
uploadRoots: [./fixtures]
exportRoots: [./exports]
```
Then pass absolute paths in the tool call.
Multipart:
```json
{
"body": {
"multipart": {
"fields": { "description": "demo" },
"files": [
{ "field": "document", "path": "/absolute/path/fixtures/demo.pdf", "filename": "demo.pdf", "contentType": "application/pdf" }
]
}
}
}
```
Binary:
```json
{ "body": { "binary": { "file": "/absolute/path/fixtures/demo.bin", "contentType": "application/octet-stream" } } }
```
The multipart `files` array may repeat the same `field`. Files outside `uploadRoots`, symlinks that escape a root, and protected files are refused. The media type must match what the operation declares, so read the contract before sending.
### Choosing auth, and negative testing
`invoke_operation.auth` has four modes:
| Mode | Behavior |
|---|---|
| `{"mode":"default"}` or no `auth` | Use the service's default auth, or anonymous if it has none |
| `{"mode":"profile","profileId":"qa"}` | Use a named profile the service allows |
| `{"mode":"session","sessionId":"<id>"}` | Use an interactive session bound to the same service |
| `{"mode":"anonymous"}` | Inherit no credentials or cookies. Data you pass explicitly in `input` is still sent |
To test a wrong token, pass your own `input.headers.Authorization`. It overrides the matching credential for that request only, leaves managed credentials untouched, and does not trigger an automatic refresh.
The server **serializes what you give it and does not fix your data**. It adds no defaults, does no type coercion, and does not strip unknown fields or `null`. Omitting the body is different from sending `{"json":{"value":null}}`, which sends the bytes `null`. HTTP 4xx/5xx responses are real results and are not turned into "tool failed validation" errors.
Requests that cannot be sent safely are blocked locally: a missing path parameter, a path escape, CR/LF in a header, a media type the operation does not declare, or an attempt to override headers the transport manages.
### Reading the result
- `sent: false`: the request was not sent. Look at `error.code` and `error.message`.
- `http.status` and `http.headers`: the actual HTTP outcome.
- `bodyView`: the part of the response meant for the agent to read.
- `diagnostics.request` and `diagnostics.response`: spec-based checks, for diagnosis only. They do not replace the HTTP outcome. States are `valid`, `invalid`, `not_available` and `error`.
- `bodyComplete: false`: the body is partial because of a limit or a stream error. Do not conclude the whole response was valid.
- `authRecovery`: the state of a background refresh after a 401. It does not mean the request was replayed.
- Network, TLS and timeout failures do not produce a fake HTTP status. `error.mayHaveBeenSent` says whether the request may already have left the machine. Be careful about retrying write operations.
Redirects are not followed by default. With `followRedirects: true`, only same-origin redirects are followed, within a limit: 303, and POST after 301/302, become GET; 307/308 keep the method and body. Credentials are never forwarded to another origin.
## Authentication
Pick the approach that matches your API:
| You have... | Use |
|---|---|
| A ready bearer token, basic credentials, API key or cookies | [Static credentials](#static-credentials) |
| A login or refresh endpoint to call | [Login and refresh](#login-and-refresh) |
| Several accounts, or credentials shared by several services | [Multiple accounts and shared profiles](#multiple-accounts-and-shared-profiles) |
| OTP, SSO, or a token you receive interactively | [OTP, SSO and interactive sessions](#otp-sso-and-interactive-sessions) |
### Static credentials
**Bearer token**
```yaml
auth:
bearer:
token: { fromEnv: ORDERS_TOKEN }
```
The server adds `Authorization: Bearer <token>`. The source value holds only the token, without the `Bearer ` prefix.
**Basic authentication**
```yaml
auth:
basic:
username: tester
password: { fromEnv: API_PASSWORD }
```
Username and password are read independently and Basic-encoded. Both can also come from a file or `fromCredential`. Only a Basic literal may be an empty string.
**API key in a header, query string or cookie**
```yaml
auth:
apiKey:
header: X-API-Key
value: { fromEnv: API_KEY }
```
Replace `header` with `query` or `cookie` if your API needs that. Each entry picks **one** destination. You can also use a list and combine methods:
```yaml
auth:
bearer: { token: { fromEnv: ACCESS_TOKEN } }
apiKey:
- { header: X-CSRF-Token, value: { fromEnv: CSRF_TOKEN } }
- { query: tenant, value: demo }
cookies: true
```
Bearer, API key and the cookie jar can be combined. Bearer and basic cannot, because both use `Authorization`. Header names that collide case-insensitively are rejected. Query and cookie names are case-sensitive.
**Cookie jar**
`auth: { cookies: true }` enables the auth's own cookie jar. It is not a raw `Cookie` string. It is normally paired with a login recipe that has `capture.cookies: true` (see below). A cookie-only auth is not considered logged in while the jar is empty. To import cookies you already have, use a [session](#otp-sso-and-interactive-sessions).
### Secret sources
The same syntax works for `token`, `value`, `username`, `password` and the initial values in `credentials`:
| Syntax | Meaning |
|---|---|
| `{ fromEnv: API_TOKEN }` | Read an environment variable of the MCP process |
| `{ fromFile: ./secrets.json, pointer: /orders/token }` | Read a field from a JSON file |
| `{ fromFile: ./token.json }` | Read the JSON root. The file must contain a JSON value such as `"demo-token"`, not plain text |
| `demo-token` | A literal. Produces a warning based on the field name; the value is never logged |
| `{ fromCredential: accessToken }` | Use a named value from the current auth, usually captured by a recipe |
`fromCredential` cannot be used in initial `credentials`, in catalog `credentialSources`, or in spec-fetch auth. Env and file values for a binding must be strings; objects and numbers are not silently converted. A missing source never silently falls back to anonymous. Secret files are protected from upload and export, but you are responsible for their permissions, and you should not commit them.
### Login and refresh
Use a recipe when you must call an API to obtain credentials. A username and password are not required: the body can be a grant code, client credentials, or whatever your API's contract needs.
Three parts have different roles:
- `auth`: **what to send** on business requests.
- `credentials`: initial named values. They are not sent unless referenced.
- `authentication` / `refresh`: call **one operation**, then capture the result.
**Example: grant code plus refresh token**
```yaml
services:
accounts:
baseUrl: https://accounts.dev.example.test
spec: https://accounts.dev.example.test/v3/api-docs
auth:
bearer: { token: { fromCredential: accessToken } }
apiKey: { header: X-CSRF-Token, value: { fromCredential: csrf } }
cookies: true
credentials:
grantCode: { fromEnv: GRANT_CODE }
authentication:
request:
operation: POST /session/exchange
body:
json:
grantCode: { fromCredential: grantCode }
capture:
credentials:
accessToken: { bodyPointer: /session/accessToken }
refreshToken: { bodyPointer: /session/refreshToken }
csrf: { header: X-CSRF-Token }
cookies: true
refresh:
request:
operation: POST /session/refresh
body:
form:
refresh_token: { fromCredential: refreshToken }
capture:
credentials:
accessToken: { bodyPointer: /access_token }
refreshToken: { bodyPointer: /refresh_token }
```
Both operations must exist in the spec. A JSON Pointer such as `/session/accessToken` reads `response.session.accessToken`. The refresh token stays in the credential store. In this example it is sent only by the refresh recipe, never on business requests.
**How to use it.** The tool name comes first, followed by its JSON arguments. These are not shell commands.
1. `authenticate` with `{"service":"accounts"}`.
2. `get_auth_status` with `{"service":"accounts"}` until it stops reporting `recovering`.
3. When the state is `ready`, call the API normally.
4. To refresh manually: `authenticate` with `{"service":"accounts","action":"refresh"}`.
You can also ask the agent: "Authenticate the accounts service with the configured recipe, wait until it finishes, then report the status. Do not show any credentials."
**Rules to remember**
- `authenticate` runs only `authentication`. Without a recipe it reloads static sources. It never logs in automatically when a business call lacks credentials.
- `action: refresh` runs only `refresh`. Without a refresh recipe it fails locally with `AUTH_REFRESH_UNAVAILABLE`.
- A **401** can start a background refresh, but **the original request is never replayed automatically**. The caller still receives the 401 plus `authRecovery`. After the refresh finishes, you or the agent decide whether to call again.
- A **403**, an anonymous request, a session, or a request with an `Authorization` override does not trigger a refresh.
- While a recipe runs, new business requests fail with `AUTH_RECOVERING`. Calling the same action again joins the running one; a different action fails with `AUTH_BUSY`.
- A failed refresh does not retry in a loop. Request a retry explicitly with `authenticate` and the right action.
- Captured credentials, cookies and binding updates are applied together. If a required capture is missing, nothing is applied partially. A credential not named in the capture keeps its old value, so to rotate a refresh token you must capture it again.
- Header captures in managed recipes keep the header content as-is, including `Bearer `. A scalar capture accepts a non-empty string or a finite number; numbers become strings.
- A recipe accepts HTTP 200-299 by default. Set `successStatuses: [200, 201]` to change that. Recipes do not follow redirects, do not replay, and do not run prerequisite recipes.
**Recipe requests.** `request` supports `service`, `operation`, `path`, `query`, `headers`, `cookies`, `body` and `auth`.
- `service` defaults to the service that owns the recipe. A recipe in a named profile must set it explicitly.
- `request.auth` defaults to anonymous and does **not inherit** the owner's bearer or cookies. To send them, declare the auth methods explicitly. `cookies: true` uses the current jar.
- Templates are substituted in payloads: `{fromEnv}`, `{fromFile,pointer?}`, `{fromCredential}`. Wrap a value as `{literal: {fromEnv: "text"}}` to send that object unchanged.
- A `fromCredential` name must be declared in initial credentials or in a capture. Authentication can only use credentials with an initial definition, not its own future captures. Refresh can use what authentication captured.
- A capture needs non-empty credentials or `cookies: true`. Capturing cookies requires the owner to have `auth.cookies: true`.
- An authentication capture starts a fresh cookie jar. A refresh keeps and updates the existing jar.
A recipe body takes **one** of these forms. They are alternatives, so do not paste all four into one recipe:
```yaml
# JSON; you can add contentType next to json
body: { json: { name: demo } }
# application/x-www-form-urlencoded, for example client credentials
body:
form:
grant_type: client_credentials
client_id: { fromEnv: CLIENT_ID }
client_secret: { fromEnv: CLIENT_SECRET }
# Multipart; files must be under uploadRoots
body:
multipart:
fields: { note: demo }
files: [{ field: document, path: ./fixtures/demo.pdf }]
# Binary
body: { binary: { file: ./fixtures/demo.bin, contentType: application/octet-stream } }
```
File paths in recipes are fixed configuration. They cannot come from credential substitution.
### Multiple accounts and shared profiles
```yaml
profiles:
qa:
auth: { bearer: { token: { fromEnv: QA_TOKEN } } }
admin:
auth: { bearer: { token: { fromEnv: ADMIN_TOKEN } } }
services:
orders:
baseUrl: https://orders.dev.example.test
spec: ./orders.json
auth:
profiles: [qa, admin]
defaultProfile: qa
reports:
baseUrl: https://reports.dev.example.test
spec: ./reports.json
auth: { profile: qa }
```
- A call to `orders` with no auth choice uses `qa`. To use `admin`, pass `"auth":{"mode":"profile","profileId":"admin"}` to `invoke_operation`.
- If a service lists `profiles` but has no `defaultProfile`, requests are anonymous by default. The first profile is never picked for you. Auth tools aimed at that service ask you to name a profile.
- To control a profile directly, call `authenticate` or `get_auth_status` with `{"profile":"qa"}`. Do not pass both `service` and `profile`.
- A profile is usable only on services that reference it. Two services using `qa` share the same credentials, cookie jar and refresh task. Cookies are still scoped by domain and path.
- A named profile can have its own `credentials`, `authentication` and `refresh`. For recipes, set `request.service` explicitly; it may point to a separate identity service. That only lets the recipe call that operation. It does not give the identity service your business credentials.
- A service that references a profile must not also have its own `credentials`, `authentication` or `refresh`. Put them in the profile.
### OTP, SSO and interactive sessions
Use a session when an agent and a person must work through several steps: request an OTP, enter it, receive a token or cookie, then call the API. The server never requests an OTP, polls, opens a browser or solves a CAPTCHA by itself. A session is bound to one service.
**Capture from a response**
1. Call the operation that requests the OTP **once, on explicit request**, with `auth: {mode: anonymous}`.
2. The person provides the OTP. Call the verify operation with `invoke_operation`:
```json
{
"service": "accounts",
"operation": "POST /otp/verify",
"revision": "<revision>",
"auth": { "mode": "anonymous" },
"input": { "body": { "json": { "value": { "code": "<OTP-provided-by-the-user>" } } } },
"capture": {
"tokens": [
{ "id": "access", "type": "bearer", "from": "body", "pointer": "/token" }
],
"cookies": true
}
}
```
3. Read `capture.sessionId` when `capture.state` is `captured`.
4. Call business operations with `auth: {mode: session, sessionId: ...}`.
Enable `capture.cookies` only if the response really sets cookies. To capture a token from a header, use `{"id":"access","type":"bearer","from":"header","header":"Authorization"}`. Unlike managed recipes, a session's bearer-header capture strips the `Bearer ` prefix. For an API key, use `type: apiKey` with `in: header|query|cookie` and `name`.
A capture applies only to 200-299 responses by default; set `successStatuses` to change that. A failed capture never updates a session partially. To update an existing session, add `capture.sessionId`.
**Import credentials you already have**
Prefer declaring a catalog in the config:
```yaml
credentialSources:
qaToken: { fromEnv: QA_SESSION_TOKEN }
```
Then call `manage_session`:
```json
{
"action": "create",
"service": "accounts",
"credentialSourceIds": [{ "sourceId": "qaToken", "type": "bearer" }]
}
```
Only ids declared in top-level `credentialSources` can be imported. Inline secrets in a service or profile, and captured refresh tokens, do not appear in the catalog automatically.
If you must import values directly (they may end up in AI conversation history):
```json
{
"action": "create",
"service": "accounts",
"credentials": {
"components": [{ "id": "access", "type": "bearer", "value": "<token>" }],
"cookies": [{ "name": "sid", "value": "<cookie-value>" }]
}
}
```
Inspect with `{"action":"inspect","sessionId":"<id>"}`, or omit `sessionId` to list sessions. Delete with `{"action":"delete","sessionId":"<id>"}`. Inspect returns metadata only, never secrets.
Sessions are bound to one service, do not refresh themselves, do not change managed profiles, and are never promoted to profiles. They live in memory and disappear when the process ends.
## Large responses and files
The server stores responses as artifacts and returns a bounded view, so long files and base64 strings do not flood the conversation.
- `bodyTransformed: true`: the view was shortened and is not the full response.
- `omissions`: which fields were shortened, why, their JSON Pointer and the artifact id.
- `rawBodyArtifact`: the response entity bytes after HTTP content decoding, with metadata and a SHA-256.
### Read part of an artifact
Call `read_artifact` with a pointer:
```json
{ "artifactId": "<id>", "pointer": "/data/status" }
```
Or page through it:
```json
{ "artifactId": "<id>", "offset": 0, "limit": 4096 }
```
Use `nextOffset` from the result to continue. A pointer to a string returns text, and a pointer to an object or array returns JSON. Reading a binary artifact returns metadata only; use export to get the file. Pointers work on the parsed JSON value (number precision is limited); exporting `original` keeps the exact bytes.
### Export a file
The destination must be under `files.exportRoots`. Call `export_artifact`:
```json
{
"artifactId": "<id>",
"pointer": "/data/imageBase64",
"destination": "/absolute/path/exports/image.png",
"representation": "decoded"
}
```
| Representation | Result |
|---|---|
| `original` | The artifact's original bytes |
| `text` | The text of the artifact, or of the field at the pointer |
| `decoded` | Strict base64 decoding of the string |
Export never overwrites an existing file and never follows a symlink out of a root. File names from `Content-Disposition` are not trusted. Artifacts exist only for the lifetime of the process and are removed on normal shutdown; exported files stay.
To mark a base64 field explicitly in the config:
```yaml
encodedFields:
- service: orders
pointer: /data/*/thumbnail
encoding: base64
mediaType: image/png
```
`*` matches one path segment. Add `operation` to limit a rule to one operation. Schema hints such as `format: byte`, `contentEncoding` and data URIs also help confirm an encoding. A string that merely looks like base64 is not asserted to be base64.
If a response exceeds `responseBytes`, you still get the status and headers, but the body may be incomplete (`bodyComplete: false`). Total artifact storage is capped by `sessionArtifactBytes`.
## Troubleshooting
Start with the config check:
```bash
npx -y @nhan7777/openapi-swagger-mcp@0.1.0 --config /absolute/path/config.yaml --check
```
The report contains `ok`, `services`, `checks`, `remoteSpecsChecked: false` and `operationsChecked: false`. Each check has a `field`, a `state` (`ok`, `pending` or `error`) and possibly a `code`.
- `pending` means a credential will be captured at runtime. It is not a static error.
- It checks env and file sources (even when a recipe exists), local spec and CA files, and upload/export roots.
- It does not fetch remote specs, parse spec content, check operations remotely, run recipes or create the artifact directory.
- If the config cannot be parsed or fails the schema, stderr has a sanitized error and stdout is empty. If it parses, stdout has one JSON report with no secret values.
| Exit code | Meaning |
|---|---|
| `0` | Static checks passed |
| `1` | A config, source, file or root problem |
| `2` | CLI usage error, such as a missing `--config` or an unknown flag |
### Common problems
| Symptom / code | What to do |
|---|---|
| The MCP client cannot connect | Check Node is 22.19 or newer, that the CLI and config paths are absolute, and that the entry does not contain `--check`. If you run from source, check it is built |
| `CONFIG_INVALID` | Run `--check` and read the failing field. Use the JSON Schema for suggestions, and do not mix in old syntax (see [Limitations](#limitations)) |
| `SOURCE_UNAVAILABLE` / `CREDENTIAL_INVALID` | Check the MCP process's environment, the JSON file, the pointer, and that the value is a string. Do not paste secrets into logs |
| A service shows `unavailable` | Read the error in `list_apis`. Check the spec, its refs, the CA file and file permissions, fix them, then run `refresh_specs`. Minor spec problems only produce [warnings](#imperfect-specs-warnings-instead-of-errors) and do not make a service unavailable |
| `AUTH_REQUIRED` | Check the static sources or call `authenticate`. For OTP flows, use a session |
| `AUTH_RECOVERING` | Wait until `get_auth_status` stops reporting `recovering`. Do not mass-retry write operations |
| `AUTH_BUSY` | Wait for the running action to finish before requesting a different one |
| `AUTH_REFRESH_UNAVAILABLE` | No refresh recipe is configured. Authenticate again explicitly or import a session |
| `AUTH_RECIPE_INPUT` | A credential or template the recipe needs has no value. Recipes do not run prerequisite steps |
| `AUTH_RECIPE_INVALID` / `AUTH_RECIPE_FAILED` | Check the recipe's operation, request body, status and service |
| `AUTH_CAPTURE_FAILED` | Check the JSON Pointer, header or Set-Cookie. Capture results are never published partially |
| `AUTH_TARGET_REQUIRED` | A service with several profiles has no default. Pass `{profile: ...}` |
| `PROFILE_NOT_ALLOWED` / `SESSION_SERVICE_MISMATCH` | Use a profile the service lists, or a session created for that service |
| `SPEC_REVISION_MISMATCH` | Get a fresh revision from list, search or schema, and re-read the contract before calling |
| `UPLOAD_NOT_ALLOWED` / export errors | Check the roots, the real path, protected files, and that the destination does not exist |
| HTTP 401 after a refresh started | That is still the original request's response. Wait for the refresh, then decide whether to send a new request |
**Restart or refresh?** Config changes need a restart of the MCP process. `refresh_specs` only reloads specs. `authenticate` without a recipe reloads static auth sources, not the whole config.
## Advanced configuration and limitations
### Authentication for spec downloads and `$ref`s
Spec-fetch auth is separate from business auth. It only uses static secrets and never runs a recipe:
```yaml
services:
privateApi:
baseUrl: https://api.dev.example.test
spec:
source: { url: https://docs.dev.example.test/openapi.json }
auth:
bearer: { token: { fromEnv: DOCS_TOKEN } }
allowedOrigins: [https://schemas.dev.example.test]
roots: [./specs]
auth:
bearer: { token: { fromEnv: API_TOKEN } }
```
Spec-fetch credentials are sent only to the origin of the spec source, never to a different origin reached through a `$ref`. A remote spec cannot reference local files. A local `$ref` must stay inside the allowed roots; the directory of a local spec is used as that spec's root. Spec fetches do not follow redirects. A missing or broken ref causes that service's snapshot to be rejected, and other services keep working.
### Imperfect specs: warnings instead of errors
Framework-generated specs (NestJS, for example) often have small mistakes. These three do **not** block a service. They appear in `warnings` from `list_apis`. The list is capped at 20 entries, 4096 bytes in total, and 512 bytes and one line per entry. Priority order is excluded operations, then security schemes, then duplicate `operationId`. Exact counts are in `warningSummary` (`total`, `shown`, `omitted`, `excludedOperations`), not in an extra line at the end of the list. If `list_apis` exceeds its output budget, warning text is shortened (counts stay accurate). If even the metadata is too large, the result has `servicesInline: false` and a `servicesArtifact` you read in full with `read_artifact`.
| Spec problem | Handling | Effect |
|---|---|---|
| Duplicate `operationId` across endpoints | Warning only | No effect on calls, because operations are called by `METHOD /path`. Searching for that id returns several hits; tell them apart by the `operation` field |
| Extra `in`/`name` on a `type: http` security scheme | The extra fields are ignored during validation; the original spec is kept | No effect |
| An operation declares a path parameter that is not in the path (for example `customerId` with no `{customerId}` in the path) | **That operation alone is excluded** from the service | It cannot be found. Calling it directly returns `OPERATION_UNKNOWN` with the message `operation was excluded from this specification` and `details.reason` = `PATH_PARAMETER_NOT_IN_TEMPLATE`. Other operations work normally |
Other problems still block the whole service with `SPEC_INVALID`: for example a path template `{id}` with no matching parameter, a missing `$ref` target, or a malformed OpenAPI structure.
Warnings only report problems. They do not repair the spec, so fix it at the backend where possible.
**`$ref` combined with a method or `parameters` is rejected.** Any object in a spec that has a string `$ref` together with `get`, `put`, `post`, `delete`, `options`, `head`, `patch`, `trace` or `parameters` is refused (`<document>: an object combines $ref with execution siblings (a method or parameters)`). Tools disagree about what to do here (ignore, override or merge), and bundlers merge silently, so the server does not pick one; the spec author must choose. `summary`, `description` and `x-*` siblings are still allowed.
The scan runs on every original document (the root and every allowed file or URL a `$ref` points to) as it is read, before the bundler merges them, and again on the bundled document. This is because an extended `$ref` on a container (for example `paths: { $ref: 'p.json', '/a': { $ref: '#/x' } }`) can make the bundler produce a `$ref` with a method from two objects that were each valid on their own. The server checks object shape before and after bundling and does not re-resolve `$ref` paths itself. The cost: every object with this shape is rejected, including not only sample data (`example`, `enum`, `default`, `x-*`) but also schemas, Links and Responses, including shapes that only appear after a merge. This is a compatibility limit. When you hit it, change the spec (rename the key, or remove the `$ref` from that object) if doing so does not change what the spec means.
**Extended `$ref` is accepted.** When a `$ref` is extended on a container and the merged result no longer contains a forbidden combination, the server accepts the bundler library's merge: the extender wins for scalar values, objects are merged, and arrays are replaced entirely. For example, if an external file declares `parameters` with a required query parameter `external`, and the extender declares `parameters` with `root`, the published contract has only `root`. The `external` parameter disappears because arrays are replaced, not combined. The contract published over MCP, and used to serialize requests, is the one **after bundling and validation**. There is no evidence that the real server accepts this contract, so read it with `get_operation_schema` and try it in a suitable environment before sending requests with side effects.
**Read limits.** The number of distinct `$ref` strings the bundler asks to read is capped by `refGraphDocuments`. The total bytes read, including the same file read several times under different spellings such as `.//a.json`, is capped by `refGraphBytes`. Exceeding a configured read budget stops with `SPEC_LIMIT_EXCEEDED`. These input byte budgets are not hard limits on heap or RSS. Spec load and parse errors are capped at 2048 bytes.
### TLS per service
```yaml
tls:
caFile: ./certs/dev-ca.pem
verify: true
```
This block goes under a service. `verify: false` turns off TLS verification for that service only and produces a warning. Use it only when you really need it in a test environment; it is not the way to fix a default certificate problem. No tool changes TLS settings at call time.
### Files and limits
```yaml
files:
uploadRoots: [./fixtures]
exportRoots: [./exports]
artifactRoot: ./runtime-artifacts
limits:
requestTimeoutMs: 30000
responseBytes: 52428800
uploadBytes: 52428800
toolOutputBytes: 32768
```
Declare only the values you want to change. Every limit is a positive integer with a maximum; check the JSON Schema for the ranges.
| Key | Default | Scope |
|---|---:|---|
| `requestTimeoutMs` | 30000 ms | A request, including redirect hops. Recipes also have a timeout |
| `maxConcurrentRequests` | 8 | Normal requests |
| `recipeConcurrency` | 2 | A separate lane for auth recipes |
| `specDocumentBytes` | 10 MiB | One spec document |
| `refGraphDocuments` | 100 | Documents in the ref graph |
| `refGraphBytes` | 50 MiB | Total bytes in the ref graph |
| `refGraphDepth` | 64 | Ref and template depth |
| `responseBytes` | 50 MiB | One response |
| `uploadBytes` | 50 MiB | One upload |
| `sessionArtifactBytes` | 250 MiB | Total artifact quota |
| `inlineStringBytes` | 4096 bytes | Threshold for showing a string inline |
| `toolOutputBytes` | 32768 bytes | Budget for one tool result |
| `artifactPageBytes` | 16384 bytes | One artifact read page |
| `maxRedirects` | 5 | Redirects followed when enabled |
`uploadRoots` and `exportRoots` default to empty, so upload and export are disabled until you declare them. `artifactRoot` may not exist yet; the server creates it at runtime.
### Limitations
- A local MCP server over stdio. It is not a hosted gateway or an HTTP MCP server.
- Not a workflow engine: one recipe is one operation. The agent or a person orchestrates multi-step flows.
- No automation of OAuth/PKCE callbacks, OTP polling, CAPTCHA or browser login. No scripting, HMAC signing or mTLS client authentication.
- No telemetry. Specs and responses are untrusted data, not instructions for the agent, and descriptions and extensions are never executed.
- Validation depends on spec quality. An unknown format is recorded as unverified, not as passed. Search is deterministic token scoring; the project makes no claims about token savings or semantic search accuracy.
- Compatibility note: older config and tool forms were removed with no aliases. `bearer.login`, profile `components`/`recovery`, service-level `allowedProfiles`/`defaultProfile`, `$source`/`$component`, `authenticate_profile` and `get_profile_status` no longer work. Use the forms in this guide.
## Tool reference
| Tool | Use it to |
|---|---|
| `list_apis` | See services, spec readiness and revision, operation counts, spec `warnings`, auth mode and capability, and spec load errors |
| `search_operations` | Search by path, operationId, tag, summary or description, with paging |
| `get_operation_schema` | See an operation's parameters, body, response schemas, security and refs. Large contracts go to an artifact |
| `invoke_operation` | Send a real request and get the HTTP outcome, diagnostics and an optional capture into a session |
| `refresh_specs` | Reload and validate specs. On failure the previous valid snapshot is kept |
| `authenticate` | Run authentication, run an explicit refresh, or reload static auth sources |
| `get_auth_status` | See state, revision, capability, credential and binding presence, cookie names and failures. Never returns credential values |
| `manage_session` | Create, inspect or delete a session, importing credentials directly or from the catalog |
| `read_artifact` | Read a stored response or contract by page or JSON Pointer |
| `export_artifact` | Write bytes, text or base64-decoded data to a new file under `exportRoots` |
The tool count does not grow with the number of endpoints. No tool accepts an arbitrary URL to call outside the configured services, an arbitrary env or file secret name, a script, or a parameter that switches environments.
## Development
Work from a source checkout:
```bash
npm ci
npm run build
npm run typecheck
npm run gen:schema
npm test
npm run test:integration
npm run smoke
npm run verify:package
npm audit --omit=dev --audit-level=high
```
Build before the integration tests and the smoke test, because some of them run the compiled CLI over stdio. Tests use a loopback backend, synthetic data and self-signed certificates meant only for tests. No real credentials are needed. Run `npm run gen:notices` to update the third-party notices when dependencies change.
`npm run verify:package` needs `npm run build` first. It packs the package for real, installs the tarball into a separate consumer directory, runs `--check`, the bin shim and the smoke test against the installed copy, then audits that installed copy. Only if all of that passes does the tarball land in `.release/<name>.tgz` with a `.sha256` file. If the audit fails, the tarball is kept as evidence in `.release/diagnostic/NOT-RELEASABLE-...`. `npm pack --dry-run` is not a substitute. Verifying a local candidate this way is separate from publishing it. Publishing is a separate, deliberate registry action. The CI matrix (Ubuntu, macOS and Windows with Node 22.19.0, 22 and 24) is its own release condition.
Released under the MIT license. Dependency licenses are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues