Skip to content

skills: add SEP-2640 support - #1238

Open
sambhav wants to merge 1 commit into
modelcontextprotocol:mainfrom
sambhav:skills-sep-2640
Open

skills: add SEP-2640 support#1238
sambhav wants to merge 1 commit into
modelcontextprotocol:mainfrom
sambhav:skills-sep-2640

Conversation

@sambhav

@sambhav sambhav commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

Add typed Go SDK support for SEP-2640:

  • add generic skills/list and skills/get server handlers and typed client calls
  • add optional resources/directory/read support
  • add cursor-aware server pagination helpers and non-mutating client iterators
  • support static manifests and explicit "dynamic" resources
  • add AddDirectory and AddFS utilities with configurable startup, interval, request, and externally triggered catalog refresh
  • apply SEP and Agent Skills validation by default, with explicit unsafe overrides and custom skill, list, directory, and whole-catalog validators
  • support nested skills, directory resources, and resource integrity verification

Existing API and behavior

This change is additive. It does not change the signature or documented behavior of existing resource APIs.

The only addition to the existing mcp package is (*Server).AddExtension, which safely adds an extension capability without mutating caller-owned ServerCapabilities. Existing AddResource, AddResourceTemplate, resources/list, and resources/read behavior is unchanged. The filesystem utility uses the existing dynamic resource-template dispatch path rather than modifying startup resource registration semantics.

Filesystem refresh policy

DirectoryOptions.RefreshMode makes catalog discovery explicit:

// Zero-value default: scan for each request.
skills.AddDirectory(server, dir, nil)

// Build and validate one catalog during provider construction.
skills.AddDirectory(server, dir, &skills.DirectoryOptions{
    RefreshMode: skills.RefreshOnStartup,
})

// Cache the catalog and refresh it on the first request after the interval.
skills.AddDirectory(server, dir, &skills.DirectoryOptions{
    RefreshMode:     skills.RefreshPeriodically,
    RefreshInterval: 5 * time.Minute,
})

Cached modes can also refresh early from a caller-owned clock, filesystem watcher, or monitor:

refresh := make(chan struct{}, 1)
skills.AddDirectory(server, dir, &skills.DirectoryOptions{
    RefreshMode: skills.RefreshOnStartup,
    Refresh:     refresh,
})

// From a watcher or monitor callback. Multiple pending events are coalesced.
select {
case refresh <- struct{}{}:
default:
}
  • RefreshOnRequest discovers additions, changes, and removals immediately. List/get requests build complete manifests and hashes; directory and individual resource reads only index metadata and do not hash unrelated files.
  • RefreshOnStartup builds, validates, and hashes the catalog once when NewDirectoryProvider, NewFSProvider, AddDirectory, or AddFS is called. A Refresh signal can explicitly replace that snapshot.
  • RefreshPeriodically builds the same initial snapshot and refreshes it lazily on the first request after RefreshInterval, or earlier when Refresh is signaled.
  • Refresh is receive-only to the provider. Signals are coalesced and consumed on requests, so callers should use a buffered channel and retain ownership of any producer lifecycle.
  • The cache covers catalog membership, metadata, and manifests. resources/read still reads the requested file at call time in every mode, so file bytes are not retained in memory.

Simple usage

Server

server := mcp.NewServer(&mcp.Implementation{
    Name: "skill-server", Version: "v1.0.0",
}, nil)

// Serves skills/list, skills/get, resources/read, and directory reads.
if err := skills.AddDirectory(server, "./skills", nil); err != nil {
    log.Fatal(err)
}
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
    log.Fatal(err)
}

./skills may contain one or more skill directories, each rooted by a SKILL.md. The zero-value options rescan on every request; pass DirectoryOptions to select another refresh policy.

Client

ctx := context.Background()
client := mcp.NewClient(&mcp.Implementation{
    Name: "skill-client", Version: "v1.0.0",
}, nil)
if err := skills.AddClient(client); err != nil {
    log.Fatal(err)
}

session, err := client.Connect(ctx, &mcp.CommandTransport{
    Command: exec.Command("./skill-server"),
}, nil)
if err != nil {
    log.Fatal(err)
}
defer session.Close()

