# Pet Store from OpenAPI

> A help desk server that wraps the classic Swagger Petstore API with the OpenAPI adapter, then makes its tools fit for a model with clear names and descriptions, keys sent by the server, no writes, errors that say what to do next, and a search tool of its own.

Source: https://frontmcp.dev/examples/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](https://frontmcp.dev/learn/wrapping-an-openapi-service), 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.

```ts petstore.app.ts active
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 {}
```

```ts pet.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

type Status = "available" | "pending" | "sold";
type Pet = { id: number; name: string; category?: { name: string }; tags?: { name: string }[]; status: Status };

@Tool({
  name: "search_pets",
  description:
    "Search pets for a word in their name, category or tags, like 'cat' or 'rex'. Looks at the available pets unless you pass another status. " +
    "The Petstore can't search, so this reads the whole list for that status and filters it.",
  inputSchema: {
    query: z.string().describe("A word to look for in a pet's name, category or tags"),
    status: z.enum(["available", "pending", "sold"]).optional().describe("Which pets to search: available (the default), pending or sold"),
  },
  outputSchema: { pets: z.array(z.object({ id: z.number(), name: z.string(), category: z.string().optional(), status: z.string() })) },
  annotations: { readOnlyHint: true },
})
export class SearchPets extends ToolContext {
  async execute({ query, status = "available" }: { query: string; status?: Status }) {
    const answer = await this.callTool("find_pets_by_status", { status });
    if (answer.isError) {
      const [errorBlock] = answer.content as { text: string }[];
      const { error, data } = JSON.parse(errorBlock.text) as { error: string; data: { hint: string } };
      this.fail(new PublicMcpError(`The Petstore couldn't list the ${status} pets, so nothing was searched: ${error}. ${data.hint}`, "PETSTORE_UNAVAILABLE"));
    }
    const { data: pets } = answer.structuredContent as { data: Pet[] };
    const word = query.toLowerCase();
    const mentionsWord = (pet: Pet) =>
      [pet.name, pet.category?.name, ...(pet.tags ?? []).map((tag) => tag.name)].some((text) => text?.toLowerCase().includes(word));
    return {
      pets: pets.filter(mentionsWord).map((pet) => ({ id: pet.id, name: pet.name, category: pet.category?.name, status: pet.status })),
    };
  }
}
```

```ts main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { PetstoreApp } from "./petstore.app";

@FrontMcp({
  info: { name: "Petstore", version: "1.0.0" },
  apps: [PetstoreApp],
})
export default class PetstoreServer {}
```

```ts petstore-spec.ts
const pet = { $ref: "#/components/schemas/Pet" };
const order = { $ref: "#/components/schemas/Order" };
const json = (schema: object) => ({ "application/json": { schema } });
const petAuth = [{ petstore_auth: ["write:pets", "read:pets"] }];
const idParameter = (name: string, description: string) => ({ name, in: "path", required: true, description, schema: { type: "integer", format: "int64" } });

