# @Skill

> Declare a skill, a written procedure with the tools it uses, that clients find and load for their model to follow.

Source: https://frontmcp.dev/reference/sdk/skill

`@Skill` declares a [skill](https://frontmcp.dev/learn/teaching-the-model-skills): 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()`](#skilldirdir). To run a procedure on your server's model instead, write an [agent](https://frontmcp.dev/reference/sdk/agent).

```ts
@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.

```ts triage-ticket.skill.ts
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 {}
```

```ts help-desk.app.ts
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 {}
```

[See more examples below.](#usage)

#### 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`](#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](#invalid-tool-class--tool-class-must-be-decorated-with-tool-and-have-a-name-property). |
| `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](#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](#server-options-skillsconfig) 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`](#skill--failed-tool-validation-missing-tools-) (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](https://agentskills.io/specification) 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()`](#skilldirdir) 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](#loading-a-skill-from-a-skillmd-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](#a-skill-isnt-listed-and-cant-be-loaded) and [Environment awareness](https://frontmcp.dev/reference/server/environment). |
| `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](#skillsload-fails-with-enoent-or-fetch-failed). 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](https://agentskills.io/specification) 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`](#skillmd). |
| `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](#skillssearch). |
| `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](#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`

```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](#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](#skill--failed-tool-validation-missing-tools-). (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](https://frontmcp.dev/reference/auth/authorities#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`](https://frontmcp.dev/reference/auth/authorities) is left out for a caller its rule refuses, and `/skills/<id>` answers `404` for it. See [Serving skills over HTTP](#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:

```ts
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

- `tools` takes tool names or tool classes. A class that isn't a tool stops the server from starting.
- A tool is `available` only if it's in the server's `tools/list`. A tool that only an [agent](https://frontmcp.dev/reference/sdk/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](https://frontmcp.dev/reference/plugins/skilled-openapi).
- The class doesn't need to extend anything. Extend `SkillContext` to override `loadInstructions()` or `build()`, which FrontMCP calls once, when the server starts: see [Building a skill in code](#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](https://frontmcp.dev/reference/sdk/scope#adding-a-skill-at-runtime).

### `skillDir(dir)`

```ts
const triage = await skillDir("/srv/help-desk/skills/triage-ticket");
```

Reads a skill written as a folder in the [Agent Skills](https://agentskills.io/specification) 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`](#invalid-skill--skillmd-not-found-in-directory). Use `path.resolve("skills/triage-ticket")`. |
| `logger` | `FrontMcpLogger` | Optional. A logger for the [name warning](#skill-name--does-not-match-directory-name-), 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`](#options).

#### 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`](#skillmd), 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's `skills` takes the skill, not a promise. Load the folders before you build the app: see [Usage](#loading-a-skill-from-a-skillmd-folder).
- **The name is the skill's.** A `name` that isn't the folder's name logs [a warning](#skill-name--does-not-match-directory-name-), and the skill is served under its `name`.
- **A skill folder can't hold another skill.** A `SKILL.md` in any folder below it is refused: see [Troubleshooting](#skill-directory-contains-nested-skillmd-files).
- **The files are read from disk while the server runs**, so ship the folders with the server, at the paths `resources` holds.

### External skill storage

```ts
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()`](https://frontmcp.dev/reference/sdk/scope#skills) 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](#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:

```ts triage-ticket.skill.ts active
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 {}
```

```ts ticket.tools.ts
import { 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" };
  }
}

@Tool({
  name: "set_priority",
  description: "Set a ticket's priority.",
  inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) },
})
export class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: string }) {
    return { id, priority };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, SetPriority } from "./ticket.tools";
import { TriageTicket } from "./triage-ticket.skill";

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [TriageTicket] })
export class HelpDeskApp {}
```

```ts skill.test.ts
import { test, expect } from "@frontmcp/testing";

test("the skill adds no tools", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_ticket", "set_priority"]);
});

test("it adds the index and its SKILL.md as resources", async ({ mcp }) => {
  const uris = (await mcp.resources.list()).map((r) => r.uri);
  expect(uris.sort()).toEqual(["skill://index.json", "skill://triage-ticket/SKILL.md"]);

  const index = (await mcp.resources.read("skill://index.json")).json();
  expect(index.skills[0]).toEqual({
    type: "skill-md",
    name: "triage-ticket",
    description: "Decide a new support ticket's priority.",
    url: "skill://triage-ticket/SKILL.md",
  });
});

test("SKILL.md is frontmatter, then the instructions", async ({ mcp }) => {
  const text = (await mcp.resources.read("skill://triage-ticket/SKILL.md")).text();
  expect(text).toMatch(/^---\nname: triage-ticket\ndescription: Decide a new support ticket's priority\.\ntools:\n/);
  expect(text).toContain("  - name: set_priority\n    purpose: Record the priority\n    required: true\n");
  expect(text).toMatch(/---\n\n1\. Read the ticket with get_ticket\.\n2\. Set its priority/);
});

test("the server's instructions list it", async ({ mcp }) => {
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.instructions).toBe(
    "Available skills (read the `skill://index.json` resource for each skill's `SKILL.md` URI):\n\n- **triage-ticket**: Decide a new support ticket's priority.",
  );
});
```

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](https://frontmcp.dev/learn/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()`:

