Skilled OpenAPI

SkilledOpenApiPlugin, from @frontmcp/plugin-skilled-openapi, serves a REST API to the model as a few named skills instead of a tool per endpoint. You describe the API in a bundle: its services, credentials, operations, and skills that each group some operations with instructions. The model sees three tools. search_skill finds a skill, load_skill reads its instructions and the operations it offers, and run_workflow runs a short script that calls those operations, each one checked, authorized and sent with credentials the model never sees. Use it for an API with too many operations to list as tools; for a small one, where the model should see every operation, use the OpenAPI adapter. The two work side by side.

@FrontMcp({ plugins: [SkilledOpenApiPlugin.init({ source, requireSignature?, trustedKeys?, credentials?, outbound?, unprotectedOps?, ... })] })

Reference

SkilledOpenApiPlugin.init(options)

Install the plugin, and the two packages run_workflow runs scripts with:

npm install @frontmcp/plugin-skilled-openapi @enclave-vm/core @enclave-vm/ast

List it in the plugins of @FrontMcp, or of an @App. Here a CI job publishes a signed bundle, and the server polls for it:

main.ts
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";

@FrontMcp({
  info: { name: "billing", version: "1.0.0" },
  apps: [],
  plugins: [
    SkilledOpenApiPlugin.init({
      source: {
        type: "saas",
        endpoint: "https://bundles.acme.example/v1/bundles/billing-prod",
        authToken: process.env.BUNDLE_PULL_TOKEN!,
        expectedAudience: "billing-prod",
        jwksUrl: "https://bundles.acme.example/.well-known/jwks.json",
        expectedIssuer: "https://bundles.acme.example",
      },
      trustedKeys: [{ keyId: "ci-2026", alg: "EdDSA", publicKeyPem: process.env.BUNDLE_PUBLIC_KEY! }],
      credentials: { "billing-token": process.env.BILLING_API_TOKEN! },
      unprotectedOps: "deny",
    }),
  ],
})
export default class Server {}

SkilledOpenApiPlugin is also the package's default export. It isn't part of the @frontmcp/plugins package. See more examples below.

Options

init() checks the options with a Zod schema (exported as skilledOpenApiPluginOptionsSchema) and throws a ZodError for a bad one, like source.endpoint: must use https://. Keys it doesn't know are dropped.

OptionTypeDefaultDescription
source{ type: "inline" | "static" | "npm" | "saas", ... }requiredWhere the bundle comes from. See sources.
requireSignaturebooleantrue, or false with dev: trueApply only bundles with a valid signature from one of trustedKeys. An unsigned bundle is rejected, and the server has no skills.
trustedKeys{ keyId, alg: "RS256" | "EdDSA", publicKeyPem }[][]Public keys a bundle's signature may be made with, matched by keyId.
credentialsRecord<string, string>Secrets for the bundle's auth bindings, keyed by their vaultRef: { "billing-token": process.env.BILLING_API_TOKEN }. A trailing newline is removed. To fetch them from a vault instead, see credentials.
unprotectedOps"allow" | "deny""allow"Whether an operation with no requiredAuthorities, on itself or its skill, may be called. "deny" blocks it unless it's marked public: true. See authorization.
outboundOutboundOptionssee belowLimits on the requests to your API.
devbooleanfalseFor development: turns off signature checks (unless you set requireSignature), allows http: URLs (sets outbound.allowHttp), and logs dev=true: signature verification BYPASSED and http:// URLs allowed. NEVER use this in production.
bundleCacheDirstring.frontmcp/skilled-openapi/Where the saas source keeps the last bundle it pulled.
exposeOperationsAsInternalToolsbooleantrueRegisters each operation as an internal tool, <bundleId>.<operationId>, like acme:billing.getInvoice, for your tools to call with this.callTool(). They aren't listed, and a client can't call them. See What the plugin adds.
sourceConflictPolicy"static-wins" | "last-wins" | "reject""static-wins"Accepted and unused: the plugin has one source.
providersProviderType[]More providers for the plugin, as for any plugin's init(). Use it to replace the credential resolver.

outbound:

FieldTypeDefaultDescription
allowHttpbooleanfalseAllow http: services. Otherwise a request to one fails with forbidden scheme "http:" (https: required).
allowPrivateNetworksbooleanfalseAllow services on private and loopback addresses (10.0.0.0/8, 127.0.0.0/8, …), and hosts whose name doesn't resolve. Link-local and cloud metadata addresses stay blocked.
defaultTimeoutMsnumber30000Milliseconds a request may take; an operation's timeoutMs overrides it.
defaultMaxResponseBytesnumber262144 (256 KB)Largest response read; an operation's maxResponseBytes overrides it.
maxConcurrencyPerHostnumber10Requests in flight to one host; more wait their turn. Must be greater than 0.
egressProxystringA proxy URL, like http://proxy.internal:3128, that every request to the bundle's services goes through. On Node it needs the undici package, which the plugin loads only when this is set. Changed in 1.9.2: it was accepted and unused.

init() with no argument throws a ZodError: source is required.

Sources

typeFieldsWhat it does
inlinecontentThe bundle object itself. Loaded once. The source for tests, for bundles built in code, and for runtimes without a file system.
staticpath, watch (default false)A bundle file, JSON (.json) or YAML (.yaml, .yml; other extensions are guessed from the first character). With watch: true, the plugin reloads it 250 ms after it changes.
npmpackageName, exportName (default: the default export), verifyProvenance (default true)Imports a package and uses one of its exports as the bundle. Loaded once; a new version needs a redeploy. Provenance checks aren't implemented, so with verifyProvenance: true the plugin refuses to import the package and the server has no skills. Set it to false to load the package.
saasendpoint, authToken, expectedAudience, pollIntervalMs (default 300000), enableWebhook, jwksUrl, expectedIssuerGETs the bundle (JSON or YAML) from endpoint, which must be https:, with Authorization: Bearer <authToken>, when the server starts and then every pollIntervalMs. Before each pull it checks authToken (see below). Each bundle is saved to <bundleCacheDir>/<expectedAudience>.json (characters other than letters, digits, _, . and - become _); when a pull fails at startup, the saved one is used. A redirect is refused.

authToken is a JWT that the bundle server issued for your server. Before every pull, at startup, on each poll and on source.refresh(), the saas source fetches the keys at jwksUrl (without the token; a redirect is refused) and checks the token: it must be signed by one of those keys, its iss must be expectedIssuer, its aud must include expectedAudience (and so must its resource claim, when it has one), and it must have an exp that hasn't passed. A token that fails stops the pull, and the saved bundle isn't used in its place: the server starts with no skills, or keeps the bundle it has. A jwksUrl that can't be reached counts as the bundle server being down, so the saved bundle is used. Pulling from a bundle server shows each case. enableWebhook does nothing.

The bundle

A bundle is a JSON object, or YAML, with this shape. The plugin also accepts it wrapped in an OpenAPI Overlay document, under info["x-frontmcp-bundle"] or a top-level x-frontmcp-bundle, so it can live next to the spec it describes.

