# OpenAPI adapter

> OpenapiAdapter turns every operation of an OpenAPI 3 spec into a tool that calls your REST API. Every option, how arguments are checked, what a call sends and returns, credentials, transforms and spec polling.

Source: https://frontmcp.dev/reference/adapters/openapi

`OpenapiAdapter`, from `@frontmcp/adapters`, reads an OpenAPI 3 description of a REST API and gives your app one tool per operation. When the model calls `getTicket`, the adapter builds the HTTP request the spec describes, sends it with the credentials you configure, and hands back the API's answer. Use it to put an API you already have in front of a model without writing a tool for each endpoint; write tools yourself, with [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services), when the model needs a few well-shaped calls rather than the whole API. For an API too large to list as tools, see [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi). [Wrapping an OpenAPI Service](https://frontmcp.dev/learn/wrapping-an-openapi-service) teaches the adapter step by step. The [Pet Store from OpenAPI](https://frontmcp.dev/examples/pet-store-from-openapi) example wraps a whole API and makes it fit for a model.

```ts
@App({ adapters: [OpenapiAdapter.init({ name, baseUrl, spec | url, ...options })] })
```

---

## Reference

### `OpenapiAdapter.init(options)`

Install the package with `npm install @frontmcp/adapters` and list the result of `init()` in the `adapters` of an [`@App`](https://frontmcp.dev/reference/sdk/app). A [plugin](https://frontmcp.dev/reference/sdk/plugin) can bring adapters too, in its own `adapters` option, and they add their tools to the app the plugin is on. In `@FrontMcp({ adapters })`, the adapter's tools are served from every app ([Registering an adapter](https://frontmcp.dev/reference/plugins#registering-an-adapter)); before 1.9, `@FrontMcp` ignored `adapters`.

```ts desk.app.ts
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import spec from "./desk-openapi.json";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://api.desk.example/v1",
      spec,
      staticAuth: { jwt: process.env.DESK_API_TOKEN },
    }),
  ],
})
export class DeskApp {}
```

It's also the default export of `@frontmcp/adapters/openapi`. [See more examples below.](#usage)

#### Options

Required:

| Option | Type | Description |
| --- | --- | --- |
| `name` | `string` | Names this adapter. It must be unique among the `OpenapiAdapter.init()` calls of the whole process, not only of one server: a second call with the same name throws (see [Troubleshooting](#duplicate-adapter-name-x-for-openapiadapter)). It's also the prefix a tool gets when its name clashes with another tool's, and the adapter's logger is `adapter:<name>`. |
| `baseUrl` | `string` | Where the API is, like `https://api.desk.example/v1`. Each operation's path is appended to it. It wins over the spec's `servers`. It must be `http:` or `https:`; any other value fails every call with `Invalid base URL: …`. |
| `spec` **or** `url` | `object` **or** `string` | The OpenAPI 3.0 or 3.1 document: `spec` as an object (an imported JSON file, or a literal), or `url`, an `http:` or `https:` address to download it from when the server starts. Exactly one. See [loading the spec from a URL](#loading-the-spec-from-a-url). |

Calling the API:

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `additionalHeaders` | `Record<string, string>` | | Headers sent with every request. They replace headers of the same name that the adapter built, `accept` included. A security scheme's header set here counts as that scheme's credential (see [how credentials are chosen](#how-credentials-are-chosen)). |
| `headersMapper` | `(ctx, headers) => Headers` | | Called for every request with the request's [context](https://frontmcp.dev/reference/sdk/context) (`ctx.authInfo`, `ctx.requestId`, `ctx.traceContext`, …) and the headers built so far. Headers in the `Headers` object it returns are set on the request. Runs after `additionalHeaders`, so it can override them. A security scheme's header it sets counts as that scheme's credential. |
| `bodyMapper` | `(ctx, body) => object` | | Called with the context and the JSON body, for operations that send one; the body is replaced by what it returns. Not called when the request has no body. |
| `requestTimeoutMs` | `number` | `30000` | Milliseconds a request may take. After that it's aborted and the call fails with `Request timeout after …ms for tool '…'`. |
| `maxRequestSize` | `number` | `10485760` (10 MB) | Largest JSON body, in bytes, the adapter sends. A larger one fails the call before anything is sent. |
| `maxResponseSize` | `number` | `10485760` (10 MB) | Largest response, in bytes, the adapter reads. A larger one fails the call. |

Credentials, described in [how credentials are chosen](#how-credentials-are-chosen):

| Option | Type | Description |
| --- | --- | --- |
| `staticAuth` | `Partial<SecurityContext>` | Fixed credentials for every call, like `{ jwt: process.env.DESK_API_TOKEN }`, whoever the caller is. With `authProviderMapper`, they fill in what its functions don't return. |
| `authProviderMapper` | `Record<string, (ctx) => string \| undefined>` | One function per security scheme in the spec (`components.securitySchemes`), returning that scheme's credential for this call, or `undefined` for none. Every scheme the spec uses must have one, or another source: `staticAuth`, `securitySchemesInInput`, a header from `additionalHeaders` or `headersMapper`, or `passthroughCallerToken` for an HTTP bearer scheme. Otherwise the server doesn't start. |
| `securityResolver` | `(tool, ctx) => SecurityContext \| Promise<SecurityContext>` | Returns the credentials for one call to one tool. It wins over the other options. |
| `securitySchemesInInput` | `string[]` | Schemes whose credential the model provides, as a required tool argument named after the scheme. The adapter sends it where the scheme says, unless the options above give that scheme a credential, which wins. The other schemes still come from the options above. Rated `HIGH` risk. |
| `passthroughCallerToken` | `boolean` | Default `false`. Send the MCP client's own token (`ctx.authInfo.token`) to the API as a bearer token, when neither `authProviderMapper` nor `staticAuth` gave a credential. It fills HTTP bearer schemes only: an operation secured by an API key still needs its key. Only for an API that accepts the tokens your server accepts. Not used with `securityResolver`. |

Which operations become tools, and how they look:

| Option | Type | Description |
| --- | --- | --- |
| `generateOptions` | `OpenApiGenerateOptions` | Which operations to include, how to name them, and how schemas are built. See [`generateOptions`](#generateoptions). |
| `descriptionMode` | `"summaryOnly" \| "descriptionOnly" \| "combined" \| "full"` | How a tool's description is made from the operation. Default `"summaryOnly"`. See [how operations become tools](#how-operations-become-tools). |
| `toolTransforms` | `{ global?, perTool?, generator? }` | Rename a tool, change its description, add annotations, tags or examples, or hide it from `tools/list`. See [`toolTransforms`](#tooltransforms). |
| `inputTransforms` | `{ global?, perTool?, generator? }` | Remove arguments from a tool's input and fill them in on the server. See [`inputTransforms`](#inputtransforms). |
| `schemaTransforms` | `{ input?, output? }` | Functions that return a new input or output JSON Schema for a tool. See [`schemaTransforms`](#schematransforms). |
| `outputSchema` | `{ mode?, descriptionFormat?, descriptionFormatter? }` | Keep the response schema as the tool's `outputSchema`, move it into the description, or both. See [`outputSchema`](#outputschema). |
| `dataTransforms` | `{ preToolTransforms?, postToolTransforms? }` | Change output schemas and descriptions when tools are built, and the API's data before the model gets it. See [`dataTransforms`](#datatransforms). |

Loading and watching the spec:

| Option | Type | Description |
| --- | --- | --- |
| `loadOptions` | `LoadOptions` | How the spec is loaded and checked. See [`loadOptions`](#loadoptions). |
| `polling` | `SpecPollerOptions & { enabled: boolean }` | Fetch `url` again every so often and rebuild the tools when the spec changes. Only with `url`. See [polling](#picking-up-spec-changes). |
| `logger` | `FrontMcpLogger` | A logger for the adapter. Inside a server FrontMCP replaces it with its own, `adapter:<name>`, before the tools are built. |

#### `generateOptions`

The adapter passes these fields on to the generator ([`mcp-from-openapi`](https://github.com/agentfront/mcp-from-openapi)) that turns operations into tools:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `includeOperations` | `string[]` | | Only these `operationId`s. Operations without an `operationId` are left out too. |
| `excludeOperations` | `string[]` | | Leave these `operationId`s out. |
| `filterFn` | `(op) => boolean` | | Called for each operation, with the operation object plus its `path` and `method` (lowercase). Return `false` to leave it out. |
| `namingStrategy.toolNameGenerator` | `(path, method, operationId?, operation?) => string` | `operationId`, else `<method>_<path>` | Names each tool. |
| `namingStrategy.conflictResolver` | `(name, location, index) => string` | the location in front: `pathId`, `queryId`, `bodyId` | Renames an input whose name is used in two places, like a path `id` and a query `id`. |
| `includeDeprecated` | `boolean` | `false` | Also turn operations marked `deprecated: true` into tools. |
| `preferredStatusCodes` | `number[]` | `[200, 201, 202, 204]` | Which response's schema describes success, in order of preference. |
| `includeAllResponses` | `boolean` | `true` | Describe every documented response in the output schema (a `oneOf`), not only the preferred one. |
| `includeSecurityInInput` | `boolean \| string[]` | `false` | Add each security scheme (or the ones listed) to the tool's input, as a required string argument named after the scheme, so the model supplies credentials. They're sent like `securitySchemesInInput`'s, and a list means the same as `securitySchemesInInput`: every scheme it leaves out needs another credential source, or the server doesn't start. (Before 1.8.5, a list's values were never sent.) The adapter logs a `SECURITY WARNING` and rates the setup `HIGH` risk. |
| `maxSchemaDepth` | `number` | `10` | Nesting kept in input and output schemas; deeper levels are cut, with a note in the description. |
| `includeExamples` | `boolean` | `false` | Copy parameter and media-type `example`/`examples` into the schemas. |
| `resolveFormats` | `boolean` | `false` | Turn formats into constraints: `format: "uuid"` gets a `pattern` and the description `UUID string (RFC 4122)`, `int32` a `minimum` and `maximum`, `email` the description `Email address (RFC 5322)`, and so on. |
| `formatResolvers` | `Record<string, (schema) => schema>` | | Your own formats, like `phone`, or replacements for the built-in ones. Without `resolveFormats`, only these apply. |

`OpenApiGenerateOptions`, exported by `@frontmcp/adapters`, is `mcp-from-openapi`'s `GenerateOptions`, which has more fields, with method filters that take any case. The adapter passes every field on. These select operations, as [Choosing which operations become tools](#choosing-which-operations-become-tools) shows:

| Field | Type | Keeps |
| --- | --- | --- |
| `includeTags`, `excludeTags` | `string[]` | Operations with at least one of these tags; or leaves out those with any of them. |
| `includeMethods`, `excludeMethods` | `OpenApiHttpMethod[]` | Operations with these methods, or without them, in any case: `"delete"`, `"DELETE"` or `"Delete"`. A name that isn't an HTTP method makes `init()` throw. Before 1.9.3 the type took lower case only, so `["DELETE"]` was a type error (TS2820), though it matched at run time. |
| `includePaths`, `excludePaths` | `string[]` | Operations whose path matches one of these globs, or none of them. `*` matches within one segment and `**` across segments: `"/tickets"` is that path only, `"/tickets/*"` is `/tickets/{id}`, and `"/admin/**"` is everything under `/admin`. |
| `readOnlyOnly` | `boolean` | Only operations that only read, as the generator judges from the method, like `GET`, unless the spec's `x-frontmcp` annotations say otherwise. |

Three add to what `tools/list` shows, as [how operations become tools](#how-operations-become-tools) describes:

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `inferAnnotations` | `boolean` | `true` | Give each tool annotations guessed from its HTTP method. `false` leaves annotations to `x-frontmcp` and `toolTransforms`. |
| `emitMeta` | `boolean` | `false` | Add `_meta["dev.agentfront.openapi/operation"]`, the operation's `path`, `method`, `operationId`, `specTitle` and `specVersion`, to each tool. |
| `inheritDocumentIcons` | `boolean` | `false` | Give tools without icons of their own the spec's `info["x-logo"]` as their icon. |

The others, `descriptionStrategy`, `appendResponseSummary`, `maxProperties`, `maxDescriptionLength`, `stripExamples`, `target`, `maxToolNameLength` and `emitTypeSignatures`, change how tools are built: see `mcp-from-openapi`'s documentation.

Changed in 1.9: the adapter passed on only the fields in the first table, so the others were accepted and had no effect. Changed in 1.9.2: the annotations, title, icons and `_meta` the generator makes reach the tool, so every tool now lists inferred annotations and its summary as its `title`.

#### `loadOptions`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `validate` | `boolean` | `true` | Check the document before building tools. An invalid one stops the server: `Invalid OpenAPI document`. |
| `dereference` | `boolean` | `true` | Resolve `$ref`s. |
| `refResolution` | `{ allowedProtocols?, allowedHosts?, blockedHosts?, allowInternalIPs? }` | `{ allowedProtocols: [] }` | Which `$ref`s outside the document may be fetched, and which hosts a `url` may be loaded from. See below. |
| `headers` | `Record<string, string>` | | Headers for fetching `url`, like a token for a private spec. Only with `url`. |
| `timeout` | `number` | `30000` | Milliseconds to fetch `url`. Only with `url`. |
| `followRedirects` | `boolean` | `false` | Follow redirects when fetching `url`, checking each hop like the first. Only with `url`. |

References inside the document (`#/components/...`) are always resolved. A `$ref` to another file or URL isn't fetched by default: FrontMCP sets `allowedProtocols` to `[]`, and the `$ref` stays in the tool's schema unresolved. Set `refResolution` to allow some: `allowedProtocols: ["https"]` with `allowedHosts: ["schemas.desk.example"]`, say. Addresses on private networks, loopback and cloud metadata endpoints are refused unless `allowInternalIPs: true`, for `$ref`s and for `url` alike.

`LoadOptions` has two more fields, and the adapter passes on every field. `overlays` takes one [OpenAPI Overlay](https://spec.openapis.org/overlay/v1.0.0.html) document or a list of them, applied in order before the spec is checked: a way to rewrite summaries, or add `x-frontmcp`, in a file of your own that survives the next version of the spec (see [Naming and describing the tools](#naming-and-describing-the-tools)). `secureDefaults: true` turns off redirects and external `$ref`s, which the adapter already does by default.

#### `toolTransforms`

`global` applies to every tool, `perTool` to the tool with that name (the name before any rename), and `generator` is called with each tool and returns a transform or `undefined`. For one tool, `perTool` and `generator` override `global`'s `name`, `description`, `hideFromDiscovery` and `ui`; `annotations` are merged, and `tags` and `examples` are added together.

| Field | Type | Description |
| --- | --- | --- |
| `name` | `string \| (name, tool) => string` | The tool's new name. |
| `description` | `string \| (description, tool) => string` | The tool's new description. The function gets the current one. |
| `annotations` | `ToolAnnotations` | `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`: merged over the annotations inferred from the method and those the spec's `x-frontmcp` set. |
| `hideFromDiscovery` | `boolean` | Leave the tool out of `tools/list`. It can still be called by name. |
| `tags`, `examples`, `ui` | | As the [`@Tool`](https://frontmcp.dev/reference/sdk/tool) options of the same names. |

A transform's `tool` argument is the generated tool: `tool.name`, `tool.description`, `tool.inputSchema`, and `tool.metadata`, with `method` (lowercase), `path`, `operationId`, `operationSummary`, `operationDescription` and `tags`.

#### `inputTransforms`

`global` is a list for every tool, `perTool` maps a tool name to a list, and `generator` returns a list for a tool. Each entry is `{ inputKey, inject }`:

- `inputKey` is removed from the tool's input schema (and from `required`), so the model never sees it.
- `inject({ ctx, env, tool })` runs on every call, with the request's context, `process.env` and the tool, and its result becomes that argument. A value the model sends for the argument anyway is dropped before the [argument check](#arguments-are-checked-against-the-spec), and the injected one is sent. (In 1.9.2 such a call was refused with `Unrecognized key`.) Returning `undefined` leaves the argument out, which fails the call for a parameter the spec requires (see [Troubleshooting](#required-query-parameter--input--is-missing-for-operation-)). It may be `async`, and must answer within 5 seconds or the call fails with `Input transform for '…' failed: Transform timeout after 5000ms`.
- `__proto__`, `constructor` and `prototype` aren't allowed as `inputKey`: the server doesn't start.

#### `schemaTransforms`

`input` and `output` each take `global`, `perTool` and `generator`: functions `(schema, { tool, adapterOptions }) => schema` that return the tool's new input or output JSON Schema. For one tool only one applies: `generator`'s, else `perTool`'s, else `global`'s. An output function may return `undefined` to drop the output schema. The schema you return is what clients see; arguments are still [sent as the spec describes](#what-a-call-sends).

#### `outputSchema`

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `"definition" \| "description" \| "both"` | `"definition"` | Where the response schema goes: the tool's `outputSchema`, the end of its description (and no `outputSchema`), or both. |
| `descriptionFormat` | `"summary" \| "jsonSchema"` | `"summary"` | In the description: a `## Returns` list of fields with their types, or an `## Output Schema` block with the JSON Schema. |
| `descriptionFormatter` | `(schema, { tool, adapterOptions, originalDescription }) => string \| Promise<string>` | | Your own text, appended to the description instead. |

The schema in the description is the API's response body; the tool's `outputSchema`, when there is one, is the [result envelope](#what-the-model-gets-back) around it.

#### `dataTransforms`

- `preToolTransforms` (`global`, `perTool`, `generator`) runs when tools are built: `transformSchema(outputSchema, ctx)` returns a new output schema, or `undefined` to drop it, and `transformDescription(description, outputSchema, ctx)` returns a new description.
- `postToolTransforms` (`global`, `perTool`, `generator`) runs on every call: `transform(data, ctx)` returns what the model gets as `data`, and an optional `filter(ctx)` returning `false` skips it. `ctx` has `status`, `ok`, `tool`, `ctx` (the request context) and `adapterOptions`. For one tool only one transform runs: `generator`'s, else `perTool`'s, else `global`'s. If `transform` throws, the error is logged and the model gets the data unchanged.

#### `polling`

Only with `url`: `polling` with `spec` throws `Polling requires URL-based options (use 'url' instead of 'spec').` The fields are those of [`OpenApiSpecPoller`](#openapispecpoller) plus `enabled: true`. See [picking up spec changes](#picking-up-spec-changes).

#### Returns

A record for `adapters`, not the adapter: `{ ...options, provide: Symbol.for("adapter:OpenapiAdapter:<name>"), useValue: <the OpenapiAdapter> }`. `init()` constructs the adapter at once, when your module is imported. The spec is read, and the tools built, when the server starts. To call the adapter's own methods, like `stopPolling()`, use `record.useValue`.

### How operations become tools

| | From the spec |
| --- | --- |
| Name | The `operationId`. Without one: the method and path, like `get_stats` for `GET /stats` or `post_tickets_By_id_comments` for `POST /tickets/{id}/comments`. |
| Title | The operation's `summary`. An operation without one has no `title`. |
| Description | `descriptionMode`: `summaryOnly` (the default) uses `summary`, else `description`, else `METHOD path`; `descriptionOnly` prefers `description`; `combined` is summary, a blank line, then description; `full` adds `Operation: <operationId>` and `METHOD path`. |
| Input schema | One object, with a property for each path, query, header and cookie parameter and each property of the JSON request body. Each property keeps its schema, with an `x-parameter-location` saying where it goes. Header parameters also get `x-mcp-header` (see [header parameters](#header-parameters)). `required` lists the required parameters and body properties, and `additionalProperties` is `false`. |
| Output schema | `{ status, ok, data, error }`, with `status` and `ok` required and `data` being the success response's schema, or a `oneOf` of every documented response. A response without a body schema, like a `204`, is described as `data: { type: "null" }`. |
| Annotations | All four hints, guessed from the method (`inferAnnotations`): `GET` is `readOnlyHint` and `idempotentHint`; `PUT` and `DELETE` are `destructiveHint` and `idempotentHint`; `POST` and `PATCH` are `destructiveHint`. `openWorldHint` is `false`. The spec's `x-frontmcp`, then `toolTransforms`, are merged over them. |
| Icons | The operation's `x-frontmcp.icons`, a list of `{ src, mimeType?, sizes? }`. With `inheritDocumentIcons`, the spec's `info["x-logo"]` for the others. |
| `_meta` | The operation's `x-frontmcp.meta`. With `emitMeta`, also `"dev.agentfront.openapi/operation"`: `{ path, method, operationId, specTitle, specVersion }`. |
| Left out | Operations marked `deprecated` (see `includeDeprecated`) and those your `generateOptions` filter out. |

Two operations that end up with the same name stop the server: `Tool name collision: "…" produced by 2 operations: …`. When an adapter's tool has the same name as a tool from another adapter or app, FrontMCP lists both with their owner's name in front: `desk:getStatus` and `billing:getStatus`, or `<app id>:getStatus` for the app's own tool.

#### The `x-frontmcp` extension

An operation in the spec can carry an `x-frontmcp` object, which the adapter copies onto the tool:

| Field | Becomes |
| --- | --- |
| `annotations` | The tool's [annotations](https://frontmcp.dev/reference/sdk/tool): `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. |
| `hideFromDiscovery` | The tool's `hideFromDiscovery`. |
| `tags`, `examples`, `cache`, `codecall` | The [`@Tool`](https://frontmcp.dev/reference/sdk/tool) options of the same names, for the plugins that read them ([Cache](https://frontmcp.dev/reference/plugins/cache), [CodeCall](https://frontmcp.dev/reference/plugins/codecall)). |
| `icons` | The tool's icons, a list of `{ src, mimeType?, sizes? }`. |
| `meta` | Entries for the tool's `_meta`, like `{ "desk.example/team": "support" }`. |

A field it doesn't know is dropped, with a warning in the log: `Unknown field 'priority' in x-frontmcp (will be ignored)`. Before 1.9.3 `icons` and `meta` got that warning too, though the tool got them. `toolTransforms` applies after `x-frontmcp`, so it wins.

### Arguments are checked against the spec

Before anything is sent, a call's arguments are checked against the tool's input schema: the one clients see in `tools/list`, after any [`schemaTransforms`](#schematransforms). A call that doesn't match is refused with `INVALID_INPUT`, and the API receives nothing. The message names each argument and what's wrong with it, separated by `; `:

| The model sends | The call fails with |
| --- | --- |
| No `id`, which is required | `Invalid arguments for tool 'getTicket': id: Invalid input: expected string, received undefined` |
| A value outside an `enum` | `Invalid arguments for tool 'listTickets': status: Invalid option: expected one of "open"\|"closed"` |
| A key the operation doesn't have | `Invalid arguments for tool 'listTickets': input: Unrecognized key: "page"` |
| A number above `maximum` | `Invalid arguments for tool 'listTickets': limit: Too big: expected number to be <=50` |
| A string for an integer | `Invalid arguments for tool 'listTickets': limit: Invalid input: expected number, received string` |

Ranges, lengths and `pattern` are checked the same way, in path, query, header and cookie parameters and in body fields. Values aren't converted: `"5"` isn't a number. What isn't checked:

- **`format`.** `"yesterday"` passes as a `date-time`, and is sent.
- **`oneOf`** is read as `anyOf`: a value that matches more than one branch passes.
- **Credentials in the input**, from `securitySchemesInInput` or `includeSecurityInInput`: the check doesn't require them, so the server's own credential can fill the scheme. The [credential check](#how-credentials-are-chosen) decides.
- **A required parameter with a `default`.** The check lets the model leave it out, and the adapter [sends the default](#what-a-call-sends). Changed in 1.9.3: the default wasn't sent, and the call failed when the request was built, `Required query parameter 'format' (input: 'format') is missing for operation 'exportTickets'`, as a `TOOL_EXECUTION_ERROR`.

Changed in 1.9.2: arguments weren't checked, so a wrong value reached the API, and only a missing required parameter failed, when the request was built. [Handling what the API answers](#handling-what-the-api-answers) shows the check.

### What a call sends

For each call, in this order:

1. **The arguments are checked** against the input schema: see [above](#arguments-are-checked-against-the-spec). An argument that `inputTransforms` fills in isn't part of it: a value the model sent for one is dropped first.
2. **`inputTransforms`** fill in their arguments.
3. **Credentials** are resolved: see [how credentials are chosen](#how-credentials-are-chosen). Credentials the model sent in the tool's input fill the schemes the server gave none.
4. **The request is built** from the arguments. Path parameters are URL-encoded into the path, query parameters go in the query string (an array becomes comma-separated), header and cookie parameters go in headers, and body properties make a JSON body. The URL is `baseUrl` plus the path. Headers start with `accept: application/json` and the credentials' headers. A required parameter the model left out is sent with the spec's `default`; an optional one is left out, default or not, so the API applies its own. A required parameter that still has no value fails here, which happens when an `inputTransforms` `inject` returns `undefined` for it: `Required body parameter 'channel' (input: 'channel') is missing for operation 'createTicket'`. A header value with a line break fails with `INVALID_HEADER_VALUE`.
5. **`additionalHeaders`** are set, then **`headersMapper`** runs.
6. **The credential is checked**, on the request as it will be sent: an operation that requires credentials is refused unless the request carries one for at least one of its schemes. See [how credentials are chosen](#how-credentials-are-chosen).
7. **`bodyMapper`** runs. A body gets `content-type: application/json` unless a header already set one.
8. **The body is checked** against `maxRequestSize`.
9. **The request is sent** with the global `fetch`, with the operation's method, a `requestTimeoutMs` timeout, and `redirect: "manual"`: redirects aren't followed.

### What the model gets back

| The API answers | The model gets |
| --- | --- |
| `2xx` | A result whose `structuredContent`, and text, is `{ status, ok: true, data }`. `data` is the parsed JSON when the response's `content-type` includes `application/json`, else the text: `""` for a `204`. |
| `4xx`, `5xx` | An error result (`isError: true`) whose text is `{"status":404,"error":"…","data":…}`, with `_meta: { status, errorCode: "OPENAPI_ERROR" }`. `error` is the body's `message` field, or the body when it's text, or `HTTP <status> error`. |
| `3xx` | An error result: `{"status":302,"error":"Upstream returned a redirect (302); not followed to protect injected credentials."}`, `_meta.errorCode` `OPENAPI_REDIRECT_NOT_FOLLOWED`. |
| Nothing in time, a response over `maxResponseSize`, a network failure | The call fails with `TOOL_EXECUTION_ERROR`: `Request timeout after 30000ms for tool 'getTicket'`, `Response size (… bytes) exceeds maximum allowed (… bytes)`, or the network error. In production FrontMCP hides these messages from the client. |

`postToolTransforms` changes `data` before the model gets it, for errors too.

### How credentials are chosen

The spec says which operations need credentials, with `security` and `components.securitySchemes`. For each call the adapter builds a `SecurityContext` from the first option you set:

1. **`securityResolver(tool, ctx)`**: whatever it returns.
2. **`authProviderMapper`**: for each scheme the operation uses, the matching function's string becomes the credential its type needs (`jwt` for HTTP bearer, `apiKey`, `basic`, `oauth2Token`). A function that returns `undefined` or `null` gives that scheme nothing. `staticAuth`, when set, fills in every credential no function returned. If there's still no credential and `passthroughCallerToken` is `true`, the caller's own token (`ctx.authInfo.token`) is used as the bearer token.
3. **`staticAuth`**: the fixed credentials.
4. **`passthroughCallerToken: true`**: the caller's own token, as the bearer token.
5. **None of these**: no credentials.

The caller's token is never sent unless you set `passthroughCallerToken`, and then only for HTTP bearer schemes. It was issued for your server, not for the API, and forwarding it is the token passthrough the MCP specification forbids.

Credentials the model sends in the tool's input, for the schemes in `securitySchemesInInput` or an `includeSecurityInInput` list (or every scheme, with `includeSecurityInInput: true`), are added for the schemes these options left empty. A credential from the server always wins, so a prompt that talks the model into sending another key can't replace the server's. A value with a line break or another control character fails the call with `INVALID_HEADER_VALUE`.

Then each of the operation's schemes takes its value from the context and puts it where the scheme says:

| Scheme in the spec | From the `SecurityContext` | Sent as |
| --- | --- | --- |
| `http`, `scheme: bearer` | `jwt` | `Authorization: Bearer <jwt>` |
| `http`, `scheme: basic` | `basic`: `username:password`, already base64-encoded | `Authorization: Basic <basic>` |
| `apiKey` (`in: header`, `query` or `cookie`) | `apiKeys[<the key's name>]`, else `customHeaders[<name>]`, else `apiKey` | the named header, query parameter or cookie |
| `oauth2`, `openIdConnect` | `oauth2Token` | `Authorization: Bearer <oauth2Token>` |

A scheme with nothing to send is left out. The context also has `digest`, `cookies` (sent as cookies), `clientCertificate`, `customResolver` and signing fields; see `SecurityContext` in `mcp-from-openapi`.

Then, after `additionalHeaders` and `headersMapper`, the request as it will be sent must carry a credential for at least one of the operation's schemes, or the call fails before anything is sent: `Authentication required for tool 'getTicket': no credential for its security schemes.`, followed by the schemes (`Required security schemes: DeskToken (http)`) and the options that would supply one. What counts for each scheme:

| Scheme | Counts as its credential |
| --- | --- |
| `apiKey`, `in: header` | A non-empty header with the key's name, like `X-Reports-Key` |
| `apiKey`, `in: cookie` | A cookie with the key's name, in `Cookie` |
| `apiKey`, `in: query` | The query parameter |
| `http`, `scheme: bearer`; `oauth2`; `openIdConnect` | `Authorization: Bearer <something>` |
| `http`, `scheme: basic` | `Authorization: Basic <something>` |

So a key that `additionalHeaders` or `headersMapper` sets counts as much as one from `staticAuth`, while `Authorization: Token abc` doesn't count for a bearer scheme. Every scheme an operation lists counts as required, even when its `security` also allows `{}` (no credentials).

When the server starts, the adapter logs a `Security Analysis` with a risk rating: `LOW` for `securityResolver` or `authProviderMapper`; `MEDIUM` for `staticAuth`, or a spec with security and no credential option, which also logs a `SECURITY WARNING` that those operations fail unless `additionalHeaders` or `headersMapper` sets their credential; `HIGH` for `securitySchemesInInput` and `includeSecurityInInput`, and for `passthroughCallerToken` with an HTTP bearer scheme unless `securityResolver` or `staticAuth` keeps the caller's token from being used. With `authProviderMapper`, a scheme the spec uses that has no function stops the server, `Invalid security configuration. Missing auth provider mappings for security schemes: …`, unless another source covers it: `staticAuth`, `securitySchemesInInput`, `additionalHeaders` with the scheme's header (an `Authorization` header only for the scheme its first word names), `headersMapper` for a scheme sent in a header or cookie, or `passthroughCallerToken` for an HTTP bearer scheme.

> **Note**
Changed in 1.8.3: before, an adapter without credentials of its own, or an `authProviderMapper` whose functions returned nothing, sent the caller's token to the API. Now such calls fail with `Authentication required for tool '…'`. Give the adapter credentials issued for the API, or set `passthroughCallerToken: true` if the API really accepts your server's tokens.

> **Note**
Changed in 1.8.4: the credential check looks at the request as it will be sent. A key set by `additionalHeaders` or `headersMapper` now counts, and credentials in the tool's input now reach the API. `passthroughCallerToken` fills HTTP bearer schemes only: an operation secured by an API key, which 1.8.3 sent with no key, is now refused, and an `authProviderMapper` without a function for such a scheme stops the server. `securitySchemesInInput` is rated `HIGH`.

> **Pitfall: `passthroughCallerToken` gives the API your users' tokens**
The token a client sends was issued for your server. With `passthroughCallerToken: true`, whoever runs the API, or can read what it logs, holds a token that works on your server as that user. Set it only for an API that trusts the same identity provider and audience as your server. Otherwise use `staticAuth`, or a `securityResolver` that returns only credentials meant for the API. See [sending the API's credentials](#sending-the-apis-credentials).

### Other exports

`@frontmcp/adapters` also exports the pieces the adapter is made of, for tools of your own:

| Export | What it does |
| --- | --- |
| `forceJwtSecurity(spec, options?)` | Returns a copy of the spec in which every operation, or those in `options.operations`, requires a scheme it adds: a bearer scheme named `BearerAuth` by default, or `schemeType: "apiKey"` (`apiKeyIn`, `apiKeyName`, default header `X-API-Key`) or `"basic"`, named `schemeName`. For a spec that doesn't declare the security its API needs. |
| `removeSecurityFromOperations(spec, operations?)` | Returns a copy of the spec in which those operations, or all, require no credentials. |
| `buildRequest(tool, input, security, baseUrl)` | Builds `{ url, headers, body }` for a generated tool, as in step 4 above. |
| `parseResponse(response, { maxResponseSize? })` | Reads a `Response` into `{ status, ok, data, error? }`, as the adapter does. |
| `applyAdditionalHeaders(headers, additional)` | Sets each header on a `Headers` object. |
| `createSecurityContextFromAuth(tool, ctx, options)`, `resolveToolSecurity(tool, ctx, options)` | Build the `SecurityContext`, and the headers and query parameters it resolves to, as described above. |
| `extractSecuritySchemes(tools)`, `validateSecurityConfiguration(tools, options)` | The scheme names tools use, and the startup check with its risk rating. |
| `OpenApiSpecPoller` | Watches a spec URL for changes. See below. |
| `OpenAPIFetchError` | Thrown by the poller when the spec URL answers with an error status. Code `OPENAPI_FETCH_FAILED`. |
| `FRONTMCP_EXTENSION_KEY` | `"x-frontmcp"`. |
| `OpenApiGenerateOptions`, `OpenApiHttpMethod` | Types: what [`generateOptions`](#generateoptions) takes, and a method for `includeMethods` and `excludeMethods`, like `"delete"`, `"DELETE"` or `"Delete"`. New in 1.9.3. |

### `OpenApiSpecPoller`

The adapter's polling uses this class, which you can also use on its own:

```ts
const poller = new OpenApiSpecPoller(url, options?, callbacks?);
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `intervalMs` | `number` | `60000` | Milliseconds between polls. |
| `fetchTimeoutMs` | `number` | `10000` | Milliseconds one fetch may take. |
| `changeDetection` | `"auto" \| "etag" \| "content-hash"` | `"auto"` | `auto` and `etag` send `If-None-Match`/`If-Modified-Since` from the last response, so an unchanged spec can answer `304`; `content-hash` always downloads it. All three compare a SHA-256 of the body. |
| `retry` | `{ maxRetries?, initialDelayMs?, maxDelayMs?, backoffMultiplier? }` | `{ 3, 1000, 10000, 2 }` | Retries within one poll, waiting longer each time. |
| `unhealthyThreshold` | `number` | `3` | Failed polls in a row before `onUnhealthy`. |
| `headers` | `Record<string, string>` | | Sent with every poll. |
| `ssrf` | `{ allowedHosts, blockedHosts, allowInternalIPs }` | private addresses blocked | Which hosts may be polled. The adapter sets it from `loadOptions.refResolution`. |
| `followRedirects` | `boolean` | `false` | Follow redirects, checking each hop. |

| Callback | Called |
| --- | --- |
| `onChanged(spec, hash)` | With the new spec text, when its hash differs from the last one. The first poll always counts as a change. |
| `onUnchanged()` | When the spec is the same, or the server answered `304`. |
| `onError(error)` | When a poll fails after its retries, like an `OpenAPIFetchError` for a `500`. |
| `onUnhealthy(failures)`, `onRecovered()` | After `unhealthyThreshold` failures in a row, and at the first success after that. |

`start()` polls at once and then every `intervalMs`; `stop()` and `dispose()` stop; `poll()` polls once; `getStats()` returns `{ hash, consecutiveFailures, health: "unknown" | "healthy" | "unhealthy", isRunning }`.

#### Caveats

- Adapter names are **remembered for the whole process**. Restarting a server in the same process, as tests and hot reload do, fails with `Duplicate adapter name`, even though the first server is gone. Create the `init()` record once, at module level, and reuse it.
- **Arguments are checked** against the input schema before anything is sent, except `format`, and `oneOf` is read as `anyOf`. See [Arguments are checked against the spec](#arguments-are-checked-against-the-spec).
- `init()` builds the adapter when your module is imported, and the spec is read when the server starts. A spec that can't be loaded or doesn't validate stops the server.
- `init({ name, inject, useFactory })` builds the options from providers, when the server starts: `useFactory` gets the providers `inject` returns, and returns the options, all but `name`, which is the one given to `init()`. It may be `async`. A factory that returns something else stops the server with `Invalid adapter '…'. Expected useFactory to return the adapter's options object.` Changed in 1.9: a server with such a record didn't start, and a second error escaped as an unhandled rejection, which ended a Node process that didn't handle it.
- In an ES module project, `@frontmcp/adapters` loads and runs as it does in CommonJS.

---

## Usage

> **Note**
The Playground can't reach a real API, so every example on this page has a file, like `desk.example.ts`, that plays the API at `https://desk.example`. It replaces `globalThis.fetch`, which the adapter calls, answers requests for that host in-process, and records them so the tests can check what the API received. Your server doesn't need it.

### Turning an OpenAPI spec into tools

Give the adapter a name, the API's address and its spec. Each operation becomes a tool, named by its `operationId`, whose input has one property per parameter and body field:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })],
})
export class DeskApp {}
```

```ts spec.ts
export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: {
        operationId: "listTickets",
        summary: "List support tickets, newest first",
        parameters: [
          { name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } },
          { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 50 } },
        ],
        responses: {
          "200": {
            description: "The tickets",
            content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Ticket" } } } },
          },
        },
      },
      post: {
        operationId: "createTicket",
        summary: "Open a support ticket",
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: {
                type: "object",
                required: ["title"],
                properties: { title: { type: "string" }, priority: { type: "string", enum: ["low", "high"] } },
              },
            },
          },
        },
        responses: {
          "201": { description: "Created", content: { "application/json": { schema: { $ref: "#/components/schemas/Ticket" } } } },
        },
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: {
          "200": { description: "The ticket", content: { "application/json": { schema: { $ref: "#/components/schemas/Ticket" } } } },
        },
      },
    },
  },
  components: {
    schemas: {
      Ticket: {
        type: "object",
        properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string" } },
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
// It answers requests in-process and records them, so tests can check what the API received.
export const received: { method: string; url: string; headers: Record<string, string>; body?: unknown }[] = [];

const tickets: Record<string, { id: string; title: string; status: string }> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open" },
};

async function desk(request: Request): Promise<Response> {
  const text = await request.text();
  received.push({ method: request.method, url: request.url, headers: Object.fromEntries(request.headers), body: text ? JSON.parse(text) : undefined });
  const path = new URL(request.url).pathname;
  if (request.method === "POST") return Response.json({ id: "T-2", status: "open", ...JSON.parse(text) }, { status: 201 });
  if (path === "/v1/tickets") return Response.json(Object.values(tickets));
  const ticket = tickets[path.replace("/v1/tickets/", "")];
  return ticket ? Response.json(ticket) : Response.json({ message: "No such ticket" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init);
};
```

```ts desk.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Provider, ProviderScope } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { received } from "./desk.example";
import { spec } from "./spec";

test("each operation is a tool named by its operationId", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.sort()).toEqual(["createTicket", "getTicket", "listTickets"]);
});

test("parameters and body fields make one input object", async ({ mcp }) => {
  const create = (await mcp.tools.list()).find((t: { name: string }) => t.name === "createTicket");
  expect(create.description).toBe("Open a support ticket");
  expect(create.inputSchema).toEqual({
    type: "object",
    properties: {
      title: { type: "string", "x-parameter-location": "body" },
      priority: { type: "string", enum: ["low", "high"], "x-parameter-location": "body" },
    },
    required: ["title"],
    additionalProperties: false,
  });
  expect(create.outputSchema.required).toEqual(["status", "ok"]);
});

test("a call sends the request and returns status, ok and data", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-1" });
  expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "open" } });
  expect(received.at(-1)).toEqual({ method: "GET", url: "https://desk.example/v1/tickets/T-1", headers: { accept: "application/json" } });
});

test("query parameters go in the URL, body fields in a JSON body", async ({ mcp }) => {
  await mcp.tools.call("listTickets", { status: "open", limit: 5 });
  expect(received.at(-1)?.url).toBe("https://desk.example/v1/tickets?status=open&limit=5");

  const created = await mcp.tools.call("createTicket", { title: "Printer on fire", priority: "high" });
  expect(created.json()).toMatchObject({ status: 201, ok: true, data: { id: "T-2" } });
  expect(received.at(-1)).toMatchObject({
    method: "POST",
    headers: { "content-type": "application/json" },
    body: { title: "Printer on fire", priority: "high" },
  });
});

test("init() without an argument reaches the name check", () => {
  expect(() => OpenapiAdapter.init()).toThrow("Adapter OpenapiAdapter.init() requires a non-empty 'name' option");
});

test("useFactory builds the options from a provider when the server starts", async () => {
  @Provider({ name: "DeskSettings", scope: ProviderScope.GLOBAL })
  class DeskSettings {
    readonly baseUrl = "https://desk.example/v1";
  }

  @App({
    id: "desk",
    name: "Desk",
    providers: [DeskSettings],
    adapters: [
      OpenapiAdapter.init({
        name: "desk-from-settings",
        inject: () => [DeskSettings] as const,
        useFactory: async (settings: DeskSettings) => ({ baseUrl: settings.baseUrl, spec }),
      }),
    ],
  })
  class DeskFromSettings {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [DeskFromSettings] });
  expect((await server.callTool("getTicket", { id: "T-1" })).structuredContent).toMatchObject({ status: 200, ok: true });
  await server.dispose();
});
```

Open the **Capabilities** tab to see the three tools as a client lists them. The model gets the API's status and body as they are; the sections below change what it sees.

### Handling what the API answers

A `4xx` or `5xx` answer becomes an error result that still carries the API's status and body, so the model can tell a missing ticket from an outage. Arguments that don't match the spec never get that far:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, requestTimeoutMs: 200 })],
})
export class DeskApp {}
```

```ts spec.ts
const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string" } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: {
        operationId: "listTickets",
        summary: "List support tickets",
        parameters: [
          { name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } },
          { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 50, default: 20 } },
          { name: "since", in: "query", schema: { type: "string", format: "date-time" } },
        ],
        responses: { "200": { description: "The tickets", content: { "application/json": { schema: { type: "array", items: ticket } } } } },
      },
    },
    "/tickets/export": {
      get: {
        operationId: "exportTickets",
        summary: "Export every ticket",
        parameters: [{ name: "format", in: "query", required: true, schema: { type: "string", enum: ["csv", "json"], default: "csv" } }],
        responses: { "200": { description: "The export" } },
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } },
      },
      delete: {
        operationId: "deleteTicket",
        summary: "Delete a support ticket",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: { "204": { description: "Deleted" } },
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { method: string; url: string }[] = [];

async function desk(request: Request): Promise<Response> {
  received.push({ method: request.method, url: request.url });
  const path = new URL(request.url).pathname;
  if (path === "/v1/tickets/T-5") {
    // A connection that stays open and never answers, until the caller gives up
    return new Promise((_, reject) => {
      const open = setInterval(() => {}, 1_000);
      request.signal.addEventListener("abort", () => (clearInterval(open), reject(request.signal.reason)));
    });
  }
  if (path === "/v1/tickets/T-6") return new Response(null, { status: 301, headers: { location: "https://elsewhere.example/" } });
  if (path === "/v1/tickets/T-7") return new Response("database unavailable", { status: 503, headers: { "content-type": "text/plain" } });
  if (request.method === "DELETE") return new Response(null, { status: 204 });
  if (path === "/v1/tickets") return Response.json([{ id: "T-1", title: "Cannot log in", status: "open" }]);
  if (path === "/v1/tickets/export") return new Response("id,title\nT-1,Cannot log in", { headers: { "content-type": "text/csv" } });
  if (path === "/v1/tickets/T-1") return Response.json({ id: "T-1", title: "Cannot log in", status: "open" });
  return Response.json({ message: "No ticket with that id" }, { status: 404 });
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  return url.hostname === "desk.example" ? desk(new Request(input, init)) : realFetch(input, init);
};
```

```ts errors.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";

test("a 404 is an error result with the API's status and body", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-9" });
  expect(result).toBeError();
  expect(result.raw._meta).toMatchObject({ status: 404, errorCode: "OPENAPI_ERROR" });
  expect(JSON.parse(result.text())).toEqual({ status: 404, error: "No ticket with that id", data: { message: "No ticket with that id" } });
});

test("a text error body becomes the error message", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-7" });
  expect(JSON.parse(result.text())).toEqual({ status: 503, error: "database unavailable", data: "database unavailable" });
});

test("a 204 has data \"\"", async ({ mcp }) => {
  expect((await mcp.tools.call("deleteTicket", { id: "T-1" })).json()).toEqual({ status: 204, ok: true, data: "" });
});

test("redirects aren't followed", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-6" });
  expect(result.raw._meta).toMatchObject({ status: 301, errorCode: "OPENAPI_REDIRECT_NOT_FOLLOWED" });
  expect(received.some((r) => r.url.includes("elsewhere"))).toBe(false);
});

test("an API that doesn't answer in requestTimeoutMs fails the call", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-5" });
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result).toHaveTextContent("Request timeout after 200ms for tool 'getTicket'");
});

const refusal = async (mcp: any, tool: string, args: object) => {
  const result = await mcp.tools.call(tool, args);
  expect(result).toBeError("INVALID_INPUT");
  return result.text();
};

test("arguments that don't match the spec are refused, and nothing is sent", async ({ mcp }) => {
  const before = received.length;
  expect(await refusal(mcp, "getTicket", {})).toBe("Invalid arguments for tool 'getTicket': id: Invalid input: expected string, received undefined");
  expect(await refusal(mcp, "listTickets", { status: "bogus" })).toBe(`Invalid arguments for tool 'listTickets': status: Invalid option: expected one of "open"|"closed"`);
  expect(await refusal(mcp, "listTickets", { page: 2 })).toBe(`Invalid arguments for tool 'listTickets': input: Unrecognized key: "page"`);
  expect(await refusal(mcp, "listTickets", { limit: "5" })).toBe("Invalid arguments for tool 'listTickets': limit: Invalid input: expected number, received string");
  expect(await refusal(mcp, "listTickets", { status: "bogus", limit: 99 })).toBe(
    `Invalid arguments for tool 'listTickets': status: Invalid option: expected one of "open"|"closed"; limit: Too big: expected number to be <=50`,
  );
  expect(received.length).toBe(before);
});

test("format isn't checked", async ({ mcp }) => {
  expect(await mcp.tools.call("listTickets", { since: "yesterday" })).toBeSuccessful();
  expect(received.at(-1)?.url).toBe("https://desk.example/v1/tickets?since=yesterday");
});

test("a required parameter with a default can be left out, and the default is sent", async ({ mcp }) => {
  expect(await mcp.tools.call("exportTickets", {})).toBeSuccessful();
  expect(received.at(-1)?.url).toBe("https://desk.example/v1/tickets/export?format=csv");
});

test("an optional parameter's default isn't sent", async ({ mcp }) => {
  await mcp.tools.call("listTickets", {});
  expect(received.at(-1)?.url).toBe("https://desk.example/v1/tickets");
});
```

A refused call is an `INVALID_INPUT` error whose message says which argument is wrong and why, so the model can correct it and call again; `postToolTransforms` doesn't run, since there's no answer. [Arguments are checked against the spec](#arguments-are-checked-against-the-spec) lists what the check covers. The last two tests show what happens to a spec's `default`. The required `format` has one, so the check accepts a call without it, and the adapter sends `format=csv`. The optional `limit` isn't sent when the model leaves it out: the API applies its own default. Before 1.9.3 the adapter didn't send the default, and a call without `format` failed with `TOOL_EXECUTION_ERROR`.

> **Pitfall: The check is only as strict as the spec**
The adapter checks what the spec says, and nothing more. A parameter declared as a plain `string`, or a `date-time` (the check ignores `format`), lets through values the API may refuse with a `400`. Keep validating input in the API, as you would for any client.

### Choosing which operations become tools

Most APIs have operations the model shouldn't call. Name the ones to keep, or decide from the operation:

<Examples title="Selecting operations">

#### Example: By operationId
```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      generateOptions: { includeOperations: ["listTickets", "getTicket"] },
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };
const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: { operationId: "listTickets", summary: "List support tickets", tags: ["tickets"], responses: ok },
      post: { operationId: "createTicket", summary: "Open a support ticket", tags: ["tickets"], responses: ok },
    },
    "/tickets/{id}": {
      get: { operationId: "getTicket", summary: "Get one support ticket", tags: ["tickets"], parameters: id, responses: ok },
      delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["admin"], parameters: id, responses: ok },
    },
    "/admin/stats": {
      get: { summary: "Ticket statistics", tags: ["admin"], responses: ok },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  return url.hostname === "desk.example" ? Promise.resolve(Response.json({ path: url.pathname })) : realFetch(input, init);
};
```

```ts select.test.ts
import { test, expect } from "@frontmcp/testing";

test("only the listed operations, so not one without an operationId", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.sort()).toEqual(["getTicket", "listTickets"]);
});
```

#### Example: With filterFn
```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      generateOptions: {
        // Read-only operations that aren't tagged admin
        filterFn: (op) => op.method === "get" && !op.tags?.includes("admin"),
      },
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };
const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: { operationId: "listTickets", summary: "List support tickets", tags: ["tickets"], responses: ok },
      post: { operationId: "createTicket", summary: "Open a support ticket", tags: ["tickets"], responses: ok },
    },
    "/tickets/{id}": {
      get: { operationId: "getTicket", summary: "Get one support ticket", tags: ["tickets"], parameters: id, responses: ok },
      delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["admin"], parameters: id, responses: ok },
    },
    "/admin/stats": {
      get: { summary: "Ticket statistics", tags: ["admin"], responses: ok },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  return url.hostname === "desk.example" ? Promise.resolve(Response.json({ path: url.pathname })) : realFetch(input, init);
};
```

```ts select.test.ts
import { test, expect } from "@frontmcp/testing";

test("only GET operations outside admin", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.sort()).toEqual(["getTicket", "listTickets"]);
});
```

#### Example: By tag, method and path
```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      generateOptions: {
        includeTags: ["tickets"], // leaves out deleteTicket and GET /admin/stats, tagged admin
        excludeMethods: ["post"], // leaves out createTicket
      },
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };
const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: { operationId: "listTickets", summary: "List support tickets", tags: ["tickets"], responses: ok },
      post: { operationId: "createTicket", summary: "Open a support ticket", tags: ["tickets"], responses: ok },
    },
    "/tickets/{id}": {
      get: { operationId: "getTicket", summary: "Get one support ticket", tags: ["tickets"], parameters: id, responses: ok },
      delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["admin"], parameters: id, responses: ok },
    },
    "/admin/stats": {
      get: { summary: "Ticket statistics", tags: ["admin"], responses: ok },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  return url.hostname === "desk.example" ? Promise.resolve(Response.json({ path: url.pathname })) : realFetch(input, init);
};
```

```ts select.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

let adapters = 0;

/** The tools the same spec gives with these generateOptions. */
async function toolsWith(generateOptions: object) {
  @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: `desk-${++adapters}`, baseUrl: "https://desk.example/v1", spec, generateOptions })] })
  class Desk {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [Desk] });
  const { tools } = await server.listTools();
  await server.dispose();
  return tools.map((t) => t.name).sort();
}

test("operations tagged tickets, without POST", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.sort()).toEqual(["getTicket", "listTickets"]);
});

test("includePaths takes globs: * stays within one segment", async () => {
  expect(await toolsWith({ includePaths: ["/tickets"] })).toEqual(["createTicket", "listTickets"]);
  expect(await toolsWith({ includePaths: ["/tickets/*"] })).toEqual(["deleteTicket", "getTicket"]);
  expect(await toolsWith({ excludePaths: ["/admin/**"] })).toEqual(["createTicket", "deleteTicket", "getTicket", "listTickets"]);
});

test("readOnlyOnly keeps the operations that only read: GET here", async () => {
  expect(await toolsWith({ readOnlyOnly: true })).toEqual(["getTicket", "get_admin_stats", "listTickets"]);
});

test("methods match in any case, and one that isn't an HTTP method throws", async () => {
  expect(await toolsWith({ excludeMethods: ["delete"] })).not.toContain("deleteTicket");
  expect(await toolsWith({ excludeMethods: ["DELETE"] })).not.toContain("deleteTicket");
  await expect(toolsWith({ excludeMethods: ["remove"] })).rejects.toThrow('generateOptions.excludeMethods lists "remove", which is not an HTTP method');
});
```

`includeOperations` names operations by `operationId`, so `GET /admin/stats`, which has none, is left out; before 1.9.2 it got through as `get_admin_stats`. `filterFn` sees every operation. The tag, method and path filters, and `readOnlyOnly`, see every operation too, and can be combined: an operation must pass each one. Changed in 1.9: they were accepted and had no effect.

### Naming and describing the tools

An `operationId` is often a poor tool name, and a summary a thin description. `toolTransforms` rewrites them, corrects the [annotations](https://frontmcp.dev/reference/sdk/tool) the adapter guesses from each method, and can hide a tool from `tools/list`:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      descriptionMode: "combined",
      toolTransforms: {
        perTool: {
          getTicket: { name: "get_ticket" },
          listTickets: { name: "list_tickets", description: (d) => `${d} Use status to filter.` },
          // Closing a closed ticket changes nothing
          closeTicket: { name: "close_ticket", annotations: { idempotentHint: true } },
          deleteTicket: { name: "delete_ticket", hideFromDiscovery: true },
        },
      },
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };
const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: {
        operationId: "listTickets",
        summary: "List support tickets.",
        description: "Newest first, 20 at a time.",
        parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }],
        responses: ok,
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id.",
        "x-frontmcp": {
          icons: [{ src: "https://desk.example/icons/ticket.png", mimeType: "image/png", sizes: ["48x48"] }],
          meta: { "desk.example/team": "support" },
        },
        parameters: id,
        responses: ok,
      },
      delete: { operationId: "deleteTicket", summary: "Delete a support ticket.", parameters: id, responses: ok },
    },
    "/tickets/{id}/close": {
      post: {
        operationId: "closeTicket",
        summary: "Close a support ticket.",
        // A closed ticket can be reopened: nothing is lost
        "x-frontmcp": { annotations: { destructiveHint: false } },
        parameters: id,
        responses: ok,
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.hostname !== "desk.example") return realFetch(input, init);
  return Promise.resolve(Response.json({ id: url.pathname.split("/").pop(), title: "Cannot log in" }));
};
```

```ts names.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

const byName = async (mcp: any) => Object.fromEntries((await mcp.tools.list()).map((t: any) => [t.name, t]));

test("tools are renamed and described", async ({ mcp }) => {
  const tools = await byName(mcp);
  expect(Object.keys(tools).sort()).toEqual(["close_ticket", "get_ticket", "list_tickets"]);
  expect(tools.list_tickets.description).toBe("List support tickets.\n\nNewest first, 20 at a time. Use status to filter.");
});

test("annotations: the guess from the method, then x-frontmcp, then toolTransforms", async ({ mcp }) => {
  const tools = await byName(mcp);
  expect(tools.get_ticket.annotations).toEqual({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
  expect(tools.close_ticket.annotations).toEqual({ readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false });
});

test("the summary is the title", async ({ mcp }) => {
  expect((await byName(mcp)).get_ticket.title).toBe("Get one support ticket by its id.");
});

test("x-frontmcp icons and meta reach the tool", async ({ mcp }) => {
  const tool = (await byName(mcp)).get_ticket;
  expect(tool.icons).toEqual([{ src: "https://desk.example/icons/ticket.png", mimeType: "image/png", sizes: ["48x48"] }]);
  expect(tool._meta).toMatchObject({ "desk.example/team": "support" });
});

test("an overlay changes the spec before the tools are built", async () => {
  const overlay = { overlay: "1.0.0", actions: [{ target: "$.paths['/tickets'].get", update: { summary: "List support tickets, newest first." } }] };
  @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk-overlay", baseUrl: "https://desk.example/v1", spec, loadOptions: { overlays: overlay } })] })
  class DeskWithOverlay {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [DeskWithOverlay] });
  const { tools } = await server.listTools();
  await server.dispose();
  expect(tools.find((t) => t.name === "listTickets")?.title).toBe("List support tickets, newest first.");
});

test("a hidden tool isn't listed, and can still be called", async ({ mcp }) => {
  expect((await mcp.tools.call("delete_ticket", { id: "T-1" })).json()).toMatchObject({ status: 200, ok: true });
});
```

`perTool` is keyed by the name the tool had before the transform. Annotations are built in layers: the guess from the method, then the spec's `x-frontmcp`, then `toolTransforms`. `closeTicket` is a `POST`, so it's guessed destructive and not idempotent; its `x-frontmcp` says closing destroys nothing, and `perTool` that closing twice changes nothing. Each tool's `title` is its summary, and `getTicket`'s `x-frontmcp` gives it icons and a `_meta` entry. To change the spec's words without editing the spec file, which may be regenerated, apply an overlay with `loadOptions.overlays`, as the overlay test does.

A `hideFromDiscovery` tool answers anyone who knows its name, so hiding isn't a way to protect an operation: leave it out with `generateOptions` instead, or [require authorities](https://frontmcp.dev/reference/auth/authorities).

### Sending the API's credentials

When one account on the API serves every caller, give its credentials as `staticAuth`. The adapter puts each one where the spec's security scheme says:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      // In a real server: process.env.DESK_API_TOKEN and process.env.DESK_REPORTS_KEY
      staticAuth: { jwt: "desk-service-token", apiKey: "reports-key-1" },
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  components: {
    securitySchemes: {
      DeskToken: { type: "http", scheme: "bearer" },
      ReportsKey: { type: "apiKey", in: "header", name: "X-Reports-Key" },
    },
  },
  security: [{ DeskToken: [] }],
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
    "/reports/weekly": {
      get: { operationId: "weeklyReport", summary: "This week's ticket report", security: [{ ReportsKey: [] }], responses: ok },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { url: string; headers: Record<string, string> }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ url: request.url, headers: Object.fromEntries(request.headers) });
  return Promise.resolve(Response.json({ id: "T-1" }));
};
```

```ts auth.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";

test("a bearer scheme gets Authorization: Bearer <jwt>", async ({ mcp }) => {
  await mcp.tools.call("getTicket", { id: "T-1" });
  expect(received.at(-1)?.headers).toEqual({ accept: "application/json", authorization: "Bearer desk-service-token" });
});

test("an apiKey scheme gets its header", async ({ mcp }) => {
  await mcp.tools.call("weeklyReport", {});
  expect(received.at(-1)?.headers).toEqual({ accept: "application/json", "x-reports-key": "reports-key-1" });
});

test("credentials never appear in the tools' input", async ({ mcp }) => {
  for (const tool of await mcp.tools.list()) {
    expect(Object.keys(tool.inputSchema.properties ?? {})).not.toContain("DeskToken");
  }
});
```

When the API has an account per user or per tenant, pick the credential from the caller. `ctx.authInfo.user` holds the claims of the caller's token (see [authentication modes](https://frontmcp.dev/reference/auth/modes)). This server accepts tokens from an identity provider, and each tenant has its own API token; the tests call as two users, with tokens signed ahead of time:

```ts main.ts active
import "./desk.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

// In a real server these come from a secret store
const deskTokens: Record<string, string> = { acme: "desk-token-acme" };

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      securityResolver: (tool, ctx) => {
        // The claims of the caller's token; tenant is a claim this provider adds
        const tenant = (ctx.authInfo?.user as { tenant?: string } | undefined)?.tenant;
        return tenant && deskTokens[tenant] ? { jwt: deskTokens[tenant] } : {};
      },
    }),
  ],
})
class DeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [DeskApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts spec.ts
export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } },
  security: [{ DeskToken: [] }],
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } },
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { url: string; authorization: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ url: request.url, authorization: request.headers.get("authorization") });
  return Promise.resolve(Response.json({ id: "T-1" }));
};
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

/** Sends one tools/call over HTTP, as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: no tenant
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts tenant.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";
import { callAs } from "./call-as";
import { NOUR, SAM } from "./tokens";

test("nour's call uses her tenant's API token", async () => {
  const result = await callAs(NOUR, "getTicket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ status: 200, ok: true, data: { id: "T-1" } });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-token-acme");
});

test("a caller without a tenant is refused, and nothing is sent", async () => {
  const before = received.length;
  const result = await callAs(SAM, "getTicket", { id: "T-1" });
  expect(result.isError).toBe(true);
  expect(result.content[0].text).toContain("Authentication required for tool 'getTicket': no credential for its security schemes.");
  expect(received.length).toBe(before);
});
```

`securityResolver` returns `{}` when it has nothing for the caller, and the call fails without reaching the API. `authProviderMapper` works the same way, one function per scheme: a function that returns `undefined` gives the call no credential, and it fails. Two options give the adapter somewhere else to look: `staticAuth`, a shared account for the callers the mapper has nothing for, and `passthroughCallerToken`, the caller's own token:

<Examples title="When the mapper has nothing for the caller">

#### Example: Refused
```ts main.ts active
import "./desk.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

const deskTokens: Record<string, string> = { acme: "desk-token-acme" };

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      // Sam has no tenant, so this returns undefined for him
      authProviderMapper: { DeskToken: (ctx) => deskTokens[(ctx.authInfo?.user as { tenant?: string } | undefined)?.tenant ?? ""] },
    }),
  ],
})
class DeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [DeskApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } },
  security: [{ DeskToken: [] }],
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { url: string; authorization: string | null; reportsKey: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ url: request.url, authorization: request.headers.get("authorization"), reportsKey: request.headers.get("x-reports-key") });
  return Promise.resolve(Response.json({ id: "T-1" }));
};
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

/** Sends one tools/call over HTTP, as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: no tenant
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts refused.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";
import { callAs } from "./call-as";
import { NOUR, SAM } from "./tokens";

test("nour's call uses her tenant's API token", async () => {
  const result = await callAs(NOUR, "getTicket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ status: 200, ok: true, data: { id: "T-1" } });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-token-acme");
});

test("sam's call is refused, and his token isn't sent", async () => {
  const before = received.length;
  const result = await callAs(SAM, "getTicket", { id: "T-1" });
  expect(result.isError).toBe(true);
  expect(result.content[0].text).toContain("Authentication required for tool 'getTicket': no credential for its security schemes.");
  expect(received.length).toBe(before);
  expect(received.some((r) => r.authorization === `Bearer ${SAM}`)).toBe(false);
});
```

#### Example: A shared account
```ts main.ts active
import "./desk.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

const deskTokens: Record<string, string> = { acme: "desk-token-acme" };

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      // A tenant's own token when there is one, else the shared account
      authProviderMapper: { DeskToken: (ctx) => deskTokens[(ctx.authInfo?.user as { tenant?: string } | undefined)?.tenant ?? ""] },
      staticAuth: { jwt: "desk-token-shared" },
    }),
  ],
})
class DeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [DeskApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  components: { securitySchemes: { DeskToken: { type: "http", scheme: "bearer" } } },
  security: [{ DeskToken: [] }],
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { url: string; authorization: string | null; reportsKey: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ url: request.url, authorization: request.headers.get("authorization"), reportsKey: request.headers.get("x-reports-key") });
  return Promise.resolve(Response.json({ id: "T-1" }));
};
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

/** Sends one tools/call over HTTP, as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: no tenant
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts shared.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";
import { callAs } from "./call-as";
import { NOUR, SAM } from "./tokens";

test("nour's call uses her tenant's API token", async () => {
  const result = await callAs(NOUR, "getTicket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ status: 200, ok: true, data: { id: "T-1" } });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-token-acme");
});

test("sam's call uses the shared account", async () => {
  const result = await callAs(SAM, "getTicket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ status: 200, ok: true, data: { id: "T-1" } });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-token-shared");
});
```

#### Example: The caller's token
```ts main.ts active
import "./desk.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

const deskTokens: Record<string, string> = { acme: "desk-token-acme" };
const reportsKeys: Record<string, string> = { acme: "reports-key-acme" };
const tenantOf = (ctx: { authInfo?: { user?: unknown } }) => (ctx.authInfo?.user as { tenant?: string } | undefined)?.tenant ?? "";

export const credentials = {
  // For an API that accepts this server's tokens: the caller's own token when the mapper has none
  authProviderMapper: {
    DeskToken: (ctx: Parameters<typeof tenantOf>[0]) => deskTokens[tenantOf(ctx)],
    // passthroughCallerToken doesn't fill an API key, so ReportsKey needs a source of its own
    ReportsKey: (ctx: Parameters<typeof tenantOf>[0]) => reportsKeys[tenantOf(ctx)],
  },
  passthroughCallerToken: true,
};

@App({
  id: "desk",
  name: "Desk",
  adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec, ...credentials })],
})
class DeskApp {}

export const config = {
  info: { name: "help-desk", version: "1.0.0" },
  apps: [DeskApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  components: { securitySchemes: {
      DeskToken: { type: "http", scheme: "bearer" },
      ReportsKey: { type: "apiKey", in: "header", name: "X-Reports-Key" },
    } },
  security: [{ DeskToken: [] }],
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
    "/reports/weekly": {
      get: { operationId: "weeklyReport", summary: "This week's ticket report", security: [{ ReportsKey: [] }], responses: ok },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { url: string; authorization: string | null; reportsKey: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ url: request.url, authorization: request.headers.get("authorization"), reportsKey: request.headers.get("x-reports-key") });
  return Promise.resolve(Response.json({ id: "T-1" }));
};
```

```ts call-as.ts
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

/** Sends one tools/call over HTTP, as a client with this token would. */
export async function callAs(token: string | undefined, name: string, args: Record<string, unknown> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": name,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name, arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  return (await response.json()).result;
}
```

```ts tokens.ts
// Access tokens for two users, signed ahead of time with the key in main.ts.
// nour: tenant "acme"
export const NOUR = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJub3VyIiwiYXVkIjoiaHR0cHM6Ly9kZXNrLmV4YW1wbGUuY29tIiwiaWF0IjoxNzkwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsInNjb3BlIjoidGlja2V0czpyZWFkIHRpY2tldHM6d3JpdGUiLCJyb2xlcyI6WyJhZ2VudCJdLCJuYW1lIjoiTm91ciBIYWRkYWQiLCJlbWFpbCI6Im5vdXJAZXhhbXBsZS5jb20iLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsibGVhZCJdfSwidGVuYW50IjoiYWNtZSJ9.NGr_C0VEuc3p0Fc0GKq92Gytmn3Ig8XaDEO2GfA-X8g1rzN2flqNbuSB3Z_b1Mb0hy4mkv8KW7_ngW5sWE_YPw";
// sam: no tenant
export const SAM = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRlc2stMSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJzYW0iLCJhdWQiOiJodHRwczovL2Rlc2suZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwic2NvcGUiOiJ0aWNrZXRzOnJlYWQiLCJuYW1lIjoiU2FtIE9ydGl6In0.VHIKGx65AgtNp4Y4PtdLCJoQgXg5pOeBYp03oLgpr1l2elL9Gui6EcQuuBUeb-sYYFJo2-labA_nJwhSV9BLpw";
```

```ts passthrough.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { received } from "./desk.example";
import { callAs } from "./call-as";
import { config, credentials } from "./main";
import { spec } from "./spec";
import { NOUR, SAM } from "./tokens";

test("nour's call uses her tenant's API token", async () => {
  const result = await callAs(NOUR, "getTicket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ status: 200, ok: true, data: { id: "T-1" } });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-token-acme");
});

test("sam's own token goes to the API", async () => {
  const result = await callAs(SAM, "getTicket", { id: "T-1" });
  expect(result.structuredContent).toEqual({ status: 200, ok: true, data: { id: "T-1" } });
  expect(received.at(-1)?.authorization).toBe(`Bearer ${SAM}`);
});

test("a caller without a token is refused", async () => {
  const before = received.length;
  const result = await callAs(undefined, "getTicket", { id: "T-1" });
  expect(result.content[0].text).toContain("Authentication required for tool 'getTicket'");
  expect(received.length).toBe(before);
});

test("the caller's token doesn't stand in for an API key", async () => {
  expect((await callAs(NOUR, "weeklyReport")).structuredContent).toMatchObject({ status: 200, ok: true });
  expect(received.at(-1)).toMatchObject({ authorization: null, reportsKey: "reports-key-acme" });

  const before = received.length;
  const result = await callAs(SAM, "weeklyReport");
  expect(result.isError).toBe(true);
  expect(result.content[0].text).toContain("Required security schemes: ReportsKey (apiKey)");
  expect(received.length).toBe(before);
});

test("without a source for ReportsKey, the server doesn't start", async () => {
  @App({
    id: "desk",
    name: "Desk",
    adapters: [
      OpenapiAdapter.init({
        name: "desk-without-reports-key",
        baseUrl: "https://desk.example/v1",
        spec,
        authProviderMapper: { DeskToken: credentials.authProviderMapper.DeskToken },
        passthroughCallerToken: true,
      }),
    ],
  })
  class WithoutReportsKey {}

  await expect(FrontMcpInstance.createFetchHandler({ ...config, apps: [WithoutReportsKey] })).rejects.toThrow(
    "Missing auth provider mappings for security schemes: ReportsKey",
  );
});
```

Use `securityResolver` or `authProviderMapper` for per-caller credentials, and `staticAuth` for a service account. `passthroughCallerToken` only fills HTTP bearer schemes, with the caller's token as `jwt`. An API key gets nothing from it, so `ReportsKey` needs a source of its own: without one the server doesn't start, and an API-key call for a caller the mapper has nothing for is refused, with nothing sent.

A credential can also come from a header you set, or from the model. The check before each call looks at the request as it will be sent, so a scheme's header that `additionalHeaders` or `headersMapper` sets counts as its credential, and the scheme needs no `authProviderMapper` function. Credentials the model sends, for schemes in `securitySchemesInInput`, are sent too, unless the server has one of its own:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      // In a real server: process.env.DESK_API_TOKEN and process.env.DESK_REPORTS_KEY
      authProviderMapper: { DeskToken: () => "desk-service-token" },
      additionalHeaders: { "X-Reports-Key": "reports-key-1" }, // ReportsKey's credential: no mapper function needed
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  components: {
    securitySchemes: {
      DeskToken: { type: "http", scheme: "bearer" },
      ReportsKey: { type: "apiKey", in: "header", name: "X-Reports-Key" },
    },
  },
  security: [{ DeskToken: [] }],
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
    "/reports/weekly": {
      get: { operationId: "weeklyReport", summary: "This week's ticket report", security: [{ ReportsKey: [] }], responses: ok },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { url: string; authorization: string | null; reportsKey: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ url: request.url, authorization: request.headers.get("authorization"), reportsKey: request.headers.get("x-reports-key") });
  return Promise.resolve(Response.json({ id: "T-1" }));
};
```

```ts sources.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { received } from "./desk.example";
import { spec } from "./spec";

let servers = 0;
/** Starts a server with one adapter over the same spec, with these credential options. */
async function serverWith(options: object) {
  const name = `desk-${++servers}`; // adapter names are unique per process
  @App({ id: name, name, adapters: [OpenapiAdapter.init({ name, baseUrl: "https://desk.example/v1", spec, ...options })] })
  class Desk {}
  return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
}

test("a key from additionalHeaders is the scheme's credential", async ({ mcp }) => {
  await mcp.tools.call("weeklyReport", {});
  expect(received.at(-1)).toMatchObject({ reportsKey: "reports-key-1" });
  await mcp.tools.call("getTicket", { id: "T-1" });
  expect(received.at(-1)).toMatchObject({ authorization: "Bearer desk-service-token", reportsKey: "reports-key-1" }); // sent with every request
});

test("an Authorization header counts only for the scheme it names", async () => {
  const bearer = await serverWith({ additionalHeaders: { Authorization: "Bearer desk-service-token" } });
  await bearer.callTool("getTicket", { id: "T-1" });
  expect(received.at(-1)).toMatchObject({ authorization: "Bearer desk-service-token" });

  const before = received.length;
  const token = await serverWith({ additionalHeaders: { Authorization: "Token desk-service-token" } });
  await expect(token.callTool("getTicket", { id: "T-1" })).rejects.toThrow(
    "Authentication required for tool 'getTicket': no credential for its security schemes.",
  );
  expect(received.length).toBe(before);
});

test("a header headersMapper sets counts, and without it the call is refused", async () => {
  const server = await serverWith({
    authProviderMapper: { DeskToken: () => "desk-service-token" },
    // Only callers of the acme tenant get the reports key
    headersMapper: (ctx: { authInfo?: { user?: { tenant?: string } } }, headers: Headers) => {
      if (ctx.authInfo?.user?.tenant === "acme") headers.set("x-reports-key", "reports-key-acme");
      return headers;
    },
  });
  await server.callTool("weeklyReport", {}, { authContext: { user: { sub: "nour", tenant: "acme" } } });
  expect(received.at(-1)).toMatchObject({ reportsKey: "reports-key-acme" });
  await expect(server.callTool("weeklyReport", {}, { authContext: { user: { sub: "sam" } } })).rejects.toThrow(
    "Required security schemes: ReportsKey (apiKey)",
  );
});

test("the model's credential is sent, and the server's wins", async () => {
  const server = await serverWith({
    authProviderMapper: { DeskToken: () => "desk-service-token" },
    securitySchemesInInput: ["DeskToken", "ReportsKey"],
  });
  await server.callTool("weeklyReport", { ReportsKey: "key-from-the-model" });
  expect(received.at(-1)).toMatchObject({ reportsKey: "key-from-the-model" });
  await server.callTool("getTicket", { id: "T-1", DeskToken: "token-from-the-model" });
  expect(received.at(-1)).toMatchObject({ authorization: "Bearer desk-service-token" });
  await expect(server.callTool("weeklyReport", { ReportsKey: "key\r\nX-Admin: 1" })).rejects.toThrow("contains control characters");

  // An includeSecurityInInput list works the same way
  const listed = await serverWith({ authProviderMapper: { DeskToken: () => "desk-service-token" }, generateOptions: { includeSecurityInInput: ["ReportsKey"] } });
  await listed.callTool("weeklyReport", { ReportsKey: "key-from-the-model" });
  expect(received.at(-1)).toMatchObject({ reportsKey: "key-from-the-model" });
});
```

A header you set is sent for every operation, secured or not, so an API that doesn't expect `X-Reports-Key` on `/tickets` still gets it. `headersMapper` can set a per-caller credential, like a tenant's key, the same way. A credential the model provides is one it can be talked into sending, from whatever it has read, which is why `securitySchemesInInput` is rated `HIGH`: prefer a source on the server.

### Filling in inputs on the server

Some parameters shouldn't be the model's choice: a tenant id, a request id for tracing, who created a record. `inputTransforms` removes an argument from the tool's input and computes it on every call; `headersMapper` and `bodyMapper` add headers and body fields the spec doesn't declare:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      inputTransforms: {
        global: [{ inputKey: "X-Request-Id", inject: ({ ctx }) => ctx.requestId }],
        perTool: { createTicket: [{ inputKey: "channel", inject: () => "mcp" }] },
      },
      headersMapper: (ctx, headers) => {
        headers.set("x-trace-id", ctx.traceContext.traceId);
        return headers;
      },
      bodyMapper: (ctx, body) => ({ ...body, receivedAt: "2026-09-27T09:00:00Z" }),
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const requestId = { name: "X-Request-Id", in: "header", required: true, schema: { type: "string" } };
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      post: {
        operationId: "createTicket",
        summary: "Open a support ticket",
        parameters: [requestId],
        requestBody: {
          required: true,
          content: {
            "application/json": {
              schema: {
                type: "object",
                required: ["title", "channel"],
                properties: { title: { type: "string" }, channel: { type: "string", enum: ["email", "phone", "mcp"] } },
              },
            },
          },
        },
        responses: ok,
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [requestId, { name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { method: string; headers: Record<string, string>; body?: any }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = async (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  const text = await request.text();
  received.push({ method: request.method, headers: Object.fromEntries(request.headers), body: text ? JSON.parse(text) : undefined });
  return Response.json({ id: "T-2" });
};
```

```ts inputs.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";

test("the model no longer sees the injected arguments", async ({ mcp }) => {
  const create = (await mcp.tools.list()).find((t: { name: string }) => t.name === "createTicket");
  expect(Object.keys(create.inputSchema.properties)).toEqual(["title"]);
  expect(create.inputSchema.required).toEqual(["title"]);
});

test("the server fills them in, and adds its own header and body field", async ({ mcp }) => {
  await mcp.tools.call("createTicket", { title: "Printer on fire" });
  const sent = received.at(-1)!;
  expect(sent.body).toEqual({ title: "Printer on fire", channel: "mcp", receivedAt: "2026-09-27T09:00:00Z" });
  expect(sent.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
  expect(sent.headers["x-trace-id"]).toMatch(/^[0-9a-f]{32}$/);
});

test("a value the model sends anyway is dropped, and the server's is sent", async ({ mcp }) => {
  expect(await mcp.tools.call("createTicket", { title: "Printer on fire", channel: "phone", "X-Request-Id": "made-up" })).toBeSuccessful();
  const sent = received.at(-1)!;
  expect(sent.body).toMatchObject({ title: "Printer on fire", channel: "mcp" });
  expect(sent.headers["x-request-id"]).toMatch(/^[0-9a-f-]{36}$/);
});

test("bodyMapper isn't called for a request without a body", async ({ mcp }) => {
  await mcp.tools.call("getTicket", { id: "T-1" });
  expect(received.at(-1)).toMatchObject({ method: "GET", body: undefined });
});
```

`inputTransforms` also works for arguments the spec requires: here both `X-Request-Id` and `channel` are required, and the model is asked for neither. They're the server's: a value the model sends for one anyway is dropped before the [argument check](#arguments-are-checked-against-the-spec), and the injected value is sent. In 1.9.2 such a call was refused with `Unrecognized key: "channel"`. `inject` gets the request's [context](https://frontmcp.dev/reference/sdk/context), so a transform can read the caller's claims from `ctx.authInfo.user` too.

### Header parameters

A header parameter the model does fill in is an argument like the others, marked `x-mcp-header` in the input schema. Under the 2026-07-28 protocol that marker means the client must send the value twice: in the arguments, and as an `Mcp-Param-<name>` HTTP header, so proxies can route on it without reading the body. A call without the header is refused before the tool runs:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

@App({
  id: "desk",
  name: "Desk",
  adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })],
})
export class DeskApp {}
```

```ts spec.ts
export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      post: {
        operationId: "createTicket",
        summary: "Open a support ticket. Send the same Idempotency-Key to retry safely.",
        parameters: [{ name: "Idempotency-Key", in: "header", schema: { type: "string" } }],
        requestBody: {
          required: true,
          content: { "application/json": { schema: { type: "object", required: ["title"], properties: { title: { type: "string" } } } } },
        },
        responses: { "201": { description: "Created", content: { "application/json": { schema: { type: "object" } } } } },
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { headers: Record<string, string> }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  if (new URL(request.url).hostname !== "desk.example") return realFetch(input, init);
  received.push({ headers: Object.fromEntries(request.headers) });
  return Promise.resolve(Response.json({ id: "T-2" }, { status: 201 }));
};
```

```ts headers.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { DeskApp } from "./desk.app";
import { received } from "./desk.example";

test("the header parameter is marked x-mcp-header", async ({ mcp }) => {
  const create = (await mcp.tools.list()).find((t: { name: string }) => t.name === "createTicket");
  expect(create.inputSchema.properties["Idempotency-Key"]).toEqual({
    type: "string",
    "x-parameter-location": "header",
    "x-mcp-header": "Idempotency-Key",
  });
});

test("without the Mcp-Param header, the call is refused with -32020", async ({ mcp }) => {
  const result = await mcp.tools.call("createTicket", { title: "Printer on fire", "Idempotency-Key": "k-1" });
  expect(result).toBeError(-32020);
  expect(result.error?.message).toBe("Header mismatch: Mcp-Param-idempotency-key header is required for argument 'Idempotency-Key'");
});

test("with it, the value reaches the API as a header", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ info: { name: "help-desk", version: "1.0.0" }, apps: [DeskApp] });
  const args = { title: "Printer on fire", "Idempotency-Key": "k-1" };
  const response = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "mcp-protocol-version": "2026-07-28",
        "mcp-method": "tools/call",
        "mcp-name": "createTicket",
        "mcp-param-idempotency-key": "k-1",
      },
      body: JSON.stringify({
        jsonrpc: "2.0",
        id: 1,
        method: "tools/call",
        params: { name: "createTicket", arguments: args, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } },
      }),
    }),
  );
  expect((await response.json()).result.structuredContent).toMatchObject({ status: 201, ok: true });
  expect(received.at(-1)?.headers["idempotency-key"]).toBe("k-1");
});

test("an optional header parameter can be left out", async ({ mcp }) => {
  expect(await mcp.tools.call("createTicket", { title: "Printer on fire" })).toBeSuccessful();
});
```

A 2026-07-28 client is expected to read `x-mcp-header` from `tools/list` and add the header. The Playground's test client doesn't, so the second test gets the error such a client would. Credentials that `securitySchemesInInput` or `includeSecurityInInput` put in the input are marked `x-mcp-header` too, with the scheme's header name (`Authorization` for a bearer token). When the value isn't the model's to choose, remove the parameter with [`inputTransforms`](#filling-in-inputs-on-the-server), and the question doesn't arise.

### Reshaping what the model gets back

APIs return fields the model doesn't need, or shouldn't see. `postToolTransforms` changes `data` on every call; `outputSchema` can move the response's description into the tool's description, for clients that ignore `outputSchema`:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./spec";

type Ticket = { id: string; title: string; status: string; internal_notes?: string; assignee_email?: string };
const publicFields = ({ id, title, status }: Ticket) => ({ id, title, status });

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      outputSchema: { mode: "description" },
      dataTransforms: {
        postToolTransforms: {
          perTool: {
            getTicket: { transform: (data) => publicFields(data as Ticket), filter: ({ ok }) => ok },
            listTickets: { transform: (data) => ({ count: (data as Ticket[]).length, tickets: (data as Ticket[]).map(publicFields) }) },
          },
        },
      },
    }),
  ],
})
export class DeskApp {}
```

```ts spec.ts
const ticket = {
  type: "object",
  required: ["id", "title"],
  properties: {
    id: { type: "string", description: "Like T-1" },
    title: { type: "string" },
    status: { type: "string" },
  },
};

export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets": {
      get: {
        operationId: "listTickets",
        summary: "List support tickets",
        responses: { "200": { description: "The tickets", content: { "application/json": { schema: { type: "array", items: ticket } } } } },
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } },
      },
    },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
const ticket = { id: "T-1", title: "Cannot log in", status: "open", internal_notes: "VIP, handle first", assignee_email: "ana@desk.example" };

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (url.hostname !== "desk.example") return realFetch(input, init);
  if (url.pathname === "/v1/tickets") return Promise.resolve(Response.json([ticket]));
  if (url.pathname === "/v1/tickets/T-1") return Promise.resolve(Response.json(ticket));
  return Promise.resolve(Response.json({ message: "No ticket with that id" }, { status: 404 }));
};
```

```ts shape.test.ts
import { test, expect } from "@frontmcp/testing";

test("the model gets only the public fields", async ({ mcp }) => {
  expect((await mcp.tools.call("getTicket", { id: "T-1" })).json()).toEqual({
    status: 200,
    ok: true,
    data: { id: "T-1", title: "Cannot log in", status: "open" },
  });
  expect((await mcp.tools.call("listTickets", {})).json().data).toEqual({ count: 1, tickets: [{ id: "T-1", title: "Cannot log in", status: "open" }] });
});

test("filter skips the transform for errors", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-9" });
  expect(JSON.parse(result.text()).data).toEqual({ message: "No ticket with that id" });
});

test("the response schema moves into the description", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t: { name: string }) => t.name === "getTicket");
  expect(tool.description).toBe(
    "Get one support ticket by its id\n\n## Returns\n- **id**: string (required) - Like T-1\n- **title**: string (required)\n- **status**: string (optional)",
  );
  expect(tool.outputSchema).toBeUndefined();
});
```

The description still describes the API's ticket, not what the transform returns; when a transform changes the shape, say so with `toolTransforms` or `preToolTransforms.transformDescription`.

### Two APIs in one app

Each adapter needs its own `name`. When both specs have an operation with the same `operationId`, FrontMCP lists both tools with the adapter's name in front:

```ts desk.app.ts active
import "./apis.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { specFor } from "./specs";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec: specFor("Desk") }),
    OpenapiAdapter.init({ name: "billing", baseUrl: "https://billing.example/v2", spec: specFor("Billing") }),
  ],
})
export class DeskApp {}
```

```ts specs.ts
export const specFor = (api: string) => ({
  openapi: "3.0.3",
  info: { title: `${api} API`, version: "1.0.0" },
  paths: {
    "/status": {
      get: {
        operationId: "getStatus",
        summary: `Whether the ${api} API is up`,
        responses: { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } },
      },
    },
  },
});
```

```ts apis.example.ts
// Plays both APIs, which the Playground can't reach.
const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const url = new URL(input instanceof Request ? input.url : String(input));
  if (!url.hostname.endsWith(".example")) return realFetch(input, init);
  return Promise.resolve(Response.json({ up: true, url: url.href }));
};
```

```ts two.test.ts
import { test, expect } from "@frontmcp/testing";

test("clashing names get the adapter's name in front", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names.sort()).toEqual(["billing:getStatus", "desk:getStatus"]);
});

test("each tool calls its own API", async ({ mcp }) => {
  expect((await mcp.tools.call("billing:getStatus", {})).json().data.url).toBe("https://billing.example/v2/status");
  expect((await mcp.tools.call("desk:getStatus", {})).json().data.url).toBe("https://desk.example/v1/status");
});
```

The prefix appears only while the names clash: remove one adapter and the other's tool is `getStatus` again. To keep names stable, give them a prefix yourself: `toolTransforms: { global: { name: (n) => "billing_" + n } }`.

### Adding security the spec doesn't declare

Specs often leave out `security`, even when the API wants a token. `forceJwtSecurity()` returns a copy that requires a bearer token (or an API key, or basic credentials) on every operation, and `removeSecurityFromOperations()` takes it off again where the API is public:

```ts desk.app.ts active
import "./desk.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter, forceJwtSecurity, removeSecurityFromOperations } from "@frontmcp/adapters";
import { spec } from "./spec";

const secured = removeSecurityFromOperations(forceJwtSecurity(spec as any), ["getHealth"]);

@App({
  id: "desk",
  name: "Desk",
  adapters: [OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec: secured, staticAuth: { jwt: "desk-service-token" } })],
})
export class DeskApp {}
```

```ts spec.ts
const ok = { "200": { description: "OK", content: { "application/json": { schema: { type: "object" } } } } };

// No security declared, although the API wants a token on /tickets
export const spec = {
  openapi: "3.0.3",
  info: { title: "Desk API", version: "1.0.0" },
  paths: {
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
        responses: ok,
      },
    },
    "/health": { get: { operationId: "getHealth", summary: "Whether the Desk API is up", responses: ok } },
  },
};
```

```ts desk.example.ts
// Plays the Desk API at https://desk.example, which the Playground can't reach.
export const received: { path: string; authorization: string | null }[] = [];

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  const url = new URL(request.url);
  if (url.hostname !== "desk.example") return realFetch(input, init);
  received.push({ path: url.pathname, authorization: request.headers.get("authorization") });
  return Promise.resolve(Response.json({ ok: true }));
};
```

```ts security.test.ts
import { test, expect } from "@frontmcp/testing";
import { spec } from "./spec";
import { forceJwtSecurity } from "@frontmcp/adapters";
import { received } from "./desk.example";

test("forceJwtSecurity adds a BearerAuth scheme to every operation", () => {
  const secured: any = forceJwtSecurity(spec as any);
  expect(secured.components.securitySchemes.BearerAuth).toMatchObject({ type: "http", scheme: "bearer" });
  expect(secured.paths["/tickets/{id}"].get.security).toEqual([{ BearerAuth: [] }]);
  expect((spec as any).components).toBeUndefined(); // the original is unchanged
});

test("secured operations get the token, the public one doesn't", async ({ mcp }) => {
  await mcp.tools.call("getTicket", { id: "T-1" });
  await mcp.tools.call("getHealth", {});
  expect(received.slice(-2)).toEqual([
    { path: "/v1/tickets/T-1", authorization: "Bearer desk-service-token" },
    { path: "/v1/health", authorization: null },
  ]);
});
```

### Loading the spec from a URL

With `url` instead of `spec`, the adapter downloads the spec when the server starts. The Playground can't show it: in Node the download uses Node's own HTTP client, which checks the address it connects to, not `fetch`.

```ts desk.app.ts
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://api.desk.example/v1",
      url: "https://api.desk.example/openapi.json",
      loadOptions: {
        headers: { authorization: `Bearer ${process.env.DESK_SPEC_TOKEN}` },
        timeout: 10_000,
      },
      staticAuth: { jwt: process.env.DESK_API_TOKEN },
    }),
  ],
})
export class DeskApp {}
```

- If the spec can't be downloaded or doesn't validate, the server doesn't start.
- The spec's host must resolve to a public address. `http://127.0.0.1:4010/openapi.json`, a spec served by your own mock server, stops the server with `Host "127.0.0.1" maps to a blocked internal address` unless you set `loadOptions: { refResolution: { allowInternalIPs: true } }`.
- A redirect isn't followed unless `loadOptions.followRedirects` is `true`.
- `url` must be an `http:` or `https:` address. A file path, which its type's comment suggests, fails with `Invalid spec URL: ./openapi.json`. To load a local file, import the JSON and pass it as `spec`.

#### Picking up spec changes

With `polling`, the adapter downloads the spec again every `intervalMs` and rebuilds its tools when it changed, so a new operation becomes a tool without a restart:

```ts
OpenapiAdapter.init({
  name: "desk",
  baseUrl: "https://api.desk.example/v1",
  url: "https://api.desk.example/openapi.json",
  loadOptions: { headers: { authorization: `Bearer ${process.env.DESK_SPEC_TOKEN}` } },
  polling: {
    enabled: true,
    intervalMs: 60_000,
    // Polls don't send loadOptions.headers
    headers: { authorization: `Bearer ${process.env.DESK_SPEC_TOKEN}` },
  },
});
```

Checked in Node against a local server:

- FrontMCP starts polling once the adapter's first tools are registered. The first poll always counts as a change, so the tools are built twice at startup.
- When the spec changes, the adapter downloads it again (with `loadOptions.headers`), rebuilds every tool, and replaces the app's tools from this adapter; the next `tools/list` has the new ones. Rebuilds run one at a time. If a rebuild fails, the old tools stay and the error is logged.
- With `changeDetection: "auto"` (the default) and a server that sends an `ETag`, an unchanged spec costs a `304`.
- Poll errors are logged as warnings, and `unhealthyThreshold` failures in a row as an error; the tools keep working.
- Nothing stops the poller when the server shuts down. Keep the record and call `record.useValue.stopPolling()` on shutdown.

To watch a spec without an adapter, for a deploy check say, use `OpenApiSpecPoller` directly:

```ts
import { OpenApiSpecPoller } from "@frontmcp/adapters";

const poller = new OpenApiSpecPoller(
  "https://api.desk.example/openapi.json",
  { intervalMs: 300_000, headers: { authorization: `Bearer ${process.env.DESK_SPEC_TOKEN}` } },
  {
    onChanged: (spec, hash) => console.log(`Desk API spec changed: ${hash.slice(0, 12)}`),
    onUnhealthy: (failures) => console.error(`Desk API spec unreachable ${failures} times in a row`),
  },
);
poller.start();
```

---

## Troubleshooting

### `Duplicate adapter name 'x' for OpenapiAdapter`

Two `OpenapiAdapter.init()` calls used the same `name` in one process. Give each adapter its own name. If you only have one, `init()` ran twice: a server restarted in the same process (tests, hot reload), or a function that builds the config was called again. Call `init()` once at module level and reuse the record. The Playground starts each run with fresh names, so it doesn't hit this.

### `Adapter OpenapiAdapter.init() requires a non-empty 'name' option`

`name` is missing or empty. `init()` with no argument, or with `init({})`, reaches this error too. (Before 1.8.7, `init()` with no argument failed with `Cannot read properties of undefined (reading 'name')`.)

### `Invalid security configuration. Missing auth provider mappings for security schemes: …`

You set `authProviderMapper`, and the spec uses a security scheme it has no function for, and no other source covers. The server doesn't start. Add a function for each scheme listed (it gets the request's context: `(ctx) => …`), set `staticAuth` for the schemes one account covers, set the scheme's header with `additionalHeaders` or `headersMapper`, or put the scheme in `securitySchemesInInput` if the model should provide it. `passthroughCallerToken: true` covers HTTP bearer schemes only, and the message suggests it only for those: an API key isn't filled by the caller's token.

### `Authentication required for tool '…': no credential for its security schemes.`

The operation requires credentials, and the request, as it would have been sent, carries none for any of its schemes, listed on the next line (`Required security schemes: ReportsKey (apiKey)`). Nothing was sent. Set `staticAuth`, return credentials for this caller from `securityResolver` or `authProviderMapper`, or set the scheme's header with `additionalHeaders` or `headersMapper`. For a caller you have no API credentials for, this is the error they get, which is what you want. Check what counts for the scheme in [how credentials are chosen](#how-credentials-are-chosen): an `Authorization` header must start with `Bearer` (or `Basic`), and `passthroughCallerToken` fills only HTTP bearer schemes. The adapter doesn't fall back to the caller's own token: if the API accepts your server's tokens, set `passthroughCallerToken: true`, and read [its pitfall](#how-credentials-are-chosen) first.

### `Tool name collision: "…" produced by 2 operations`

Two operations of one spec got the same tool name, usually from a `toolTransforms` rename or a `toolNameGenerator`. The server doesn't start. Rename one with `toolTransforms.perTool`.

### `Header mismatch: Mcp-Param-… header is required for argument '…'`

JSON-RPC error `-32020`. The tool has a [header parameter](#header-parameters), and a 2026-07-28 client sent its value in the arguments without the `Mcp-Param-` header. Update the client, or fill the parameter in on the server with `inputTransforms`.

### `Invalid arguments for tool '…': …`

Code `INVALID_INPUT`. The model's arguments don't match the operation's schema in the spec, and nothing was sent; the message names each argument and what's wrong with it. A model reads it and calls again. If it keeps sending the same wrong value, say what's allowed in the description, or check the spec: an `enum` or a range that's stricter than the API refuses values the API would take. On FrontMCP 1.9.2, `Unrecognized key` for an argument you fill in with `inputTransforms` meant the model sent it anyway; 1.9.3 drops the model's value. See [Arguments are checked against the spec](#arguments-are-checked-against-the-spec).

### `Required query parameter '…' (input: '…') is missing for operation '…'`

A `TOOL_EXECUTION_ERROR`, or `Required body parameter '…'` for a field of the body. The spec requires the parameter, an [`inputTransforms`](#filling-in-inputs-on-the-server) entry fills it in, and its `inject` returned `undefined`, so there was nothing to send. The spec's `default` doesn't apply to an argument a transform fills in. Return a value from `inject`. On FrontMCP 1.9.2 a required parameter with a `default` that the model left out failed here too: update, and the default is sent. (Before 1.9.2, every missing required parameter failed here.)

### The API receives values that aren't in the spec

Only values the [argument check](#arguments-are-checked-against-the-spec) doesn't cover get through: a string that doesn't match its `format`, and a value that matches more than one branch of a `oneOf`. On FrontMCP before 1.9.2, arguments weren't checked at all: update.

### `generateOptions.excludeMethods lists "…", which is not an HTTP method`

`OpenapiAdapter.init()` throws for a name in `includeMethods` or `excludeMethods` that isn't `get`, `post`, `put`, `patch`, `delete`, `head`, `options` or `trace`, in any case.

### `Unknown field '…' in x-frontmcp (will be ignored)`

An operation's `x-frontmcp` has a field the adapter doesn't know, and the tool doesn't get it. [The `x-frontmcp` extension](#the-x-frontmcp-extension) lists the fields it knows. On FrontMCP 1.9.2 `icons` and `meta` got this warning too, though the tool listed them: the warning could be left alone.

### The API receives the MCP client's token

The adapter has `passthroughCallerToken: true`, the operation uses an HTTP bearer scheme, and neither `authProviderMapper` nor `staticAuth` gave that call a credential. Without the option, the caller's token is never sent. See [how credentials are chosen](#how-credentials-are-chosen).

### `Invalid OpenAPI document`

The spec didn't validate. A Swagger 2.0 document fails with `Missing required field: openapi`: convert it to OpenAPI 3 first. To build tools from a spec that's valid enough but fails the check, set `loadOptions: { validate: false }`.

### A `$ref` stays unresolved in a tool's schema

It points outside the document, and external references aren't fetched by default. Allow its host with `loadOptions.refResolution`, or bundle the spec into one file.

### `Host "…" maps to a blocked internal address`

The spec `url` (or an external `$ref`) is on a private network or loopback. Set `loadOptions: { refResolution: { allowInternalIPs: true } }` if you trust it, or pass the spec as `spec`.

### `Polling requires URL-based options (use 'url' instead of 'spec').`

`polling` needs `url`: there's nothing to poll with an in-memory `spec`.

### `Invalid adapter '…'. Expected useFactory to return the adapter's options object.`

An adapter registered with `init({ name, inject, useFactory })` has a factory that returned something other than the options: `undefined`, say, from a function that forgot to `return`. The server doesn't start. See [Caveats](#caveats). `Cannot read properties of undefined (reading 'name')` for the same record is FrontMCP before 1.9.0, where `useFactory` didn't work: update, or build the options at module level and pass them to `init()` directly.

### Options in `generateOptions` do nothing

On FrontMCP before 1.9.0, `includeTags`, `readOnlyOnly`, `descriptionStrategy`, `maxToolNameLength` and the other fields listed under [`generateOptions`](#generateoptions) weren't passed on, and before 1.9.2 `inferAnnotations`, `emitMeta` and icons didn't reach the tools: update. Then check the values: paths are globs, where `*` stays within one segment, and `includeOperations` leaves out operations without an `operationId`.