```ts help-desk.app.ts active
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 {}
```

```ts skills.test.ts active
import { test, expect } from "@frontmcp/testing";
import { App, Skill, Tool, ToolContext, connect } from "@frontmcp/sdk";
import { GetTicket } from "./help-desk.app";

test("`skills/search` finds skills by their description", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "who handles a ticket" } });
  expect(response.result).toMatchObject({
    skills: [
      {
        id: "triage-ticket",
        score: expect.any(Number),
        tags: ["support"],
        tools: [
          { name: "get_ticket", available: true },
          { name: "set_priority", available: false },
          { name: "assign_ticket", available: false },
        ],
      },
    ],
    total: 1,
    guidance: "Found 1 matching skill(s). Use skills/load with skill IDs to load full content.",
  });
});

test("`tags` narrows the search", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/search", params: { query: "customer ticket invoice", tags: ["billing"] } });
  expect(response.result.skills.map((s: { id: string }) => s.id)).toEqual(["refund-duplicate-charge"]);
});

test("`skills/load` returns the instructions, and which tools are missing", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["triage-ticket"] } });
  const [skill] = response.result.skills;
  expect(skill.instructions).toBe("1. Read the ticket with get_ticket.\n2. Set its priority with set_priority.\n3. Assign it with assign_ticket.");
  expect(skill).toMatchObject({ availableTools: ["get_ticket"], missingTools: ["set_priority", "assign_ticket"], isComplete: false });
  expect(skill.formattedContent).toMatch(/^# Skill: triage-ticket\n/);
  expect(skill.formattedContent).toContain("### [✓] get_ticket");
  expect(skill.formattedContent).toContain("### [✗] set_priority");
  expect(response.result.summary.combinedWarnings).toEqual([
    'Skill "triage-ticket" references missing tools: set_priority, assign_ticket. Some functionality may be limited.',
  ]);
});

test("a tool that `surface` keeps from the caller counts as missing", async () => {
  @Tool({ name: "merge_tickets", description: "Merge duplicate tickets", inputSchema: {}, availableWhen: { surface: ["agent"] } })
  class MergeTickets extends ToolContext {
    async execute() {
      return { merged: 2 };
    }
  }
  @Skill({ name: "clean-up-duplicates", description: "Find duplicate tickets and merge them.", instructions: "1. Merge them with merge_tickets.", tools: ["get_ticket", "merge_tickets"] })
  class CleanUpDuplicates {}
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, MergeTickets], skills: [CleanUpDuplicates] })
  class Desk {}
  const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  const { skills } = await client.loadSkills(["clean-up-duplicates"]);
  await client.close();
  expect(skills[0]).toMatchObject({ availableTools: ["get_ticket"], missingTools: ["merge_tickets"], isComplete: false });
});

test("a tool class in `tools` stands for the tool's name", async () => {
  @Tool({ name: "set_priority", description: "Set a ticket's priority", inputSchema: {} })
  class SetPriority extends ToolContext {
    async execute() {
      return { priority: "high" };
    }
  }
  @Skill({ name: "triage", description: "Decide a ticket's priority.", instructions: "1. Read the ticket. 2. Set its priority.", tools: [GetTicket, { tool: SetPriority, purpose: "Record the priority" }] })
  class Triage {}
  @App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SetPriority], skills: [Triage] })
  class Desk {}
  const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  const { skills } = await client.loadSkills(["triage"]);
  await client.close();
  expect(skills[0]).toMatchObject({ availableTools: ["get_ticket", "set_priority"], isComplete: true });
});

test("a skill's `SKILL.md` is at its name, and at its id", async () => {
  @Skill({ name: "triage-ticket", id: "triage", description: "Decide a ticket's priority.", instructions: "1. Read the ticket." })
  class Triage {}
  @App({ id: "help-desk", name: "Help Desk", skills: [Triage] })
  class Desk {}
  const client = await connect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk] });
  const byName = await client.readResource("skill://triage-ticket/SKILL.md");
  const byId = await client.readResource("skill://triage/SKILL.md");
  await client.close();
  expect((byId.contents[0] as { text: string }).text).toBe((byName.contents[0] as { text: string }).text);
});

test("an unknown id is a warning, not an error", async ({ mcp }) => {
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["close-ticket"] } });
  expect(response.result).toMatchObject({
    skills: [],
    summary: { combinedWarnings: ['Skill "close-ticket" not found'] },
    nextSteps: "No skills were loaded. Check the skill IDs and try again.",
  });
});
```