FieldTypeDescription
schemaVersion1
bundleIdstringThe bundle's id: letters, digits, _ . - :.
versionstringShown to the model as bundleVersion, so it can tell when the bundle changed.
generatedAtstringAn ISO 8601 date.
sourceDigeststringA hex digest of the spec the bundle was made from, for your records.
services{ id, baseUrl, description? }[]The APIs, at least one. An operation's URL is its service's baseUrl plus its pathTemplate.
authBindingsRecord<string, AuthBinding>How operations authenticate. See credentials.
skillsBundledSkill[]What the model searches and loads.
operationsRecord<string, OperationDescriptor>Keyed by operationId.
integrity{ alg, keyId, signature, digest }The signature. See signing.

A skill:

FieldTypeDescription
idstringWhat load_skill takes. Letters, digits, _ . -, unique in the bundle.
name, descriptionstringWhat search_skill matches and returns.
instructionsstringMarkdown load_skill returns: how to use the operations, in what order, what to check.
operationIdsstring[]The operations the skill offers, as actions.
tagsstring[]For search_skill's tags filter.
requiredAuthoritiesobjectA rule every caller of the skill's actions must pass. See authorization.
requiresstring[]Skills this one builds on. The plugin registers them first, and rejects a bundle with a missing one or a cycle.

An operation:

FieldTypeDescription
operationIdstringThe action's id, the same as its key in operations.
serviceIdstringOne of services.
httpMethod"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"
pathTemplatestringLike /invoices/{id}/refunds. Must start with /, with no spaces, ?, #, .., \, backticks, $( or ${.
inputSchemaJSON SchemaThe action's input. Checked before every request.
outputSchemaJSON SchemaThe response. A JSON response that doesn't match fails the action.
mapper{ inputKey, type: "path" | "query" | "header" | "cookie" | "body", key, required? }[]Where each input field goes in the request: { inputKey: "id", type: "path", key: "id" } fills {id}. Input fields without a mapper entry aren't sent.
authBindingRefstringOne of authBindings.
requiredAuthoritiesobjectA rule callers of this operation must pass, as well as the skill's.
publicbooleanWith unprotectedOps: "deny", lets this operation be called without a rule.
summary, descriptionstringShown by load_skill.
timeoutMs, maxResponseBytesnumberOverride outbound's defaults.

Before a bundle is applied, the plugin checks it against this shape (unknown fields are errors) and checks that every operation's service and auth binding, and every skill's operations, exist. A bundle that fails isn't applied: the log says why, and the server keeps the bundle it had, or has no skills. @frontmcp/adapters/skills exports compileSkilledBundleFromOpenApi(), which builds services, operations and their mappers from an OpenAPI spec and a list of skills; see building a bundle.

What the plugin adds

Three tools, and nothing for the operations themselves:

ToolInputResult
search_skillquery (1–2048 characters), limit? (default 20, at most 50), tags?, notQuery? (a string or strings: skills that match it rank lower), notWeight?{ skills: [{ skillId, name, description, score }] }, best first. Read-only.
load_skillskillId{ skill: { id, name, description, instructions, bundleVersion, actions: [{ actionId, summary, description?, inputJsonSchema, outputJsonSchema, requiredAuthorities? }] }, isComplete }. actions has only the actions the caller may run. An unknown id, or a skill the caller may not use, is an error result with code SKILL_NOT_FOUND. Read-only.
run_workflowscript (1–20000 characters){ success, value?, error?, stats: { durationMs, toolCalls, steps } }. Annotated destructiveHint: true, openWorldHint: true.

With exposeOperationsAsInternalTools (the default), each operation is also a tool, <bundleId>.<operationId>, that isn't listed and that a client calling it gets Tool "…" not found for. Your own tools call it with this.callTool("acme:billing.getInvoice", { id }), with the same checks as a script's callTool, requiredAuthorities included, and get { ok, status, data, contentType }. A failure doesn't throw: it's ok: false, with the status and the API's answer in data, or an error for a refusal before anything was sent, like authority denied: …. Changed in 1.9: these tools were never registered, and the plugin logged per-op internal tools disabled.

The descriptions tell the model the order: search, load, then run. On every tools/list, the plugin appends the skills the caller may use to search_skill's description, one line each (- **Invoices**: Look up and refund invoices.), so the model knows what the server can do before it searches.

search_skill and load_skill also find the server's other skills. The bundle's skills are registered in the same registry, as skills added at runtime, so they're listed in skills/list and in skill://index.json too. The plugin tells the server it adds skills while it runs (@Plugin({ dynamicSkills: true })), so a server whose only skills come from a bundle has the skills methods and skill://index.json from the start: skills/list answers with an empty list until the bundle has loaded, and stays empty if the bundle is rejected. The index names each bundle skill's SKILL.md by the skill's name, like skill://Invoices/SKILL.md, which serves its instructions. The skill's id works too: skill://invoices/SKILL.md. (Before 1.8.5, the id form wasn't found.)

The plugin's providers include two tokens you can get with this.get(): SkilledOpenApiConfig (options, the parsed options; outbound; unprotectedOps) and SkilledOpenApiCredentialResolver. SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN is for hosts without a file system or background timers: a provider under it can give the saas source a cache of its own (cache: { read, write }), turn off polling (disablePolling), and receive the source (attach(source)) to call source.refresh() on a schedule.

How run_workflow runs a script

The script is AgentScript, a subset of JavaScript that @enclave-vm/ast checks and rewrites and @enclave-vm/core interprets, step by step, in plain JavaScript: it doesn't use Node's vm. The script calls operations with await callTool(actionId, input) and ends with return <value>, which becomes value.

For each callTool:

  1. The action is found by actionId among all the bundle's operations; the script doesn't have to have loaded its skill. An unknown one throws unknown action "…" — run search_skill then load_skill to discover available actions.
  2. The skill's and the operation's requiredAuthorities are checked against the caller. See authorization.
  3. The input is checked against inputSchema: input validation failed: id: Invalid input: expected string, received undefined.
  4. The request is built, checked and sent, with the auth binding's credential.
  5. A JSON answer is checked against outputSchema: upstream response failed output schema: ….
  6. callTool returns the response's data. Any failure, including a 4xx or 5xx answer (action "getInvoice" failed with status 404, without the API's body), throws.

The first throw ends the script with success: false and the message as error. Other limits end it the same way:

LimitError
25 callToolsTool call limit exceeded (25)
2,000,000 stepsExecution step limit exceeded (2000000)
8 secondsExecution timed out after 8000ms

The language has const and let, arrow functions, array methods like map, for loops, Math and JSON. It has no try/catch (Unsupported statement: TryStatement), no ++ (Unsupported expression: UpdateExpression; write i = i + 1), no Promise (so calls run one at a time), no fetch and no process. Without @enclave-vm/core and @enclave-vm/ast installed, run_workflow answers workflow execution unavailable: the @enclave-vm sandbox is not installed. Install @enclave-vm/core and @enclave-vm/ast to enable run_workflow.

Outbound requests

Each request goes to the operation's service with the global fetch, redirect: "manual". Before it's sent:

  • The URL must be https: (or http: with allowHttp).
  • The host must be the service's host, and not a cloud metadata name (metadata.google.internal, metadata.azure.com, metadata.aws.com, metadata, metadata.goog).
  • An IP address in the URL is checked, and a host name is resolved and each address it resolves to is checked: private and loopback addresses are refused unless allowPrivateNetworks, link-local and metadata addresses always. A name that doesn't resolve is refused unless allowPrivateNetworks.