// All follows nextCursor until every listed skill has been yielded.
for skill, err := range skills.All(ctx, session, nil) {
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(skill.URI, skill.Frontmatter["description"])
}

entry, err := skills.Get(ctx, session, &skills.GetSkillParams{
    URI: "skill://pdf-processing/SKILL.md",
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(entry.Skill.Frontmatter["name"])

skills.ReadDirectory and skills.DirectoryEntries expose the optional resources/directory/read capability; ordinary file content is read with session.ReadResource.

Custom filesystem

A minimal in-memory filesystem uses the standard library fstest.MapFS:

skillFS := fstest.MapFS{
    "demo/SKILL.md": &fstest.MapFile{Data: []byte(`---
name: demo
description: Demonstrates an in-memory skill.
---
# Demo
`)},
    "demo/references/guide.md": &fstest.MapFile{Data: []byte("# Guide\n")},
}

refresh := make(chan struct{}, 1)
if err := skills.AddFS(server, skillFS, &skills.DirectoryOptions{
    RefreshMode: skills.RefreshOnStartup,
    Refresh:     refresh,
}); err != nil {
    log.Fatal(err)
}

AddFS works with embed.FS, fstest.MapFS, or any implementation of fs.FS. A production mutable in-memory or database-backed implementation must synchronize its reads and updates according to the fs.FS contract. After committing an update, its watcher can request a new catalog without blocking:

select {
case refresh <- struct{}{}:
default: // A refresh is already pending.
}

Filesystem cost model

Policy/operation Cost
RefreshOnRequest directory or resource lookup Walk and index filesystem metadata; the requested resource alone is read
RefreshOnRequest list/get Walk metadata and hash content to produce complete static SEP manifests
RefreshOnStartup cached request Catalog lookup; resources/read still reads only the requested file
Startup build or channel-triggered refresh One complete walk, frontmatter parse, validation, and content hashing
RefreshPeriodically Same cached cost until expiry; the first request after expiry pays one complete refresh

Static manifests cannot avoid reading and hashing their resources: SEP-2640 requires complete {uri, digest, size} entries. For a database or remote object store where full scans are expensive, prefer a startup/periodic cache with explicit monitor signals. If complete manifests are inherently expensive or impossible, implement AddHandlers and return skills.DynamicResources() instead.

Custom list/get handlers

Servers that do not map skills to a filesystem can implement the extension methods directly. This example publishes generated content with the explicit "dynamic" resource marker:

generated := &skills.Skill{
    URI: "skill://generated/SKILL.md",
    Frontmatter: skills.Frontmatter{
        "name":        "generated",
        "description": "Instructions generated for the current workspace.",
    },
    Resources: skills.DynamicResources(),
}
catalog := []*skills.Skill{generated}

err := skills.AddHandlers(server, &skills.Handlers{
    List: func(ctx context.Context, _ *mcp.ServerSession, params *skills.ListSkillsParams) (*skills.ListSkillsResult, error) {
        page, next, err := skills.PaginateSkills(catalog, params.Cursor, 100)
        if err != nil {
            return nil, &jsonrpc.Error{
                Code: jsonrpc.CodeInvalidParams, Message: err.Error(),
            }
        }
        return &skills.ListSkillsResult{Skills: page, NextCursor: next}, nil
    },
    Get: func(ctx context.Context, _ *mcp.ServerSession, params *skills.GetSkillParams) (*skills.GetSkillResult, error) {
        if params.URI != generated.URI {
            return nil, &jsonrpc.Error{
                Code: jsonrpc.CodeInvalidParams, Message: "unknown skill",
            }
        }
        return &skills.GetSkillResult{Skill: generated}, nil
    },
}, nil)
if err != nil {
    log.Fatal(err)
}

Custom handlers register content separately through the existing server.AddResource or server.AddResourceTemplate APIs. AddHandlers validates returned skills using SEP defaults and advertises the extension capability.

Design decisions and tradeoffs

Decision Benefits Costs
Put SEP support in a top-level skills package Keeps the extension isolated and avoids expanding core protocol types before the SEP lands Users import one additional package
Use existing typed custom-method registration internally Matches current SDK patterns and avoids a parallel extension transport abstraction Extension setup still requires skills.AddHandlers or skills.AddClient
Make catalog refresh configurable as per request, startup snapshot, interval, or external signal Users can choose immediate discovery, explicit invalidation, or bounded rescan cost; per-request remains the zero-value default Startup and interval modes can serve stale catalog metadata until their next trigger
Consume interval and channel triggers lazily No provider shutdown API, leaked ticker, or server lifecycle coupling Refresh does not happen while the provider is idle, and the first request after a trigger pays the refresh cost
Accept a receive-only caller-owned channel Clocks and platform-specific filesystem monitors can integrate without coupling the SDK to one watcher implementation Callers own producer shutdown and should use a buffered channel to avoid blocking
Cache metadata but read file bytes on demand Avoids retaining all skill content in memory and preserves normal resource-read behavior A file can change after its cached manifest was built; clients detect this through size/digest verification and refresh with skills/get
Hash files only when building complete catalogs for skills/list and skills/get Static manifests remain complete and verifiable while default per-request directory and resource reads avoid hashing unrelated files Building startup or periodic snapshots hashes all files, and listing uncached static catalogs remains proportional to total content size
Use keyset cursors based on sorted URIs Stable across many live additions and removals and does not mutate caller data Items inserted before an existing cursor are observed on a later fresh traversal
Enforce SEP and Agent Skills rules by default Interoperable and safe behavior is the default Non-conforming or oversized skills require an explicit unsafe configuration
Keep unsafe limits and validation bypass explicit while allowing custom validators Supports larger deployments and policies such as total skill count without weakening defaults accidentally Adds configuration surface for advanced users
Reject symlinks and non-regular files in the folder utility Prevents path escapes and ambiguous manifests Symlink-based skill layouts must be materialized before serving

Validation

  • go test ./...
  • go test -race ./skills
  • go vet ./...
  • go build ./...
  • govulncheck ./... — no reachable vulnerabilities
  • server conformance: 40 passed
  • client conformance: 239 passed

Generated client and server documentation is included.

@sambhav
sambhav force-pushed the skills-sep-2640 branch 2 times, most recently from a92d749 to adc1ddf Compare September 4, 2026 16:19
@panyam

panyam commented Sep 4, 2026

Copy link
Copy Markdown

Woooot great to see this @sambhav. I ran the SEP-2640 conformance scenarios against this branch (conformance PR 330, the traceability extraction and server scenarios Il share soon for the skills extension). 41 checks, 0 failures.

Scenario Result
sep-2640-skills-enumeration 30/30
sep-2640-skills-manifest 4/4, 2 untestable
sep-2640-skills-directory 7/7

Repro, pointing a minimal skills.AddDirectory server at any skills tree:

node dist/index.js server --url http://localhost:18299/ \
  --scenario sep-2640-skills-enumeration --spec-version 2025-11-25 --force

Got three tiny notes (none blocking):

1. ttlMs and cacheScope are emitted on protocol 2025-11-25. In the SEP - "In protocol versions 2026-07-28 and later, the result also carries the base protocol's list-caching attributes". ttlMs does not appear in the 2025-11-25 schema at all, so this is emitting a field that is not defined in the negotiated version. We might want a version guard if it was not deliberate? If it was, this is a nice data point. I pointed out in PR 138 that it dropped the condition and was wondering if it was intention so looks like two efforts came to this point independently. So the condition itself may need to be removed instead of changing impls.

2. Two untestable SHOULD rows instead of passing. sep-2640-skillmd-metadata-name and -description check that the SKILL.md resource carries name and description from frontmatter. Since the dir utility serves through resource-template dispatch, SKILL.md is not in resources/list, so the metadata is not observable. I dont think this is a bug and I also do not think it needs changing, but figured Id flag it. A server registering SKILL.md as a listed resource does/would exercise those two.

3. Possible doc gap dueto needing flags against this branch. The runner defaults to the draft stateless wire. here it asserts MCP-Protocol-Version: 2026-07-28 with no handshake, so the server correctly refuses with -32022. --spec-version 2025-11-25 --force and selects the stateful wire and overrides the extension-applicability skip. The scenarios themselves are version-portable, they just do not advertise that. Just wanted to call this out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants