Field notes

How to serve Agent Skills over MCP

SEP-2640 went Final on 2026-09-13 and, as of 2026-09-18, no official SDK had merged it. Here is a working skills/list, skills/get and skill:// server on the released packages.

Mark

Head of Marketing, MCPOrbit

Published
Updated
· Updated
Read time
· 11 min read
A three card diagram. An agent asks a server what skills it has. The server in the middle answers three methods, skills/list, skills/get and resources/read, and is marked with a warning that resultType is eaten in transit. On the right a skill namespace holds SKILL.md, a references file and a sha256 digest for each file.

You can serve Agent Skills over MCP today, and you will write the three protocol methods yourself. SEP-2640 reached Final status on 2026-09-13, and on 2026-09-18 not one official MCP SDK had merged support for it.

That is less work than it sounds. The extension is small: two required methods, one optional one, and a URI convention layered on the Resources primitive you already have. We built a working skills server on the released @modelcontextprotocol/server package, ran a real client against it, and inlined the result below. We also hit two traps that are not in the spec, and those are the part worth reading.

What does the Skills extension actually add?

A skill is a directory holding a SKILL.md file and any supporting files. The Agent Skills specification owns that format. SEP-2640 does not redefine it. It defines only how the directory travels over MCP.

Each file in the skill becomes an MCP resource, conventionally under the skill:// scheme:

skill://release-notes/SKILL.md
skill://release-notes/references/FORMAT.md
skill://acme/billing/refunds/SKILL.md

The final path segment must equal the name in the SKILL.md frontmatter. Anything before it is an organizational prefix the server picks. So a host can read the skill's name straight off the URI without fetching the file.

On top of that convention the extension defines three methods. A server that declares the extension must implement skills/list and skills/get. The third, resources/directory/read, is optional and gated behind a directoryRead flag in the capability object.

  • skills/list enumerates the skills, each with its frontmatter and a digest-pinned manifest of its files. A server with a huge or generated catalog may return an empty or partial list.
  • skills/get returns one skill's entry by URI, including skills that never appear in the listing.
  • resources/read serves the file contents, using the resources primitive you already have.

If you have not served resources before, start with how to serve resources from an MCP server. If you are still deciding whether a skill or a server is the right container for your workflow, MCP servers vs agent skills is the decision rule.

Which SDKs and hosts support it today?

We checked every official SDK on 2026-09-18, the day the spec repo merged the pull request that marks the extension as published. Here is what shipped, as opposed to what is written:

  • TypeScript SDK: pull request #2818, open. Phase 1 only, covering schemas, client operations and server handlers.
  • Python SDK: pull request #3485, open.
  • C# SDK: pull requests #1856 and #1864, both open, with maintainers still choosing between them.
  • Go SDK: pull request #1238, open. It already passes the conformance suite.

Zero merged. The working-group tracking list says the same thing in its own words: all four official SDKs are marked in progress.

Hosts are further behind than servers. No client is listed as fully supporting the extension. MCP Inspector has partial support from version 2.6.0, released 2026-09-09, including a command line skill verifier. ChatGPT plugins import skills once at submission time. fast-agent was the first host to interoperate end to end. Claude Desktop, Cursor and VS Code Copilot have no entry in the skills column of the official support matrix.

On the server side a few real implementations already answer the v1 wire contract, including Hugging Face's hf-mcp-server. So servers can publish skills before hosts can consume them, which is the normal order for an MCP extension and a good reason to ship yours now.

Install the right package, because there are two

This is the first trap, and it has nothing to do with skills. The TypeScript MCP packages split in two, and only one half knows the protocol revision the extension is written against.

npm install @modelcontextprotocol/server@2.0.0 \
            @modelcontextprotocol/client@2.0.0 \
            zod@4

@modelcontextprotocol/sdk is the v1 package name. It is still latest on npm at 1.30.0, published 2026-07-27, and its supported protocol list is 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05 and 2024-10-07. The string 2026-07-28 does not appear in it. You cannot serve this extension from that package, because the extension's list result carries fields that only exist on the newer revision.