A refusal ends the action with ssrf check rejected request: …, like private/loopback IPv4 10.0.0.5 (in 10.0.0.0/8) is blocked. A redirect answer isn't followed, a response larger than the limit fails with response exceeded maxResponseBytes (…), and a slow one with timeout after …ms.

A body is sent as JSON, with content-type: application/json, unless a mapper header entry sets Content-Type. A body that can't be turned into JSON fails the action with request body serialization failed: …. Changed in 1.9: the body went out as text/plain;charset=UTF-8, and an API that insists on application/json answered 415.

Credentials

An operation's authBindingRef names one of the bundle's authBindings:

kindFieldsSends
nonenothing
bearervaultRef, passthroughCallerToken?Authorization: Bearer <secret>
apiKeyin: "header" | "query", name, vaultRefthe secret as header or query parameter name
oauth2flow: "client_credentials", vaultRefAuthorization: Bearer <secret>: the secret is used as the token, nothing is exchanged

The secret for a vaultRef comes from the SkilledOpenApiCredentialResolver provider: resolve(ref, { bundleId }) returns it, or undefined, which fails the action with auth resolution failed: bearer vaultRef "billing-token" did not resolve. The default resolver reads the credentials option. To read a vault, extend SkilledOpenApiCredentialResolver and pass an instance in init({ providers }); see the example. The resolver doesn't learn who the caller is, so every caller shares the bundle's credentials.

passthroughCallerToken: true sends the caller's own token, the one the server verified for the request, to the API, and the plugin's resolver isn't asked. Only a token issued for that API goes out: it must be a JWT whose aud or resource claim names the service's baseUrl, or a URL above it on the same origin, like https://203.0.113.10. Otherwise the action fails before anything is sent:

The callererror
Has no tokenauth resolution failed: passthrough caller token requested but the caller presented none
Has a token that isn't a JWT, like a static key… passthrough caller token refused: the caller token is not a JWT, so the API it was issued for cannot be checked
Has a JWT issued for something else, like your MCP server… passthrough caller token refused: the caller token was not issued for https://203.0.113.10/v1 (no resource or aud claim names it)

So the client needs a token whose audience includes your API, from an identity provider that issues those. Supplying credentials runs each case. Changed in 1.9: the token was never passed along, and every call failed with passthrough caller token requested but not supplied.

Authorization

requiredAuthorities, on a skill, an operation or both, is a rule in the grammar of authorities: roles, permissions, attributes, anyOf, allOf, not, operator. Both rules must pass. A bundle rule is an object, so it can't name a profile.

When your server has the authorities option, the plugin checks bundle rules with the same engine and settings as your entries' authorities, so the caller's roles come from where its claimsMapping says. Without it, the plugin uses these defaults:

  • the caller's roles are the token's roles claim, and permissions its permissions claim;
  • claims are all the token's claims, and input is the action's input, so { attributes: { conditions: [{ path: "input.amount", op: "lte", value: 100 }] } } refuses a refund over 100 with authority denied: attributes.conditions: 'input.amount' failed 'lte' check against '100';
  • an anonymous caller has no roles.

A refusal ends the action with authority denied: roles.any: user has none of 'agent', or the like. With no rule on the operation or its skill, the action runs under unprotectedOps: "allow"; under "deny" it fails with authority denied: unprotected_operation_denied unless the operation has public: true. A rule that checks nothing, or has a field the grammar doesn't have, refuses everyone: authority denied: invalid authorities rule: has an unknown field "role"; checks nothing. Bundle rules aren't checked when the bundle is applied, only when an action runs.

Each caller sees only what they may use. search_skill, the catalog in its description and load_skill leave out a skill whose own rule refuses the caller, and load_skill answers SKILL_NOT_FOUND for it, as for a skill that doesn't exist. load_skill's actions leave out the actions the caller can't run. A rule that reads the action's input can't be judged before the call, so its action stays listed, and the call is checked when the script makes it.

Signing

With requireSignature (the default), a bundle is applied only if its integrity checks out:

  1. digest is the SHA-256, in hex, of the bundle without integrity, as canonical JSON: keys sorted at every level, no spaces.
  2. keyId is one of trustedKeys, with the same alg.
  3. signature is the base64url signature of that canonical JSON (the text, not the digest) with the key: RSA PKCS#1 v1.5 with SHA-256 for RS256, Ed25519 for EdDSA.

A bundle that fails isn't applied, and the log says why: bundle missing integrity envelope, digest mismatch (envelope=… computed=…), unknown signing keyId "…" — not in trustedKeys allowlist, signature verification failed. @frontmcp/adapters/skills exports canonicalize(), bundleDigest() and verifyBundleSignature() to sign and check bundles in CI; see signing a bundle.

Checking a signature needs Node's crypto, so the Playground below only shows bundles being rejected.

What the checks protect against

The plugin lets a model call your API, with your credentials, through scripts the model writes. Most of the checks above are there to keep that within bounds. Here they are against the OWASP MCP Top 10, a list of the main risks of MCP servers (its 2025 edition, still a beta):

RiskWhat the plugin does about it
MCP01 Token mismanagement and secret exposureCredentials stay on the server: the resolver adds them to each request, and callTool hands the script only the API's answer. A caller's own token goes only to an API it was issued for. A redirect isn't followed, so a credential isn't sent on to wherever the API points. The audit log keeps hashes of inputs and answers, not their values.
MCP02 Privilege escalation via scope creeprequiredAuthorities on skills and operations, checked on every callTool, and unprotectedOps: "deny" for operations without a rule (Authorization). Loading a skill grants nothing: a script can call any operation in the bundle, so these rules are the limit.
MCP03 Tool poisoningWhat the model reads, the skills' descriptions and instructions and the operations' summaries, comes from a bundle that's applied only with a valid signature. Each bundle applied is logged with a count of what it changed: [bundle-sync] applied acme:billing@1.0.0 (+1/-0/~0 skills, +2/-0/~0 ops).
MCP04 Software supply chain attacks and dependency tamperingThe signature covers the whole bundle, so one changed after CI signed it isn't applied. The npm source doesn't check provenance, and importing a package runs its code before the bundle's signature is checked: pin the version (Sources).
MCP05 Command injection and executionA script is AgentScript, interpreted step by step, with no fetch and no process (How run_workflow runs a script). Path templates can't hold $(, ${ or backticks, path parameters are percent-encoded, and a header value with a line break is refused (see below).
MCP06 Prompt injection via contextual payloadsIn part. An API's answer reaches the model only through the script, but its content isn't checked (see below).
MCP07 Insufficient authentication and authorizationYour server's auth decides who may call run_workflow, and requiredAuthorities what each caller may run. The saas source checks its pull token's signature, issuer, audience and expiry before every pull (Sources).
MCP08 Lack of audit and telemetryThe audit log records each action's authorization and outcome, signed and chained. Bundle pulls and signature checks are counted, and a rejected bundle is logged with the reason.
MCP09 Shadow MCP serversNothing: this is about servers running without anyone knowing, which a plugin can't see.
MCP10 Context injection and over-sharingA caller is shown only the skills and actions their rules allow, the operations aren't listed as tools, and an input field without a mapper entry isn't sent to the API.