`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](#server-options-skillsconfig):

```ts help-desk.app.ts active
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 {}
```

```ts hidden.test.ts
import { test, expect } from "@frontmcp/testing";

const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params });

test("only the visible skill is a resource", async ({ mcp }) => {
  const uris = (await mcp.resources.list()).map((r) => r.uri);
  expect(uris.sort()).toEqual(["skill://index.json", "skill://triage-ticket/SKILL.md"]);

  const hidden = await mcp.raw.request(request("resources/read", { uri: "skill://write-internal-note/SKILL.md" }));
  expect(hidden.error).toMatchObject({ code: -32602, message: "Resource not found: skill://write-internal-note/SKILL.md" });
});

test("`skills/list` shows the http skill, and the hidden one only on request", async ({ mcp }) => {
  const ids = async (params: object) =>
    (await mcp.raw.request(request("skills/list", params))).result.skills.map((s: { id: string }) => s.id);
  expect(await ids({})).toEqual(["triage-ticket", "weekly-report"]);
  expect(await ids({ includeHidden: true })).toEqual(["triage-ticket", "weekly-report", "write-internal-note"]);
});

test("both still load by id", async ({ mcp }) => {
  const response = await mcp.raw.request(request("skills/load", { skillIds: ["write-internal-note", "weekly-report"] }));
  expect(response.result.skills.map((s: { id: string }) => s.id)).toEqual(["write-internal-note", "weekly-report"]);
});
```

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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), the way a client would:

```ts main.ts active
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 {}
```

```ts http.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config } from "./main";

// The same server, with a key required for MCP
const withKey = { ...config, auth: { mode: "static" as const, tokens: ["desk-key-1"] } };

async function get(server: typeof config, path: string, headers: Record<string, string> = {}) {
  const handler = await FrontMcpInstance.createFetchHandler(server);
  return handler(new Request(`https://desk.example.com${path}`, { headers }));
}

test("a server without `auth` serves them to anyone", async () => {
  expect(await (await get(config, "/llm.txt")).text()).toBe("# triage-ticket\nDecide a new support ticket's priority.");
});

