# Wrapping an OpenAPI Service

> How to turn a REST API with an OpenAPI description into FrontMCP tools with the OpenAPI adapter, what each call sends and returns, how to give the adapter the API's credentials, how to choose and name the operations the model sees, and when an API is too big for one tool per operation.

Source: https://frontmcp.dev/learn/wrapping-an-openapi-service

The help desk's tickets don't live in your server. They live in the Desk API, a REST service another team runs, and that team publishes an OpenAPI description of it. You could write a tool for each endpoint with [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services), but the description already says everything those tools would repeat: the paths, the parameters, the bodies, the responses. The **OpenAPI adapter**, from `@frontmcp/adapters`, reads it and gives your app one tool per operation, which builds and sends the request the description says, with credentials you configure.

**You will learn**
- How to turn an OpenAPI description into tools
- What a call sends to the API, and what the model gets back
- How to give the adapter the API's credentials, without the model seeing them
- How to choose which operations become tools, and name them for the model
- What to do when an API is too big for one tool per operation

## Turning a spec into tools

Install the adapters package:

```bash
npm install @frontmcp/adapters
```

Then give the adapter a name, the API's address and its description, the **spec**, and list it in the app's `adapters`:

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

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

```ts desk-spec.ts
const ticket = {
  type: "object",
  properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["open", "closed"] } },
};
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, newest first",
        parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }],
        responses: { "200": { description: "The tickets", content: { "application/json": { schema: { type: "array", items: 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: ticket } } } },
      },
    },
    "/tickets/{id}": {
      get: {
        operationId: "getTicket",
        summary: "Get one support ticket by its id",
        parameters: [id],
        responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } },
      },
      delete: {
        operationId: "deleteTicket",
        summary: "Delete a support ticket",
        parameters: [id],
        responses: { "204": { description: "Deleted" } },
      },
    },
  },
};
```

```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; body?: unknown }[] = [];

const tickets: Record<string, { id: string; title: string; status: string }> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open" },
  "T-2": { id: "T-2", title: "Invoice total is wrong", status: "closed" },
  "T-3": { id: "T-3", title: "Login link expired", status: "open" },
};

async function desk(request: Request): Promise<Response> {
  const text = await request.text();
  received.push({ method: request.method, url: request.url, ...(text ? { body: JSON.parse(text) } : {}) });
  const url = new URL(request.url);
  if (request.method === "POST") return Response.json({ id: "T-4", status: "open", ...JSON.parse(text) }, { status: 201 });
  if (request.method === "DELETE") return new Response(null, { status: 204 });
  if (url.pathname === "/v1/tickets") {
    const status = url.searchParams.get("status");
    return Response.json(Object.values(tickets).filter((t) => !status || t.status === status));
  }
  const ticket = tickets[url.pathname.replace("/v1/tickets/", "")];
  return ticket ? Response.json(ticket) : 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 desk.test.ts
import { test, expect } from "@frontmcp/testing";
import { received } from "./desk.example";

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).toEqual(["createTicket", "deleteTicket", "getTicket", "listTickets"]);
});

test("its parameters and body fields make one input", async ({ mcp }) => {
  const create = (await mcp.tools.list()).find((t: { name: string }) => t.name === "createTicket");
  expect(create.description).toBe("Open a support ticket");
  expect(Object.keys(create.inputSchema.properties)).toEqual(["title", "priority"]);
  expect(create.inputSchema.required).toEqual(["title"]);
});

test("a call sends the request the spec describes", async ({ mcp }) => {
  await mcp.tools.call("listTickets", { status: "open" });
  expect(received.at(-1)).toEqual({ method: "GET", url: "https://desk.example/v1/tickets?status=open" });

  await mcp.tools.call("createTicket", { title: "Printer on fire", priority: "high" });
  expect(received.at(-1)).toEqual({ method: "POST", url: "https://desk.example/v1/tickets", body: { title: "Printer on fire", priority: "high" } });
});

test("the model gets the API's status and body", 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" } });
});
```

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

Open the **Capabilities** tab: four tools, one per operation, with no tool code of yours.