export const spec = {
  openapi: "3.0.4",
  info: { title: "Swagger Petstore - OpenAPI 3.0", version: "1.0.27" },
  servers: [{ url: "/api/v3" }],
  paths: {
    "/pet": {
      post: {
        tags: ["pet"],
        operationId: "addPet",
        summary: "Add a new pet to the store.",
        requestBody: { required: true, content: json(pet) },
        responses: { "200": { description: "successful operation", content: json(pet) } },
        security: petAuth,
      },
    },
    "/pet/findByStatus": {
      get: {
        tags: ["pet"],
        operationId: "findPetsByStatus",
        summary: "Finds Pets by status.",
        description: "Multiple status values can be provided with comma separated strings.",
        parameters: [
          {
            name: "status",
            in: "query",
            description: "Status values that need to be considered for filter",
            required: true,
            schema: { type: "string", default: "available", enum: ["available", "pending", "sold"] },
          },
        ],
        responses: {
          "200": { description: "successful operation", content: json({ type: "array", items: pet }) },
          "400": { description: "Invalid status value" },
        },
        security: petAuth,
      },
    },
    "/pet/{petId}": {
      get: {
        tags: ["pet"],
        operationId: "getPetById",
        summary: "Find pet by ID.",
        description: "Returns a single pet.",
        parameters: [idParameter("petId", "ID of pet to return")],
        responses: {
          "200": { description: "successful operation", content: json(pet) },
          "400": { description: "Invalid ID supplied" },
          "404": { description: "Pet not found" },
        },
        security: [{ api_key: [] }, ...petAuth],
      },
      delete: {
        tags: ["pet"],
        operationId: "deletePet",
        summary: "Deletes a pet.",
        parameters: [{ name: "api_key", in: "header", schema: { type: "string" } }, idParameter("petId", "Pet id to delete")],
        responses: { "200": { description: "Pet deleted" } },
        security: petAuth,
      },
    },
    "/store/inventory": {
      get: {
        tags: ["store"],
        operationId: "getInventory",
        summary: "Returns pet inventories by status.",
        description: "Returns a map of status codes to quantities.",
        responses: {
          "200": { description: "successful operation", content: json({ type: "object", additionalProperties: { type: "integer", format: "int32" } }) },
        },
        security: [{ api_key: [] }],
      },
    },
    "/store/order": {
      post: {
        tags: ["store"],
        operationId: "placeOrder",
        summary: "Place an order for a pet.",
        requestBody: { content: json(order) },
        responses: { "200": { description: "successful operation", content: json(order) } },
      },
    },
    "/store/order/{orderId}": {
      get: {
        tags: ["store"],
        operationId: "getOrderById",
        summary: "Find purchase order by ID.",
        description: "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.",
        parameters: [idParameter("orderId", "ID of order that needs to be fetched")],
        responses: {
          "200": { description: "successful operation", content: json(order) },
          "400": { description: "Invalid ID supplied" },
          "404": { description: "Order not found" },
        },
      },
    },
  },
  components: {
    schemas: {
      Category: { type: "object", properties: { id: { type: "integer", format: "int64" }, name: { type: "string" } } },
      Tag: { type: "object", properties: { id: { type: "integer", format: "int64" }, name: { type: "string" } } },
      Pet: {
        type: "object",
        required: ["name", "photoUrls"],
        properties: {
          id: { type: "integer", format: "int64" },
          name: { type: "string" },
          category: { $ref: "#/components/schemas/Category" },
          photoUrls: { type: "array", items: { type: "string" } },
          tags: { type: "array", items: { $ref: "#/components/schemas/Tag" } },
          status: { type: "string", description: "pet status in the store", enum: ["available", "pending", "sold"] },
        },
      },
      Order: {
        type: "object",
        properties: {
          id: { type: "integer", format: "int64" },
          petId: { type: "integer", format: "int64" },
          quantity: { type: "integer", format: "int32" },
          shipDate: { type: "string", format: "date-time" },
          status: { type: "string", description: "Order Status", enum: ["placed", "approved", "delivered"] },
          complete: { type: "boolean" },
        },
      },
    },
    securitySchemes: {
      petstore_auth: {
        type: "oauth2",
        flows: {
          implicit: {
            authorizationUrl: "https://petstore3.swagger.io/oauth/authorize",
            scopes: { "write:pets": "modify pets in your account", "read:pets": "read your pets" },
          },
        },
      },
      api_key: { type: "apiKey", name: "api_key", in: "header" },
    },
  },
};
```

```ts petstore.example.ts
export const received: { method: string; path: string; headers: Record<string, string> }[] = [];

const pets = [
  { id: 1, name: "Rex", category: { id: 1, name: "Dogs" }, photoUrls: [], tags: [{ id: 1, name: "friendly" }], status: "available" },
  { id: 2, name: "Whiskers", category: { id: 2, name: "Cats" }, photoUrls: [], tags: [{ id: 2, name: "indoor" }], status: "available" },
  { id: 3, name: "Bubbles", category: { id: 3, name: "Fish" }, photoUrls: [], tags: [], status: "pending" },
  { id: 4, name: "Tweety", category: { id: 4, name: "Birds" }, photoUrls: [], tags: [{ id: 3, name: "singer" }], status: "sold" },
  { id: 5, name: "Milo", category: { id: 2, name: "Cats" }, photoUrls: [], tags: [{ id: 1, name: "friendly" }], status: "available" },
];

const orders = [{ id: 1, petId: 3, quantity: 1, status: "placed", complete: false }];

