Skills MCP servers serve as skill:// resources, indexed for the model and cached, for sessions without native MCP skills

A basic MCP server that serves Agent Skills, plus Claude Code mods that make those skills feel native, plus configs for other harnesses.
skills/ three demo skills (SKILL.md folders)
server/ skills-mcp: the MCP server (Python, MCP SDK 2.x), and skills-mcp-ui
demo/ live demo: run.sh, SCRIPT.md (talk track), skill packs
mods/ Claude Code mods: skills-mcp, skills-mcp-commands, skills-mcp-browser
scripts/ enable-native-mcp-skills.sh (optional, see below)
.mcp.json runs the server for Claude Code in this repo
Change skills in a web page and watch a running Claude Code pick them up within about two seconds:
demo/run.sh ui # terminal 1: http://127.0.0.1:8766
demo/run.sh claude # terminal 2
demo/SCRIPT.md is the talk track: add packs of skills, write one live, edit it, and swap the whole set. Behind it:
--watch, following a symlink swap);skills-mcp mod polls the server's live skill://index.json;skills-mcp serves every folder under --skills-dir that holds a SKILL.md, re-read on every request, so editing a skill needs no restart. It does not offer every client everything: each client gets one way to reach the skills, the best it supports.
| Tier | Offered | Chosen when |
|---|---|---|
| sep | The MCP Skills extension (SEP-2640): skills/list, skills/get (manifests with sha256 digests), resources/directory/read, and the skill:// resources they name. No instructions, no tools. | The client declares io.modelcontextprotocol/skills in its capabilities, or calls skills/list. Claude Code with native MCP skills does the latter: it calls skills/list on seeing the server's declaration but doesn't declare anything itself. |
| resources | A one-line instructions naming the skills, plus every skill file listed as a skill://<name>/<path> resource. | Any other client, by default. |
| tools | A one-line instructions, plus list_skills, get_skill(name, args?), read_skill_file(name, path). | Clients known not to read resources (Zed, Copilot CLI, by clientInfo.name), or --fallback tools. MCP has no capability that says "I read resources", so this is the server's call. |
Live changes: the server re-scans its folders every --watch seconds (default 1; a root that is a symlink can be swapped), lists resources from the live catalog, and on a change publishes resources/list_changed and resources/updated to clients subscribed through subscriptions/listen. It also serves skill://index.json, a live index in the pre-v1 SEP-2640 shape, for clients that cache resources/list and poll a read instead. Claude Code 2.1.296 is one: it doesn't subscribe at connect, so the skills-mcp mod polls the index.
The server always declares the extension, so a client that speaks it can find it. Instructions go out in the handshake, before a client can be seen calling skills/list, so they stay one line. Protocol 2026-07-28 keeps no connection between requests, so a skills/list call is remembered against the client's name and version. That's exact over stdio, where one process serves one client. On a shared HTTP server, clients with the same name and version share it; --fallback resources|tools fixes the non-SEP tier, and --trace FILE logs each request with what the client declared and the tier it got.
cd server
uv run skills-mcp # stdio, serves ../skills
uv run skills-mcp --skills-dir ~/my-skills --skills-dir ./team-skills
uv run skills-mcp --transport streamable-http --port 8765 # http://127.0.0.1:8765/mcp
uv run skills-mcp --list # print what it finds
uv run skills-mcp --fallback tools # tools tier for every non-SEP client
uv run skills-mcp --watch 0.5 --trace /tmp/trace.jsonl # check for changes twice a second; log requests
uv run pytest # tests
From anywhere, without a checkout: uvx --from 'git+https://github.com/sambhav/mcp-demo#subdirectory=server' skills-mcp --skills-dir /path/to/skills
Add the server (this repo's .mcp.json already does, for sessions started here):
claude mcp add --scope user skills -- uv run --directory /path/to/mcp-demo/server skills-mcp
Then pick the experience:
With nothing else, the skills are live over MCP in any Claude Code setup (Anthropic API, Bedrock, Vertex, Foundry, a gateway): Claude Code gets the resources tier, so the model learns the skill names from the server's instructions and reads skill://<name>/SKILL.md with its resource tool.
/plugin install skills-mcp --marketplace sambhav/mcp-demo
/plugin install skills-mcp-commands --marketplace sambhav/mcp-demo
/plugin install skills-mcp-browser --marketplace sambhav/mcp-demo
(y to add the marketplace, then pick a scope.)
| Mod | What it does |
|---|---|
skills-mcp | Finds every skill://…/SKILL.md resource on every connected server, caches the index (session state, and across sessions so turn one has it), and adds one stable system-prompt section telling the model to read a skill with ReadMcpResourceTool before a matching task. The section changes only when the index does, so the prompt cache holds. Claude Code caches resources/list from connect time, so for servers that publish a live skill://index.json (the pre-v1 SEP-2640 index; this server does) it reads that every 2 seconds instead. When the index changes, it toasts the difference and appends a note to the conversation, which the model reads from its next request. Some managed builds keep user mods out of the system prompt, and the note covers that case. A name two servers share is shown as server:name. Skills Claude Code already loads natively are left to it. |
skills-mcp-commands | A /<skill> [args] command per skill. Typing one starts the turn with the skill's SKILL.md (read live as a resource) attached for the model, like a local skill's command. Never takes a name a local command or skill already has. |
skills-mcp-browser | /mcp-skills opens a pane: the skills, their server and URI, a preview of SKILL.md, and Use to fill the prompt. |
Settings (/config): skills-mcp.server limits the index to one server; skills-mcp.refreshSeconds sets the refresh (default 2; 0: start and /skills-mcp only); skills-mcp-commands.prefix prefixes the commands.
To work on the mods from this checkout: claude --plugin-dir mods/skills-mcp --plugin-dir mods/skills-mcp-commands --plugin-dir mods/skills-mcp-browser; claude plugin test mods/<mod> runs a mod's tests.
Claude Code (seen in 2.1.296) ships its own SEP-2640 client: at connect it calls skills/list on any server that declares the extension, and the skills appear as real skills named <server>:<name> (skills:commit-message), verified by digest and loaded on demand. It is behind the remote feature flag tengu_mcp_skills, off by default. scripts/enable-native-mcp-skills.sh turns it on locally. That is unsupported and relies on undocumented internals; it only works with a first-party provider (Anthropic API or a compatible gateway), and it stops the flag fetch (non-essential traffic off), freezing every other flag at its cached value. Read the script before running it. When the flag reaches you for real, the server needs no change, and the skills-mcp mod steps aside.
The server offers each harness one tier (see above): today that's resources for most of them, and tools for Zed and Copilot CLI. If a harness on the resources tier turns out not to use resources, start the server with --fallback tools for it. What else a harness picks up decides how skills show up. Checked against each project's docs and source in October 2026; unverified where they did not say.
| Harness | reads resources | passes instructions to the model | Tier it gets | How skills show up |
|---|---|---|---|---|
| Claude Code | ✓ | ✓ | resources; sep with native MCP skills | skill:// reads; native skills:<name> skills when on |
| Codex CLI | ✓ (read_mcp_resource) | ✓ | resources | the model reads skill://<name>/SKILL.md |
| VS Code (Copilot agent) | ✓ | ✓ | resources | resource reads; resources under Add Context |
| Copilot CLI | partial | allowlisted servers, or --allow-all-mcp-server-instructions | tools | list_skills / get_skill |
| Gemini CLI | ✓ (read_mcp_resource, @skill://…) | ✓ | resources | resource reads, @skill://name/SKILL.md |
| Antigravity | ✓ | unverified | resources | resource reads |
| OpenCode | ✓ (list_mcp_resources) | ✓ | resources | resource reads |
| Goose | ✓ | ✓ | resources | resource reads |
| Cursor | ✓ | unverified | resources | resource reads |
| Zed | ✗ | unverified | tools | list_skills / get_skill |
| Cline, Continue | ✓ | unverified | resources | resource reads |
None of these other harnesses implement the SEP-2640 methods yet.
In each snippet replace /path/to/mcp-demo with your checkout (or use the uvx --from … command above).
Codex CLI — ~/.codex/config.toml (or codex mcp add skills -- uv run --directory /path/to/mcp-demo/server skills-mcp)
[mcp_servers.skills]
command = "uv"
args = ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"]
VS Code / GitHub Copilot agent — .vscode/mcp.json
{ "servers": { "skills": { "type": "stdio", "command": "uv", "args": ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"] } } }
Copilot CLI — ~/.copilot/mcp-config.json (or copilot mcp add skills -- uv run --directory /path/to/mcp-demo/server skills-mcp)
{ "mcpServers": { "skills": { "type": "local", "command": "uv", "args": ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"], "tools": ["*"] } } }
Gemini CLI — ~/.gemini/settings.json; Antigravity — ~/.gemini/config/mcp_config.json or .agents/mcp_config.json; Cursor — ~/.cursor/mcp.json; Cline — cline_mcp_settings.json
{ "mcpServers": { "skills": { "command": "uv", "args": ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"] } } }
OpenCode — opencode.json
{ "mcp": { "skills": { "type": "local", "command": ["uv", "run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"], "enabled": true } } }
Goose — ~/.config/goose/config.yaml
extensions:
skills: { type: stdio, name: skills, cmd: uv, args: ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"], enabled: true, timeout: 300 }
Zed — settings.json
{ "context_servers": { "skills": { "command": "uv", "args": ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"], "env": {} } } }
Continue — .continue/mcpServers/skills.yaml
name: skills
version: 0.0.1
schema: v1
mcpServers:
- name: skills
command: uv
args: ["run", "--directory", "/path/to/mcp-demo/server", "skills-mcp"]
Remote harnesses (or several machines) can share one server over HTTP: uv run skills-mcp --transport streamable-http --host 0.0.0.0 --port 8765, then point the client at http://<host>:8765/mcp (url / serverUrl / httpUrl, per harness).
hooks/register.ts 171 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RemoteSkill, SkillsMcpStatus } from '../types'
5
6const skills = atom({ plugin: 'skills-mcp', key: 'skills' } as const, [] as RemoteSkill[])
7const status = atom({ plugin: 'skills-mcp', key: 'status' } as const, {} as SkillsMcpStatus)
8
9const SECTION_ID = 'skills-mcp:index'
10const STORE_KEY = 'skills-mcp:index'
11/** `skill://<skill-path>/SKILL.md`, the skill's name the path's last segment (SEP-2640). */
12const SKILL_MD = /^skill:\/\/(?:.+\/)?([a-z0-9][a-z0-9-]*)\/SKILL\.md$/
13/**
14 * The pre-v1 SEP-2640 index a server may still publish. Claude Code caches resources/list, but
15 * reads go to the server, so polling it sees skills added since connect.
16 */
17const INDEX_URI = 'skill://index.json'
18
19type ListedResource = { uri: string; name: string; description?: string; server: string }
20/** Either pre-v1 shape: `url` + `frontmatter` (as servers shipped it), or top-level `name` / `description`. */
21type IndexEntry = { url: string; name?: string; description?: string; frontmatter?: { name?: string; description?: string } }
22
23function errorText(err: unknown): string {
24 return err instanceof Error ? err.message : String(err)
25}
26
27/** Labels skills `server:name` where two servers share a name. */
28function labelled(found: readonly Omit<RemoteSkill, 'label'>[]): RemoteSkill[] {
29 const servers = new Map<string, Set<string>>()
30 for (const s of found) servers.set(s.name, (servers.get(s.name) ?? new Set()).add(s.server))
31 return found.map(s => ({ ...s, label: (servers.get(s.name)?.size ?? 0) > 1 ? `${s.server}:${s.name}` : s.name }))
32}
33
34/** The skills among a server's listed resources. */
35function fromListing(resources: readonly ListedResource[]) {
36 return resources.flatMap(r => {
37 const name = SKILL_MD.exec(r.uri)?.[1]
38 return name ? [{ name, description: r.description ?? '', uri: r.uri, server: r.server }] : []
39 })
40}
41
42/** Skills Claude Code already loads natively (as `<server>:<name>`) are left to it. */
43async function withoutNative($: EngineInterface, list: RemoteSkill[]): Promise<RemoteSkill[]> {
44 const commands = new Set((await $.command.list()).map(c => c.name))
45 return list.filter(s => !commands.has(`${s.server}:${s.name}`))
46}
47
48/** A server's live index, or undefined when it publishes none or the read fails. */
49async function readIndex($: EngineInterface, server: string) {
50 const ran = await $.tool.call({ tool: 'ReadMcpResourceTool', server, uri: INDEX_URI })
51 if (ran.deny !== undefined || ran.isError || ran.result.error) return undefined
52 const index = JSON.parse(ran.result.contents.map(c => c.text ?? '').join('')) as { skills?: IndexEntry[] }
53 return (index.skills ?? []).flatMap(e => {
54 const name = e.frontmatter?.name ?? e.name ?? SKILL_MD.exec(e.url)?.[1]
55 const description = e.frontmatter?.description ?? e.description ?? ''
56 return name && SKILL_MD.test(e.url) ? [{ name, description, uri: e.url, server }] : []
57 })
58}
59
60/** Whether this session has read the servers once: the first read replaces the cached index unannounced. */
61let hasRead = false
62
63async function refresh($: EngineInterface, server: string): Promise<RemoteSkill[]> {
64 try {
65 const ran = await $.tool.call({ tool: 'ListMcpResourcesTool', ...(server ? { server } : {}) })
66 if (ran.deny !== undefined) throw new Error(ran.deny)
67 if (ran.isError) throw new Error(String(ran.result ?? 'ListMcpResourcesTool failed'))
68 const listed = ran.result as ListedResource[]
69 const found = []
70 for (const name of new Set(listed.map(r => r.server))) {
71 const own = listed.filter(r => r.server === name)
72 // The listing is as of connect; a server's index is read live, so new skills show at once.
73 const live = own.some(r => r.uri === INDEX_URI) ? await readIndex($, name) : undefined
74 found.push(...(live ?? fromListing(own)))
75 }
76 const fresh = await withoutNative($, labelled(found))
77 const previous = await read($, skills)
78 // The index rides in the system prompt: write it only when it changed, so the prompt cache holds.
79 if (JSON.stringify(previous) !== JSON.stringify(fresh)) {
80 await update($, skills, () => fresh)
81 await $.store.set(STORE_KEY, fresh)
82 if (hasRead) await announce($, previous, fresh)
83 }
84 hasRead = true
85 await update($, status, () => ({ refreshedAt: Date.now() }))
86 $.ui.status(fresh.length ? `${fresh.length} MCP skills` : undefined)
87 return fresh
88 } catch (err) {
89 await update($, status, s => ({ ...s, error: errorText(err) }))
90 return read($, skills)
91 }
92}
93
94/**
95 * Tells the person (a toast) and the model (a note in the conversation, read from its next
96 * request, mid-turn included) what came and went. The note matters where a managed plugin keeps
97 * user mods out of the system prompt, and it shows the change the moment it lands.
98 */
99async function announce($: EngineInterface, before: readonly RemoteSkill[], after: readonly RemoteSkill[]) {
100 const had = new Map(before.map(s => [s.label, s]))
101 const has = new Map(after.map(s => [s.label, s]))
102 const added = after.filter(s => !had.has(s.label))
103 const removed = before.filter(s => !has.has(s.label))
104 const edited = after.filter(s => had.has(s.label) && had.get(s.label)?.description !== s.description)
105 if (added.length + removed.length + edited.length === 0) return
106
107 $.ui.toast(`MCP skills: ${[...added.map(s => `+${s.label}`), ...removed.map(s => `-${s.label}`), ...edited.map(s => `~${s.label}`)].join(' ')}`)
108 const line = (s: RemoteSkill) => `- ${s.label}: ${s.description} (server: ${s.server}, uri: ${s.uri})`
109 const text = [
110 'The MCP skills available changed just now.',
111 ...(added.length ? ['Added:', ...added.map(line)] : []),
112 ...(edited.length ? ['Changed:', ...edited.map(line)] : []),
113 ...(removed.length ? [`Removed (no longer available): ${removed.map(s => s.label).join(', ')}`] : []),
114 'To use one, read its SKILL.md with ReadMcpResourceTool (server and uri above) before the task, then follow it.',
115 ].join('\n')
116 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
117}
118
119/** Seeds the index from the last session's, so the first turn has it before the servers answer. */
120async function seed($: EngineInterface) {
121 const cached = await $.store.get(STORE_KEY)
122 if (Array.isArray(cached) && (await read($, skills)).length === 0) {
123 await update($, skills, () => cached as RemoteSkill[])
124 }
125}
126
127function indexSection(list: readonly RemoteSkill[]): string {
128 return [
129 '# Skills served over MCP',
130 "MCP servers serve these Agent Skills as resources. When a task matches a skill's",
131 'description, read its SKILL.md with ReadMcpResourceTool (the server and uri below)',
132 'before starting, then follow it. Paths a skill mentions are relative to its folder:',
133 'for skill://<path>/SKILL.md, `references/x.md` is skill://<path>/references/x.md, read',
134 'the same way; ReadMcpResourceDirTool lists a folder. This index is kept current for',
135 'you: do not list resources to look for skills. The person can also run one as /<name>.',
136 '',
137 ...list.map(s => `- ${s.label}: ${s.description} (server: ${s.server}, uri: ${s.uri})`),
138 ].join('\n')
139}
140
141export const register: Register = (on, options) => {
142 const server = String(options.server ?? '')
143 const refreshMs = Number(options.refreshSeconds ?? 2) * 1000
144
145 on('session.start', async ($, e, next) => {
146 await $.command.register({
147 name: 'skills-mcp',
148 description: 'Re-read the skills MCP servers serve',
149 })
150 await seed($)
151 void refresh($, server)
152 if (refreshMs > 0) $.clock.every(refreshMs, () => void refresh($, server))
153 return next(e)
154 })
155
156 on('command.run', { command: 'skills-mcp' }, async $ => {
157 const list = await refresh($, server)
158 const { error } = await read($, status)
159 if (error) return { text: `Could not read the MCP skill index: ${error}` }
160 if (list.length === 0) return { text: 'No connected MCP server serves skill:// resources.' }
161 return { text: [`${list.length} MCP skills:`, ...list.map(s => ` ${s.label} ${s.description}`)].join('\n') }
162 })
163
164 on('prompt.compose', async ($, e, next) => {
165 const composed = await next(e)
166 const list = await read($, skills)
167 if (list.length === 0 || e.traits.includes('bare')) return composed
168 return { sections: [...composed.sections, { id: SECTION_ID, text: indexSection(list), scope: 'session' as const }] }
169 })
170}
171types/index.d.ts 28 lines1/** One skill an MCP server serves, found as a `skill://<path>/SKILL.md` resource. */
2export type RemoteSkill = {
3 /** The skill's name: the last segment of its path. */
4 name: string
5 /** What the model and the person call it: `name`, or `server:name` when two servers serve one name. */
6 label: string
7 description: string
8 /** The SKILL.md resource's URI. */
9 uri: string
10 /** The MCP server serving it, as /mcp lists it. */
11 server: string
12}
13
14export type SkillsMcpStatus = {
15 /** When the index was last read from the servers, as ms since the epoch. */
16 refreshedAt?: number
17 error?: string
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'skills-mcp': {
23 skills: RemoteSkill[]
24 status: SkillsMcpStatus
25 }
26 }
27}
28