- **`name`** names the adapter. It must be unique among all the adapters in the process.
- **`baseUrl`** is where the API is. Each operation's path is added to it.
- **`spec`** is the OpenAPI 3.0 or 3.1 document, as an object: import the JSON file, or pass `url` instead to download it when the server starts.
- **Each tool** is named by the operation's `operationId` and described by its `summary`. Its input has one property for each path, query, header and cookie parameter and each field of the JSON body, and the adapter puts each value back where the spec says it goes.
- **The result** is `{ status, ok, data }`: the API's HTTP status and its parsed JSON body.

That already works, and it already has problems, which the rest of this lesson fixes: `deleteTicket` is a tool the model can call, the names are the API's, and the real Desk API wants a token, which nothing sends yet.

## What the model gets back

The API's answers pass through as they are, errors included. A `4xx` or `5xx` is an error result that still carries the status and the body, so the model can tell a ticket that doesn't exist from an API that's down:

```ts errors.test.ts active
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(JSON.parse(result.text())).toEqual({ status: 404, error: "No ticket with that id", data: { message: "No ticket with that id" } });
});

test("a 204 comes back with empty data", async ({ mcp }) => {
  expect((await mcp.tools.call("deleteTicket", { id: "T-2" })).json()).toEqual({ status: 204, ok: true, data: "" });
});

test("a missing required argument is refused before anything is sent", async ({ mcp }) => {
  const before = received.length;
  const result = await mcp.tools.call("getTicket", {});
  expect(result).toBeError("INVALID_INPUT");
  expect(result).toHaveTextContent("Invalid arguments for tool 'getTicket': id: Invalid input: expected string, received undefined");
  expect(received.length).toBe(before);
});

test("so is a value the spec doesn't allow, or a key it doesn't have", async ({ mcp }) => {
  const before = received.length;
  const urgent = await mcp.tools.call("listTickets", { status: "urgent" });
  expect(urgent).toBeError("INVALID_INPUT");
  expect(urgent).toHaveTextContent(`Invalid arguments for tool 'listTickets': status: Invalid option: expected one of "open"|"closed"`);
  const unknownKey = await mcp.tools.call("listTickets", { assignee: "sam" });
  expect(unknownKey).toHaveTextContent(`Invalid arguments for tool 'listTickets': input: Unrecognized key: "assignee"`);
  expect(received.length).toBe(before);
});
```

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

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

```ts desk-spec.ts
const ticket = {
  type: "object",
  properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["open", "closed"] } },
};
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, newest first",
        parameters: [{ name: "status", in: "query", schema: { type: "string", enum: ["open", "closed"] } }],
        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: [id],
        responses: { "200": { description: "The ticket", content: { "application/json": { schema: ticket } } } },
      },
      delete: {
        operationId: "deleteTicket",
        summary: "Delete a support ticket",
        parameters: [id],
        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 }[] = [];

const tickets: Record<string, { id: string; title: string; status: string }> = {
  "T-1": { id: "T-1", title: "Cannot log in", status: "open" },
  "T-2": { id: "T-2", title: "Invoice total is wrong", status: "closed" },
};

async function desk(request: Request): Promise<Response> {
  received.push({ method: request.method, url: request.url });
  const url = new URL(request.url);
  if (request.method === "DELETE") return new Response(null, { status: 204 });
  if (url.pathname === "/v1/tickets") return Response.json(Object.values(tickets));
  const ticket = tickets[url.pathname.replace("/v1/tickets/", "")];
  return ticket ? Response.json(ticket) : 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);
};
```

