@Skill
@Skill declares a skill: a procedure written down for the client's model, with the tools it uses, its parameters and examples. FrontMCP serves it as skill:// resources, and answers FrontMCP's own skills/search, skills/list and skills/load requests. A skill adds no tool: the client's model reads the steps and does the work itself, with your tools. A skill written as a folder with a SKILL.md loads with skillDir(). To run a procedure on your server's model instead, write an agent.
@Skill(options)
class MySkill {}
Reference
@Skill(options)
Apply @Skill to a class, and list the class in an app's skills array. The class holds no code: FrontMCP only reads the options.
import { Skill } from "@frontmcp/sdk";
@Skill({
name: "triage-ticket",
description: "Decide a new support ticket's priority and who handles it.",
instructions: `1. Read the ticket with get_ticket.
2. Search for tickets from the same customer with search_tickets. If one is open about the same problem, say so.
3. Set the priority with set_priority: high if the customer can't work, normal otherwise.
4. Assign it with assign_ticket: billing questions to the billing team, everything else to support.`,
tools: [
"get_ticket",
"search_tickets",
{ name: "set_priority", purpose: "Record the priority", required: true },
"assign_ticket",
],
parameters: [{ name: "ticketId", description: "The ticket to triage, like T-1", required: true }],
examples: [{ scenario: "Acme can't log in", parameters: { ticketId: "T-1" }, expectedOutcome: "T-1 is high priority, assigned to support" }],
tags: ["support"],
})
export class TriageTicket {}import { App } from "@frontmcp/sdk";
import { AssignTicket, GetTicket, SearchTickets, SetPriority } from "./ticket.tools";
import { TriageTicket } from "./triage-ticket.skill";
@App({
id: "help-desk",
name: "Help Desk",
tools: [GetTicket, SearchTickets, SetPriority, AssignTicket],
skills: [TriageTicket],
})
export class HelpDeskApp {}Options
Required:
| Option | Type | Description |
|---|---|---|
name | string | The skill's name, in kebab-case: lowercase letters, digits and hyphens. Any other name stops the server from starting. The resource is skill://<name>/SKILL.md. |
description | string | What the procedure is for. Clients show it in search results and in skill://index.json, and the client's model decides from it whether to load the skill. |
instructions | string | { file: string } | { url: string } | The procedure: inline, or read from a file or a URL. See instructions. |
Optional:
| Option | Type | Default | Description |
|---|---|---|---|
tools | (string | ToolClass | { name, purpose?, required? } | { tool, purpose?, required? })[] | [] | The tools the procedure uses, by name or by class. They're listed by name in SKILL.md and in skills/load, each marked available if it's in the server's tools/list. A tool that availableWhen.surface keeps from the caller counts as missing too (since 1.8.5). purpose says what the tool is for in this procedure. Tool classes work since 1.9: before, they stopped the server from starting. |
parameters | { name, description?, required?, type?, default? }[] | [] | What the procedure needs to know before it starts. type is "string" (the default), "number", "boolean", "object" or "array". |
examples | { scenario, parameters?, expectedOutcome? }[] | [] | When to use the skill, and what should happen. |
tags | string[] | [] | For clients to filter skills/search and skills/list by. Not in SKILL.md. |
id | string | name | Replaces name as the skill's id in skills/search, skills/list and skills/load. The resource is listed as skill://<name>/SKILL.md, and since 1.8.5 skill://<id>/SKILL.md reads it too. An id that is another skill's name stops the server from starting, with InvalidSkillError. |
priority | number | 0 | skills/list can sort by it. It doesn't change the order of skills/search results. |
hideFromDiscovery | boolean | false | Leaves the skill out of the resources, skill://index.json, skills/search and skills/list. skills/load still loads it by id. See Hiding a skill. |
visibility | "mcp" | "http" | "both" | "both" | "http" leaves the skill out of the resources, the index and skills/search, for the HTTP endpoints only. It's still in skills/list, and skills/load loads it. |
toolValidation | "strict" | "warn" | "ignore" | "warn" | What to do when a tool in tools doesn't exist. "warn" logs a warning, and the skill loads with the tool in missingTools. "strict" stops the server from starting, with a SkillValidationError (since 1.9; before, it started anyway). |
skillPath | string[] | [name] | A longer resource path, ending with name: ["billing", "refunds"] serves skill://billing/refunds/SKILL.md. |
license, compatibility, specMetadata, allowedTools | string, string, Record<string, string>, string | none | Fields from the Agent Skills format. They're written into SKILL.md's frontmatter as license, compatibility, metadata and allowed-tools. FrontMCP doesn't act on allowedTools: it's there for the client to read. |
resources | { scripts?, references?, assets?, examples? } | none | Folders of files that come with the skill, each an absolute path or one relative to the file that declares the skill. Clients read a file in them at skill://<name>/<folder>/<file>, like skill://triage-ticket/references/priorities.md, and the files in references are listed in a table under the instructions. skillDir() fills it in from a skill's folder. They're read from disk, so the Playground can't show them: see Loading a skill from a SKILL.md folder. |
availableWhen | EntryAvailability | none | Only serve the skill on some platforms or runtimes, or to some callers: a surface without "mcp" keeps it from MCP clients. Where it doesn't match, the skill isn't served at all: it's left out of the resources, skill://index.json, skills/list, skills/search, skills/load and the HTTP endpoints, and its SKILL.md can't be read. See A skill isn't listed, and can't be loaded and Environment awareness. |
category, rating | string, number | none | For the HTTP endpoints. Not in the MCP responses. |
referencedOperations, alwaysLoad | none | For FrontMCP's code-execution plugin. This page doesn't cover them. |
instructions
| Source | Example | When it's read |
|---|---|---|
| Inline | instructions: "1. Read the ticket…" | At once. |
| A file | instructions: { file: "./triage-ticket.md" } | When the server starts, like a URL, then kept. The path is relative to the file that declares the skill. |
| A URL | instructions: { url: "https://docs.example.com/triage.md" } | When the server starts, with fetch, then kept. |
A file or URL that can't be read doesn't stop the server. The skill is left out of skills/list, and each request that needs it tries again and fails, as in Troubleshooting. A frontmatter block at the top of a fetched file is kept as it is, so SKILL.md ends up with two. The Playground has no files and no network, so its examples use inline instructions.
What a skill adds to the server
| What | Kind | Description |
|---|---|---|
skill://index.json | resource | The discovery index, in the Agent Skills discovery format: each skill's name, description and url. |
skill://<name>/SKILL.md | resource, one per skill | The skill as a Markdown file: YAML frontmatter, then the instructions. See SKILL.md. |
skill://{+skillPath}/SKILL.md, skill://{+skillPath}/{+filePath} | resource templates | Read any skill's SKILL.md, or a file in its directory. |
skills/search, skills/list, skills/load | JSON-RPC requests | FrontMCP's own requests, not part of MCP. See below. |
io.modelcontextprotocol/skills | capability | In capabilities.extensions of server/discover, so clients know the server has skills. |
| A list of the skills | instructions | Added to the server's instructions in server/discover and initialize, after the server's own: Available skills, a pointer to skill://index.json, then a line per skill, - **<name>**: <description>. See Serving a skill. (Changed in 1.9.3: before, server/discover didn't have it.) |
A skill adds no tools: tools/list is the same with or without it. Clients that support skills read the resources; the rest can still list and read them as resources, and their model finds the skills in the server's instructions.
SKILL.md
---
name: triage-ticket
description: Decide a new support ticket's priority and who handles it.
tools:
- get_ticket
- name: set_priority
purpose: Record the priority
required: true
parameters:
- name: ticketId
description: The ticket to triage, like T-1
required: true
type: string
examples:
- scenario: Acme can't log in
parameters:
ticketId: T-1
expectedOutcome: T-1 is high priority, assigned to support
---
1. Read the ticket with get_ticket.
…
The frontmatter has name, description, tools, parameters, examples and the Agent Skills fields, as you wrote them. It doesn't have tags or priority.
skills/search
Finds skills whose description or tags match a query.
| Parameter | Type | Description |
|---|---|---|
query | string | Required. Words to look for. |
tags | string[] | Only skills with one of these tags. |
tools | string[] | Only skills that use these tools. |
requireAllTools | boolean | Leave out skills that use a tool the server doesn't have. |
limit | number | At most this many skills. |
Returns { skills, total, hasMore, guidance }. Each skill has id, name, description, score (from 0 to 1), tags, tools (each { name, available }) and source: "local". guidance is a sentence for the model: Found 1 matching skill(s). Use skills/load with skill IDs to load full content., or, with no match, No matching skills found. Try different search terms or list all skills with skills/list. Skills hidden with hideFromDiscovery or visibility: "http" aren't searched.
skills/list
Lists skills, sorted by name.
| Parameter | Type | Description |
|---|---|---|
tags | string[] | Only skills with one of these tags. |
sortBy | "name" | "priority" | "createdAt" | The order. |
sortOrder | "asc" | "desc" | |
offset, limit | number | A page of the list. |
includeHidden | boolean | Also list skills with hideFromDiscovery. |
Returns { skills, total, hasMore }, each skill with id, name, description, tags and priority.
skills/load
Loads skills by id or name, with everything the model needs to follow them.
| Parameter | Type | Description |
|---|---|---|
skillIds | string[] | Required. The skills to load. |
format | "full" | "instructions-only" | "instructions-only" leaves the tools' input schemas out of tools. Default "full". |
Returns { skills, summary, nextSteps }. Each skill has id, name, description, instructions, tools (each { name, purpose?, available, inputSchema? }), parameters, availableTools, missingTools, isComplete, and formattedContent: the whole skill as one Markdown text for the model, with a [✓] or [✗] for each tool and its input schema. summary has totalSkills, totalTools, allToolsAvailable and combinedWarnings, which names missing tools and ids that weren't found. An id that isn't found isn't an error: it's left out, with a warning.
Server options: skillsConfig
The server's skillsConfig option controls how skills are served beyond MCP. You don't need it to use skills.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Adds HTTP endpoints for skills: /llm.txt, /llm_full.txt and /skills. See Serving skills over HTTP. |
mcpResources | boolean | true | false removes every skill:// resource and template. skills/search, skills/list and skills/load still work. |
failOnInvalidSkills | boolean | true | false lets the server start when a skill with toolValidation: "strict" names a tool it doesn't have: the server logs an error instead, and serves the skill with the tool in missingTools. See Troubleshooting. (New in 1.9.2: before, it couldn't be set.) |
skillsConfig also has options for the HTTP endpoints (prefix, auth, apiKeys, jwt, llmTxt, llmFullTxt, api, cache), and injectInstructions, sep2640InInstructions, scoring and audit, which this page doesn't cover. Skills over HTTP covers who can reach the endpoints.
With the default skillsConfig.auth, "inherit", the endpoints use the server's auth: a server that requires a key or a token for MCP requires the same one for /skills, /llm.txt and /llm_full.txt, and answers 401 without it. A server without auth, or in public mode, serves them to anyone. A skill with authorities is left out for a caller its rule refuses, and /skills/<id> answers 404 for it. See Serving skills over HTTP.
Changed in 1.8.3: before, "inherit" let anyone read the endpoints, even on a server that required a key for MCP, and /llm.txt and /llm_full.txt listed skills with authorities, instructions and all. To keep the endpoints open on a server with auth, set skillsConfig.auth: "public".
skill(options)
The function form takes the same options and returns a skill to list in skills, like a class:
import { skill } from "@frontmcp/sdk";
export const refundPolicy = skill({
name: "refund-policy",
description: "How to answer a customer who asks for a refund.",
instructions: "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.",
});
Caveats
toolstakes tool names or tool classes. A class that isn't a tool stops the server from starting.- A tool is
availableonly if it's in the server'stools/list. A tool that only an agent has counts as missing. - A skill is instructions, not code. Nothing makes the client's model follow them, or stops it calling tools the skill doesn't list.
- To serve a REST API as skills, generated from its OpenAPI description, see Skilled OpenAPI.
- The class doesn't need to extend anything. Extend
SkillContextto overrideloadInstructions()orbuild(), which FrontMCP calls once, when the server starts: see Building a skill in code. - To add or remove a skill while the server runs, use
this.scope.skills.registerSkillContent(): see Adding a skill at runtime.
skillDir(dir)
const triage = await skillDir("/srv/help-desk/skills/triage-ticket");
Reads a skill written as a folder in the Agent Skills format: a SKILL.md, with YAML frontmatter and then the procedure, and any of the folders references/, examples/, scripts/ and assets/. It returns a skill to list in an app's skills, like a class. loadSkillDirectory(dir) is the same function.
| Parameter | Type | Description |
|---|---|---|
dir | string | The skill's folder, as an absolute path. A relative one, even ./skills/triage-ticket, is looked up from the root of the file system and fails with SKILL.md not found in directory. Use path.resolve("skills/triage-ticket"). |
logger | FrontMcpLogger | Optional. A logger for the name warning, which otherwise goes to the console. |
It returns a promise of the skill, a record whose metadata holds the options read from SKILL.md. Each folder it finds becomes an entry of resources.
How SKILL.md maps to options
In SKILL.md | Becomes |
|---|---|
| The Markdown after the frontmatter | instructions. Required: a SKILL.md with nothing after its frontmatter is refused. |
name, description | The options of the same name. Required. |
id, license, compatibility, tags, tools, parameters, examples, priority, skillPath, visibility, category, rating | The options of the same name. |
allowed-tools | allowedTools. |
metadata | specMetadata. A value that isn't a string is kept as JSON text: { max: 5 } becomes the string {"max":5}. |
hide-from-discovery, tool-validation, available-when, and an example's expected-outcome | hideFromDiscovery, toolValidation, availableWhen and expectedOutcome. The camelCase keys work too. |
Any other key, like user-invocable: true | Added to specMetadata, as a string, so the served SKILL.md has it under metadata. |
What clients read
| In the folder | Clients read it at | As |
|---|---|---|
SKILL.md | skill://<name>/SKILL.md, listed in resources/list | Rebuilt from the options, like a declared skill's: the frontmatter of SKILL.md, without tags, then the instructions, then a ## References table with the name and description of each file in references/. skills/load returns the same instructions and table. |
references/priorities.md | skill://<name>/references/priorities.md | Text, without its frontmatter. |
examples/acme-login.md | skill://<name>/examples/acme-login.md | Text, without its frontmatter. |
scripts/check-sla.sh | skill://<name>/scripts/check-sla.sh | Text, as written, typed application/x-sh. |
assets/teams.csv | skill://<name>/assets/teams.csv | A blob, base64, typed application/octet-stream. |
Only SKILL.md is in resources/list. The other files are read through the skill://{+skillPath}/{+filePath} template: a client finds the references in the table, and the rest where the instructions name them. A file that isn't there, or a path with .., fails with Resource not found: skill://….
scanSkillResources(dir) returns what skillDir() looks for, without reading SKILL.md: { resources, hasSkillMd }, with the path of each of the four folders that exists. It takes an absolute path too.
Caveats
skillDir()is asynchronous, and an app'sskillstakes the skill, not a promise. Load the folders before you build the app: see Usage.- The name is the skill's. A
namethat isn't the folder's name logs a warning, and the skill is served under itsname. - A skill folder can't hold another skill. A
SKILL.mdin any folder below it is refused: see Troubleshooting. - The files are read from disk while the server runs, so ship the folders with the server, at the paths
resourcesholds.
External skill storage
class CatalogSkills extends ExternalSkillProviderBase { /* seven methods */ }
(this.scope.skills as SkillRegistry).setExternalProvider(new CatalogSkills({ mode: "persistent", syncStateStore }));
await this.scope.skills.syncToExternal();
ExternalSkillProviderBase connects the server's skills to storage outside it, like a database several servers share. You write the class that talks to the storage; FrontMCP calls it. Nothing in FrontMCP installs one: call setExternalProvider() on the scope's skill registry once the server runs, from a tool, say. A provider's factory runs too early, before the registry exists.
| Method to write | Called for |
|---|---|
fetchSkill(id) | skills/load of a skill that isn't the server's, in read-only mode. Return the skill, or null. |
fetchSkills(options) | skills/list, in read-only mode. |
searchExternal(query, options) | skills/search, in read-only mode. Return results with metadata, score, availableTools, missingTools and source: "external". |
upsertSkill(skill), deleteSkill(id) | syncToExternal(), in persistent mode, for each skill that's new or changed since the last sync, and each that's gone. Skills added with registerSkillContent() are copied too. |
countExternal(options), existsExternal(id) | Counting and checking skills in the storage. |
Each skill is a SkillContent, the same shape registerSkillContent() takes.
| Constructor option | Type | Description |
|---|---|---|
mode | "read-only" | "persistent" | Required. See below. |
syncStateStore | { load(), save(state), clear() } | Where persistent mode keeps what it copied, with a hash of each skill, so the next sync copies only what changed. Without one, it's kept in the provider: a new provider, as after a restart, copies every skill again. |
logger, defaultTopK, defaultMinScore | A logger, and the topK and minScore passed to searchExternal(): 10, unless the request's limit says otherwise, and 0.1. |
| Mode | skills/list, skills/search | skills/load | syncToExternal() |
|---|---|---|---|
read-only | Answered by the storage alone: the server's own skills aren't listed or found. | The server's own skills by id, the others from fetchSkill(), with every tool marked available. | Does nothing, and returns null. |
persistent | The server's own skills, as without storage. | The server's own skills. | Copies the server's skills to the storage, and returns { added, updated, unchanged, removed, failed, durationMs }: lists of ids, failed as { skillId, error }, and the time it took. |
A skill that comes from the storage has no SKILL.md resource, and isn't in skill://index.json. Keeping skills in external storage shows both modes.
Usage
Serving a skill
Register the skill next to the tools it uses. Open the Tests tab:
import { Skill } from "@frontmcp/sdk";
@Skill({
name: "triage-ticket",
description: "Decide a new support ticket's priority.",
instructions: `1. Read the ticket with get_ticket.
2. Set its priority with set_priority: high if the customer can't work, normal otherwise.`,
tools: ["get_ticket", { name: "set_priority", purpose: "Record the priority", required: true }],
parameters: [{ name: "ticketId", description: "The ticket to triage, like T-1", required: true }],
})
export class TriageTicket {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The Call tab shows SKILL.md, the way a client reads it. A client that supports skills reads skill://index.json to see which skills there are, and the SKILL.md of one that fits the task. Teaching the Model Skills follows a client through it. A client that doesn't still gives its model the server's instructions, which list each skill by name and description, as the last test shows; a server with instructions of its own gets the list after them. (Changed in 1.9.3: before, server/discover left the list out.)
Searching and loading skills
skills/search and skills/load are FrontMCP's own requests, for clients that know them: search by words, then load the skills that fit. A test sends them with mcp.raw.request():
import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "get_ticket", description: "Get a support ticket by id.", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, title: "Cannot log in since this morning" };
}
}
@Skill({
name: "triage-ticket",
description: "Decide a new support ticket's priority and who handles it.",
instructions: "1. Read the ticket with get_ticket.\n2. Set its priority with set_priority.\n3. Assign it with assign_ticket.",
tools: ["get_ticket", "set_priority", "assign_ticket"],
tags: ["support"],
})
export class TriageTicket {}
@Skill({
name: "refund-duplicate-charge",
description: "Refund a customer who was charged twice for the same invoice.",
instructions: "1. Read the invoice.\n2. Refund every charge after the first.",
tags: ["billing"],
})
export class RefundDuplicateCharge {}
@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket], skills: [TriageTicket, RefundDuplicateCharge] })
export class HelpDeskApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
set_priority and assign_ticket aren't tools of this server, so the skill is incomplete: the model is told which steps it can't take. Search results say the same, with available: false, so a client can prefer skills it can finish. A tool the caller can't reach because of its surface counts as missing the same way, and a skill's SKILL.md can be read at its id as well as its name. Both changed in 1.8.5: before, such a tool was marked available, and only the name worked. tools also takes a tool's class, alone or as { tool, purpose, required }, and a class can't fall behind when the tool is renamed. (Changed in 1.9: before, a tool class stopped the server from starting.)
Hiding a skill
hideFromDiscovery keeps a skill out of every list, for a skill a client should only load when it already knows the id, such as one that another skill's instructions name. visibility: "http" keeps it out of MCP discovery, for the HTTP endpoints:
import { App, Skill } from "@frontmcp/sdk";
@Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket." })
export class TriageTicket {}
@Skill({
name: "write-internal-note",
description: "Write a note on a ticket that only support agents see.",
instructions: "Start the note with the date, and never paste the customer's password.",
hideFromDiscovery: true,
})
export class WriteInternalNote {}
@Skill({
name: "weekly-report",
description: "Summarize the week's tickets for the support team lead.",
instructions: "Count the tickets by priority, then list the ones still open.",
visibility: "http",
})
export class WeeklyReport {}
@App({ id: "help-desk", name: "Help Desk", skills: [TriageTicket, WriteInternalNote, WeeklyReport] })
export class HelpDeskApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Hiding a skill isn't access control: anyone who knows the id can load it.
Serving skills over HTTP
With skillsConfig: { enabled: true }, FrontMCP also serves skills at /skills, /llm.txt and /llm_full.txt, for programs that don't speak MCP. By default they take the same credential as the MCP endpoint, and leave out skills the caller's authorities refuse. The tests send HTTP requests through createFetchHandler(), the way a client would:
import { App, FrontMcp, Skill } from "@frontmcp/sdk";
@Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket. 2. Set its priority." })
export class TriageTicket {}
@Skill({
name: "refund-runbook",
description: "Refund a customer.",
instructions: "1. Check the order. 2. Refund up to 500 EUR.",
authorities: "admin",
})
export class RefundRunbook {}
@App({ id: "help-desk", name: "Help Desk", skills: [TriageTicket, RefundRunbook] })
class HelpDeskApp {}
export const config = {
info: { name: "help-desk", version: "1.0.0" },
apps: [HelpDeskApp],
authorities: { profiles: { admin: { roles: { any: ["admin"] } } } },
skillsConfig: { enabled: true },
};
@FrontMcp(config)
export default class Server {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A static key carries no roles, so the admin skill is left out for it, over HTTP as over MCP. "api-key" and "bearer" give the endpoints credentials of their own, apart from the server's: Skills over HTTP has every option.
Giving a skill and an agent the same procedure
A skill has the client's model follow a procedure; an agent runs it on your server's model. You can offer both: keep the text in one constant, and give it to the skill as instructions and to the agent as systemInstructions. The tools go in the app, so the client's model can call them, and in the agent, so its model can:
import { Agent, AgentContext, Skill, z } from "@frontmcp/sdk";
import { model } from "./model.example";
import { GetTicket, SetPriority } from "./ticket.tools";
export const TRIAGE_STEPS = `1. Read the ticket with get_ticket.
2. Set its priority with set_priority: high if the customer can't work, normal otherwise.`;
// For clients whose own model should do it
@Skill({
name: "triage-ticket",
description: "Decide a new support ticket's priority.",
instructions: TRIAGE_STEPS,
tools: ["get_ticket", "set_priority"],
})
export class TriageSkill {}
// For clients that would rather hand it off
@Agent({
name: "triage",
description: "Decide a new support ticket's priority. Pass the ticket's id.",
systemInstructions: TRIAGE_STEPS,
inputSchema: { ticketId: z.string() },
tools: [GetTicket, SetPriority],
llm: { adapter: model },
})
export class TriageAgent extends AgentContext {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The skill is complete because both tools are in the app. With them only in the agent's tools, the client's model would be told they're missing. Teaching the Model Skills compares skills, agents and prompts.
Writing a skill as a function
skill() builds the same skill without a class. List what it returns in an app's skills:
import { App, skill } from "@frontmcp/sdk";
export const refundPolicy = skill({
name: "refund-policy",
description: "How to answer a customer who asks for a refund.",
instructions: "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.",
});
@App({ id: "help-desk", name: "Help Desk", skills: [refundPolicy] })
export class HelpDeskApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Building a skill in code
A class that extends SkillContext can override two methods. loadInstructions() returns the instructions, and super.loadInstructions() reads the ones instructions names. build() returns the whole skill, SkillContent, and super.build() builds it from the options. Both have this.get() for the app's providers:
import { Skill, SkillContext } from "@frontmcp/sdk";
import type { SkillContent } from "@frontmcp/sdk";
import { RefundPolicy } from "./refund-policy";
export const calls: string[] = [];
@Skill({
name: "refund-duplicate-charge",
description: "Refund a customer who was charged more than once for the same invoice.",
instructions: "1. Read the invoice with get_invoice.\n2. Refund every charge after the first with refund_charge.",
tools: ["get_invoice", "refund_charge"],
})
export class RefundDuplicateCharge extends SkillContext {
async loadInstructions() {
calls.push("loadInstructions");
const steps = await super.loadInstructions();
return `${steps}\n3. Refunds take ${this.get(RefundPolicy).businessDays} business days: tell the customer.`;
}
}
@Skill({
name: "escalate-outage",
description: "Escalate a ticket that reports an outage.",
instructions: "Page the on-call engineer with page_on_call.",
})
export class EscalateOutage extends SkillContext {
async build(): Promise<SkillContent> {
calls.push("build");
const content = await super.build();
return { ...content, description: `${content.description} Only for enterprise customers.` };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
FrontMCP creates the class once, calls the methods when the server starts, and keeps what they return, as it keeps instructions read from a file or a URL. So this.get() reaches the app's and the server's providers, but there's no request, and nothing about the caller: every client reads the same skill. (Changed in 1.9.2: before, FrontMCP never created the class, and overrides had no effect.)
A description that build() returns is the skill's description everywhere: in SKILL.md, skills/load, skills/search, skill://index.json, resources/list and the skill list in the server's instructions. (Changed in 1.9.3: before, skill://index.json, resources/list and the skill list in initialize's instructions kept the option's description.)
Loading a skill from a SKILL.md folder
A skill written for other agents is often a folder: a SKILL.md, and the files its steps refer to. skillDir() serves it as it is. Here skills/triage-ticket/ holds a SKILL.md, one file in references/, one in examples/, a script and a spreadsheet:
---
name: triage-ticket
description: Decide a new support ticket's priority and who handles it.
license: Apache-2.0
allowed-tools: get_ticket set_priority
metadata:
owner: support-team
tags:
- support
tools:
- get_ticket
- name: set_priority
purpose: Record the priority
parameters:
- name: ticketId
description: The ticket to triage, like T-1
required: true
---
1. Read the ticket with get_ticket.
2. Pick the priority as references/priorities.md says.
3. Set it with set_priority.
---
name: priorities
description: How to pick a ticket's priority
---
- high: the customer can't work.
- normal: everything else.
skillDir() returns a promise, so load the folder before you build the app, and give it an absolute path:
import "reflect-metadata";
import { resolve } from "node:path";
import { App, FrontMcpInstance, skillDir } from "@frontmcp/sdk";
import { GetTicket, SetPriority } from "./ticket.tools";
async function main() {
// An absolute path: skillDir() doesn't resolve a relative one
const triage = await skillDir(resolve("skills/triage-ticket"));
@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [triage] })
class HelpDeskApp {}
await FrontMcpInstance.bootstrap({ info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] });
}
main();resources/list has skill://index.json and skill://triage-ticket/SKILL.md, as for a declared skill. A client that reads that SKILL.md gets the frontmatter rebuilt from the options, without tags, the steps, and a table of the references:
---
name: triage-ticket
description: Decide a new support ticket's priority and who handles it.
license: Apache-2.0
allowed-tools: get_ticket set_priority
metadata:
owner: support-team
tools:
- get_ticket
- name: set_priority
purpose: Record the priority
parameters:
- name: ticketId
description: The ticket to triage, like T-1
required: true
type: string
---
1. Read the ticket with get_ticket.
2. Pick the priority as references/priorities.md says.
3. Set it with set_priority.
## References
| Reference | Description |
| --------- | ----------- |
| `priorities` | How to pick a ticket's priority |
Then it reads skill://triage-ticket/references/priorities.md, and gets the two lines without their frontmatter. skill://triage-ticket/scripts/check-sla.sh comes back as text, skill://triage-ticket/assets/teams.csv as a base64 blob, and skill://triage-ticket/references/escalation.md, which isn't in the folder, fails with Resource not found. skills/load returns the same steps and table, with both tools available. The Playground has no file system, so this was run in Node, with a client sending each request over HTTP.
Keeping skills in external storage
A help desk run as several servers can keep its skills in one catalog that each server reads, or copy each server's skills to it. Here the catalog is a Map, standing in for a database, and sync_skill_catalog copies the server's skills to it. The tests also run the other mode, where the catalog answers the skills requests:
import { SkillRegistry, Tool, ToolContext } from "@frontmcp/sdk";
import { CatalogSkills, syncState } from "./skill-catalog";
@Tool({ name: "sync_skill_catalog", description: "Copy this server's skills to the team's shared skill catalog", inputSchema: {} })
export class SyncSkillCatalog extends ToolContext {
async execute() {
const skills = this.scope.skills as SkillRegistry;
if (!skills.hasExternalProvider()) {
skills.setExternalProvider(new CatalogSkills({ mode: "persistent", syncStateStore: syncState }));
}
const result = await skills.syncToExternal();
return { added: result?.added, updated: result?.updated, unchanged: result?.unchanged, removed: result?.removed };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
persistent mode leaves the server's skills as they were and copies them out: the first sync adds triage-ticket, the next finds it unchanged, because the sync state holds a hash of each skill. In read-only mode the catalog takes over skills/list and skills/search, so triage-ticket isn't listed, though it still loads by id. A skill from the catalog loads with every tool marked available, even get_invoice, which this server doesn't have, and has no SKILL.md to read.
Connecting for skills only
A planner agent that only chooses which skills to run doesn't need the tools in its context. A client that opens its session with ?mode=skills_only on the URL of its initialize request gets an empty tools/list:
| Request, in that session | Answer |
|---|---|
tools/list | { "tools": [] } |
tools/call | Runs the tool, as in any session: the mode only hides the list. |
resources/list | skill://index.json and each skill's SKILL.md, as without the mode. |
skills/search, skills/list, skills/load | As without the mode. skills/load still marks the tools available. |
When it applies:
- Only for a session. The mode is kept in the session id, from the
initializerequest's URL.?mode=skills_onlyon a later request changes nothing, and a 2026-07-28 request, which has no session, gets every tool with or without it. So does a client ofcreateFetchHandler(), which opens no sessions. - Only for a client that sends a JWT the server verifies. It took effect for a client whose token
transparentauth verified. On a server withoutauth, or withpublicorstaticauth, whose keys aren't JWTs, FrontMCP opens the session while it checks the caller, before it reads the URL, and the session lists every tool: see Troubleshooting.
This was run against FrontMCP's own HTTP server in Node, since the Playground can't open a session.
Troubleshooting
Skill '…' failed tool validation: missing tools […]
The server doesn't start, because a skill with toolValidation: "strict" names a tool that isn't in tools/list. Add the tool to an app's tools, fix the name, or leave toolValidation at "warn", which logs a warning and serves the skill with the tool marked available: false. To let the server start while keeping "strict" on the skill, set skillsConfig: { failOnInvalidSkills: false } on the server: it logs an error instead. Changed in 1.9: before, "strict" didn't stop the server.
import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "get_ticket", description: "Get one support ticket by its id", inputSchema: { id: z.string() } })
export class GetTicket extends ToolContext {
async execute({ id }: { id: string }) {
return { id, status: "open" };
}
}
@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket] })
export class HelpDeskApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Invalid tool class '…'. Tool class must be decorated with @Tool and have a name property.
The server doesn't start, because tools lists a class that isn't a tool: it has no @Tool. List the tool's class, or its name. Before 1.9, every class failed this way, even a tool's, with an empty name: Invalid tool class ''.
Skill name must be kebab-case
The server doesn't start, because a skill's name has uppercase letters, underscores or spaces, or starts or ends with a hyphen. Use lowercase words joined by hyphens, like triage-ticket.
skills/load fails with ENOENT or fetch failed
The skill's instructions come from a file or a URL that couldn't be read. FrontMCP reads them when the server starts, but a failure doesn't stop it: the skill is left out of skills/list, and each request that needs it tries again, so skills/load fails with JSON-RPC error -32603 and ENOENT: no such file or directory, open '…', fetch failed, or, for a URL that answers with an error, Failed to fetch skill instructions from "…": 404, and reading its SKILL.md fails too. A relative file path is relative to the file that declares the skill, not to where the server runs. Check the path, or that the server can reach the URL. The Playground can do neither.
Resource not found: skill://…/SKILL.md
The client read a SKILL.md that isn't served. Check, in order:
- The skill is in an app's
skills, and the app is in@FrontMcp({ apps }). - The URI uses the skill's
name, or itsskillPathjoined with/, not itsid. - The skill doesn't set
hideFromDiscovery: trueorvisibility: "http", and itsavailableWhenmatches where the server runs. Such skills have no resource. See Hiding a skill. - The server doesn't set
skillsConfig: { mcpResources: false }.
A tool shows as available: false
skills/load marks a tool available only if it's in the server's tools/list. Check the name as the tool spells it, and that the tool is in an app's tools: a tool that only an agent has doesn't count. A missing tool doesn't stop the server unless the skill sets toolValidation: "strict": see Skill '…' failed tool validation.
A skill isn't listed, and can't be loaded
The skill has an availableWhen that doesn't match where the server runs, or a surface that leaves out "mcp". Either way, FrontMCP doesn't serve it to MCP clients: it's missing from skills/list, skills/search and resources/list, skills/load warns Skill "…" not found, and reading its SKILL.md fails with -32602.
import { App, Skill } from "@frontmcp/sdk";
@Skill({ name: "triage-ticket", description: "Decide a new support ticket's priority.", instructions: "1. Read the ticket." })
export class TriageTicket {}
// 🚩 This server doesn't run on Deno
@Skill({
name: "restart-importer",
description: "Restart the stuck ticket importer.",
instructions: "1. Stop the importer. 2. Start it again.",
availableWhen: { runtime: ["deno"] },
})
export class RestartImporter {}
// For the server's own agents, not for MCP clients
@Skill({
name: "merge-duplicates",
description: "Merge duplicate tickets into one.",
instructions: "1. Keep the oldest ticket. 2. Move the replies. 3. Close the others.",
availableWhen: { surface: ["agent"] },
})
export class MergeDuplicates {}
@App({ id: "help-desk", name: "Help Desk", skills: [TriageTicket, RestartImporter, MergeDuplicates] })
export class HelpDeskApp {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
Remove availableWhen, or change it to match where the server runs, to offer the skill everywhere.
Changed in 1.8.5: before, a skill excluded by where the server runs was still in skills/list and skills/search, and one hidden by surface still had its SKILL.md in resources/list.
Invalid skill "…": SKILL.md not found in directory
skillDir() found no SKILL.md in the folder it was given. In 1.9.4 that's also what any relative path gives, even one that's right: skillDir("./skills/triage-ticket") looks for the folder from the root of the file system. Pass an absolute path, like skillDir(resolve("skills/triage-ticket")), and check that the folder has a SKILL.md. The other errors skillDir() throws name what's missing, like Invalid skill "triage-ticket": SKILL.md is missing required 'description' field in frontmatter, or SKILL.md has no body content for instructions for a file with nothing after its frontmatter.
Skill name "…" does not match directory name "…"
A warning from skillDir(): the name in SKILL.md isn't the folder's name. The skill loads anyway, under its name: a folder refund-policy/ whose SKILL.md says name: refunds is served at skill://refunds/SKILL.md. Rename one of them, as the Agent Skills format expects them to match.
Skill directory contains nested SKILL.md files
skillDir() refused the folder because a folder inside it holds a SKILL.md too: Invalid skill "triage-ticket": Skill directory contains nested SKILL.md files (forbidden by SEP-2640 §Resource Mapping): references/escalation/SKILL.md. A skill's files are served under its skill:// path, so a skill inside another would make the same URI mean two things. Move the inner skill to a folder of its own, beside the other, and load each with skillDir().
?mode=skills_only still lists every tool
The mode takes effect only for a session that a client opens with ?mode=skills_only on its initialize request, signed in with a JWT that the server verifies, as with transparent auth. In 1.9.4 it's lost in these cases, and tools/list lists every tool:
- The server has no
auth, orpublicorstaticauth. FrontMCP opens the session while it checks the caller, before it reads the URL. - The client is on MCP 2026-07-28, or reaches the server through
createFetchHandler(). Neither has a session to keep the mode in. - The mode is on a later request rather than on
initialize.