The 2026 behaviour lives in the scoped v2 packages: @modelcontextprotocol/core, @modelcontextprotocol/server and @modelcontextprotocol/client, all at 2.0.0 and all published 2026-07-27. Those do know 2026-07-28.

What they do not know is anything about skills. We grepped all three packages for skills/list, skills/get, io.modelcontextprotocol/skills, resources/directory/read and skill://. Every one of those five strings matched zero files. That is the whole reason this post exists.

Turn on the protocol revision the extension needs

Second trap, and this one is quiet. The v2 packages ship with the 2026 revision switched off.

import { SUPPORTED_PROTOCOL_VERSIONS, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server'

console.log(SUPPORTED_PROTOCOL_VERSIONS)
// [ '2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07' ]
console.log(LATEST_PROTOCOL_VERSION)
// '2025-11-25'

supportedProtocolVersions defaults to that list, so a server built with no options advertises no modern revision at all. Ask it what it would answer on server/discover and you get an empty array:

new Server(info, { capabilities }).                // default options
// server/discover -> { supportedVersions: [], capabilities: { ... } }

new Server(info, {                                  // opted in
  capabilities,
  supportedProtocolVersions: ['2026-07-28', ...SUPPORTED_PROTOCOL_VERSIONS],
})
// server/discover -> { supportedVersions: ['2026-07-28'], capabilities: { ... } }

The client half has the same posture. Its version negotiation defaults to legacy, so it never probes for the newer revision. To land on 2026-07-28 you set supportedProtocolVersions on both ends and give the client a negotiation mode of auto or a pin.

One more piece is needed on stdio. new StdioServerTransport() plus server.connect(transport) pins the legacy era for the life of the connection. The era-aware entry point is serveStdio(factory), which takes a function that builds a server and lets the transport choose the era from the opening exchange. Use it, or your negotiation will keep failing for a reason no error message explains.

The server, in full

This is the whole thing. It serves one skill made of two files, answers both required methods, and reads any file in the skill through resources/read. Save it as server.mjs.

// server.mjs
import { Server, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'
import { serveStdio } from '@modelcontextprotocol/server/stdio'
import { z } from 'zod'

// One skill: a SKILL.md plus one supporting file.
const SKILL_MD = `---
name: release-notes
description: Draft release notes from a tag range using this team's format
---

# Release notes

1. Run \`git log <from>..<to> --oneline\`.
2. Group the lines by area.
3. One line per change, no marketing copy.
`

const FILES = {
  'skill://release-notes/SKILL.md': { text: SKILL_MD, mimeType: 'text/markdown' },
  'skill://release-notes/references/FORMAT.md': {
    text: '# Format\n\nOne line per change. Past tense. No adjectives.\n',
    mimeType: 'text/markdown',
  },
}

const digest = async (text) => {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
  return 'sha256:' + [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
}

// A skills/list and skills/get entry are the same shape.
const entry = async () => ({
  uri: 'skill://release-notes/SKILL.md',
  frontmatter: {
    name: 'release-notes',
    description: "Draft release notes from a tag range using this team's format",
  },
  resources: await Promise.all(
    Object.entries(FILES).map(async ([uri, f]) => ({
      uri,
      digest: await digest(f.text),
      size: f.text.length,
    })),
  ),
})

serveStdio(() => {
  const server = new Server(
    { name: 'skills-demo', version: '0.1.0' },
    {
      capabilities: {
        resources: {},
        extensions: { 'io.modelcontextprotocol/skills': {} },
      },
      // Without this line the server never offers 2026-07-28.
      supportedProtocolVersions: ['2026-07-28', ...SUPPORTED_PROTOCOL_VERSIONS],
    },
  )

  server.setRequestHandler(
    'skills/list',
    { params: z.object({ cursor: z.string().optional() }).optional() },
    async () => ({
      resultType: 'complete',
      ttlMs: 60_000,
      cacheScope: 'session',
      skills: [await entry()],
    }),
  )

  server.setRequestHandler(
    'skills/get',
    { params: z.object({ uri: z.string() }) },
    async (params) => {
      if (!FILES[params.uri]) throw new Error(`no skill at ${params.uri}`)
      return { resultType: 'complete', skill: await entry() }
    },
  )

  server.setRequestHandler(
    'resources/read',
    { params: z.object({ uri: z.string() }) },
    async (params) => {
      const file = FILES[params.uri]
      if (!file) throw new Error(`no resource ${params.uri}`)
      return { contents: [{ uri: params.uri, mimeType: file.mimeType, text: file.text }] }
    },
  )

  return server
})

Three details in there are worth calling out.

The resources array on each entry carries a sha256 digest and a byte size per file. That is not decoration. It is how a host pins the content it approved, so a skill cannot change under it between the listing and the read. Compute it from the same bytes you serve.

On 2026-07-28 connections the skills/list result also carries the base protocol's list caching attributes, ttlMs and cacheScope, with the same meaning they have on tools/list. The SDK will not add them for you here, because skills/list is not one of its own methods, so your handler has to emit them. If those fields are new to you, caching your MCP tool list covers what the two values mean.

The capability object is {}, which declares the extension without directory reading. Set { directoryRead: true } only once you actually implement resources/directory/read.

The client, and the field that disappears

Now the part that will cost you an hour if nobody warns you. Save this as client.mjs.

// client.mjs
import { Client, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/client'
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
import { z } from 'zod'

// Note what is NOT in these schemas: resultType. See the next section.
const ListSchema = z
  .object({ ttlMs: z.number().optional(), cacheScope: z.string().optional(), skills: z.array(z.any()) })
  .loose()
const GetSchema = z.object({ skill: z.any() }).loose()

const client = new Client(
  { name: 'skills-demo-client', version: '0.1.0' },
  {
    capabilities: {},
    supportedProtocolVersions: ['2026-07-28', ...SUPPORTED_PROTOCOL_VERSIONS],
    versionNegotiation: { mode: { pin: '2026-07-28' } },
  },
)

await client.connect(
  new StdioClientTransport({ command: 'node', args: ['server.mjs'], env: { ...process.env } }),
)

console.log('negotiated        :', client.getNegotiatedProtocolVersion())
console.log('extension declared:', JSON.stringify(client.getServerCapabilities()?.extensions))

const list = await client.request({ method: 'skills/list', params: {} }, ListSchema)
console.log('skills/list       :', list.skills.length, 'skill, ttlMs', list.ttlMs, list.cacheScope)
console.log('  first entry     :', list.skills[0].uri, list.skills[0].resources.length, 'files')

const got = await client.request(
  { method: 'skills/get', params: { uri: 'skill://release-notes/SKILL.md' } },
  GetSchema,
)
console.log('skills/get        :', got.skill.frontmatter.name)

const read = await client.readResource({ uri: 'skill://release-notes/SKILL.md' })
console.log('resources/read    :', read.contents[0].mimeType, read.contents[0].text.length, 'bytes')

await client.close()

Look again at the schemas at the top. Neither requires resultType, even though the spec says a 2026-07-28 result must carry it and our server does send it. Require it and every call fails like this:

SdkError: Invalid result for skills/list: resultType: Invalid input: expected string, received undefined

We tapped both transports to find out who was lying. Nobody was:

handler returned : {"resultType":"complete","skills":[ ... ]}
server SEND      : {"result":{"resultType":"complete","skills":[ ... ]},"jsonrpc":"2.0","id":1}
client RECV      : {"result":{"resultType":"complete","skills":[ ... ]},"jsonrpc":"2.0","id":1}
client received  : {"skills":[ ... ]}

The server put resultType on the wire. The client received the frame with resultType in it. The object handed back to the caller does not have it. The loss happens in the client's decode seam, between the transport and your schema, and the code doing it is not subtle:

decodeResult(_method, raw) {
  if (isPlainObject(raw) && "resultType" in raw) {
    const stripped = { ...raw };
    delete stripped["resultType"];
    return { kind: "complete", result: toNeutralResult(stripped) };
  }
  ...
}

This is deliberate. On the 2026 revision resultType is a wire level discriminator that tells the client whether a response is a complete result or an input_required result waiting on another round trip. The codec reads it, routes on it, and strips it, exactly as it does for every other built-in method. SEP-2640 then names the same field inside its own result shape, so a schema written from the spec text asks for a field the plumbing already ate.

We measured it on both eras. A default session negotiating 2025-11-25 loses the field. A session we genuinely pinned to 2026-07-28, confirmed by the _meta server info envelope the modern era stamps on every result, loses it in the same place. The upstream fix is TypeScript SDK pull request #2791, opened 2026-09-11 and still open on 2026-09-18. The Inspector hit it too and filed it as issue #2330.

If the phrase multi round trip is new, how an MCP server asks the client for input explains what resultType is discriminating between and why the field exists at all.

What the working server prints

Run node client.mjs with both files in the same directory. This is the real output from the code above, not a sketch of it:

negotiated        : 2026-07-28
extension declared: {"io.modelcontextprotocol/skills":{}}
skills/list       : 1 skill, ttlMs 60000 session
  first entry     : skill://release-notes/SKILL.md 2 files
skills/get        : release-notes
resources/read    : text/markdown 120 bytes

A negotiated revision of 2026-07-28, the extension visible in the server's capabilities, both methods answering, cache hints surviving the trip, and the SKILL.md readable by URI. A reader who asks for a skill that does not exist gets an error rather than an empty success, which is the control we ran to make sure the lookups were real.

Frequently asked questions

Can I serve Agent Skills over MCP without waiting for my SDK?
Yes. Register skills/list and skills/get as custom request handlers, declare io.modelcontextprotocol/skills in your server capabilities, and serve the files through resources/read. On the TypeScript v2 packages this is roughly 60 lines, and every piece of it is shown above.
Which MCP SDK supports the Skills extension?
None of the official four had merged support as of 2026-09-18. TypeScript #2818, Python #3485, C# #1864 and Go #1238 were all open pull requests. The Go implementation already passes the conformance suite, so it is likely to land first.
Why does my skills/list result fail validation with a missing resultType?
Because the client consumed it. The 2026-07-28 codec reads resultType as a routing discriminator and deletes it from the result before your schema runs. Remove resultType from your client-side schema. Keep emitting it from the server.
Do I have to use the skill:// URI scheme?
No. A server may serve skills under any scheme native to its domain, such as github://owner/repo/skills/refunds/SKILL.md. The structural rules still apply: the path must end in the skill's name and SKILL.md must be explicit in the URI. A host must never assume a resource is a skill just because of its scheme.
Which MCP clients can consume skills served this way?
Partial support only, as of 2026-09-18. MCP Inspector since 2.6.0, MCPJam, ChatGPT plugins at submission time, and fast-agent. No client on the official support matrix is listed as complete, and Claude Desktop, Cursor and VS Code Copilot have no entry at all.
Does the extension change the SKILL.md format?
No. SEP-2640 is only a transport binding. The directory layout, YAML frontmatter, naming rules and progressive disclosure model all belong to the Agent Skills specification, and future revisions of that spec apply automatically.

Ship it before the hosts catch up

Servers can publish skills before hosts can read them, and that ordering is normal. The cost of shipping early is one small block of custom handlers you will delete when your SDK merges its own. The cost of waiting is that your skill arrives after the hosts are already looking for one.

MCPOrbit connects to the server you just built, shows you what it declares, and writes the config for you.

About the author

Mark

Head of Marketing, MCPOrbit

Mark leads marketing at MCPOrbit, the free desktop client for the Model Context Protocol. He writes the build-it and reliability guides, and the code in them is run before it ships.

Share this post

MCPOrbit

Test an MCP server in 60 seconds.

Download MCPOrbit for free — no account, no telemetry. Hear about a server and test it before the curiosity wears off.

macOS 14+ · Apple silicon · No account needed