test("with `auth`, they need the same key as MCP", async () => {
  const refused = await get(withKey, "/skills");
  expect(refused.status).toBe(401);
  expect(refused.headers.get("www-authenticate")).toBe('Bearer realm="mcp"');
  expect((await get(withKey, "/skills", { authorization: "Bearer desk-key-1" })).status).toBe(200);
});

test("a skill the caller's `authorities` refuse is left out", async () => {
  const key = { authorization: "Bearer desk-key-1" };
  expect(await (await get(withKey, "/llm_full.txt", key)).text()).not.toContain("Refund up to 500 EUR.");
  expect((await get(withKey, "/skills/refund-runbook", key)).status).toBe(404);
});

test("`auth: \"public\"` opens them on a server with `auth`", async () => {
  const open = { ...withKey, skillsConfig: { enabled: true, auth: "public" as const } };
  expect((await get(open, "/skills")).status).toBe(200);
});
```

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](https://frontmcp.dev/reference/auth/authorities#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:

```ts triage.ts active
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 {}
```

```ts ticket.tools.ts
import { 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" };
  }
}

@Tool({
  name: "set_priority",
  description: "Set a ticket's priority.",
  inputSchema: { id: z.string(), priority: z.enum(["high", "normal"]) },
})
export class SetPriority extends ToolContext {
  async execute({ id, priority }: { id: string; priority: string }) {
    return { id, priority };
  }
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { GetTicket, SetPriority } from "./ticket.tools";
import { TriageAgent, TriageSkill } from "./triage";

@App({
  id: "help-desk",
  name: "Help Desk",
  tools: [GetTicket, SetPriority],
  skills: [TriageSkill],
  agents: [TriageAgent],
})
export class HelpDeskApp {}
```

```ts model.example.ts
// Stands in for a real model, which the Playground can't reach. It keeps the
// instructions it's given and answers at once. A real server doesn't need this file.
import type { AgentLlmAdapter } from "@frontmcp/sdk";

export const systems: (string | undefined)[] = [];

export const model: AgentLlmAdapter = {
  async completion(prompt) {
    systems.push(prompt.system);
    return { content: "T-1 is high priority.", finishReason: "stop" };
  },
};
```

```ts both.test.ts
import { test, expect } from "@frontmcp/testing";
import { systems } from "./model.example";
import { TRIAGE_STEPS } from "./triage";

test("the client gets the tools, the agent and the skill", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t) => t.name)).toEqual(["get_ticket", "invoke_triage", "set_priority"]);
  const response = await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["triage-ticket"] } });
  expect(response.result.skills[0]).toMatchObject({ instructions: TRIAGE_STEPS, isComplete: true });
});

test("the agent's model follows the same steps", async ({ mcp }) => {
  await mcp.tools.call("invoke_triage", { ticketId: "T-1" });
  expect(systems).toEqual([TRIAGE_STEPS]);
});
```

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](https://frontmcp.dev/learn/teaching-the-model-skills#skill-agent-or-prompt) 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`:

```ts help-desk.app.ts active
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 {}
```

```ts refund.test.ts
import { test, expect } from "@frontmcp/testing";

test("it's served like a class", async ({ mcp }) => {
  const text = (await mcp.resources.read("skill://refund-policy/SKILL.md")).text();
  expect(text).toBe(
    "---\nname: refund-policy\ndescription: How to answer a customer who asks for a refund.\n---\n\n" +
      "Refunds within 30 days of the charge are automatic. After 30 days, assign the ticket to billing.\n",
  );
});
```

### 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:

```ts refund.skill.ts active
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.` };
  }
}
```

```ts refund-policy.ts
import { Provider } from "@frontmcp/sdk";

@Provider({ name: "RefundPolicy" })
export class RefundPolicy {
  businessDays = "3 to 5";
}
```

```ts help-desk.app.ts
import { App } from "@frontmcp/sdk";
import { EscalateOutage, RefundDuplicateCharge } from "./refund.skill";
import { RefundPolicy } from "./refund-policy";