const errorResponse = (status: number, message: string) => Response.json({ code: status, message }, { status });
const internalError = (id: string) => errorResponse(500, `There was an error processing your request. It has been logged (ID: ${id})`);

function petstore(request: Request): Response {
  const url = new URL(request.url);
  const path = url.pathname.replace("/api/v3", "");
  received.push({ method: request.method, path: path + url.search, headers: Object.fromEntries(request.headers) });
  if (request.method !== "GET") return Response.json({});

  if (path === "/pet/findByStatus") {
    const status = url.searchParams.get("status");
    if (status === "sold") return internalError("6b1f0c2d9a3e4f57");
    if (status !== "available" && status !== "pending") {
      return errorResponse(400, `Input error: query parameter \`status value \`${status}\` is not in the allowable values \`[available, pending, sold]\``);
    }
    return Response.json(pets.filter((pet) => pet.status === status));
  }
  if (path === "/store/inventory") {
    const count = (status: string) => pets.filter((pet) => pet.status === status).length;
    return Response.json({ available: count("available"), pending: count("pending"), sold: count("sold") });
  }

  const [, kind, rawId] = /^\/(pet|store\/order)\/(.*)$/.exec(path) ?? [];
  const id = Number(rawId);
  if (!Number.isInteger(id)) return errorResponse(400, `Input error: couldn't convert \`${rawId}\` to type \`class java.lang.Long\``);
  if (kind === "pet") {
    const pet = pets.find((candidate) => candidate.id === id);
    return pet ? Response.json(pet) : errorResponse(404, "Pet not found");
  }
  if (id >= 6 && id <= 10) return internalError("0c9a41d77be2a1c8");
  const order = orders.find((candidate) => candidate.id === id);
  return order ? Response.json(order) : errorResponse(404, "Order not found");
}

