Teaching the Model Skills
Some of what a support agent knows isn't a single action but a procedure: to refund a duplicate charge, read the invoice, refund every charge after the first and never the first, then tell the customer how long it takes. The tools for each step already exist. What the client's model lacks is the order and the rules. A skill is that procedure, written down: instructions, the tools they use, and when to use them, served by your server for the client's model to read and follow itself. This lesson writes one, and shows how a client finds and reads it.
You will learn
- How to write a skill with
@Skill: description, instructions, tools, parameters and examples - What a skill adds to your server, and how a client finds and reads it
- How to search for and load skills with FrontMCP's
skills/searchandskills/load - Where a skill's instructions can come from: inline, a file or a URL
- When to write a skill, an agent or a prompt
Writing a skill
The help desk has the three tools a duplicate-charge refund needs. Here's the procedure that ties them together. Open the Tests tab:
import { Skill, SkillContext } from "@frontmcp/sdk";
@Skill({
name: "refund-duplicate-charge",
description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
instructions: `1. Read the invoice with get_invoice. Refund only if it has more than one charge.
2. Refund every charge after the first with refund_charge. Never refund the first charge.
3. Reply on the ticket with reply_to_ticket: say how much was refunded, and that it takes 3 to 5 business days.`,
tools: [
"get_invoice",
{ name: "refund_charge", purpose: "Refund one duplicate charge", required: true },
"reply_to_ticket",
],
parameters: [
{ name: "ticketId", description: "The customer's ticket, like T-2", required: true },
{ name: "invoiceId", description: "The invoice that was charged twice, like INV-7", required: true },
],
examples: [
{
scenario: "A customer writes on T-2 that INV-7 was charged twice",
parameters: { ticketId: "T-2", invoiceId: "INV-7" },
expectedOutcome: "The second charge is refunded, and T-2 says so",
},
],
})
export class RefundDuplicateCharge extends SkillContext {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
A skill is a class, like a tool, with no code of its own:
nameis its id, in kebab-case: lowercase letters, numbers and hyphens.descriptionis what a model reads when it decides whether this skill fits the task, so say when to use it.instructionsare the procedure. Write them for a model that has never seen your help desk: the steps in order, the tool for each, and the rules it mustn't break.toolsnames the tools the procedure uses. A name is enough;{ name, purpose, required }adds why the tool is there and whether the skill works without it.parametersandexamplessay what the model needs to know before it starts, and show one case from start to end.skills: [...]on@Appregisters it.SkillContextis the base class, and the class body stays empty.
The first test is the one to notice: a skill adds no tools. It doesn't do anything. FrontMCP serves it as a document, SKILL.md: the metadata as YAML, then the instructions. That's the format of the Agent Skills standard, so a model that knows skills from elsewhere knows how to read it.
How a client finds a skill
A skill reaches the client as resources, following the proposed MCP skills extension (SEP-2640): skill://index.json lists every skill with its description and address, and skill://<name>/SKILL.md is each one. A client that supports skills reads the index, picks a skill by its description, reads its SKILL.md, and then its model follows the steps with your tools. This test plays that client:
import { test, expect } from "@frontmcp/testing";
import { refunded, replies } from "./tools";
test("a client finds the skill, reads it, and follows it", async ({ mcp }) => {
// 1. Read the index, and pick the skill whose description fits the task.
const index = (await mcp.resources.read("skill://index.json")).json();
const skill = index.skills.find((s: { description?: string }) => s.description?.includes("charged more than once"));
expect(skill).toEqual({
type: "skill-md",
name: "refund-duplicate-charge",
description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
url: "skill://refund-duplicate-charge/SKILL.md",
});
// 2. Read its SKILL.md.
const md = (await mcp.resources.read(skill.url)).text();
expect(md).toContain("Never refund the first charge.");
// 3. Follow the steps with the server's tools, as the model would.
const invoice = (await mcp.tools.call("get_invoice", { id: "INV-7" })).json();
for (const charge of invoice.charges.slice(1)) {
await mcp.tools.call("refund_charge", { chargeId: charge.id });
}
await mcp.tools.call("reply_to_ticket", { ticketId: "T-2", message: "We refunded the duplicate charge of 49 EUR. It takes 3 to 5 business days." });
expect(refunded).toEqual(["ch_2"]);
expect(replies).toHaveLength(1);
});
test("the server says it has skills, and its instructions list them", async ({ mcp }) => {
const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
expect(result.capabilities.extensions).toEqual({
"io.modelcontextprotocol/tasks": {},
"io.modelcontextprotocol/skills": {},
});
expect(result.instructions).toBe(
"Help desk tools.\n\n---\n\nAvailable skills (read the `skill://index.json` resource for each skill's `SKILL.md` URI):\n\n" +
"- **refund-duplicate-charge**: Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
);
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The skill never runs. The client's model reads it and does the work, with your tools and its own tokens, and FrontMCP checks each tool call as it would any other: the skill's rules are advice to the model, not limits on the tools. "Never refund the first charge" is only as strong as the model that reads it, so a rule that must hold belongs in refund_charge itself.
The second test is how a client learns the server has skills. server/discover lists the extension io.modelcontextprotocol/skills, and a client that knows it looks for skill://index.json. A client that doesn't know it still sees the SKILL.md resources in resources/list, marked for the assistant. And FrontMCP lists every skill in the server's instructions, after your own: its name, its description, and where to read it, so the model knows the skills are there before it reads anything. (Changed in 1.9.3: before, under 2026-07-28, the instructions had nothing about skills, and you had to write a sentence there pointing the model at the index.)
Searching and loading skills
FrontMCP also answers requests of its own for skills: skills/search finds skills by the words in a task, and skills/load returns skills ready to use. They're JSON-RPC methods, not tools, so they're for clients written to use them, like your own. FrontMCP's docs call them searchSkills and loadSkill tools; in 1.9.3 they aren't in tools/list. Here a second skill names a tool the server doesn't have:
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { byClassConfig, strictConfig } from "./other-servers";
test("skills/search finds skills by the words in a task", async ({ mcp }) => {
const { result } = (await mcp.raw.request({
jsonrpc: "2.0",
id: 1,
method: "skills/search",
params: { query: "customer was charged twice" },
})) as any;
expect(result.skills).toEqual([
expect.objectContaining({ name: "refund-duplicate-charge", score: expect.any(Number), source: "local" }),
]);
expect(result.guidance).toBe("Found 1 matching skill(s). Use skills/load with skill IDs to load full content.");
});
test("skills/load returns instructions, and which tools are missing", async ({ mcp }) => {
const { result } = (await mcp.raw.request({
jsonrpc: "2.0",
id: 2,
method: "skills/load",
params: { skillIds: ["refund-duplicate-charge", "escalate-outage"] },
})) as any;
const [refund, outage] = result.skills;
expect(refund).toMatchObject({ isComplete: true, missingTools: [] });
expect(refund.instructions).toContain("Never refund the first charge.");
expect(outage).toMatchObject({
isComplete: false,
availableTools: ["get_invoice"],
missingTools: ["page_on_call"],
});
expect(outage.tools).toEqual([
{ name: "get_invoice", available: true, inputSchema: expect.objectContaining({ type: "object" }) },
{ name: "page_on_call", purpose: "Wake the on-call engineer", available: false },
]);
expect(outage.formattedContent).toMatch(/^# Skill: escalate-outage\n/);
expect(result.summary.combinedWarnings).toEqual([
'Skill "escalate-outage" references missing tools: page_on_call. Some functionality may be limited.',
]);
});
test('`toolValidation: "strict"` stops a server that lacks a tool', async () => {
await expect(FrontMcpInstance.createDirect(strictConfig)).rejects.toThrow(
"Skill 'escalate-outage' failed tool validation: missing tools [page_on_call]",
);
});
test("a tool class in `tools` works like its name", async () => {
const server = await FrontMcpInstance.createDirect(byClassConfig);
try {
const { contents } = await server.readResource("skill://check-invoice/SKILL.md");
expect(contents[0].text).toContain("tools:\n - get_invoice\n - name: refund_charge\n purpose: Refund one duplicate charge\n");
} finally {
await server.dispose();
}
});Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
skills/load answers with more than SKILL.md: each tool the skill names, with available and, if it's there, its input schema; availableTools and missingTools; isComplete, false when a named tool is missing; and formattedContent, all of it as one markdown document a client can hand its model. A skill that names a missing tool is still served and still loads, and the server starts anyway, so check missingTools in your tests, as the first challenge does. Or set toolValidation: "strict" on the skill: then a missing tool stops the server from starting, as the third test shows. (Changed in 1.9: before, "strict" didn't stop it.) The last test names the tools by class.
Where instructions come from
Instructions can be written inline, as so far, or kept in a markdown file next to the code: { file: "./refund.md" }, relative to the file that declares the skill. Long procedures are easier to write and review as markdown. The Playground has no files, so here's what it looks like on a real server:
@Skill({
name: "refund-duplicate-charge",
description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
instructions: { file: "./refund-duplicate-charge.md" },
tools: ["get_invoice", "refund_charge", "reply_to_ticket"],
})
export class RefundDuplicateCharge extends SkillContext {}The third source is a URL, for procedures that live somewhere else, like the support team's handbook. docs.example.ts stands in for that site, as billing.example.ts does for billing in Calling Other Services:
import { Skill, SkillContext } from "@frontmcp/sdk";
@Skill({
name: "refund-duplicate-charge",
description: "Refund a customer who was charged more than once for the same invoice, and tell them on their ticket.",
instructions: { url: "https://handbook.help-desk.example/refunds/duplicate-charge.md" },
tools: ["get_invoice", "refund_charge", "reply_to_ticket"],
})
export class RefundDuplicateCharge extends SkillContext {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
FrontMCP fetches the page when the server starts, and then keeps it: three reads, one fetch. If the handbook is down then, the server still starts: it logs Failed to load skill refund-duplicate-charge: … and tries again the next time it needs the page. A page that changes later isn't picked up until the server restarts. A file is also read at startup; a missing one is logged then, and reading the skill fails with ENOENT: no such file or directory.
Skill, agent or prompt?
A skill, an agent and a prompt all hand a model some instructions. What differs is whose model follows them, and who picks them:
| Skill | Agent | Prompt | |
|---|---|---|---|
| The client sees | Resources, skill://…/SKILL.md | A tool, invoke_<name> | An entry in prompts/list |
| Who picks it | The client's model, from its description | The client's model, like any tool | The user, from a menu |
| Who does the work | The client's model, with your tools | Your server's model, with the agent's own tools | The client's model |
| Who pays for the model | The client | You | The client |
| Use it when | The client's model can do the job if it knows the steps | The job needs its own model, tools the client shouldn't have, or should run the same way for every client | The user starts a task and fills in a form |
The same procedure can be served both ways: the refund steps as a skill for clients whose model should do the work, and as an agent's systemInstructions for clients that would rather hand it off. Put the text in one constant, and give it to both.
Recap
@Skill({ name, description, instructions, tools, parameters, examples })writes a procedure down, and@App({ skills })registers it. A skill adds no tools and runs no code: the client's model reads it and does the work.- FrontMCP serves each skill as
skill://<name>/SKILL.md, its metadata as YAML and then its instructions, and lists them all inskill://index.json.server/discoverlists the extensionio.modelcontextprotocol/skills, and FrontMCP adds a list of the skills to the server'sinstructions. skills/searchandskills/loadare FrontMCP's own JSON-RPC requests, not tools.skills/loadsays which of a skill's tools are missing; a skill with missing tools still loads.- Name a skill's tools as strings, as
{ name, purpose, required }, or by class (since 1.9).toolValidation: "strict"stops the server from starting when one is missing. - Instructions can be inline,
{ file }or{ url }. Files and URLs are read when the server starts (a failure is logged, not fatal), and a URL is fetched once. - Write a skill when the client's model can do the job once it knows the steps, an agent when your server's model should do it, and a prompt when the user starts it.
Try some challenges
Each challenge runs hidden checks against your code. Edit the code, then press Check.
Challenge 1 of 2
Point the skill at the right tools
close-solved-ticket loads, but skills/load says it's incomplete: one of the tools it names doesn't exist on the server. Find it, and fix the skill so every tool it names is available.
import { Skill, SkillContext } from "@frontmcp/sdk";
@Skill({
name: "close-solved-ticket",
description: "Close a ticket the customer confirmed is solved, and thank them.",
instructions: "1. Read the ticket with get_ticket and check the customer said it's solved.\n2. Thank them with reply_to_ticket.\n3. Close it with close_ticket.",
tools: ["get_ticket", "reply_to_ticket", "close_tickets"],
})
export class CloseSolvedTicket extends SkillContext {}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.