Pet Store from OpenAPI
A pet shop's help desk gets the same questions all day: is the cat I saw still available, how many dogs are in stock, where is my order? The answers are in the Swagger Petstore, the sample REST API everyone has met, and it publishes an OpenAPI description. This example wraps it with the OpenAPI adapter, which turns that description into tools, then does what the adapter leaves to you: the tools get names and descriptions a model can act on, the server sends the API's keys, every operation that writes stays out of reach, the API's errors say what to do next, and one tool of our own does what the API can't, search.
You will learn
- How to turn an OpenAPI description into tools, and what the adapter makes of the Petstore's
- How to name and describe generated tools so a model knows when to use each
- How the server sends the API's credentials, when a spec declares two schemes
- How to keep an API's writes out of the model's reach, and why a list of operations beats a rule
- How to make an API's errors readable, and how to write a tool of your own next to the generated ones
The server
Here is the whole server. Open the Call tab: find_pets_by_status has listed the available pets. Call get_pet with { "petId": 99 } to see a missing pet, get_order with { "orderId": 8 } to see one of the Petstore's own failures, and search_pets with { "query": "cat" } to use the tool we wrote. The Capabilities tab lists the five tools a client sees: the spec has seven operations, and the three that write aren't among them. Then open the Tests tab.
import "./petstore.example";
import { App } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./petstore-spec";
import { SearchPets } from "./pet.tools";
function hintFor(status: number) {
if (status === 400) return "The Petstore rejected an argument. Ids are whole numbers, and status is available, pending or sold.";
if (status === 404) return "There is no such pet or order. Pet ids come from find_pets_by_status.";
if (status >= 500) return "The Petstore failed on its side. Nothing you sent was wrong, and nothing changed. Try once more, then tell the user and quote the id in the message.";
return "The Petstore refused the request.";
}
function withHint(errorBody: unknown, status: number) {
const body = typeof errorBody === "object" ? errorBody : { message: errorBody };
return { ...body, hint: hintFor(status) };
}
const petstore = OpenapiAdapter.init({
name: "petstore",
baseUrl: "https://petstore3.swagger.io/api/v3",
spec,
staticAuth: { apiKey: "demo-api-key", oauth2Token: "demo-oauth-token" },
generateOptions: { includeOperations: ["findPetsByStatus", "getPetById", "getInventory", "getOrderById"] },
toolTransforms: {
perTool: {
findPetsByStatus: {
name: "find_pets_by_status",
description: "List the pets with one status: available (for sale), pending (reserved) or sold. Each has its id, name, category, tags and status.",
},
getPetById: {
name: "get_pet",
description: "Get one pet by its numeric id, with its category, tags, photo URLs and status. Ids come from find_pets_by_status.",
},
getInventory: {
name: "get_inventory",
description: "Count the pets in each status: how many are available, pending and sold.",
},
getOrderById: {
name: "get_order",
description: "Get one order by its numeric id: the pet it is for, the quantity and its status.",
},
},
},
dataTransforms: {
postToolTransforms: {
global: {
filter: ({ ok }) => !ok,
transform: (data, { status }) => withHint(data, status),
},
},
},
});
@App({ id: "petstore", name: "Petstore", adapters: [petstore], tools: [SearchPets] })
export class PetstoreApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Playground can't reach the Petstore, so petstore.example.ts plays it at https://petstore3.swagger.io, the way the lesson plays its Desk API. It answers in-process, records every request so the tests can check what the API received, and fails the way the real demo server did when I tried it, with a 500 that names an id in its log. It fails the sold list and orders 6 to 10 on purpose, so the tests have failures to read: the spec itself says that only orders with an id <= 5 or > 10 are valid, and the rest "will generate exceptions". Your server doesn't need this file.
How it fits together
- When the server starts, the adapter reads the spec and makes a tool for each operation on its list, four of the seven. It renames them and rewrites their descriptions.
- A model calls
get_petwith apetId. The adapter checks the arguments against the spec, buildsGET /pet/99, adds the credentials the spec's security schemes ask for, and sends it. - The Petstore answers. A
2xxreaches the model as{ status, ok, data }. - A
4xxor5xxgoes through a transform that adds ahintto the error'sdata, and reaches the model as an error result. The API's own message stays inerror. search_petsis ours. It callsfind_pets_by_statuswiththis.callTool(), so it sends the same credentials and gets the same errors, and it filters the list by a word in the name, the category or the tags.addPet,deletePetandplaceOrderwere never made into tools. A client that calls them by name getsTool "addPet" not found, and the tests check that the Petstore never received a write.
The files
petstore-spec.ts: the API's description
The adapter needs the OpenAPI description, the spec. This one is the Petstore's own (OpenAPI 3.0.4) cut down to seven of its 19 operations: four reads and three writes, the schemas they use, and both security schemes. The spec says which credentials each operation accepts, and the adapter follows it:
findPetsByStatusaccepts onlypetstore_auth, an OAuth2 scheme.getPetByIdacceptsapi_keyorpetstore_auth.getInventoryaccepts onlyapi_key, an API key sent in theapi_keyheader.- The orders and the writes on orders need nothing.
petstore.app.ts: the adapter, made fit for a model
OpenapiAdapter.init() is called once, at module level, because adapter names are remembered for the whole process. Each option does one job:
generateOptions: { includeOperations: ["findPetsByStatus", "getPetById", "getInventory", "getOrderById"] },includeOperationsis a list of the operations that become tools. Anything else,addPetanddeletePetincluded, is never made into a tool: it isn't listed, and it can't be called. A list is safer than a rule like "onlyGETs", because the full Petstore has reads that aren't harmless (logoutUseris aGET, andloginUsertakes a password in its query string), and a new operation in the spec stays out until you add it. Hiding an operation withhideFromDiscoverywould leave it callable by name: see Choosing which operations become tools.toolTransformsgives the tools names in one style, next tosearch_pets, and descriptions that say when to use each one and where an id comes from.perToolis keyed by theoperationId. The annotations come from the spec: the adapter marks eachGETreadOnlyHintandidempotentHintfrom its HTTP method, and gives each tool the operation'ssummaryas itstitle("Find pet by ID."), which clients show to people. Naming tools for the model has more.staticAuthholds the API's credentials:apiKeyfor theapi_keyheader, andoauth2Tokenfor an OAuth2 scheme, sent as a bearer token. The adapter sends each operation what its schemes ask for:find_pets_by_statusgets the bearer token,get_inventorythe key, andget_petboth, because it accepts either. None of it is in a tool's input, so the model can't see it, and a call the adapter has no credential for is refused before anything is sent. The values in the example are placeholders: a real server reads its credentials from the environment. See how credentials are chosen.dataTransformsrewrites what the model gets back. ThispostToolTransformsruns for every tool, only when the call failed (filter: ({ ok }) => !ok), and adds ahintto the API's error body. The model reads{ "status": 404, "error": "Pet not found", "data": { "code": 404, "message": "Pet not found", "hint": "There is no such pet or order. Pet ids come from find_pets_by_status." } }and knows what to try. A500gets a different hint, that the Petstore failed and nothing was changed, so the model tries once more and tells the user instead of retrying in a loop. Reshaping what the model gets back coverspostToolTransforms.
The adapter checks a call's arguments against the spec before it sends anything. status: "gone" is refused with Invalid arguments for tool 'find_pets_by_status': status: Invalid option: expected one of "available"|"pending"|"sold", under the code INVALID_INPUT, and the Petstore never sees it. The refusal comes from the adapter, not the API, so postToolTransforms doesn't run and there's no hint; the message already lists the values allowed. The spec requires status and gives it a default, available, so a call without it is accepted and the adapter sends status=available (before FrontMCP 1.9.3 such a call failed). Arguments are checked against the spec says what the check covers.
main.ts is the usual @FrontMcp server. It only lists the app.
pet.tools.ts: a tool of our own
The Petstore can't search. A model that's asked for a cat would have to list every available pet and read them all to find the cats, in its own context. search_pets does that on the server and answers with a line for each match.
const answer = await this.callTool("find_pets_by_status", { status });It calls the generated tool with this.callTool(), as the same caller, so it needs no credentials of its own, no fetch() and no error handling for the API's failures: the adapter does those. When the call fails, search_pets reads the error and fails with a PublicMcpError that says what didn't happen, "nothing was searched", and repeats the API's message and the hint, under a code a client can act on, PETSTORE_UNAVAILABLE. Its outputSchema describes the short lines it returns, not the Petstore's whole pet.
petstore.example.ts: the stand-in API
Five pets in four statuses, and one order. It routes the reads by path, answers 404 for a pet or an order it doesn't have, 400 with the Petstore's message for an id that isn't a number or a status that isn't one of three (the adapter's check keeps those from reaching it), and 500 with an id for the sold list and for orders 6 to 10. Every other method gets an empty 200: it's never used, and the tests check that. It replaces globalThis.fetch, which the adapter calls, for one host.
petstore.test.ts
The tests check what a client sees and what the API receives. The first two look at the tools: their names, descriptions and annotations. The next two check the writes and the credentials on the requests the Petstore got. Three tests read the errors: a missing pet, a status the spec doesn't allow, and a 500. The last two try search_pets, when the Petstore answers and when it fails. They share one server, and none of them changes the Petstore's data. Testing Your Server covers the test API.
Running it for real
Three things change. Delete petstore.example.ts and the import "./petstore.example" line, install the package, and point the adapter at the real API:
npm install @frontmcp/adaptersconst petstore = OpenapiAdapter.init({
name: "petstore",
baseUrl: "https://petstore3.swagger.io/api/v3",
url: "https://petstore3.swagger.io/api/v3/openapi.json",
staticAuth: { apiKey: process.env.PETSTORE_API_KEY, oauth2Token: process.env.PETSTORE_TOKEN },
// ...the same generateOptions, toolTransforms and dataTransforms
});url makes the adapter download the spec when the server starts, so the server needs the network then and doesn't start if the download fails. spec: petstoreSpec, with the file imported, works too, and stays the same until you change it. For your own API the changes are the same: its baseUrl, its spec, credentials issued for your server, and a list of the operations the model may use. See Loading the spec from a URL.
I ran this against the live Petstore, and against a small HTTP server of my own that recorded the requests, with a copy of the real spec: on 2026-10-05 in a project made with frontmcp create, with FrontMCP 1.9.1, and again on 2026-10-06 with FrontMCP 1.9.2 and @frontmcp/adapters 1.9.2.
- The tools. With the spec loaded from
url, the server listed the five tools, with the names, descriptions and annotations above. The four generated ones have the operation's summary as theirtitle. - The credentials. On my server, the
findPetsByStatusoperation sentAuthorization: Bearer …alone,getInventorysentapi_key: demo-api-keyalone,getPetByIdsent both, andgetOrderById, which the spec doesn't secure, sent neither. - A pet. Against the live Petstore,
get_petwithpetId: 1came back200with a pet,Pet1,available. - Errors.
find_pets_by_statuswithstatus: "gone"was refused by the adapter withINVALID_INPUT, and nothing was sent.get_inventoryandget_ordercame back500,There was an error processing your request. It has been logged (ID: …), with the hint that the Petstore failed. - A search. On 2026-10-06 the demo server listed its pets, and
search_petswithquery: "dog"found the ones with "dog" in their name or category, among the test pets other people had added.
The demo server doesn't answer the same way from one day to the next. On 2026-10-05 it failed every findPetsByStatus with a 500, so search_pets failed with PETSTORE_UNAVAILABLE: The Petstore couldn't list the available pets, so nothing was searched: …, with the hint. On 2026-10-01 it answered 200 with []. I sent no writes to a shared server. getPetById answered without any credentials too, so the demo server doesn't require the ones its spec declares. And the tests use the stand-in, so they don't run against the real API.
Two more things to know. The older Petstore at petstore.swagger.io/v2 describes itself in Swagger 2.0, and the adapter refuses it with Invalid OpenAPI document: convert it to OpenAPI 3 first, or use the v3 one. And an adapter that is given url can also poll it, to pick up a changed spec without a restart: see picking up spec changes.
Ideas to try
Each of these is a change to the Playground above. Add a test for each.
- Add a write that's safe: a
reserve_pettool that gets the pet withthis.callTool(), refuses a pet that isn'tavailablewith aPublicMcpErrorthat says which pets are, and only then sends an order withthis.fetch(). Extend the stand-in to record the order, and test that a sold pet is refused before anything is sent. - Add the total to
get_inventory: apostToolTransformsfor that tool alone, withperTool, that returns{ available, pending, sold, total }. Test that the errors still get their hint. - See why a list beats hiding: remove
includeOperations, sethideFromDiscovery: trueonaddPet,deletePetandplaceOrderintoolTransforms, and change the writes test to expect that a call by name now reaches the API. - Send each signed-in caller their own key: replace
staticAuthwith asecurityResolverthat picks the key fromctx.authInfo.user, as the lesson's tenant example does, and test two callers withFrontMcpInstance.createDirect().