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(), 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/adaptersThen give the adapter a name, the API's address and its description, the spec, and list it in the app's adapters:
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.
namenames the adapter. It must be unique among all the adapters in the process.baseUrlis where the API is. Each operation's path is added to it.specis the OpenAPI 3.0 or 3.1 document, as an object: import the JSON file, or passurlinstead to download it when the server starts.- Each tool is named by the operation's
operationIdand described by itssummary. 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:
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:
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:
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:
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:
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.
perToolis keyed by the tool's name before the transform.descriptiontakes a string, or a function that gets the current description.- Annotations start from each operation's HTTP method: a
GETisreadOnlyHintandidempotentHint, and aPOST,PUT,PATCHorDELETEisdestructiveHint. That's a guess from HTTP's rules. APOSTthat only adds, likecreateTicket, destroys nothing, so thegeneratorsays so, and what a transform sets is merged over the guess. generatoris called with every generated tool and returns a transform orundefined.tool.metadatahas the operation'smethod,path,operationIdandtags.- The title is the operation's
summary, for clients to show people. The model reads the name and the description. descriptionModedecides how the spec'ssummaryanddescriptionmake the tool's description. The default,"summaryOnly", uses thesummaryalone 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 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:
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:
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'sadaptersturns every operation of an OpenAPI 3 spec into a tool, named by itsoperationId, 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:
staticAuthfor one account,securityResolverorauthProviderMapperper caller. Without them a secured call is refused, and the caller's own token is never sent unless you setpassthroughCallerToken. generateOptionsdecides which operations become tools at all:filterFn, or fields likeincludeOperations,excludeMethodsandexcludeTags. Annotations come from each operation's HTTP method;toolTransformsrenames 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.
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.