const realFetch: typeof fetch = ((globalThis as any).__realFetch ??= globalThis.fetch);
globalThis.fetch = (input, init) => {
  const request = new Request(input, init);
  return new URL(request.url).hostname === "petstore3.swagger.io" ? Promise.resolve(petstore(request)) : realFetch(input, init);
};
```

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

const toolsByName = async (mcp: any) => Object.fromEntries((await mcp.tools.list()).map((tool: { name: string }) => [tool.name, tool]));
const lastRequest = () => received[received.length - 1];
const errorBodyOf = async (mcp: any, tool: string, input: object) => JSON.parse((await mcp.tools.call(tool, input)).text());

test("the model sees four tools from the API and one of ours, all named the same way", async ({ mcp }) => {
  expect(Object.keys(await toolsByName(mcp))).toEqual(["find_pets_by_status", "get_inventory", "get_order", "get_pet", "search_pets"]);
});

test("each tool says what it is for, and every one only reads", async ({ mcp }) => {
  const tools = await toolsByName(mcp);
  expect(tools.find_pets_by_status.description).toContain("available (for sale), pending (reserved) or sold");
  expect(tools.get_pet.description).toContain("Ids come from find_pets_by_status");
  expect(Object.values(tools).every((tool: any) => tool.annotations.readOnlyHint === true)).toBe(true);
});

test("the API's writes are not tools, so they can't be called and never reach the API", async ({ mcp }) => {
  for (const [tool, input] of [["addPet", { name: "Rex" }], ["deletePet", { petId: 1 }], ["placeOrder", { petId: 1 }]] as const) {
    const result = await mcp.tools.call(tool, input);
    expect(result).toBeError();
    expect(result).toHaveTextContent(`Tool "${tool}" not found`);
  }
  expect(received.filter((request) => request.method !== "GET")).toEqual([]);
});

test("the server sends the API's credentials, and no tool asks the model for them", async ({ mcp }) => {
  await mcp.tools.call("find_pets_by_status", { status: "available" });
  expect(lastRequest().headers).toMatchObject({ authorization: "Bearer demo-oauth-token" });
  await mcp.tools.call("get_inventory", {});
  expect(lastRequest().headers).toMatchObject({ api_key: "demo-api-key" });
  const pet = await mcp.tools.call("get_pet", { petId: 1 });
  expect(lastRequest().headers).toMatchObject({ api_key: "demo-api-key", authorization: "Bearer demo-oauth-token" });
  expect(pet.json()).toMatchObject({ status: 200, ok: true, data: { name: "Rex" } });

  for (const tool of Object.values(await toolsByName(mcp)) as any[]) {
    expect(Object.keys(tool.inputSchema.properties)).not.toContain("api_key");
  }
});

test("a pet that doesn't exist comes back with the API's message and a next step", async ({ mcp }) => {
  const errorBody = await errorBodyOf(mcp, "get_pet", { petId: 99 });
  expect(errorBody).toMatchObject({ status: 404, error: "Pet not found" });
  expect(errorBody.data.hint).toContain("find_pets_by_status");
});

test("an argument the spec doesn't allow is refused before anything is sent", async ({ mcp }) => {
  const before = received.length;
  const result = await mcp.tools.call("find_pets_by_status", { status: "gone" });
  expect(result).toBeError("INVALID_INPUT");
  expect(result).toHaveTextContent(`Invalid arguments for tool 'find_pets_by_status': status: Invalid option: expected one of "available"|"pending"|"sold"`);
  expect(received.length).toBe(before);
});

test("without a status, the spec's default is sent", async ({ mcp }) => {
  expect(await mcp.tools.call("find_pets_by_status", {})).toBeSuccessful();
  expect(lastRequest().path).toBe("/pet/findByStatus?status=available");
});

test("a server error keeps the API's id for the log, and tells the model to try once more", async ({ mcp }) => {
  const errorBody = await errorBodyOf(mcp, "get_order", { orderId: 8 });
  expect(errorBody.status).toBe(500);
  expect(errorBody.error).toContain("(ID: 0c9a41d77be2a1c8)");
  expect(errorBody.data.hint).toContain("Try once more");
});

test("search_pets reads one list and filters it", async ({ mcp }) => {
  const before = received.length;
  const { pets } = (await mcp.tools.call("search_pets", { query: "cat" })).json<{ pets: unknown[] }>();
  expect(pets).toEqual([
    { id: 2, name: "Whiskers", category: "Cats", status: "available" },
    { id: 5, name: "Milo", category: "Cats", status: "available" },
  ]);
  expect(received.slice(before).map((request) => request.path)).toEqual(["/pet/findByStatus?status=available"]);
  expect(lastRequest().headers).toMatchObject({ authorization: "Bearer demo-oauth-token" });
});

test("search_pets says why nothing was searched when the Petstore fails", async ({ mcp }) => {
  const result = await mcp.tools.call("search_pets", { query: "bird", status: "sold" });
  expect(result).toBeError("PETSTORE_UNAVAILABLE");
  expect(result).toHaveTextContent("nothing was searched");
  expect(result).toHaveTextContent("(ID: 6b1f0c2d9a3e4f57)");
});
```

The Playground can't reach the Petstore, so `petstore.example.ts` plays it at `https://petstore3.swagger.io`, the way [the lesson](https://frontmcp.dev/learn/wrapping-an-openapi-service#turning-a-spec-into-tools) 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

*[Illustration: The Pet Store example. The client's model is listed five tools. It calls get_pet with petId 99; the server adds the API's credentials and sends GET /pet/99; the Petstore API answers 404 Pet not found; the server adds a hint to the error and returns it. The model then calls search_pets with the query cat; the server sends GET /pet/findByStatus, gets the available pets, filters them by name, category and tags, and answers Whiskers and Milo. addPet, deletePet and placeOrder were never made into tools.]*
1. 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.
2. A model calls `get_pet` with a `petId`. The adapter checks the arguments against the spec, builds `GET /pet/99`, adds the credentials the spec's security schemes ask for, and sends it.
3. The Petstore answers. A `2xx` reaches the model as `{ status, ok, data }`.
4. A `4xx` or `5xx` goes through a transform that adds a `hint` to the error's `data`, and reaches the model as an error result. The API's own message stays in `error`.
5. `search_pets` is ours. It calls `find_pets_by_status` with `this.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.
6. `addPet`, `deletePet` and `placeOrder` were never made into tools. A client that calls them by name gets `Tool "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](https://petstore3.swagger.io/api/v3/openapi.json)) 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:

- `findPetsByStatus` accepts only `petstore_auth`, an OAuth2 scheme.
- `getPetById` accepts `api_key` or `petstore_auth`.
- `getInventory` accepts only `api_key`, an API key sent in the `api_key` header.
- 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:

```ts petstore.app.ts
generateOptions: { includeOperations: ["findPetsByStatus", "getPetById", "getInventory", "getOrderById"] },
```

- **`includeOperations`** is a list of the operations that become tools. Anything else, `addPet` and `deletePet` included, is never made into a tool: it isn't listed, and it can't be called. A list is safer than a rule like "only `GET`s", because the full Petstore has reads that aren't harmless (`logoutUser` is a `GET`, and `loginUser` takes a password in its query string), and a new operation in the spec stays out until you add it. Hiding an operation with `hideFromDiscovery` would leave it callable by name: see [Choosing which operations become tools](https://frontmcp.dev/learn/wrapping-an-openapi-service#choosing-which-operations-become-tools).
- **`toolTransforms`** gives the tools names in one style, next to `search_pets`, and descriptions that say when to use each one and where an id comes from. `perTool` is keyed by the `operationId`. The annotations come from the spec: the adapter marks each `GET` `readOnlyHint` and `idempotentHint` from its HTTP method, and gives each tool the operation's `summary` as its `title` ("Find pet by ID."), which clients show to people. [Naming tools for the model](https://frontmcp.dev/learn/wrapping-an-openapi-service#naming-tools-for-the-model) has more.
- **`staticAuth`** holds the API's credentials: `apiKey` for the `api_key` header, and `oauth2Token` for an OAuth2 scheme, sent as a bearer token. The adapter sends each operation what its schemes ask for: `find_pets_by_status` gets the bearer token, `get_inventory` the key, and `get_pet` both, 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](https://frontmcp.dev/reference/adapters/openapi#how-credentials-are-chosen).
- **`dataTransforms`** rewrites what the model gets back. This `postToolTransforms` runs for every tool, only when the call failed (`filter: ({ ok }) => !ok`), and adds a `hint` to 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. A `500` gets 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](https://frontmcp.dev/reference/adapters/openapi#reshaping-what-the-model-gets-back) covers `postToolTransforms`.

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](https://frontmcp.dev/reference/adapters/openapi#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.

```ts pet.tools.ts
const answer = await this.callTool("find_pets_by_status", { status });
```

It calls the generated tool with [`this.callTool()`](https://frontmcp.dev/reference/sdk/call-tool), 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`](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) 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](https://frontmcp.dev/learn/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:

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

```ts petstore.app.ts
const 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](https://frontmcp.dev/reference/adapters/openapi#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 their `title`.
- **The credentials.** On my server, the `findPetsByStatus` operation sent `Authorization: Bearer …` alone, `getInventory` sent `api_key: demo-api-key` alone, `getPetById` sent both, and `getOrderById`, which the spec doesn't secure, sent neither.
- **A pet.** Against the live Petstore, `get_pet` with `petId: 1` came back `200` with a pet, `Pet1`, `available`.
- **Errors.** `find_pets_by_status` with `status: "gone"` was refused by the adapter with `INVALID_INPUT`, and nothing was sent. `get_inventory` and `get_order` came back `500`, `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_pets` with `query: "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](https://frontmcp.dev/reference/adapters/openapi#picking-up-spec-changes).

## Ideas to try

Each of these is a change to the Playground above. Add a test for each.

1. Add a write that's safe: a `reserve_pet` tool that gets the pet with `this.callTool()`, refuses a pet that isn't `available` with a `PublicMcpError` that says which pets are, and only then sends an order with [`this.fetch()`](https://frontmcp.dev/learn/calling-other-services). Extend the stand-in to record the order, and test that a sold pet is refused before anything is sent.
2. Add the total to `get_inventory`: a `postToolTransforms` for that tool alone, with `perTool`, that returns `{ available, pending, sold, total }`. Test that the errors still get their hint.
3. See why a list beats hiding: remove `includeOperations`, set `hideFromDiscovery: true` on `addPet`, `deletePet` and `placeOrder` in `toolTransforms`, and change the writes test to expect that a call by name now reaches the API.
4. Send each signed-in caller their own key: replace `staticAuth` with a `securityResolver` that picks the key from `ctx.authInfo.user`, as [the lesson's tenant example](https://frontmcp.dev/learn/wrapping-an-openapi-service#giving-the-adapter-the-apis-credentials) does, and test two callers with `FrontMcpInstance.createDirect()`.