What reaches the model. An API's answer goes to the script, not to the model, and the model sees only what the script returns. On the way, a JSON answer must match the operation's outputSchema, an answer larger than maxResponseBytes fails the action, and an error answer's body isn't passed on at all, only its status. None of this looks at the content. A string field can hold text written for the model, an answer that isn't JSON isn't checked against outputSchema, and the script, which the model wrote, can return an answer whole. Treat what your API returns, like ticket bodies, customer names and notes, as you'd treat user input. In Testing a bundle against hostile input, a receipt with an instruction in it reaches the model.

Strings in the bundle and in the script's input. A signature says who made a bundle, not that it's well formed, so a signed bundle is checked too: its shape, with no unknown fields, path templates without .., \, backticks, $( or ${, and an apiKey binding's name limited to the characters a header name may have. A mapper entry's header name isn't checked when the bundle is applied: a bad one fails each request that sets it, with request build failed: …. The script's input is checked as each request is built:

  • A path parameter is percent-encoded, so a / in it stays inside its segment. Give each path parameter a pattern in inputSchema, like ^C-[0-9]+$, so a script can only send ids of the right shape.
  • A header value with a carriage return, line feed or other control character is refused before anything is sent: request build failed: Invalid header value for 'X-Note-Author': contains control characters (possible header injection attack).

When the bundle changes

The static source with watch and the saas source deliver new bundles while the server runs. Each is checked like the first; if it passes, the plugin replaces the skills and operations at once, and the next search_skill or load_skill sees them, with the new bundleVersion. If applying it fails partway, the previous skills are restored. A rejected bundle leaves the previous one in place.

The input and output validators, though, are cached for the life of the process by the bundle's version and the operationId, not the bundle's id. An operation whose schema changed in a bundle with the same version is still checked against the old schema, and so is an operation with the same id in another bundle with the same version: input validation failed for input that matches the new schema. Change version with every bundle you publish.

With ObservabilityPlugin from @frontmcp/observability listed before this plugin in plugins, each bundle applied and each signature checked is counted, and each bundle applied is recorded as a span: see Skilled OpenAPI's counters.

Audit log

skillsConfig.audit, an option of @FrontMcp rather than of the plugin, records each action a run_workflow script runs: whether its rules let it through, and how the request went. Each record is signed and carries the hash of the record before it, so a record edited, removed or moved afterwards breaks the chain, and verifyChain() says where. It's off by default.

The signers, stores and verifyChain() are in @frontmcp/adapters/skills, which the SDK doesn't import. Give it the module with setSkillAuditFactory(() => auditModule) before the server starts. Without that, the log stays off and the server logs a warning, even when signer and store are set; with NODE_ENV=production, the server doesn't start. See recording an audit trail.

FieldTypeDefaultDescription
enabledbooleanfalseTurns the log on.
signerHs256AuditSigner, Rs256AuditSigner or your ownan HS256 signer with a random secretSigns each record. The default secret lasts as long as the process, so its records can't be checked after a restart, and the server logs a warning. With NODE_ENV=production, the server doesn't start without a signer.
storeMemoryAuditStore, StorageAdapterAuditStore or your ownMemoryAuditStoreWhere the records go. The default keeps them in the process, and the server logs a warning, in production too.
metricsSkillAuditMetricsnoneCounts writes that failed and records dropped. Build it with createSkillAuditMetrics({ createCounter }). Anything without an incrementWriteFailure() method stops the server with a ScopeConfigurationError.
subjectMode"hash" | "plain" | "omit""hash"How the caller is written in subject. See below.
headAnchorIntervalMsnumberAccepted, as a positive integer, and unused in FrontMCP 1.9.4.

The signers:

SignerSigns with
new Hs256AuditSigner(secret, keyId)HMAC-SHA256, with secret as a string or bytes. Checking the log takes the same secret, so whoever can check it can also write records that pass.
new Rs256AuditSigner(privateJwk, keyId)RSA PKCS#1 v1.5 with SHA-256. privateJwk is an RSA private key as a JWK object, with n, e and d: a PEM string, an EC key or a public key throws. Checking the log takes only the public key.

keyId is written in each record as signatureKeyId, so verifyChain() can pick the key each record was signed with. Both throw for an empty keyId, and Hs256AuditSigner for an empty secret.

The stores:

StoreKeeps the records in
new MemoryAuditStore()The process. They're gone when it ends.
new StorageAdapterAuditStore(storage, options?)A storage adapter from @frontmcp/utils, under audit:skills:sequence (a counter) and audit:skills:records:<n>. options.sequenceKey and options.recordKeyPrefix change those keys. A server that restarts against the same storage carries on the same chain.
Your ownAny object with nextSequence(), appendAtSequence(record), which must refuse a sequence that's taken, tail() and read({ from?, limit? }). Extending one of the two above is the shortest way to also send each record somewhere else.

What each action leaves:

phaseWritten whenFields it adds
authority-check-passThe action passed its requiredAuthorities, or had none to pass.
authority-check-failIts rules refused it.errorMessage, the reason: roles.any: user has none of 'agent'
http-call-successThe API answered with a 2xx status.status, outputHash
http-call-failureThe API answered with another status, or nothing was sent.status, 0 when nothing was sent, and errorMessage: http call failed with status 404, or auth resolution failed: …

So an action that runs leaves two records, and a refused one leaves one. An action whose input fails its inputSchema leaves only authority-check-pass. One whose JSON answer fails its outputSchema is recorded as http-call-success, because the request itself succeeded. An unknown action id leaves nothing, and so do search_skill, load_skill, and your own tools' calls to the operation tools with this.callTool().

Every record also has id (a UUID), sequence (from 1), timestamp, subject, skillId, actionId, bundleId, bundleVersion, inputHash (the SHA-256 of the input as canonical JSON), prevHash (the SHA-256 of the record before it, without its signature, or 64 zeros for the first), signature, signatureKeyId and signatureAlg ("HS256" or "RS256"). The input and the answer themselves are never written.

subjectMode decides what subject holds:

  • "hash": hashed: and 32 hex characters, the same for every record of one caller, like hashed:4cc48a83b675b1b6caedd67a089837aa. For ids that must not appear in the log in any form, use "omit".
  • "plain": the caller's sub, like nour. An anonymous caller over HTTP is the id FrontMCP gave it, like anon:add67063-….
  • "omit": redacted for everyone.

Writing a record doesn't hold up the action, and a store that's slow or failing doesn't fail it. The writer writes one record at a time per process: up to 1000 wait their turn, and more are dropped with [skill-audit] queue overflow (1000/1000); dropping … record. Audit backend likely unhealthy. A record that can't be signed or stored is skipped, with [skill-audit] failed to append record at seq=…: … in the log. With metrics, these count as frontmcp_skills_audit_write_failures_total (reason: sign, append or unexpected) and frontmcp_skills_audit_dropped_total (reason: queue-overflow). So the log can miss records, and what it has is signed and in order.

Caveats

  • run_workflow can call every operation in the bundle. Skills organize what the model reads; they don't limit what a script can call. Protect operations with requiredAuthorities and unprotectedOps: "deny".
  • dev: true turns signature checks off, and lets operations call http: services: keep it out of production. With requireSignature: false and no dev, the plugin logs requireSignature=false without dev=true: bundle signing is OFF. at startup. Changed in 1.9: dev: true didn't skip signature checks, although its warning said it did.
  • Validators are cached per process by bundle version and operationId: change version whenever a schema changes.
  • The plugin starts loading the bundle when the server starts, and the three tools wait for the first load to finish. A bundle that fails to load leaves the server running with no skills; the reason is only in the log.
  • The audit log is the server's option, skillsConfig.audit on @FrontMcp, and stays off until setSkillAuditFactory() is called: see audit log.
  • @frontmcp/plugin-skilled-openapi loads and runs in an ES module project.

Usage

Serving a bundle

An inline source takes the bundle as an object. requireSignature: false lets this unsigned one load; see requiring signed bundles for what production needs. The model finds a skill, then reads it:

Open
import "./billing.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The operations aren't tools: nothing in tools/list names getInvoice. The model learns about them from load_skill, and calls them from a script.

Running actions with run_workflow

The script calls actions with await callTool(actionId, input) and returns what the model should see. Several calls that depend on each other take one round trip:

Open
import "./billing.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The script didn't call search_skill or load_skill first; it didn't need to. A real model does, to learn the action ids and their input.

If you edit an operation's schema here, change the bundle's version too: the plugin keeps the validators it built for a version until the page reloads, as a server keeps them until it restarts.

Authorizing actions

requiredAuthorities decides who may run an action, from the caller's token, and who sees it. Here refunds need the agent role, credits are capped at 100 by a rule on the input, the admin skill needs admin on top of its operation's rule, and with unprotectedOps: "deny" an operation without a rule runs only if it's public. reissueInvoice's rule has a typo. The tests call as two users, with tokens a test key signed ahead of time:

Open
import "./billing.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";

@App({
  id: "billing",
  name: "Billing",
  plugins: [
    SkilledOpenApiPlugin.init({
      source: { type: "inline", content: bundle },
      requireSignature: false,
      credentials: { "billing-token": "billing-api-token" },
      unprotectedOps: "deny",
    }),
  ],
})
class BillingApp {}

export const config = {
  info: { name: "billing", version: "1.0.0" },
  apps: [BillingApp],
  auth: {
    mode: "transparent" as const,
    provider: "https://auth.example.com",
    expectedAudience: "https://desk.example.com",
    allowAnonymous: true,
    anonymousScopes: ["tickets:read"],
    // The provider's public key, so the example runs offline. Usually FrontMCP fetches it.
    providerConfig: { jwks: { keys: [{ kty: "EC", crv: "P-256", kid: "desk-1", alg: "ES256", use: "sig", x: "1CjT9Do3B6PgOMVsIY0Amz_l1QbuUfoNLcq3E1OFip0", y: "4Je6s1T1RHT7obF4lowUe-63m0pat0TZfmnRxLfDCE8" }] } },
  },
};

@FrontMcp(config)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

getInvoice shows what unprotectedOps: "deny" is for: in the default "allow", an operation someone forgot to give a rule is open to every caller, anonymous ones included, and run_workflow reaches every operation in the bundle whichever skill it's in.

What a caller is shown follows the same rules. An anonymous caller doesn't find the admin skill, and loading it answers SKILL_NOT_FOUND; in invoices they see creditInvoice, whose rule depends on the amount, and getPlans. reissueInvoice is listed to no one: its rule checks nothing, so it refuses everyone, and nothing reports it until the action runs. Test a bundle's rules before you publish it.

Supplying credentials

The credentials option is fine for one secret from the environment. To fetch secrets from a vault, extend SkilledOpenApiCredentialResolver and give an instance to init({ providers }); it replaces the default resolver. Each auth binding puts the secret where the API expects it:

Open
import "./billing.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiCredentialResolver, SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";

/** Reads secrets from a vault. This one is a Map, for the example. */
class VaultResolver extends SkilledOpenApiCredentialResolver {
  private readonly secrets = new Map([
    ["acme:billing/billing-token", "billing-api-token"],
    ["acme:billing/crm-key", "crm-api-key"],
  ]);

  async resolve(ref: string, { bundleId }: { bundleId: string }) {
    return this.secrets.get(`${bundleId}/${ref}`);
  }
}

@FrontMcp({
  info: { name: "billing", version: "1.0.0" },
  apps: [],
  plugins: [
    SkilledOpenApiPlugin.init({
      source: { type: "inline", content: bundle },
      requireSignature: false,
      providers: [{ provide: SkilledOpenApiCredentialResolver, name: "VaultResolver", useValue: new VaultResolver() }],
    }),
  ],
})
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The resolver gets the vaultRef and the bundle's id, never the caller, so every caller uses the same secrets. It runs for each request, and the script never sees what it returns: callTool gives back only the API's answer.

Testing a bundle against hostile input

The model writes the scripts, so test each operation with the input a careless or manipulated model could send, and with the answers your API could give, before you publish the bundle. The tests here send a path parameter with a / and one that doesn't match its pattern, a header value with a line break, and a request the API answers with a redirect. The API's receipts are plain text, with a line aimed at the model:

Open
import "./billing.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";

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

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The receipt's instruction reaches the model as the script's value: outputSchema applies to JSON answers only, and it checks a value's type, not what it says. getCustomer's pattern refuses an id of the wrong shape before anything is sent. The checks are listed in what the checks protect against.

Requiring signed bundles

By default the plugin applies only a signed bundle. An unsigned one, or one changed after it was signed, is rejected: the model finds no skills, and the log says why. With dev: true it applies an unsigned one, as the second test shows.

Open
import { App, FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle, publicKeyPem } from "./bundle";

// CI signed this bundle as version 1.0.0; its version was changed afterwards
const edited = {
  ...bundle,
  integrity: {
    alg: "EdDSA",
    keyId: "ci-2026",
    signature: "syKKffz-W5oXmwfZYFMy6qiTGwCY1m4xQHaJntPCg42ci2SNakhjwecDi7AbPSJOHkZTpE2j0XyHWPOoEj8WBA",
    digest: "29817f4a232822a12963913b0d4044e5ba3c5d3668d21f8185a292ad2794c6cb",
  },
};

@App({
  id: "unsigned",
  name: "Unsigned",
  plugins: [SkilledOpenApiPlugin.init({ source: { type: "inline", content: bundle } })],
})
class Unsigned {}

@App({
  id: "edited",
  name: "Edited",
  plugins: [
    SkilledOpenApiPlugin.init({
      source: { type: "inline", content: edited },
      trustedKeys: [{ keyId: "ci-2026", alg: "EdDSA", publicKeyPem }],
    }),
  ],
})
class Edited {}

export const unsigned = { info: { name: "billing", version: "1.0.0" }, apps: [Unsigned] };
export const tampered = { info: { name: "billing", version: "1.0.0" }, apps: [Edited] };

@FrontMcp(unsigned)
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

Open the Logs tab: rejected bundle acme:billing@1.0.1: bundle missing integrity envelope. A server that can't load its bundle keeps running, with no skills, so watch for that line.

Signing a bundle

Sign in CI, where the private key lives, with Node's crypto and the helpers from @frontmcp/adapters/skills. Checked in Node with both algorithms:

sign-bundle.ts
import { readFileSync, writeFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto";
import { bundleDigest, canonicalize } from "@frontmcp/adapters/skills";

const bundle = JSON.parse(readFileSync("dist/billing-bundle.json", "utf8"));
delete bundle.integrity;

// An Ed25519 key: `openssl genpkey -algorithm ed25519`
const privateKey = createPrivateKey(process.env.BUNDLE_PRIVATE_KEY!);
const signature = sign(null, Buffer.from(canonicalize(bundle)), privateKey).toString("base64url");
// For RS256 with an RSA key: sign("sha256", Buffer.from(canonicalize(bundle)), privateKey)

bundle.integrity = { alg: "EdDSA", keyId: "ci-2026", signature, digest: bundleDigest(bundle) };
writeFileSync("dist/billing-bundle.json", JSON.stringify(bundle, null, 2));

Give the server the public key (openssl pkey -pubout) in trustedKeys, with the same keyId and alg. verifyBundleSignature(bundle, trustedKeys) returns { ok: true, keyId, alg } or { ok: false, reason }, to check a bundle before you publish it. Any change to the bundle after signing, even its version, fails with digest mismatch. To rotate keys, add the new key to trustedKeys, publish bundles signed with it, then remove the old one.

Recording an audit trail

The audit log is set on the server, in skillsConfig.audit, and its signers and stores come from @frontmcp/adapters/skills (npm install @frontmcp/adapters). The Playground can't import that module, so this section is code, checked in Node. Here the server signs each record with an RSA key and keeps the chain in a storage adapter:

main.ts
import "reflect-metadata";
import * as auditModule from "@frontmcp/adapters/skills";
import { Rs256AuditSigner, StorageAdapterAuditStore } from "@frontmcp/adapters/skills";
import { FrontMcp, setSkillAuditFactory } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { createStorage } from "@frontmcp/utils";

// The SDK doesn't import @frontmcp/adapters/skills: give it the module before the server starts
setSkillAuditFactory(() => auditModule);

// An RSA private key as a JWK. Checking the log takes only its public half.
const signer = new Rs256AuditSigner(JSON.parse(process.env.AUDIT_PRIVATE_JWK!), "audit-2026");
const store = new StorageAdapterAuditStore(await createStorage());

@FrontMcp({
  info: { name: "billing", version: "1.0.0" },
  apps: [],
  plugins: [SkilledOpenApiPlugin.init({ source, trustedKeys, credentials, unprotectedOps: "deny" })],
  skillsConfig: { audit: { enabled: true, signer, store } },
})
export default class Server {}

Make the key with openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048, and turn it into a JWK once with Node's createPrivateKey(pem).export({ format: "jwk" }). With none of REDIS_URL, UPSTASH_REDIS_REST_URL or KV_REST_API_URL set, createStorage() keeps the data in memory, and in production says so with [storage] Warning: No distributed storage backend detected in production. Using in-memory storage.: the chain then ends with the process.

Nour, an agent, refunds INV-1, and Sam, who isn't one, tries to. The store holds three records (signatures and hashes shortened):

{ "sequence": 1, "phase": "authority-check-pass", "subject": "hashed:4cc48a83b675b1b6caedd67a089837aa", "skillId": "invoices",
  "actionId": "refundInvoice", "bundleId": "acme:billing", "bundleVersion": "1.0.0", "inputHash": "91c1adf5…",
  "prevHash": "0000000000000000000000000000000000000000000000000000000000000000", "signatureKeyId": "audit-2026",
  "signatureAlg": "RS256", "signature": "EKB2BufT…", "id": "a087983d-…", "timestamp": "2026-10-10T12:24:20.229Z" }
{ "sequence": 2, "phase": "http-call-success", "status": 201, "outputHash": "992e486c…", "prevHash": "d4752813…", … }
{ "sequence": 3, "phase": "authority-check-fail", "subject": "hashed:ca365a703527adfd47c38679eb40ec4d",
  "errorMessage": "roles.any: user has none of 'agent'", "prevHash": "b18e8f58…", … }

The refund's input appears only as inputHash, the same in all three records because both callers sent the same input. Sam's refusal is in the log although nothing was sent to the API.

Checking the chain

verifyChain() walks the records in order, from the first, and checks each one's prevHash and signature with the keys you trust:

check-audit.ts
import { StorageAdapterAuditStore, defaultAuditSignatureVerifier, verifyChain } from "@frontmcp/adapters/skills";
import { createStorage } from "@frontmcp/utils";

const records = await new StorageAdapterAuditStore(await createStorage()).read();
const result = verifyChain(
  records,
  [{ keyId: "audit-2026", alg: "RS256", publicKeyPem: process.env.AUDIT_PUBLIC_KEY_PEM! }],
  defaultAuditSignatureVerifier,
);
console.log(result); // { ok: true, verified: 3 }
The logverifyChain() returns
As written{ ok: true, verified: 3 }
Record 2 edited, like its status{ ok: false, breakAt: 2, reason: 'signature verification failed at sequence 2 (keyId="audit-2026", alg=RS256)' }
Record 2 removed, or records 2 and 3 swapped{ ok: false, breakAt: 3, reason: "sequence gap: expected 2, got 3" }
Record 1 removed{ ok: false, breakAt: 2, reason: "prevHash mismatch at sequence 2: expected 000000000000.. got 250ef3710919.." }
Read from the middle, with read({ from: 3 })prevHash mismatch at sequence 3: …, the same way
Record 3 removed{ ok: true, verified: 2 }: nothing shows it
Checked with another key, or a key list without the records' keyIdsignature verification failed at sequence 1 (…)
  • Removing the newest records isn't detected. headAnchorIntervalMs is meant for that, and does nothing in FrontMCP 1.9.4. Keep the latest sequence and its hash somewhere the server can't write, and compare.
  • Check from the start. verifyChain() takes the first record it's given as the first of the chain, so a window from the middle fails at its first record.
  • Keep old keys. Each record is checked with the key its signatureKeyId names: after you change keys, keep the old one in the list for the records it signed. For an HS256 log, the entry is { keyId, alg: "HS256", secret }, with the signer's secret as bytes (new TextEncoder().encode(secret)). For RS256, publicJwk works in place of publicKeyPem.

Running several instances

A chain takes one writer. Two servers that append to the same chain at once each link their records to the record they last read, and verifyChain() then reports a prevHash mismatch, though nobody changed the log. Give each instance a chain of its own, and check each chain on its own:

const store = new StorageAdapterAuditStore(await createStorage(), {
  sequenceKey: `audit:${process.env.POD_NAME}:sequence`,
  recordKeyPrefix: `audit:${process.env.POD_NAME}:records:`,
});

A server that restarts against the same storage carries on its chain: two records before a restart and two after check out as one chain of four.

A record the store fails to write leaves the action alone, and is lost. StorageAdapterAuditStore gives its sequence number back, so the chain still checks out. MemoryAuditStore doesn't, and verifyChain() reports a sequence gap there.

Sending records elsewhere, and counting failures

To copy each record to your log pipeline or SIEM, extend a store and forward the record once it's stored. To count failed and dropped writes, pass metrics, built with createCounter from @frontmcp/observability:

audit.ts
import { StorageAdapterAuditStore, createSkillAuditMetrics, type SkillAuditRecord } from "@frontmcp/adapters/skills";
import { createCounter } from "@frontmcp/observability";

/** Keeps the chain in storage, and writes each record to the log once it's stored. */
export class ForwardingAuditStore extends StorageAdapterAuditStore {
  async appendAtSequence(record: SkillAuditRecord) {
    await super.appendAtSequence(record);
    console.log(JSON.stringify({ audit: record }));
  }
}

export const metrics = createSkillAuditMetrics({ createCounter });

Pass both in skillsConfig: { audit: { enabled: true, signer, store: new ForwardingAuditStore(storage), metrics } }. A record the store then can't write is logged as [skill-audit] failed to append record at seq=1: … and adds 1 to frontmcp_skills_audit_write_failures_total{reason="append"}, while the action itself succeeds.

Loading bundles from files, packages and servers

The static, npm and saas sources need a file system, a package or a network, so they're shown as code, checked in Node:

main.ts
import { resolve } from "node:path";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";

// A file next to the server, reloaded when it changes
SkilledOpenApiPlugin.init({
  source: { type: "static", path: resolve(__dirname, "../billing-bundle.yaml"), watch: true },
  trustedKeys,
});

// An export of a package you depend on
SkilledOpenApiPlugin.init({
  source: { type: "npm", packageName: "@acme/billing-bundle", exportName: "bundle", verifyProvenance: false },
  trustedKeys,
});

// A bundle server, pulled at startup and every 5 minutes
SkilledOpenApiPlugin.init({
  source: {
    type: "saas",
    endpoint: "https://bundles.acme.example/v1/bundles/billing-prod",
    authToken: process.env.BUNDLE_PULL_TOKEN!,
    expectedAudience: "billing-prod",
    jwksUrl: "https://bundles.acme.example/.well-known/jwks.json",
    expectedIssuer: "https://bundles.acme.example",
  },
  bundleCacheDir: "/var/cache/billing-mcp",
  trustedKeys,
});
  • static: the file can be the bundle itself, or an OpenAPI Overlay with the bundle under info.x-frontmcp-bundle. With watch, an edit shows up in the next search_skill within about a second.
  • npm: packageName is imported with import(), so it can be any specifier Node resolves. verifyProvenance: false is required: with the default true, the plugin doesn't import the package and logs npm source "@acme/billing-bundle": verifyProvenance is true, but npm provenance verification is not implemented, so the package was not loaded. …. Importing a package runs its code before the bundle's signature is checked, so pin its exact version.
  • saas: BUNDLE_PULL_TOKEN is a JWT the bundle server issued, with iss: "https://bundles.acme.example" and aud: "billing-prod". Each pull is saved, here to /var/cache/billing-mcp/billing-prod.json. When the server starts and the pull fails (in the check, the endpoint answered something that wasn't a bundle), the plugin logs initial pull failed (…); attempting cached bundle fallback and serves the saved bundle. Without a saved one, the server starts with no skills. A failed poll later on is logged, and the current bundle stays. A pull token that fails its check is different: see below.

Pulling from a bundle server

Here bundle-server.example.ts plays the bundle server. The Playground has no file system, so a provider under SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN gives the source a cache in memory, and turns off polling. The pull token is checked against the server's keys before each pull, and a token that fails stops the pull:

Open
import "./bundle-server.example";
import { FrontMcp } from "@frontmcp/sdk";
import { SkilledOpenApiPlugin, SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN } from "@frontmcp/plugin-skilled-openapi";
import { PULL_TOKEN } from "./tokens";

// The last bundle pulled. A server keeps it in bundleCacheDir; the Playground has no file system.
let saved: unknown;
// The source, so a test can call refresh() on it
export const pulls: { source?: { refresh(): Promise<unknown> } } = {};

export function billingServer(authToken: string) {
  return {
    info: { name: "billing", version: "1.0.0" },
    apps: [],
    providers: [
      {
        provide: SKILLED_OPENAPI_RUNTIME_DEPS_TOKEN,
        name: "BundleCache",
        inject: () => [],
        useFactory: () => ({
          cache: { read: async () => saved, write: async (bundle: unknown) => { saved = bundle; } },
          disablePolling: true,
          attach: (source: { refresh(): Promise<unknown> }) => { pulls.source = source; },
        }),
      },
    ],
    plugins: [
      SkilledOpenApiPlugin.init({
        source: {
          type: "saas",
          endpoint: "https://bundles.acme.example/v1/bundles/billing-prod",
          authToken,
          expectedAudience: "billing-prod",
          jwksUrl: "https://bundles.acme.example/.well-known/jwks.json",
          expectedIssuer: "https://bundles.acme.example",
        },
        requireSignature: false, // the example's bundle isn't signed
      }),
    ],
  };
}

@FrontMcp(billingServer(PULL_TOKEN))
export default class Server {}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The aud check is what keeps a token issued for another of the bundle server's customers, or for your staging server, from pulling your bundle, and the cache isn't a way around it. An unreachable jwksUrl is treated like any other outage: the saved bundle is served.

Building a bundle from an OpenAPI spec

A bundle's operations, with their schemas and mappers, can be generated from the spec. compileSkilledBundleFromOpenApi(), from @frontmcp/adapters/skills, takes the spec, your skills, and the bundle's id and version:

build-bundle.ts
import { readFileSync, writeFileSync } from "node:fs";
import { compileSkilledBundleFromOpenApi } from "@frontmcp/adapters/skills";

const spec = JSON.parse(readFileSync("billing-openapi.json", "utf8"));

const bundle = await compileSkilledBundleFromOpenApi(
  spec,
  [
    {
      id: "invoices",
      name: "Invoices",
      description: "Look up customer invoices and refund them.",
      instructions: readFileSync("skills/invoices.md", "utf8"),
      operationIds: ["getInvoice", "refundInvoice"],
    },
  ],
  { bundleId: "acme:billing", version: process.env.BUILD_VERSION!, authBinding: { kind: "bearer", vaultRef: "billing-token" } },
);

writeFileSync("dist/billing-bundle.json", JSON.stringify(bundle, null, 2));
  • It keeps only the operations your skills name, and throws for one the spec doesn't have: compileSkilledBundleFromOpenApi: operationId "…" is referenced by a skill but not defined in the OpenAPI document.
  • All operations share one service, serviceId (default api) at baseUrl (default: the spec's first servers entry; without one it throws), and one auth binding, default, which is authBinding or { kind: "none" }.
  • generatedAt defaults to now, and sourceDigest to 64 zeros.
  • It adds no requiredAuthorities and no public; add them to the result before signing it.

Next to the OpenAPI adapter

The plugin and the OpenAPI adapter don't share anything, so one server can have both: a few operations as plain tools, and the rest of the API as skills.

Open
import "./apis.example";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenapiAdapter } from "@frontmcp/adapters";
import { SkilledOpenApiPlugin } from "@frontmcp/plugin-skilled-openapi";
import { bundle } from "./bundle";
import { statusSpec } from "./status-spec";

@App({
  id: "ops",
  name: "Ops",
  adapters: [OpenapiAdapter.init({ name: "status", baseUrl: "https://status.example", spec: statusSpec })],
})
class OpsApp {}

@FrontMcp({
  info: { name: "billing", version: "1.0.0" },
  apps: [OpsApp],
  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.

A good split: operations the model needs on every conversation, and should see without searching, as adapter tools; the long tail as skills.


Troubleshooting

search_skill finds nothing, or Skill "…" not found

If other skills are found and one isn't, the caller may not be allowed to use it: a skill whose requiredAuthorities refuses the caller is left out for them, and load_skill answers as for an unknown skill. Otherwise the bundle wasn't applied, and the log has the reason:

  • rejected bundle …: bundle missing integrity envelope, digest mismatch, unknown signing keyId, signature verification failed: see signing. For an unsigned bundle in development, set dev: true or requireSignature: false.
  • initial bundle sync failed: Bundle schema validation failed (N issue(s)) or Bundle cross-reference validation failed: the bundle doesn't have the right shape, or refers to a service, auth binding or operation it doesn't have. parseOverlay() from @frontmcp/adapters/skills throws an OverlayParseError whose errors list each problem, to check a bundle before you deploy it.
  • Overlay does not contain info.x-frontmcp-bundle or a bare bundle object with schemaVersion + bundleId: the document isn't a bundle.
  • [saas-source] initial pull failed (…): the bundle server couldn't be reached and nothing was cached.
  • [saas-source] pull token rejected: …: authToken failed its check: its "aud" claim does not include "billing-prod", or not a valid token from issuer "…" signed by a key in … for a token that is expired, from another issuer, signed with another key, or not a JWT. Ask the bundle server for a token for this server. The saved bundle isn't used in this case.
  • npm source "…": verifyProvenance is true, but npm provenance verification is not implemented, so the package was not loaded: set verifyProvenance: false.

unknown action "…" — run search_skill then load_skill to discover available actions

The script called an actionId the bundle doesn't have. It's the operationId, as load_skill lists it, with no bundle or skill prefix.

authority denied: …

The caller fails the operation's or its skill's requiredAuthorities, or, with unprotectedOps: "deny", the operation has no rule and isn't public (unprotected_operation_denied). Without the server's authorities option, the plugin reads roles from the token's roles claim; with it, from where its claimsMapping says. See authorization. invalid authorities rule: … means the rule itself is wrong, like a misspelled field, and it refuses everyone until the bundle is fixed.

auth resolution failed: bearer vaultRef "…" did not resolve

Neither credentials nor your resolver has a secret for that vaultRef.

auth resolution failed: passthrough caller token …

The binding uses passthroughCallerToken, and the caller had no token, or one that wasn't issued for the API: see Credentials for each message. requested but not supplied is FrontMCP before 1.9.0, which never passed the token along: update to 1.9.0 or later.

ssrf check rejected request: …

The request's URL failed the outbound checks:

  • forbidden scheme "http:" (https: required): set outbound.allowHttp for an http: service.
  • private/loopback IPv4 10.0.0.5 (in 10.0.0.0/8) is blocked, or host "…" resolved to private/loopback …: set outbound.allowPrivateNetworks if the API really is on your private network.
  • DNS resolution failed for "…": the service's host doesn't resolve from the server.
  • metadata/link-local … is always blocked, cloud metadata hostname "…" is blocked: these can't be allowed.

upstream returned a redirect (…); not followed to protect injected credentials

The API answered the operation with a redirect. The plugin doesn't follow it, since that would send the credential to wherever the API points (What the checks protect against). Put the address the API redirects to in the bundle: as the service's baseUrl, or as the operation's pathTemplate.

request build failed: Invalid header value for '…': contains control characters (possible header injection attack)

An input that a mapper entry sends as a header held a carriage return, a line feed or another control character, and nothing was sent. It's the script's input to fix; to refuse it earlier, with input validation failed, give the field a pattern in inputSchema. request build failed: Headers.set: "…" is an invalid header name. (the wording depends on the runtime) is the bundle's to fix: a mapper entry's key isn't a valid header name.

The API answers 415 Unsupported Media Type

It wants another content-type than the application/json the plugin sends: set it with a mapper header entry (Outbound requests). Before 1.9.0, the plugin sent JSON bodies as text/plain;charset=UTF-8: update to 1.9.0 or later.

input validation failed: … or upstream response failed output schema: …

The script's input, or the API's JSON answer, doesn't match the operation's inputSchema or outputSchema. Fix the script or the bundle; a loose outputSchema like { "type": "object" } accepts any object. If the message names a field your schema no longer has, the schema changed without a new bundle version: the plugin still uses the validator it cached for the old one. Change version, or restart.

action "…" failed with status 404

The API answered with an error status. The script sees only the status: the API's body isn't passed on. A tool that calls the operation with this.callTool() gets it, in data, with ok: false (What the plugin adds).

Unsupported statement: TryStatement, Unsupported expression: UpdateExpression, '__safe_Promise' is not defined

The script uses JavaScript that AgentScript doesn't have: see the language. Tool call limit exceeded (25) and Execution step limit exceeded (2000000) are its limits.

workflow execution unavailable: the @enclave-vm sandbox is not installed

Install @enclave-vm/core and @enclave-vm/ast next to the plugin.

Tool "acme:billing.getInvoice" not found

A tool called an operation with this.callTool(), and it isn't registered: exposeOperationsAsInternalTools is false, the name isn't <bundleId>.<operationId>, or the bundle hasn't loaded yet. Or a client called it: the operation tools are only for your own tools. Before 1.9.0 they were never registered.

ZodError from init()

An option is invalid, and the error's path names it: source: Invalid input: expected object, received undefined (no source, as in SkilledOpenApiPlugin.init() with no argument), source.endpoint: must use https://, outbound.maxConcurrencyPerHost: Too small: expected number to be >0.

[skill-audit] audit.enabled is true but no audit module factory is registered

skillsConfig.audit.enabled is set, and setSkillAuditFactory() wasn't called before the server started. Outside production it's a warning and the server runs without the audit log; with NODE_ENV=production, the server doesn't start. Setting signer and store, which the message suggests, doesn't help: call setSkillAuditFactory(() => auditModule), with import * as auditModule from "@frontmcp/adapters/skills", before the server starts.

[skill-audit] refusing to use the default HS256 signer in production

With NODE_ENV=production, skillsConfig.audit needs a signer. See recording an audit trail.

Failed to get provider Symbol(frontmcp:SKILL_AUDIT_WRITER) on every run_workflow

A warning, and only that: run_workflow looks for the audit log, and the server has none. Every run logs it until skillsConfig.audit is on and setSkillAuditFactory() is called. Actions run as usual.

verifyChain() reports a sequence gap or a prevHash mismatch, and nobody changed the log

  • The records don't start at the first one: pass all of them, from read() with no from.
  • Two servers write to one chain: give each its own sequenceKey and recordKeyPrefix (Running several instances).
  • With MemoryAuditStore, a record that failed to be written leaves its sequence number unused. The log has a [skill-audit] failed to append record at seq=… line for it.