The error's text is `{"status":404,"error":"No ticket with that id","data":{…}}`: `error` is the body's `message` field, or the body itself when it's text. A redirect isn't followed, and a request that takes longer than `requestTimeoutMs` (30 seconds by default) fails the call. [What the model gets back](https://frontmcp.dev/reference/adapters/openapi#what-the-model-gets-back) covers each case.

Before it sends anything, the adapter checks the arguments against the spec, as the last two tests show. A missing required parameter, a value outside an `enum`, or a key the operation doesn't have is refused with the code `INVALID_INPUT`, and the message names the argument and what's wrong with it, so the model can fix the call and try again. The API receives nothing. [Arguments are checked against the spec](https://frontmcp.dev/reference/adapters/openapi#arguments-are-checked-against-the-spec) lists what the check covers.

> **Pitfall: The check is only as strict as the spec**
The adapter checks what the spec says, and nothing more. A `status` declared as a plain `string`, with no `enum`, lets `"urgent"` through to the API, which then answers `400`: an error result, like the `404` above. Keep validating input in the API, as you would for any client, and keep the spec as exact as the API: an `enum` in the spec is also what tells the model which values to send.

## Giving the adapter the API's credentials

The real Desk API wants a bearer token on every request, and its spec says so, with a security scheme. An adapter with no credentials doesn't guess: it refuses the call, and sends nothing. Give it the token the Desk team issued for your server, as `staticAuth`:

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

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

```ts desk-spec.ts
const ticket = {
  type: "object",
  properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["open", "closed"] } },
};

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: "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.
// It wants the token the Desk team issued for the help desk server.
export const received: { url: string; authorization: string | null }[] = [];

async function desk(request: Request): Promise<Response> {
  const authorization = request.headers.get("authorization");
  received.push({ url: request.url, authorization });
  if (authorization !== "Bearer desk-service-token") return Response.json({ message: "Invalid token" }, { status: 401 });
  return Response.json({ id: "T-1", title: "Cannot log in", status: "open" });
}

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 credentials.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 "./desk-spec";

test("the API gets the token as a bearer token", async ({ mcp }) => {
  expect((await mcp.tools.call("getTicket", { id: "T-1" })).json()).toMatchObject({ status: 200, ok: true });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-service-token");
});

test("the model never sees it: it's in no tool's input", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t: { name: string }) => t.name === "getTicket");
  expect(Object.keys(tool.inputSchema.properties)).toEqual(["id"]);
});

test("🚩 without credentials, calls are refused, and nothing is sent", async () => {
  @App({ id: "desk", name: "Desk", adapters: [OpenapiAdapter.init({ name: "desk-without-token", baseUrl: "https://desk.example/v1", spec })] })
  class DeskWithoutToken {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [DeskWithoutToken] });
  const before = received.length;
  await expect(server.callTool("getTicket", { id: "T-1" })).rejects.toThrow(
    "Authentication required for tool 'getTicket': no credential for its security schemes.",
  );
  await server.dispose();
  expect(received.length).toBe(before);
});
```

The adapter reads the spec's security scheme to know where a credential goes: `DeskToken` is an HTTP bearer scheme, so `jwt` is sent as `Authorization: Bearer desk-service-token`. An `apiKey` scheme would get `apiKey` in the header, query parameter or cookie it names. The token never appears in a tool's input or result, so the model can't see it, repeat it, or be talked into sending a different one.

`staticAuth` is one account for every caller, which suits an API that trusts your server as a whole. When the API has an account per user or per customer, pick the credential per call instead, from who's calling:

**Deep dive: A token per tenant**
`securityResolver(tool, ctx)` returns the credentials for one call. `ctx.authInfo.user` holds the claims of the caller's token, so a tenant claim can pick the tenant's API token. The tests call the server in-process as two signed-in agents: `nour`, whose tenant has a token, and `sam`, whose tenant has none:

```ts main.ts active
import "./desk.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./desk-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) => {
        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] };

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

```ts desk-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: "The ticket", 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", title: "Cannot log in", status: "open" }));
};
```

```ts tenant.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { received } from "./desk.example";
import { config } from "./main";

const nour = { authContext: { user: { sub: "nour", tenant: "acme" } } };
const sam = { authContext: { user: { sub: "sam", tenant: "globex" } } };

test("nour's call uses her tenant's token", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const result = await server.callTool("getTicket", { id: "T-1" }, nour);
  await server.dispose();
  expect(result.structuredContent).toMatchObject({ status: 200, ok: true });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-token-acme");
});

