Wrapping an OpenAPI Service

Intermediate

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(), 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:

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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);
});

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 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 lists what the check covers.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 diveA token per tenantShow detailsHide details

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 lists every option, and the order they're tried in.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

  • 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. To fill in an argument on the server, like a tenant id, rather than asking the model for it, see inputTransforms.

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 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 seesUse it when
Filter with generateOptionsThe operations you keep, as toolsThe model needs a known handful of operations.
CodeCall in front of the adapterCodeCall's tools; it searches the operations and calls them, or runs a scriptThe model needs many operations, and the spec's summaries are good enough to search.
Skilled OpenAPI instead of the adapterThree tools that find and load skills: groups of operations with instructions you writeThe 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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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:

Open
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 {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 covers bundles, signing, credentials and authorization.

The 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.

Try some challenges

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

Challenge 1 of 3

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.

Open
import "./desk.example";
import { App } from "@frontmcp/sdk";

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.