@App({ id: "help-desk", name: "Help Desk", skills: [RefundDuplicateCharge, EscalateOutage], providers: [RefundPolicy] })
export class HelpDeskApp {}
```

```ts build.test.ts
import { test, expect } from "@frontmcp/testing";
import { calls } from "./refund.skill";

test("`loadInstructions()` adds a step from a provider", async ({ mcp }) => {
  const text = (await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md")).text();
  expect(text).toContain("2. Refund every charge after the first with refund_charge.\n3. Refunds take 3 to 5 business days: tell the customer.\n");
});

const built = "Escalate a ticket that reports an outage. Only for enterprise customers.";

test("`build()` changes what `SKILL.md` and `skills/load` say", async ({ mcp }) => {
  const text = (await mcp.resources.read("skill://escalate-outage/SKILL.md")).text();
  expect(text).toContain(`description: ${built}\n`);
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "skills/load", params: { skillIds: ["escalate-outage"] } })) as any;
  expect(result.skills[0].description).toBe(built);
});

test("and every list of skills: the index, `resources/list` and the server's instructions", async ({ mcp }) => {
  const index = (await mcp.resources.read("skill://index.json")).json();
  expect(index.skills.find((s: { name: string }) => s.name === "escalate-outage").description).toBe(built);
  const resources = await mcp.resources.list();
  expect(resources.find((r: { uri: string }) => r.uri === "skill://escalate-outage/SKILL.md")?.description).toBe(built);
  const { result } = (await mcp.raw.request({ jsonrpc: "2.0", id: 1, method: "server/discover" })) as any;
  expect(result.instructions).toContain(`- **escalate-outage**: ${built}`);
});

test("each runs once, when the server starts", async ({ mcp }) => {
  expect(calls).toEqual(["loadInstructions", "build"]);
  await mcp.resources.read("skill://refund-duplicate-charge/SKILL.md");
  await mcp.resources.read("skill://escalate-outage/SKILL.md");
  expect(calls).toEqual(["loadInstructions", "build"]);
});
```

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:

```md skills/triage-ticket/SKILL.md
---
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.
```

```md skills/triage-ticket/references/priorities.md
---
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:

```ts main.ts
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:

```md
---
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:

```ts sync-skill-catalog.tool.ts active
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 };
  }
}
```

```ts skill-catalog.ts
import { ExternalSkillProviderBase } from "@frontmcp/sdk";
import type { SkillContent, SkillSearchResult, SkillSyncState, SkillSyncStateStore } from "@frontmcp/sdk";

// Stands in for a database or a service that several servers share
export const catalog = new Map<string, SkillContent>([
  [
    "refund-duplicate-charge",
    {
      id: "refund-duplicate-charge",
      name: "refund-duplicate-charge",
      description: "Refund a customer who was charged twice for the same invoice.",
      instructions: "1. Read the invoice with get_invoice.\n2. Refund every charge after the first.",
      tools: [{ name: "get_invoice" }],
    },
  ],
]);

export class CatalogSkills extends ExternalSkillProviderBase {
  protected async fetchSkill(id: string) {
    return catalog.get(id) ?? null;
  }
  protected async fetchSkills() {
    return [...catalog.values()];
  }
  protected async searchExternal(query: string): Promise<SkillSearchResult[]> {
    const words = query.toLowerCase().split(" ");
    return [...catalog.values()]
      .filter((skill) => words.some((word) => skill.description.toLowerCase().includes(word)))
      .map((skill) => ({
        metadata: { id: skill.id, name: skill.name, description: skill.description, instructions: skill.instructions },
        score: 1,
        availableTools: skill.tools.map((tool) => tool.name),
        missingTools: [],
        source: "external" as const,
      }));
  }
  protected async upsertSkill(skill: SkillContent) {
    catalog.set(skill.id, skill);
  }
  protected async deleteSkill(id: string) {
    catalog.delete(id);
  }
  protected async countExternal() {
    return catalog.size;
  }
  protected async existsExternal(id: string) {
    return catalog.has(id);
  }
}

// Remembers what was copied, and each skill's hash, between syncs
let saved: SkillSyncState | null = null;
export const syncState: SkillSyncStateStore = {
  load: async () => saved,
  save: async (state) => {
    saved = state;
  },
  clear: async () => {
    saved = null;
  },
};
```