test("sam's tenant has no token, so his call is refused, and nothing is sent", async () => {
  const server = await FrontMcpInstance.createDirect(config);
  const before = received.length;
  await expect(server.callTool("getTicket", { id: "T-1" }, sam)).rejects.toThrow("Authentication required for tool 'getTicket'");
  await server.dispose();
  expect(received.length).toBe(before);
});
```

Returning `{}` gives the call no credential, so it's refused before anything is sent. `authProviderMapper` does the same with one function per security scheme, and falls back to `staticAuth` for a caller it has nothing for. [How credentials are chosen](https://frontmcp.dev/reference/adapters/openapi#how-credentials-are-chosen) lists every option, and the order they're tried in.

> **Pitfall: Don't send the caller's token to the API**
The token a client sends to your server was issued for your server. `passthroughCallerToken: true` forwards it to the API as the bearer token, and then whoever runs the API, or reads its logs, holds a token that works on your server as that user. The MCP specification forbids that kind of token passthrough. The adapter never sends the caller's token unless you set the option; set it only for an API that accepts the same tokens as your server, from the same identity provider, for the same audience.

## Choosing which operations become tools

Most APIs have operations the model shouldn't call. `deleteTicket` is one: a support agent who asks the model to "clean up the duplicate" shouldn't end up with a ticket deleted for good. Leave it out with `generateOptions`, which decides which operations become tools. `filterFn` is called with each operation, its `method` and its `path`:

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

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      generateOptions: {
        // ✅ No deletes, and nothing tagged admin
        filterFn: (op) => op.method !== "delete" && !op.tags?.includes("admin"),
      },
    }),
  ],
})
export class DeskApp {}
```

```ts desk-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, newest first", 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 by its id", tags: ["tickets"], parameters: id, responses: ok },
      delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["tickets"], parameters: id, responses: ok },
    },
    "/admin/agents": {
      get: { operationId: "listAgents", summary: "List support agents, with their roles", tags: ["admin"], 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; url: 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({ method: request.method, url: request.url });
  return Promise.resolve(Response.json({ ok: true }));
};
```

```ts select.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 "./desk-spec";

test("only the operations the filter keeps are tools", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual(["createTicket", "getTicket", "listTickets"]);
});

test("a left-out operation can't be called at all", async ({ mcp }) => {
  const result = await mcp.tools.call("deleteTicket", { id: "T-2" });
  expect(result).toBeError();
  expect(result).toHaveTextContent('Tool "deleteTicket" not found');
  expect(received.some((r) => r.method === "DELETE")).toBe(false);
});

test("excludeMethods and excludeTags keep the same operations", async () => {
  @App({
    id: "desk",
    name: "Desk",
    adapters: [
      OpenapiAdapter.init({
        name: "desk-by-fields",
        baseUrl: "https://desk.example/v1",
        spec,
        generateOptions: { excludeMethods: ["delete"], excludeTags: ["admin"] },
      }),
    ],
  })
  class DeskByFields {}

  const server = await FrontMcpInstance.createDirect({ info: { name: "desk", version: "1.0.0" }, apps: [DeskByFields] });
  expect((await server.listTools()).tools.map((t) => t.name).sort()).toEqual(["createTicket", "getTicket", "listTickets"]);
  await server.dispose();
});
```

An operation the filter leaves out isn't a tool: it's not listed, and a client that calls it by name gets `Tool "deleteTicket" not found`. That's the difference from hiding a tool, below. Two simpler options pick operations by `operationId`: `includeOperations: ["listTickets", "getTicket"]` keeps only those, and `excludeOperations: ["deleteTicket"]` leaves those out. An operation without an `operationId` can't be listed, so `includeOperations` leaves it out; `filterFn` sees every operation.

> **Note**
`generateOptions` also has fields for the usual cases: `includeTags` and `excludeTags`, `includeMethods` and `excludeMethods` (in any case, `"delete"` or `"DELETE"`; `OpenapiAdapter.init()` throws for a name that isn't an HTTP method), `includePaths` and `excludePaths`, and `readOnlyOnly`. `excludeMethods: ["delete"], excludeTags: ["admin"]` keeps the same operations as the `filterFn` above, as the last test shows. See [`generateOptions`](https://frontmcp.dev/reference/adapters/openapi#generateoptions). (Before FrontMCP 1.9, the adapter didn't pass these fields on, so they did nothing.)

## Naming tools for the model

`listTickets` is the API's name, in the API's style. Next to your own tools, like `search_tickets`, the model reads a list in two styles. `toolTransforms` rewrites what the spec gave each tool:

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

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      descriptionMode: "combined", // the summary, then the description
      toolTransforms: {
        // A POST here only adds: it never changes or deletes what's there
        generator: (tool) => (tool.metadata.method === "post" ? { annotations: { destructiveHint: false } } : undefined),
        perTool: {
          listTickets: { name: "list_tickets" },
          getTicket: { name: "get_ticket" },
          createTicket: { name: "create_ticket", description: (d) => `${d} Search with list_tickets first, to avoid duplicates.` },
        },
      },
    }),
  ],
})
export class DeskApp {}
```

```ts desk-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" },
  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,
      },
      post: {
        operationId: "createTicket",
        summary: "Open a support ticket.",
        requestBody: { content: { "application/json": { schema: { type: "object", properties: { title: { type: "string" } } } } } },
        responses: ok,
      },
    },
    "/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.
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: "T-1", title: "Cannot log in", status: "open" }));
};
```

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

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

test("the tools have the names you gave them", async ({ mcp }) => {
  expect(Object.keys(await byName(mcp))).toEqual(["create_ticket", "get_ticket", "list_tickets"]);
});

test("descriptions combine the spec's summary and description, plus your words", async ({ mcp }) => {
  const tools = await byName(mcp);
  expect(tools.list_tickets.description).toBe("List support tickets.\n\nNewest first, 20 at a time.");
  expect(tools.create_ticket.description).toBe("Open a support ticket. Search with list_tickets first, to avoid duplicates.");
});

test("annotations come from the HTTP method, and the generator corrects the POSTs", async ({ mcp }) => {
  const tools = await byName(mcp);
  expect(tools.get_ticket.annotations).toEqual({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false });
  expect(tools.create_ticket.annotations).toEqual({ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false });
});

test("the spec's summary is each tool's title", async ({ mcp }) => {
  expect((await byName(mcp)).list_tickets.title).toBe("List support tickets.");
});
```

- **`perTool`** is keyed by the tool's name before the transform. `description` takes a string, or a function that gets the current description.
- **Annotations** start from each operation's HTTP method: a `GET` is `readOnlyHint` and `idempotentHint`, and a `POST`, `PUT`, `PATCH` or `DELETE` is `destructiveHint`. That's a guess from HTTP's rules. A `POST` that only adds, like `createTicket`, destroys nothing, so the `generator` says so, and what a transform sets is merged over the guess.
- **`generator`** is called with every generated tool and returns a transform or `undefined`. `tool.metadata` has the operation's `method`, `path`, `operationId` and `tags`.
- **The title** is the operation's `summary`, for clients to show people. The model reads the name and the description.
- **`descriptionMode`** decides how the spec's `summary` and `description` make the tool's description. The default, `"summaryOnly"`, uses the `summary` alone when there is one.

`toolTransforms` can also set `hideFromDiscovery: true`, which leaves a tool out of `tools/list`. It's still called by anyone who knows its name, so it's no way to protect an operation: leave the operation out with `generateOptions`, as above, or [require authorities](https://frontmcp.dev/learn/authorizing-calls). To fill in an argument on the server, like a tenant id, rather than asking the model for it, see [`inputTransforms`](https://frontmcp.dev/reference/adapters/openapi#filling-in-inputs-on-the-server).

## An API too big to list

The Desk API has four operations; a billing or CRM API can have four hundred. One tool per operation then has the problem the [CodeCall lesson](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall) starts with: a tool list too long for the model to read. Three ways out, from the least change to the most:

| | What the model sees | Use it when |
| --- | --- | --- |
| **Filter** with `generateOptions` | The operations you keep, as tools | The model needs a known handful of operations. |
| **CodeCall** in front of the adapter | CodeCall's tools; it searches the operations and calls them, or runs a script | The model needs many operations, and the spec's summaries are good enough to search. |
| **Skilled OpenAPI** instead of the adapter | Three tools that find and load **skills**: groups of operations with instructions you write | The API needs explaining, like which call comes first, or you want to limit who may run each operation. |

The adapter's tools are ordinary tools, so CodeCall treats them like any other. Here the model finds `closeTicket` by searching, and calls it; the token still comes from `staticAuth`:

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

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

```ts desk-spec.ts
const ticket = { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, status: { type: "string" } } };
const id = [{ name: "id", in: "path", required: true, schema: { type: "string" } }];
const returns = (description: string) => ({ "200": { description, content: { "application/json": { schema: ticket } } } });

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: id, responses: returns("The ticket") } },
    "/tickets/{id}/close": { post: { operationId: "closeTicket", summary: "Close a support ticket", parameters: id, responses: returns("The closed ticket") } },
    "/tickets/{id}/reopen": { post: { operationId: "reopenTicket", summary: "Reopen a closed support ticket", parameters: id, responses: returns("The reopened ticket") } },
  },
};
```

```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; 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({ method: request.method, url: request.url, authorization: request.headers.get("authorization") });
  const status = url.pathname.endsWith("/close") ? "closed" : "open";
  return Promise.resolve(Response.json({ id: "T-1", title: "Cannot log in", status }));
};
```

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

test("the operations are behind CodeCall's tools", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("codecall:search");
  expect(tools).not.toContainTool("closeTicket");
});

test("the model finds an operation by searching", async ({ mcp }) => {
  const { tools } = (await mcp.tools.call("codecall:search", { queries: ["close ticket"] })).json();
  expect(tools[0]).toMatchObject({ name: "closeTicket", appId: "desk" });
});

test("and calls it, with the adapter's credentials", async ({ mcp }) => {
  const result = await mcp.tools.call("codecall:invoke", { tool: "closeTicket", input: { id: "T-1" } });
  expect(result.json()).toEqual({ status: 200, ok: true, data: { id: "T-1", title: "Cannot log in", status: "closed" } });
  expect(received.at(-1)).toEqual({ method: "POST", url: "https://desk.example/v1/tickets/T-1/close", authorization: "Bearer desk-service-token" });
});
```

Skilled OpenAPI goes further. Instead of the spec, it serves a **bundle**: the API's operations, and skills, each a named group of operations with instructions in Markdown, like "read the invoice before refunding it, and refund at most its amount". The model sees three tools: `search_skill` finds a skill, `load_skill` reads its instructions and the operations it offers, and `run_workflow` runs a short script that calls them, each call checked against the operation's schema and the caller's authorities, and sent with credentials the model never sees:

```ts main.ts active
import { App, FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";

@App({ id: "billing", name: "Billing" })
class BillingApp {}

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [BillingApp],
  plugins: [
    SkilledOpenApiPlugin.init({
      source: { type: "inline", content: bundle },
      requireSignature: false, // a real server only accepts signed bundles
      credentials: { "billing-token": "billing-api-token" }, // in a real server, from the environment
    }),
  ],
})
export default class Server {}
```

```ts bundle.ts
const invoice = { type: "object", properties: { id: { type: "string" }, amount: { type: "number" }, status: { type: "string" } } };

export const bundle = {
  schemaVersion: 1,
  bundleId: "acme:billing",
  version: "1.0.0",
  generatedAt: "2026-09-01T12:00:00.000Z",
  sourceDigest: "0".repeat(64),
  services: [{ id: "billing", baseUrl: "https://203.0.113.10/v1" }],
  authBindings: { api: { kind: "bearer", vaultRef: "billing-token" } },
  skills: [
    {
      id: "invoices",
      name: "Invoices",
      description: "Look up customer invoices and refund them.",
      instructions: "# Invoices\n\nRead an invoice with getInvoice before refunding it. Refund at most its amount.",
      operationIds: ["getInvoice", "refundInvoice"],
    },
  ],
  operations: {
    getInvoice: {
      operationId: "getInvoice",
      serviceId: "billing",
      httpMethod: "GET",
      pathTemplate: "/invoices/{id}",
      summary: "Get an invoice",
      inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
      outputSchema: invoice,
      mapper: [{ inputKey: "id", type: "path", key: "id", required: true }],
      authBindingRef: "api",
    },
    refundInvoice: {
      operationId: "refundInvoice",
      serviceId: "billing",
      httpMethod: "POST",
      pathTemplate: "/invoices/{id}/refunds",
      summary: "Refund part or all of an invoice",
      inputSchema: { type: "object", properties: { id: { type: "string" }, amount: { type: "number" } }, required: ["id", "amount"] },
      outputSchema: { type: "object" },
      mapper: [
        { inputKey: "id", type: "path", key: "id", required: true },
        { inputKey: "amount", type: "body", key: "amount", required: true },
      ],
      authBindingRef: "api",
    },
  },
};
```

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

test("the model sees three tools, not one per operation", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["load_skill", "run_workflow", "search_skill"]);
});

test("it finds a skill by what it does, and loads its instructions and operations", async ({ mcp }) => {
  const { skills } = (await mcp.tools.call("search_skill", { query: "refund an invoice" })).json();
  expect(skills[0]).toMatchObject({ skillId: "invoices", name: "Invoices" });

  const { skill } = (await mcp.tools.call("load_skill", { skillId: "invoices" })).json();
  expect(skill.instructions).toContain("Refund at most its amount.");
  expect(skill.actions.map((a: { actionId: string }) => a.actionId)).toEqual(["getInvoice", "refundInvoice"]);
});
```

The price is the bundle: you write the skills, and a production server only applies bundles signed by a key it trusts. `compileSkilledBundleFromOpenApi()` builds a bundle's operations from the spec you already have. The plugin and the adapter work side by side, so a server can list the few operations the model needs in every conversation as adapter tools, and serve the long tail as skills. [Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) covers bundles, signing, credentials and authorization.

The [Pet Store from OpenAPI](https://frontmcp.dev/examples/pet-store-from-openapi) example wraps the Swagger Petstore the same way, and shows what to check when you point the adapter at a live API.

## Recap

- `OpenapiAdapter.init({ name, baseUrl, spec })` in an app's `adapters` turns every operation of an OpenAPI 3 spec into a tool, named by its `operationId`, whose input has a property for each parameter and body field.
- A call sends the request the spec describes, once its arguments match the spec: one that doesn't is refused with `INVALID_INPUT`, and nothing is sent. The model gets `{ status, ok, data }`, or an error result that still carries the API's status and body.
- Credentials go where the spec's security scheme says: `staticAuth` for one account, `securityResolver` or `authProviderMapper` per caller. Without them a secured call is refused, and the caller's own token is never sent unless you set `passthroughCallerToken`.
- `generateOptions` decides which operations become tools at all: `filterFn`, or fields like `includeOperations`, `excludeMethods` and `excludeTags`. Annotations come from each operation's HTTP method; `toolTransforms` renames the tools, rewrites descriptions and corrects annotations.
- For an API too big to list, put CodeCall in front of the adapter's tools, or serve the API as skills with Skilled OpenAPI.
- Every option, and exactly what a call sends, is in the [OpenAPI adapter reference](https://frontmcp.dev/reference/adapters/openapi).

## Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press **Check**.

### Challenge: Wrap the Desk API
The Desk app has the Desk API's spec, but no tools. Turn the spec's operations into tools that call the API at `https://desk.example/v1`.

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

@App({ id: "desk", name: "Desk" })
export class DeskApp {}
```

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

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

```ts desk-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, newest first",
        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.
export const received: { method: string; url: string }[] = [];

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

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({ method: request.method, url: request.url });
  if (url.pathname === "/v1/tickets") return Promise.resolve(Response.json(Object.values(tickets)));
  const ticket = tickets[url.pathname.replace("/v1/tickets/", "")];
  return Promise.resolve(ticket ? Response.json(ticket) : Response.json({ message: "No ticket with that id" }, { status: 404 }));
};
```

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

test("`listTickets` and `getTicket` are tools", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  expect(tools).toContainTool("listTickets");
  expect(tools).toContainTool("getTicket");
});

test("`getTicket` returns the API's ticket", 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" } });
});

test("the request goes to https://desk.example/v1", async ({ mcp }) => {
  await mcp.tools.call("getTicket", { id: "T-1" });
  expect(received.at(-1)).toEqual({ method: "GET", url: "https://desk.example/v1/tickets/T-1" });
});
```

**Hint:**
`@App` has an `adapters` list, next to `tools`. The adapter needs three options.

**Solution:**
`OpenapiAdapter.init({ name: "desk", baseUrl: "https://desk.example/v1", spec })` reads the spec when the server starts and makes a tool for each of its two operations. Each call adds the operation's path to `baseUrl`, with `{id}` filled in from the tool's input, and returns the API's status and body.

### Challenge: Send the API's token
The Desk API requires a bearer token, and every call fails with `Authentication required for tool 'getTicket'`. The Desk team issued the token `desk-service-token` for the help desk server. Make the adapter send it, without the model ever seeing it.

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

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

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

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

```ts desk-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: "The ticket", 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);
  const authorization = request.headers.get("authorization");
  received.push({ url: request.url, authorization });
  if (authorization !== "Bearer desk-service-token") return Promise.resolve(Response.json({ message: "Invalid token" }, { status: 401 }));
  return Promise.resolve(Response.json({ id: "T-1", title: "Cannot log in", status: "open" }));
};
```

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

test("`getTicket` works", async ({ mcp }) => {
  const result = await mcp.tools.call("getTicket", { id: "T-1" });
  expect(result).toBeSuccessful();
  expect(result.json()).toMatchObject({ status: 200, ok: true });
});

test("the API gets `Authorization: Bearer desk-service-token`", async ({ mcp }) => {
  await mcp.tools.call("getTicket", { id: "T-1" });
  expect(received.at(-1)?.authorization).toBe("Bearer desk-service-token");
});

test("the token isn't part of the tool's input", async ({ mcp }) => {
  const tool = (await mcp.tools.list()).find((t: { name: string }) => t.name === "getTicket");
  expect(Object.keys(tool.inputSchema.properties)).toEqual(["id"]);
});
```

**Hint:**
The spec names an HTTP bearer scheme. One adapter option gives every call the same credentials, and a bearer scheme reads the `jwt` field.

**Solution:**
`staticAuth: { jwt: "desk-service-token" }` gives every call that credential, and the adapter sends it where the spec's `DeskToken` scheme says: `Authorization: Bearer desk-service-token`. It stays on the server, out of every tool's input and result. In a real server the value comes from the environment, `staticAuth: { jwt: process.env.DESK_API_TOKEN }`, never from the source code. Adding the scheme to the model's input with `securitySchemesInInput` would make the calls work too, but the model would have to know the token, which is what the last check refuses.

### Challenge: Keep deleting out of the model's reach
The model must not be able to delete tickets, or reach the API's admin operations. Make `deleteTicket`, and every operation tagged `admin`, impossible to call through the server, and keep the rest.

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

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

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

@App({
  id: "desk",
  name: "Desk",
  adapters: [
    OpenapiAdapter.init({
      name: "desk",
      baseUrl: "https://desk.example/v1",
      spec,
      generateOptions: { filterFn: (op) => op.method !== "delete" && !op.tags?.includes("admin") },
    }),
  ],
})
export class DeskApp {}
```

```ts desk-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, newest first", 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 by its id", tags: ["tickets"], parameters: id, responses: ok },
      delete: { operationId: "deleteTicket", summary: "Delete a support ticket", tags: ["tickets"], parameters: id, responses: ok },
    },
    "/admin/agents": {
      get: { operationId: "listAgents", summary: "List support agents, with their roles", tags: ["admin"], responses: ok },
    },
    "/admin/agents/{id}": {
      put: { operationId: "setAgentRole", summary: "Change a support agent's role", tags: ["admin"], parameters: id, 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; path: string }[] = [];

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({ method: request.method, path: url.pathname });
  return Promise.resolve(Response.json({ ok: true }));
};
```

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

test("`deleteTicket` can't be called, even by name", async ({ mcp }) => {
  expect(await mcp.tools.list()).not.toContainTool("deleteTicket");
  expect(await mcp.tools.call("deleteTicket", { id: "T-2" })).toBeError();
  expect(received.some((r) => r.method === "DELETE")).toBe(false);
});

test("neither can the admin operations", async ({ mcp }) => {
  await mcp.tools.call("listAgents", {});
  await mcp.tools.call("setAgentRole", { id: "sam" });
  expect(received.some((r) => r.path.startsWith("/v1/admin"))).toBe(false);
});

test("the ticket operations that remain still work", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((t: { name: string }) => t.name);
  expect(names).toEqual(["createTicket", "getTicket", "listTickets"]);
  expect(await mcp.tools.call("getTicket", { id: "T-1" })).toBeSuccessful();
});
```

**Hint:**
Hiding a tool from `tools/list` leaves it callable by name. Look for the option that decides which operations become tools in the first place, and which gets each operation's method and tags.

**Solution:**
`generateOptions.filterFn` runs for each operation, and an operation it returns `false` for never becomes a tool, so there's nothing to call: `deleteTicket`, `listAgents` and `setAgentRole` get an unknown-tool error, and the API receives nothing. Checking `op.tags` keeps out admin operations added to the spec later too. `excludeOperations: ["deleteTicket", "listAgents", "setAgentRole"]` would pass today, but not after the next admin operation. `toolTransforms` with `hideFromDiscovery` only takes tools out of the list, and the first check calls `deleteTicket` by name.