```ts help-desk.app.ts
import { App, Skill, Tool, ToolContext, z } from "@frontmcp/sdk";
import { SyncSkillCatalog } from "./sync-skill-catalog.tool";

@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.", instructions: "1. Read the ticket with get_ticket.", tools: ["get_ticket"] })
export class TriageTicket {}

@App({ id: "help-desk", name: "Help Desk", tools: [GetTicket, SyncSkillCatalog], skills: [TriageTicket] })
export class HelpDeskApp {}
```

```ts catalog.test.ts
import { test, expect } from "@frontmcp/testing";
import { SkillRegistry, Tool, ToolContext, create } from "@frontmcp/sdk";
import { catalog, CatalogSkills } from "./skill-catalog";
import { GetTicket, TriageTicket } from "./help-desk.app";

const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params });

test("read-only: the catalog answers skills/list and skills/search", async () => {
  @Tool({ name: "use_skill_catalog", description: "Serve skills from the shared catalog", inputSchema: {} })
  class UseSkillCatalog extends ToolContext {
    async execute() {
      (this.scope.skills as SkillRegistry).setExternalProvider(new CatalogSkills({ mode: "read-only" }));
      return { ok: true };
    }
  }
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [GetTicket, UseSkillCatalog], skills: [TriageTicket] });
  const client = await server.connect();
  try {
    await client.callTool("use_skill_catalog", {});
    expect((await client.listSkills()).skills.map((s: { id: string }) => s.id)).toEqual(["refund-duplicate-charge"]);
    expect((await client.searchSkills("refund")).skills.map((s: { id: string; source: string }) => [s.id, s.source])).toEqual([["refund-duplicate-charge", "external"]]);
    const { skills } = await client.loadSkills(["refund-duplicate-charge", "triage-ticket"]);
    expect(skills.map((s: { id: string; availableTools: string[] }) => [s.id, s.availableTools])).toEqual([
      ["refund-duplicate-charge", ["get_invoice"]],
      ["triage-ticket", ["get_ticket"]],
    ]);
    await expect(client.readResource("skill://refund-duplicate-charge/SKILL.md")).rejects.toThrow("Resource not found");
  } finally {
    await client.close();
    await server.dispose();
  }
});

test("the first sync copies the skill; the next finds it unchanged", async ({ mcp }) => {
  expect((await mcp.tools.call("sync_skill_catalog", {})).json()).toEqual({ added: ["triage-ticket"], updated: [], unchanged: [], removed: [] });
  expect((await mcp.tools.call("sync_skill_catalog", {})).json()).toEqual({ added: [], updated: [], unchanged: ["triage-ticket"], removed: [] });
  expect(catalog.get("triage-ticket")?.instructions).toBe("1. Read the ticket with get_ticket.");
});

test("persistent: clients still get the server's own skills", async ({ mcp }) => {
  const { result } = await mcp.raw.request(request("skills/list", {}));
  expect(result.skills.map((s: { id: string }) => s.id)).toEqual(["triage-ticket"]);
});
```

`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 `initialize` request's URL. `?mode=skills_only` on 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 of [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler), which opens no sessions.
- **Only for a client that sends a JWT the server verifies.** It took effect for a client whose token `transparent` auth verified. On a server without `auth`, or with `public` or `static` auth, 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](#modeskills_only-still-lists-every-tool).

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.

```ts help-desk.app.ts active
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 {}
```

```ts strict.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, FrontMcpInstance, Skill, SkillValidationError } from "@frontmcp/sdk";
import { GetTicket } from "./help-desk.app";

const start = (toolValidation: "strict" | "warn", skillsConfig = {}) => {
  @Skill({
    name: "audit-tickets",
    description: "Check a day's tickets.",
    instructions: "1. Read each ticket. 2. Close the stale ones.",
    tools: ["get_ticket", "close_ticket"], // the server has no close_ticket
    toolValidation,
  })
  class AuditTickets {}
  @App({ id: "desk", name: "Desk", tools: [GetTicket], skills: [AuditTickets] })
  class Desk {}
  return FrontMcpInstance.createDirect({ info: { name: "help-desk", version: "1.0.0" }, apps: [Desk], skillsConfig });
};

test('`toolValidation: "strict"` stops a server that lacks a tool', async () => {
  const failure = start("strict");
  await expect(failure).rejects.toThrow("Skill 'audit-tickets' failed tool validation: missing tools [close_ticket]");
  await expect(failure).rejects.toBeInstanceOf(SkillValidationError);
});

test('`"warn"` starts it', async () => {
  const server = await start("warn");
  await server.dispose();
});

test("so does `failOnInvalidSkills: false`, with the tool missing", async () => {
  const server = await start("strict", { failOnInvalidSkills: false });
  try {
    const { contents } = await server.readResource("skill://audit-tickets/SKILL.md");
    expect(contents[0].text).toContain("  - close_ticket\n");
  } finally {
    await server.dispose();
  }
});
```

### `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:

1. The skill is in an app's `skills`, and the app is in `@FrontMcp({ apps })`.
2. The URI uses the skill's `name`, or its `skillPath` joined with `/`, not its `id`.
3. The skill doesn't set `hideFromDiscovery: true` or `visibility: "http"`, and its `availableWhen` matches where the server runs. Such skills have no resource. See [Hiding a skill](#hiding-a-skill).
4. 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`](#skill--failed-tool-validation-missing-tools-).

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

```ts help-desk.app.ts active
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 {}
```

```ts unavailable.test.ts
import { test, expect } from "@frontmcp/testing";

const request = (method: string, params: object) => ({ jsonrpc: "2.0" as const, id: 1, method, params });

const ids = (response: { result: { skills: { id: string }[] } }) => response.result.skills.map((s) => s.id);

test("only the skill without `availableWhen` is listed", async ({ mcp }) => {
  expect(ids(await mcp.raw.request(request("skills/list", {})))).toEqual(["triage-ticket"]);
  const uris = (await mcp.raw.request(request("resources/list", {}))).result.resources.map((r: { uri: string }) => r.uri);
  expect(uris).toEqual(["skill://index.json", "skill://triage-ticket/SKILL.md"]);
});

test("🚩 the Deno skill isn't found, loaded or read", async ({ mcp }) => {
  expect(ids(await mcp.raw.request(request("skills/search", { query: "restart the importer" })))).toEqual([]);
  const loaded = await mcp.raw.request(request("skills/load", { skillIds: ["restart-importer"] }));
  expect(loaded.result.summary.combinedWarnings).toEqual(['Skill "restart-importer" not found']);
  const read = await mcp.raw.request(request("resources/read", { uri: "skill://restart-importer/SKILL.md" }));
  expect(read.error).toMatchObject({ code: -32602 });
});

test("the agent-only skill isn't either", async ({ mcp }) => {
  expect(ids(await mcp.raw.request(request("skills/search", { query: "merge duplicate tickets" })))).toEqual([]);
  const loaded = await mcp.raw.request(request("skills/load", { skillIds: ["merge-duplicates"] }));
  expect(loaded.result.summary.combinedWarnings).toEqual(['Skill "merge-duplicates" not found']);
  const read = await mcp.raw.request(request("resources/read", { uri: "skill://merge-duplicates/SKILL.md" }));
  expect(read.error).toMatchObject({ code: -32602 });
});
```

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`, or `public` or `static` auth.** 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()`](https://frontmcp.dev/reference/sdk/create-fetch-handler). Neither has a session to keep the mode in.
- **The mode is on a later request** rather than on `initialize`.

See [Connecting for skills only](#connecting-for-skills-only).
