diff --git a/docs/client.md b/docs/client.md index ad71c119..d60cffe1 100644 --- a/docs/client.md +++ b/docs/client.md @@ -546,3 +546,8 @@ that optional capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values are per-extension settings objects. +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package provides typed clients for SEP-2640. Call `skills.AddClient` before +connecting, then use `skills.List`, `skills.Get`, or `skills.ReadDirectory`. +The `skills.All` and `skills.DirectoryEntries` iterators follow pagination +cursors automatically without modifying caller-owned parameters. diff --git a/docs/server.md b/docs/server.md index d7bc314a..0f458600 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1199,6 +1199,102 @@ capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values are per-extension settings objects. +#### Skills extension + +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package implements SEP-2640. Use `skills.AddHandlers` to provide custom +`skills/list` and `skills/get` handlers. An optional directory handler enables +`resources/directory/read` and advertises `directoryRead: true`. + +Custom providers may return `skills.DynamicResources()` for generated skills +that cannot publish stable file digests. + +For skills stored on disk, `skills.AddDirectory` installs a filesystem-backed +provider. By default it discovers skills and files on every request, so +resources added after server startup are available without re-registering +them. This applies to `skills/list`, `skills/get`, `resources/list`, +`resources/read`, and `resources/directory/read`. Changes and removals are +visible on the next request too. + +```go +if err := skills.AddDirectory(server, "./skills", &skills.DirectoryOptions{ + PageSize: 100, +}); err != nil { + return err +} +``` + +`resources/list` includes the skill files alongside ordinary registered +resources. `SKILL.md` entries carry their frontmatter name and description and +the `text/markdown` MIME type. Directory reads return only direct children, +including subdirectories with MIME type `inode/directory`, and use the same +file metadata. Neither listing reads supporting file contents just to enumerate +them; `skills/list` and `skills/get` also hash files to build static manifests. + +The helper combines all pages of the underlying resource listing with its +current catalog, deduplicates by URI, and paginates the result using +`DirectoryOptions.PageSize`. Exact resource registrations take precedence over +the template, for both listing and reading. This uses existing receiving +middleware and resource-template routing; it does not change the core SDK APIs. +The merge costs a traversal of the underlying resource list on each request. + +Live discovery does not require `skills.DynamicResources()`: that marker means +the server cannot provide a complete manifest with stable digests, not that its +catalog changes over time. A filesystem provider returns a complete static +manifest for the current catalog; a later request can return a different one. + +Clients must re-list to see changes. The helper starts no watcher and sends no +filesystem-change notifications; SEP-2640 defines no `skills/list_changed` +notification. The merged `resources/list` response has a zero TTL and private +cache scope so a TTL configured for ordinary resources cannot conceal changes. +These cache fields are omitted on older protocol versions by the core SDK. + +Set `Cache` to `&skills.DirectoryCacheOptions{}` to load the catalog on the +first request and cache it indefinitely. Set `Preload: true` to load it while +constructing the provider, which also makes the constructor report initial scan +and validation errors. A positive `MaxAge` expires the cache after that +duration; the first request after expiry rebuilds it. + +Cached providers can also be invalidated by a clock or filesystem monitor through +`DirectoryCacheOptions.Invalidate`. Signals are coalesced and consumed when a +request arrives; use a buffered channel so producers do not block. Both +`MaxAge` and `Invalidate` are lazy: they do not start a background goroutine. +A failed rebuild retains the previous catalog but leaves it stale: requests +retry rebuilding until successful, even if the invalidation signal has already +been consumed. They return the scan error instead of silently serving stale +metadata. Resource bytes are always read on demand, including in cached modes. + +To rebuild before the next request, construct a provider directly and call +`Refresh` from the application's watcher goroutine. The caller owns goroutine +lifetime, cancellation, and error handling: + +```go +provider, err := skills.NewDirectoryProvider("./skills", &skills.DirectoryOptions{ + Cache: &skills.DirectoryCacheOptions{Preload: true}, +}) +if err != nil { + return err +} +if err := provider.AddTo(server); err != nil { + return err +} +go func() { + for range changed { + if err := provider.Refresh(ctx); err != nil { + logger.Error("refreshing skills", "error", err) + } + } +}() +``` + +Register one filesystem provider per server. Applications that need to combine +multiple filesystems can use an overlay `fs.FS` or aggregate them behind custom +`AddHandlers` handlers. + +SEP validation is enabled by default, including the 512-resource and 16 MiB +per-skill limits. `skills.ServerOptions` supports additional validators and +explicit unsafe overrides. + ### Pagination Server-side feature lists may be diff --git a/go.mod b/go.mod index 3287a957..f2f70f10 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.15.0 golang.org/x/tools v0.42.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index c13454aa..b67dd1f4 100644 --- a/go.sum +++ b/go.sum @@ -20,3 +20,7 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index fdaef2ae..788404dc 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -235,3 +235,8 @@ that optional capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values are per-extension settings objects. +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package provides typed clients for SEP-2640. Call `skills.AddClient` before +connecting, then use `skills.List`, `skills.Get`, or `skills.ReadDirectory`. +The `skills.All` and `skills.DirectoryEntries` iterators follow pagination +cursors automatically without modifying caller-owned parameters. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 82b956e9..c0e8818e 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -513,6 +513,102 @@ capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values are per-extension settings objects. +#### Skills extension + +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package implements SEP-2640. Use `skills.AddHandlers` to provide custom +`skills/list` and `skills/get` handlers. An optional directory handler enables +`resources/directory/read` and advertises `directoryRead: true`. + +Custom providers may return `skills.DynamicResources()` for generated skills +that cannot publish stable file digests. + +For skills stored on disk, `skills.AddDirectory` installs a filesystem-backed +provider. By default it discovers skills and files on every request, so +resources added after server startup are available without re-registering +them. This applies to `skills/list`, `skills/get`, `resources/list`, +`resources/read`, and `resources/directory/read`. Changes and removals are +visible on the next request too. + +```go +if err := skills.AddDirectory(server, "./skills", &skills.DirectoryOptions{ + PageSize: 100, +}); err != nil { + return err +} +``` + +`resources/list` includes the skill files alongside ordinary registered +resources. `SKILL.md` entries carry their frontmatter name and description and +the `text/markdown` MIME type. Directory reads return only direct children, +including subdirectories with MIME type `inode/directory`, and use the same +file metadata. Neither listing reads supporting file contents just to enumerate +them; `skills/list` and `skills/get` also hash files to build static manifests. + +The helper combines all pages of the underlying resource listing with its +current catalog, deduplicates by URI, and paginates the result using +`DirectoryOptions.PageSize`. Exact resource registrations take precedence over +the template, for both listing and reading. This uses existing receiving +middleware and resource-template routing; it does not change the core SDK APIs. +The merge costs a traversal of the underlying resource list on each request. + +Live discovery does not require `skills.DynamicResources()`: that marker means +the server cannot provide a complete manifest with stable digests, not that its +catalog changes over time. A filesystem provider returns a complete static +manifest for the current catalog; a later request can return a different one. + +Clients must re-list to see changes. The helper starts no watcher and sends no +filesystem-change notifications; SEP-2640 defines no `skills/list_changed` +notification. The merged `resources/list` response has a zero TTL and private +cache scope so a TTL configured for ordinary resources cannot conceal changes. +These cache fields are omitted on older protocol versions by the core SDK. + +Set `Cache` to `&skills.DirectoryCacheOptions{}` to load the catalog on the +first request and cache it indefinitely. Set `Preload: true` to load it while +constructing the provider, which also makes the constructor report initial scan +and validation errors. A positive `MaxAge` expires the cache after that +duration; the first request after expiry rebuilds it. + +Cached providers can also be invalidated by a clock or filesystem monitor through +`DirectoryCacheOptions.Invalidate`. Signals are coalesced and consumed when a +request arrives; use a buffered channel so producers do not block. Both +`MaxAge` and `Invalidate` are lazy: they do not start a background goroutine. +A failed rebuild retains the previous catalog but leaves it stale: requests +retry rebuilding until successful, even if the invalidation signal has already +been consumed. They return the scan error instead of silently serving stale +metadata. Resource bytes are always read on demand, including in cached modes. + +To rebuild before the next request, construct a provider directly and call +`Refresh` from the application's watcher goroutine. The caller owns goroutine +lifetime, cancellation, and error handling: + +```go +provider, err := skills.NewDirectoryProvider("./skills", &skills.DirectoryOptions{ + Cache: &skills.DirectoryCacheOptions{Preload: true}, +}) +if err != nil { + return err +} +if err := provider.AddTo(server); err != nil { + return err +} +go func() { + for range changed { + if err := provider.Refresh(ctx); err != nil { + logger.Error("refreshing skills", "error", err) + } + } +}() +``` + +Register one filesystem provider per server. Applications that need to combine +multiple filesystems can use an overlay `fs.FS` or aggregate them behind custom +`AddHandlers` handlers. + +SEP validation is enabled by default, including the 512-resource and 16 MiB +per-skill limits. `skills.ServerOptions` supports additional validators and +explicit unsafe overrides. + ### Pagination Server-side feature lists may be diff --git a/mcp/server.go b/mcp/server.go index 5014a4f3..00ab506e 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -200,6 +200,22 @@ type ServerOptions struct { SupportedProtocolVersions []string } +// AddExtension adds an extension capability to the server. +// +// Extensions should normally be added before the server accepts connections, +// so that clients observe them during capability negotiation. If settings is +// nil, an empty object is advertised. +func (s *Server) AddExtension(name string, settings map[string]any) { + s.mu.Lock() + defer s.mu.Unlock() + if s.opts.Capabilities == nil { + s.opts.Capabilities = &ServerCapabilities{Logging: &LoggingCapabilities{}} + } else { + s.opts.Capabilities = s.opts.Capabilities.clone() + } + s.opts.Capabilities.AddExtension(name, maps.Clone(settings)) +} + // NewServer creates a new MCP server. The resulting server has no features: // add features using the various Server.AddXXX methods, and the [AddTool] function. // diff --git a/mcp/server_test.go b/mcp/server_test.go index 11e75e23..2e77e5b1 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -477,6 +477,31 @@ func TestServerCapabilities(t *testing.T) { } } +func TestServerAddExtension(t *testing.T) { + capabilities := &ServerCapabilities{Tools: &ToolCapabilities{}} + server := NewServer(testImpl, &ServerOptions{Capabilities: capabilities}) + settings := map[string]any{"enabled": true} + server.AddExtension("io.example/test", settings) + settings["enabled"] = false + + got := server.capabilities().Extensions["io.example/test"] + want := map[string]any{"enabled": true} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("extension settings mismatch (-want +got):\n%s", diff) + } + if capabilities.Extensions != nil { + t.Fatal("AddExtension mutated the caller's capabilities") + } +} + +func TestServerAddExtensionPreservesDefaultCapabilities(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddExtension("io.example/test", nil) + if server.capabilities().Logging == nil { + t.Fatal("AddExtension removed the default logging capability") + } +} + func TestServerAddResourceTemplate(t *testing.T) { tests := []struct { name string diff --git a/skills/client.go b/skills/client.go new file mode 100644 index 00000000..a8f18097 --- /dev/null +++ b/skills/client.go @@ -0,0 +1,183 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + "iter" + "maps" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// AddClient registers the Skills extension methods that client may send. +func AddClient(client *mcp.Client) error { + if client == nil { + return fmt.Errorf("skills: nil client") + } + if err := mcp.AddSendingCustomMethod[*ListSkillsParams, *ListSkillsResult](client, MethodList); err != nil { + return err + } + if err := mcp.AddSendingCustomMethod[*GetSkillParams, *GetSkillResult](client, MethodGet); err != nil { + return err + } + return mcp.AddSendingCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](client, MethodReadDirectory) +} + +// List calls skills/list and validates the response. +func List(ctx context.Context, session *mcp.ClientSession, params *ListSkillsParams) (*ListSkillsResult, error) { + if err := requireCapability(session, false); err != nil { + return nil, err + } + if params == nil { + params = &ListSkillsParams{} + } + result, err := mcp.CallCustomMethod[*ListSkillsParams, *ListSkillsResult](ctx, session, MethodList, params) + if err != nil { + return nil, err + } + if err := validateListResponse(ctx, result); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid skills/list result: %w", err) + } + return result, nil +} + +// Get calls skills/get and validates the response. +func Get(ctx context.Context, session *mcp.ClientSession, params *GetSkillParams) (*GetSkillResult, error) { + if err := requireCapability(session, false); err != nil { + return nil, err + } + if params == nil || params.URI == "" { + return nil, fmt.Errorf("skills: get requires a URI") + } + result, err := mcp.CallCustomMethod[*GetSkillParams, *GetSkillResult](ctx, session, MethodGet, params) + if err != nil { + return nil, err + } + if result == nil || result.Skill == nil { + return nil, fmt.Errorf("skills: server returned a nil skill") + } + if result.Skill.URI != params.URI { + return nil, fmt.Errorf("skills: server returned URI %q for %q", result.Skill.URI, params.URI) + } + if err := ValidateSkill(result.Skill); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid skill: %w", err) + } + return result, nil +} + +// ReadDirectory calls resources/directory/read and validates the response. +func ReadDirectory(ctx context.Context, session *mcp.ClientSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if err := requireCapability(session, true); err != nil { + return nil, err + } + if params == nil || params.URI == "" { + return nil, fmt.Errorf("skills: directory read requires a URI") + } + result, err := mcp.CallCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](ctx, session, MethodReadDirectory, params) + if err != nil { + return nil, err + } + if err := ValidateDirectoryResult(params.URI, result); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid directory result: %w", err) + } + return result, nil +} + +// All returns an iterator that follows every page of skills/list. +func All(ctx context.Context, session *mcp.ClientSession, params *ListSkillsParams) iter.Seq2[*Skill, error] { + var initial ListSkillsParams + if params != nil { + initial = *params + initial.Meta = maps.Clone(params.Meta) + } + return func(yield func(*Skill, error) bool) { + request := initial + allPages(initial.Cursor, func(cursor string) ([]*Skill, string, error) { + request.Cursor = cursor + result, err := List(ctx, session, &request) + if err != nil { + return nil, "", err + } + return result.Skills, result.NextCursor, nil + })(yield) + } +} + +// DirectoryEntries returns an iterator that follows every page of a directory read. +func DirectoryEntries(ctx context.Context, session *mcp.ClientSession, params *ReadDirectoryParams) iter.Seq2[*mcp.Resource, error] { + var initial ReadDirectoryParams + if params != nil { + initial = *params + initial.Meta = maps.Clone(params.Meta) + } + return func(yield func(*mcp.Resource, error) bool) { + request := initial + allPages(initial.Cursor, func(cursor string) ([]*mcp.Resource, string, error) { + request.Cursor = cursor + result, err := ReadDirectory(ctx, session, &request) + if err != nil { + return nil, "", err + } + return result.Resources, result.NextCursor, nil + })(yield) + } +} + +func allPages[T any](initialCursor string, fetch func(string) ([]T, string, error)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + cursor := initialCursor + seen := map[string]bool{} + if cursor != "" { + seen[cursor] = true + } + for { + items, next, err := fetch(cursor) + if err != nil { + var zero T + yield(zero, err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + if next == "" { + return + } + if seen[next] { + var zero T + yield(zero, fmt.Errorf("skills: server repeated pagination cursor %q", next)) + return + } + seen[next] = true + cursor = next + } + } +} + +func requireCapability(session *mcp.ClientSession, directoryRead bool) error { + if session == nil || session.InitializeResult() == nil || session.InitializeResult().Capabilities == nil { + return fmt.Errorf("skills: session has no server capabilities") + } + settings, ok := session.InitializeResult().Capabilities.Extensions[ExtensionID] + if !ok { + return fmt.Errorf("skills: server does not advertise %s", ExtensionID) + } + if !directoryRead { + return nil + } + m, ok := settings.(map[string]any) + if !ok { + return fmt.Errorf("skills: server advertised invalid extension settings") + } + enabled, _ := m["directoryRead"].(bool) + if !enabled { + return fmt.Errorf("skills: server does not advertise directoryRead") + } + return nil +} diff --git a/skills/directory.go b/skills/directory.go new file mode 100644 index 00000000..0d672572 --- /dev/null +++ b/skills/directory.go @@ -0,0 +1,623 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "crypto/sha256" + "fmt" + "io/fs" + "mime" + "net/url" + "os" + "path" + "path/filepath" + "slices" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DirectoryCacheOptions configures catalog caching. A zero value builds the +// catalog on the first request and caches it indefinitely. +type DirectoryCacheOptions struct { + // Preload builds the catalog during provider construction, so scan and + // validation errors are returned by the constructor. + Preload bool + // MaxAge makes the catalog expire after this duration. The next request + // rebuilds an expired catalog. A zero value disables expiry. + MaxAge time.Duration + // Invalidate marks the catalog stale. Signals are coalesced and consumed + // when a request arrives, so callers should use a buffered channel. + Invalidate <-chan struct{} +} + +// DirectoryOptions configures a filesystem-backed provider. +type DirectoryOptions struct { + URIPathPrefix string + PageSize int + // Cache enables catalog caching. A nil value rebuilds the catalog for every + // request. + Cache *DirectoryCacheOptions + ServerOptions *ServerOptions + CatalogValidators []func(context.Context, []*Skill) error +} + +// DirectoryProvider discovers and serves skills from a filesystem. +type DirectoryProvider struct { + fsys fs.FS + osPath string + rootName string + prefix []string + pageSize int + serverOptions *ServerOptions + catalogValidators []func(context.Context, []*Skill) error + cacheEnabled bool + preload bool + maxAge time.Duration + invalidate <-chan struct{} + cacheMu sync.Mutex + cached *directoryCatalog + refreshedAt time.Time + stale bool +} + +type catalogMode int + +const ( + catalogPaths catalogMode = iota + catalogMetadata + catalogManifests +) + +type directoryCatalog struct { + skills []*Skill + bySkill map[string]*Skill + files map[string]catalogFile + dirs map[string][]*mcp.Resource + seen map[[2]string]bool + resources []*mcp.Resource +} + +type catalogFile struct { + path string + mimeType string + resource *mcp.Resource +} + +type catalogEntry struct { + path string + info fs.FileInfo +} + +type catalogDigest struct { + digest string + size int64 +} + +type rootedFS struct{ root *os.Root } + +func (f rootedFS) Open(name string) (fs.File, error) { return f.root.Open(name) } + +// NewDirectoryProvider constructs a live provider rooted at dir. +func NewDirectoryProvider(dir string, options *DirectoryOptions) (*DirectoryProvider, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + info, err := os.Stat(abs) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("skills: %q is not a directory", dir) + } + provider, err := newDirectoryProvider(nil, options) + if err != nil { + return nil, err + } + provider.osPath = abs + provider.rootName = filepath.Base(abs) + return provider.initialize() +} + +// NewFSProvider constructs a filesystem-backed provider using fsys. +func NewFSProvider(fsys fs.FS, options *DirectoryOptions) (*DirectoryProvider, error) { + if fsys == nil { + return nil, fmt.Errorf("skills: nil filesystem") + } + provider, err := newDirectoryProvider(fsys, options) + if err != nil { + return nil, err + } + return provider.initialize() +} + +func newDirectoryProvider(fsys fs.FS, options *DirectoryOptions) (*DirectoryProvider, error) { + p := &DirectoryProvider{fsys: fsys, pageSize: mcp.DefaultPageSize} + if options != nil { + if options.PageSize < 0 { + return nil, fmt.Errorf("skills: invalid page size %d", options.PageSize) + } + if options.PageSize > 0 { + p.pageSize = options.PageSize + } + if options.Cache != nil { + if options.Cache.MaxAge < 0 { + return nil, fmt.Errorf("skills: invalid cache max age %s", options.Cache.MaxAge) + } + p.cacheEnabled = true + p.preload = options.Cache.Preload + p.maxAge = options.Cache.MaxAge + p.invalidate = options.Cache.Invalidate + } + p.serverOptions = cloneServerOptions(options.ServerOptions) + p.catalogValidators = slices.Clone(options.CatalogValidators) + if options.URIPathPrefix != "" { + p.prefix = strings.Split(strings.Trim(options.URIPathPrefix, "/"), "/") + for _, segment := range p.prefix { + if segment == "" || segment == "." || segment == ".." { + return nil, fmt.Errorf("skills: invalid URI path prefix %q", options.URIPathPrefix) + } + } + } + } + return p, nil +} + +func (p *DirectoryProvider) initialize() (*DirectoryProvider, error) { + if p.preload { + if _, err := p.catalog(context.Background(), catalogManifests); err != nil { + return nil, err + } + } + return p, nil +} + +// AddDirectory discovers and serves skills rooted at dir. +func AddDirectory(server *mcp.Server, dir string, options *DirectoryOptions) error { + provider, err := NewDirectoryProvider(dir, options) + if err != nil { + return err + } + return provider.AddTo(server) +} + +// AddFS discovers and serves skills from fsys. +func AddFS(server *mcp.Server, fsys fs.FS, options *DirectoryOptions) error { + provider, err := NewFSProvider(fsys, options) + if err != nil { + return err + } + return provider.AddTo(server) +} + +// AddTo registers the provider's extension handlers, resource template, and live +// resource listing. Register one filesystem provider per server. +func (p *DirectoryProvider) AddTo(server *mcp.Server) error { + if err := AddHandlers(server, &Handlers{ + List: p.ListSkills, + Get: p.GetSkill, + ReadDirectory: p.ReadDirectory, + }, p.serverOptions); err != nil { + return err + } + server.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "skills", + Description: "Resources served by the MCP Skills extension.", + URITemplate: "skill://{authority}/{+path}", + }, p.ReadResource) + server.AddReceivingMiddleware(p.listResourcesMiddleware) + return nil +} + +// Refresh rebuilds a cached catalog immediately. +func (p *DirectoryProvider) Refresh(ctx context.Context) error { + if !p.cacheEnabled { + return fmt.Errorf("skills: explicit refresh requires a cached catalog mode") + } + p.cacheMu.Lock() + defer p.cacheMu.Unlock() + return p.refreshLocked(ctx) +} + +// ListSkills returns a current, paginated view of the skills in the filesystem. +func (p *DirectoryProvider) ListSkills(ctx context.Context, _ *mcp.ServerSession, params *ListSkillsParams) (*ListSkillsResult, error) { + catalog, err := p.catalog(ctx, catalogManifests) + if err != nil { + return nil, err + } + cursor := "" + if params != nil { + cursor = params.Cursor + } + page, next, err := paginate(catalog.skills, cursor, p.pageSize, func(skill *Skill) string { return skill.URI }) + if err != nil { + return nil, invalidParams(err.Error()) + } + return &ListSkillsResult{Skills: page, NextCursor: next}, nil +} + +// GetSkill returns the current entry for one skill URI. +func (p *DirectoryProvider) GetSkill(ctx context.Context, _ *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + catalog, err := p.catalog(ctx, catalogManifests) + if err != nil { + return nil, err + } + skill, ok := catalog.bySkill[params.URI] + if !ok { + return nil, invalidParams(fmt.Sprintf("unknown skill %q", params.URI)) + } + return &GetSkillResult{Skill: skill}, nil +} + +// ReadResource resolves and reads a current skill resource. +func (p *DirectoryProvider) ReadResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + if req == nil || req.Params == nil { + return nil, invalidParams("missing required uri") + } + catalog, err := p.catalog(ctx, catalogPaths) + if err != nil { + return nil, err + } + file, ok := catalog.files[req.Params.URI] + if !ok { + return nil, mcp.ResourceNotFoundError(req.Params.URI) + } + data, err := p.readFile(file.path) + if err != nil { + if os.IsNotExist(err) { + return nil, mcp.ResourceNotFoundError(req.Params.URI) + } + return nil, err + } + contents := &mcp.ResourceContents{URI: req.Params.URI, MIMEType: file.mimeType} + if textualMIME(file.mimeType) && utf8.Valid(data) { + contents.Text = string(data) + } else { + contents.Blob = data + } + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{contents}}, nil +} + +// ReadDirectory returns a current, paginated view of a directory's direct children. +func (p *DirectoryProvider) ReadDirectory(ctx context.Context, _ *mcp.ServerSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + catalog, err := p.catalog(ctx, catalogMetadata) + if err != nil { + return nil, err + } + children, ok := catalog.dirs[params.URI] + if !ok { + return nil, invalidParams(fmt.Sprintf("unknown directory %q", params.URI)) + } + page, next, err := paginate(children, params.Cursor, p.pageSize, func(resource *mcp.Resource) string { return resource.URI }) + if err != nil { + return nil, invalidParams(err.Error()) + } + return &ReadDirectoryResult{Resources: page, NextCursor: next}, nil +} + +func (p *DirectoryProvider) catalog(ctx context.Context, mode catalogMode) (*directoryCatalog, error) { + if !p.cacheEnabled { + return p.scanCatalog(ctx, mode) + } + p.cacheMu.Lock() + defer p.cacheMu.Unlock() + invalidated := p.invalidated() + p.stale = p.stale || invalidated + if p.cached != nil && !p.stale && (p.maxAge == 0 || time.Since(p.refreshedAt) < p.maxAge) { + return p.cached, nil + } + if err := p.refreshLocked(ctx); err != nil { + return nil, err + } + return p.cached, nil +} + +func (p *DirectoryProvider) refreshLocked(ctx context.Context) error { + p.stale = true + catalog, err := p.scanCatalog(ctx, catalogManifests) + if err != nil { + return err + } + p.cached = catalog + p.refreshedAt = time.Now() + p.stale = false + return nil +} + +func (p *DirectoryProvider) invalidated() bool { + requested := false + for p.invalidate != nil { + select { + case _, ok := <-p.invalidate: + if !ok { + p.invalidate = nil + return requested + } + requested = true + default: + return requested + } + } + return requested +} + +func (p *DirectoryProvider) scanCatalog(ctx context.Context, mode catalogMode) (*directoryCatalog, error) { + manifests := mode == catalogManifests + fsys, closeFS, err := p.openFS() + if err != nil { + return nil, err + } + defer closeFS() + + var entries []catalogEntry + var skillDirs []string + err = fs.WalkDir(fsys, ".", func(name string, dirEntry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if dirEntry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("skills: symlink %q is not supported", name) + } + info, err := dirEntry.Info() + if err != nil { + return err + } + if !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("skills: non-regular file %q is not supported", name) + } + entries = append(entries, catalogEntry{path: name, info: info}) + if !info.IsDir() && path.Base(name) == "SKILL.md" { + skillDirs = append(skillDirs, path.Dir(name)) + } + return nil + }) + if err != nil { + return nil, err + } + slices.Sort(skillDirs) + catalog := &directoryCatalog{ + bySkill: make(map[string]*Skill), + files: make(map[string]catalogFile), + dirs: make(map[string][]*mcp.Resource), + seen: make(map[[2]string]bool), + } + digests := make(map[string]catalogDigest) + frontmatters := make(map[string]Frontmatter) + for _, skillDir := range skillDirs { + if err := ctx.Err(); err != nil { + return nil, err + } + var frontmatter Frontmatter + if mode >= catalogMetadata || skillDir == "." && p.rootName == "" { + data, err := fs.ReadFile(fsys, path.Join(skillDir, "SKILL.md")) + if err != nil { + return nil, err + } + frontmatter, err = parseFrontmatter(data) + if err != nil { + return nil, fmt.Errorf("skills: parsing %s/SKILL.md: %w", skillDir, err) + } + frontmatters[path.Join(skillDir, "SKILL.md")] = frontmatter + } + segments := slices.Clone(p.prefix) + if skillDir == "." { + physicalName := p.rootName + if physicalName == "" { + physicalName, _ = frontmatter["name"].(string) + } + segments = append(segments, physicalName) + } else { + segments = append(segments, strings.Split(skillDir, "/")...) + } + rootURI, err := skillURI(segments) + if err != nil { + return nil, err + } + var resources []*Resource + defaultValidation, limits := validationSettings(p.serverOptions) + var totalSize int64 + for _, item := range entries { + if err := ctx.Err(); err != nil { + return nil, err + } + if item.info.IsDir() || !withinDir(skillDir, item.path) { + continue + } + if manifests && defaultValidation { + if limits.MaxResourcesPerSkill > 0 && len(resources) == limits.MaxResourcesPerSkill { + return nil, fmt.Errorf("skill %q exceeds the resource limit of %d", rootURI, limits.MaxResourcesPerSkill) + } + if limits.MaxTotalSize > 0 && item.info.Size() > 0 && totalSize > limits.MaxTotalSize-item.info.Size() { + return nil, fmt.Errorf("skill %q exceeds the total size limit of %d", rootURI, limits.MaxTotalSize) + } + } + rel := item.path + if skillDir != "." { + rel = strings.TrimPrefix(item.path, skillDir+"/") + } + resourceURI := rootURI + "/" + escapePath(rel) + mimeType := resourceMIME(item.path, rel) + file := catalogFile{path: item.path, mimeType: mimeType} + if mode >= catalogMetadata { + file.resource = &mcp.Resource{ + URI: resourceURI, Name: path.Base(item.path), MIMEType: mimeType, Size: item.info.Size(), + } + } + catalog.files[resourceURI] = file + if !manifests { + continue + } + totalSize += item.info.Size() + digest, ok := digests[item.path] + if !ok { + data, err := fs.ReadFile(fsys, item.path) + if err != nil { + return nil, err + } + sum := sha256.Sum256(data) + digest = catalogDigest{digest: fmt.Sprintf("sha256:%x", sum), size: int64(len(data))} + digests[item.path] = digest + } + resources = append(resources, &Resource{ + URI: resourceURI, + Digest: digest.digest, + Size: digest.size, + }) + } + p.addDirectories(catalog, skillDir, rootURI, entries) + if manifests { + slices.SortFunc(resources, func(a, b *Resource) int { return strings.Compare(a.URI, b.URI) }) + skill := &Skill{URI: rootURI + "/SKILL.md", Frontmatter: frontmatter, Resources: StaticResources(resources...)} + if err := validateSkillResult(ctx, skill, p.serverOptions); err != nil { + return nil, err + } + if _, exists := catalog.bySkill[skill.URI]; exists { + return nil, fmt.Errorf("skills: duplicate skill URI %q", skill.URI) + } + catalog.skills = append(catalog.skills, skill) + catalog.bySkill[skill.URI] = skill + } + } + for _, file := range catalog.files { + if file.resource == nil { + continue + } + if frontmatter, ok := frontmatters[file.path]; ok { + file.resource.Name, _ = frontmatter["name"].(string) + file.resource.Description, _ = frontmatter["description"].(string) + } + catalog.resources = append(catalog.resources, file.resource) + } + slices.SortFunc(catalog.resources, func(a, b *mcp.Resource) int { return strings.Compare(a.URI, b.URI) }) + slices.SortFunc(catalog.skills, func(a, b *Skill) int { return strings.Compare(a.URI, b.URI) }) + for uri := range catalog.dirs { + for i, resource := range catalog.dirs[uri] { + if file, ok := catalog.files[resource.URI]; ok && file.resource != nil { + catalog.dirs[uri][i] = file.resource + } + } + slices.SortFunc(catalog.dirs[uri], func(a, b *mcp.Resource) int { return strings.Compare(a.URI, b.URI) }) + } + if manifests { + if err := runValidators(ctx, p.catalogValidators, catalog.skills); err != nil { + return nil, err + } + } + return catalog, nil +} + +func (p *DirectoryProvider) addDirectories(catalog *directoryCatalog, skillDir, rootURI string, entries []catalogEntry) { + // Kept as a separate pass so empty directories are represented too. + for _, item := range entries { + if item.path == skillDir || !withinDir(skillDir, item.path) { + continue + } + rel := item.path + if skillDir != "." { + rel = strings.TrimPrefix(item.path, skillDir+"/") + } + parts := strings.Split(rel, "/") + parentURI := rootURI + for i, part := range parts { + childURI := parentURI + "/" + url.PathEscape(part) + isDirectory := i < len(parts)-1 || item.info.IsDir() + key := [2]string{parentURI, childURI} + if !catalog.seen[key] { + mimeType := "" + var size int64 + if isDirectory { + mimeType = "inode/directory" + } else { + mimeType = mime.TypeByExtension(path.Ext(part)) + size = item.info.Size() + if part == "SKILL.md" { + mimeType = "text/markdown" + } + } + catalog.dirs[parentURI] = append(catalog.dirs[parentURI], &mcp.Resource{ + URI: childURI, Name: part, MIMEType: mimeType, Size: size, + }) + catalog.seen[key] = true + } + if isDirectory { + if _, ok := catalog.dirs[childURI]; !ok { + catalog.dirs[childURI] = []*mcp.Resource{} + } + parentURI = childURI + } + } + } + if _, ok := catalog.dirs[rootURI]; !ok { + catalog.dirs[rootURI] = []*mcp.Resource{} + } +} + +func (p *DirectoryProvider) openFS() (fs.FS, func(), error) { + if p.osPath == "" { + return p.fsys, func() {}, nil + } + root, err := os.OpenRoot(p.osPath) + if err != nil { + return nil, nil, err + } + return rootedFS{root}, func() { _ = root.Close() }, nil +} + +func (p *DirectoryProvider) readFile(name string) ([]byte, error) { + fsys, closeFS, err := p.openFS() + if err != nil { + return nil, err + } + defer closeFS() + return fs.ReadFile(fsys, name) +} + +func skillURI(segments []string) (string, error) { + if len(segments) == 0 || segments[0] == "" { + return "", fmt.Errorf("skills: cannot construct a skill URI without a path") + } + u := &url.URL{Scheme: "skill", Host: segments[0]} + if len(segments) > 1 { + u.Path = "/" + strings.Join(segments[1:], "/") + } + return strings.TrimSuffix(u.String(), "/"), nil +} + +func escapePath(name string) string { + parts := strings.Split(name, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} + +func withinDir(dir, name string) bool { + return dir == "." || name == dir || strings.HasPrefix(name, dir+"/") +} + +func textualMIME(mimeType string) bool { + return strings.HasPrefix(mimeType, "text/") || strings.HasPrefix(mimeType, "application/json") || strings.HasPrefix(mimeType, "application/xml") || strings.HasPrefix(mimeType, "application/yaml") +} + +func resourceMIME(filename, relative string) string { + if relative == "SKILL.md" { + return "text/markdown" + } + return mime.TypeByExtension(path.Ext(filename)) +} diff --git a/skills/directory_example_test.go b/skills/directory_example_test.go new file mode 100644 index 00000000..56125cd2 --- /dev/null +++ b/skills/directory_example_test.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills_test + +import ( + "log" + "testing/fstest" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +func ExampleAddFS() { + server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) + skillFS := fstest.MapFS{ + "demo/SKILL.md": { + Data: []byte("---\nname: demo\ndescription: Demonstrates an in-memory skill.\n---\n# Demo\n"), + }, + "demo/references/guide.md": {Data: []byte("# Guide\n")}, + } + if err := skills.AddFS(server, skillFS, &skills.DirectoryOptions{ + Cache: &skills.DirectoryCacheOptions{Preload: true}, + }); err != nil { + log.Fatal(err) + } +} diff --git a/skills/directory_test.go b/skills/directory_test.go new file mode 100644 index 00000000..2d66a341 --- /dev/null +++ b/skills/directory_test.go @@ -0,0 +1,352 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "testing" + "testing/fstest" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestDirectoryProviderLiveAndPaginated(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", map[string]string{ + "references/ONE.md": "one", + "references/TWO.md": "two", + }) + writeSkill(t, dir, "beta", "Beta skill.", nil) + + server := mcp.NewServer(&mcp.Implementation{Name: "skills-test", Version: "v1"}, nil) + if err := AddDirectory(server, dir, &DirectoryOptions{PageSize: 1}); err != nil { + t.Fatal(err) + } + client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1"}, nil) + if err := AddClient(client); err != nil { + t.Fatal(err) + } + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + clientSession, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = clientSession.Close() }) + + params := &ListSkillsParams{} + var uris []string + for skill, err := range All(ctx, clientSession, params) { + if err != nil { + t.Fatal(err) + } + uris = append(uris, skill.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://beta/SKILL.md"}; !slices.Equal(uris, want) { + t.Fatalf("All() = %v, want %v", uris, want) + } + if params.Cursor != "" { + t.Fatalf("All mutated caller cursor to %q", params.Cursor) + } + + writeSkill(t, dir, "gamma", "Gamma skill.", nil) + result, err := Get(ctx, clientSession, &GetSkillParams{URI: "skill://gamma/SKILL.md"}) + if err != nil { + t.Fatal(err) + } + if result.Skill.Frontmatter["name"] != "gamma" { + t.Fatalf("Get() returned %v", result.Skill.Frontmatter) + } + read, err := clientSession.ReadResource(ctx, &mcp.ReadResourceParams{URI: "skill://gamma/SKILL.md"}) + if err != nil { + t.Fatal(err) + } + if len(read.Contents) != 1 || read.Contents[0].Text == "" { + t.Fatalf("ReadResource() = %+v", read) + } + var liveURIs []string + for skill, err := range All(ctx, clientSession, nil) { + if err != nil { + t.Fatal(err) + } + liveURIs = append(liveURIs, skill.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://beta/SKILL.md", "skill://gamma/SKILL.md"}; !slices.Equal(liveURIs, want) { + t.Fatalf("live All() = %v, want %v", liveURIs, want) + } + + directoryParams := &ReadDirectoryParams{URI: "skill://alpha"} + var children []string + for resource, err := range DirectoryEntries(ctx, clientSession, directoryParams) { + if err != nil { + t.Fatal(err) + } + children = append(children, resource.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://alpha/references"}; !slices.Equal(children, want) { + t.Fatalf("DirectoryEntries() = %v, want %v", children, want) + } + if directoryParams.Cursor != "" { + t.Fatalf("DirectoryEntries mutated caller cursor to %q", directoryParams.Cursor) + } + + if _, err := List(ctx, clientSession, &ListSkillsParams{Cursor: "%%%"}); err == nil { + t.Fatal("List accepted an invalid cursor") + } + if _, err := ReadDirectory(ctx, clientSession, &ReadDirectoryParams{URI: "skill://alpha/SKILL.md"}); err == nil { + t.Fatal("ReadDirectory accepted a file URI") + } + if err := os.RemoveAll(filepath.Join(dir, "beta")); err != nil { + t.Fatal(err) + } + liveURIs = nil + for skill, err := range All(ctx, clientSession, nil) { + if err != nil { + t.Fatal(err) + } + liveURIs = append(liveURIs, skill.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://gamma/SKILL.md"}; !slices.Equal(liveURIs, want) { + t.Fatalf("All() after removal = %v, want %v", liveURIs, want) + } +} + +func TestCatalogValidatorSeesAllSkills(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + writeSkill(t, dir, "beta", "Beta skill.", nil) + provider, err := NewDirectoryProvider(dir, &DirectoryOptions{ + PageSize: 1, + CatalogValidators: []func(context.Context, []*Skill) error{ + func(_ context.Context, skills []*Skill) error { + if len(skills) > 1 { + return fmt.Errorf("too many skills") + } + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}); err == nil { + t.Fatal("catalog validator did not see skills beyond the first page") + } +} + +func TestFSProviderRootSkill(t *testing.T) { + provider, err := NewFSProvider(fstest.MapFS{ + "SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n")}, + "guide.md": {Data: []byte("guide")}, + }, nil) + if err != nil { + t.Fatal(err) + } + result, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + if len(result.Skills) != 1 || result.Skills[0].URI != "skill://demo/SKILL.md" { + t.Fatalf("ListSkills() = %+v", result.Skills) + } + directory, err := provider.ReadDirectory(context.Background(), nil, &ReadDirectoryParams{URI: "skill://demo"}) + if err != nil { + t.Fatal(err) + } + if got := len(directory.Resources); got != 2 { + t.Fatalf("ReadDirectory() returned %d resources, want 2", got) + } +} + +func TestDirectoryProviderCatalogModes(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + + invalidate := make(chan struct{}, 1) + startup, err := NewDirectoryProvider(dir, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{Preload: true, Invalidate: invalidate}, + }) + if err != nil { + t.Fatal(err) + } + lazy, err := NewDirectoryProvider(dir, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{MaxAge: time.Hour}, + }) + if err != nil { + t.Fatal(err) + } + writeSkill(t, dir, "beta", "Beta skill.", nil) + + list := func(provider *DirectoryProvider) *ListSkillsResult { + t.Helper() + result, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + return result + } + if got := len(list(startup).Skills); got != 1 { + t.Fatalf("startup snapshot contains %d skills, want 1", got) + } + if got := len(list(lazy).Skills); got != 2 { + t.Fatalf("lazy catalog contains %d skills, want 2", got) + } + writeSkill(t, dir, "gamma", "Gamma skill.", nil) + invalidate <- struct{}{} + if got := len(list(startup).Skills); got != 3 { + t.Fatalf("invalidated startup catalog contains %d skills, want 3", got) + } + lazy.cacheMu.Lock() + lazy.refreshedAt = time.Time{} + lazy.cacheMu.Unlock() + if got := len(list(lazy).Skills); got != 3 { + t.Fatalf("refreshed lazy catalog contains %d skills, want 3", got) + } +} + +func TestDirectoryProviderExplicitRefresh(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + provider, err := NewDirectoryProvider(dir, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{Preload: true}, + }) + if err != nil { + t.Fatal(err) + } + writeSkill(t, dir, "beta", "Beta skill.", nil) + if err := provider.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + result, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + if got := len(result.Skills); got != 2 { + t.Fatalf("refreshed catalog contains %d skills, want 2", got) + } +} + +func TestDirectoryProviderRejectsNegativeCacheMaxAge(t *testing.T) { + fsys := fstest.MapFS{ + "SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n")}, + } + options := &DirectoryOptions{Cache: &DirectoryCacheOptions{MaxAge: -time.Second}} + if _, err := NewFSProvider(fsys, options); err == nil { + t.Fatalf("NewFSProvider accepted options %+v", options) + } +} + +func TestDirectoryProviderRejectsSymlinks(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + if err := os.Symlink(filepath.Join(dir, "alpha", "SKILL.md"), filepath.Join(dir, "alpha", "linked.md")); err != nil { + t.Skipf("creating symlink: %v", err) + } + provider, err := NewDirectoryProvider(dir, nil) + if err != nil { + t.Fatal(err) + } + if _, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}); err == nil { + t.Fatal("provider accepted a symlink") + } +} + +func TestDirectoryProviderNestedSkillsAndEmptyDirectories(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "parent", "Parent skill.", map[string]string{ + "child/SKILL.md": "---\nname: child\ndescription: Child skill.\n---\n# Child\n", + "child/info.txt": "child info", + }) + if err := os.Mkdir(filepath.Join(dir, "parent", "empty"), 0o755); err != nil { + t.Fatal(err) + } + provider, err := NewDirectoryProvider(dir, nil) + if err != nil { + t.Fatal(err) + } + list, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + if len(list.Skills) != 2 { + t.Fatalf("got %d skills, want 2", len(list.Skills)) + } + parent := list.Skills[0] + if parent.URI != "skill://parent/SKILL.md" { + t.Fatalf("first skill is %q", parent.URI) + } + resources, static := parent.Resources.List() + if !static || len(resources) != 3 { + t.Fatalf("parent resources = %v, static %v", resources, static) + } + empty, err := provider.ReadDirectory(context.Background(), nil, &ReadDirectoryParams{URI: "skill://parent/empty"}) + if err != nil { + t.Fatal(err) + } + if empty.Resources == nil || len(empty.Resources) != 0 { + t.Fatalf("empty directory result = %+v", empty.Resources) + } +} + +func TestDirectoryProviderHashesNestedFilesOnce(t *testing.T) { + fsys := &countingFS{ + FS: fstest.MapFS{ + "parent/SKILL.md": {Data: []byte("---\nname: parent\ndescription: Parent skill.\n---\n")}, + "parent/child/SKILL.md": {Data: []byte("---\nname: child\ndescription: Child skill.\n---\n")}, + "parent/child/info.txt": {Data: []byte("child info")}, + }, + opens: make(map[string]int), + } + if _, err := NewFSProvider(fsys, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{Preload: true}, + }); err != nil { + t.Fatal(err) + } + if got := fsys.opens["parent/child/info.txt"]; got != 1 { + t.Fatalf("nested file was opened %d times, want 1", got) + } +} + +func writeSkill(t *testing.T, root, name, description string, files map[string]string) { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n# %s\n", name, description, name) + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + for filename, content := range files { + full := filepath.Join(dir, filepath.FromSlash(filename)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +type countingFS struct { + fs.FS + opens map[string]int +} + +func (f *countingFS) Open(name string) (fs.File, error) { + f.opens[name]++ + return f.FS.Open(name) +} diff --git a/skills/example_test.go b/skills/example_test.go new file mode 100644 index 00000000..3cf266ce --- /dev/null +++ b/skills/example_test.go @@ -0,0 +1,39 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills_test + +import ( + "context" + "log" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +func ExampleAddHandlers() { + server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) + entry := &skills.Skill{ + URI: "skill://generated/SKILL.md", + Frontmatter: skills.Frontmatter{ + "name": "generated", "description": "Instructions generated on demand.", + }, + Resources: skills.DynamicResources(), + } + err := skills.AddHandlers(server, &skills.Handlers{ + List: func(context.Context, *mcp.ServerSession, *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + return &skills.ListSkillsResult{Skills: []*skills.Skill{entry}}, nil + }, + Get: func(_ context.Context, _ *mcp.ServerSession, params *skills.GetSkillParams) (*skills.GetSkillResult, error) { + if params.URI != entry.URI { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "unknown skill"} + } + return &skills.GetSkillResult{Skill: entry}, nil + }, + }, nil) + if err != nil { + log.Fatal(err) + } +} diff --git a/skills/pagination.go b/skills/pagination.go new file mode 100644 index 00000000..20d64e65 --- /dev/null +++ b/skills/pagination.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "encoding/base64" + "fmt" + "slices" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) ([]T, string, error) { + start := 0 + if cursor != "" { + decoded, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil || len(decoded) == 0 { + return nil, "", fmt.Errorf("invalid cursor") + } + last := string(decoded) + start = len(items) + for i, item := range items { + if key(item) > last { + start = i + break + } + } + } + end := start + min(pageSize, len(items)-start) + page := slices.Clone(items[start:end]) + if page == nil { + page = []T{} + } + if end == len(items) { + return page, "", nil + } + next := base64.RawURLEncoding.EncodeToString([]byte(key(items[end-1]))) + return page, next, nil +} + +// PaginateSkills returns one URI-ordered page and an opaque cursor for the next page. +// It does not modify skills. +func PaginateSkills(skills []*Skill, cursor string, pageSize int) ([]*Skill, string, error) { + if pageSize < 0 { + return nil, "", fmt.Errorf("skills: invalid page size %d", pageSize) + } + if pageSize == 0 { + pageSize = mcp.DefaultPageSize + } + ordered := slices.Clone(skills) + slices.SortFunc(ordered, func(a, b *Skill) int { return strings.Compare(a.URI, b.URI) }) + return paginate(ordered, cursor, pageSize, func(skill *Skill) string { return skill.URI }) +} + +// PaginateDirectoryResources returns one URI-ordered directory page and an +// opaque cursor for the next page. It does not modify resources. +func PaginateDirectoryResources(resources []*mcp.Resource, cursor string, pageSize int) ([]*mcp.Resource, string, error) { + if pageSize < 0 { + return nil, "", fmt.Errorf("skills: invalid page size %d", pageSize) + } + if pageSize == 0 { + pageSize = mcp.DefaultPageSize + } + ordered := slices.Clone(resources) + slices.SortFunc(ordered, func(a, b *mcp.Resource) int { return strings.Compare(a.URI, b.URI) }) + return paginate(ordered, cursor, pageSize, func(resource *mcp.Resource) string { return resource.URI }) +} diff --git a/skills/resources.go b/skills/resources.go new file mode 100644 index 00000000..8a9c7603 --- /dev/null +++ b/skills/resources.go @@ -0,0 +1,107 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + "maps" + "slices" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ListResources returns a current, paginated listing of skill files. It reads +// frontmatter for resource metadata without hashing supporting files. Directory +// entries are available separately through ReadDirectory. +func (p *DirectoryProvider) ListResources(ctx context.Context, req *mcp.ListResourcesRequest) (*mcp.ListResourcesResult, error) { + catalog, err := p.catalog(ctx, catalogMetadata) + if err != nil { + return nil, err + } + cursor := "" + if req != nil && req.Params != nil { + cursor = req.Params.Cursor + } + page, next, err := paginate(catalog.resources, cursor, p.pageSize, func(r *mcp.Resource) string { return r.URI }) + if err != nil { + return nil, invalidParams(err.Error()) + } + return &mcp.ListResourcesResult{ + Resources: page, NextCursor: next, + Cacheable: mcp.Cacheable{CacheScope: "private"}, + }, nil +} + +func (p *DirectoryProvider) listResourcesMiddleware(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method != "resources/list" { + return next(ctx, method, req) + } + byURI := make(map[string]*mcp.Resource) + + // Merge the underlying listing before paginating: its opaque cursors and + // page boundaries need not match this provider's live URI-ordered catalog. + request := *req.(*mcp.ListResourcesRequest) + params := mcp.ListResourcesParams{} + if request.Params != nil { + params = *request.Params + params.Meta = maps.Clone(params.Meta) + } + cursor := params.Cursor + params.Cursor = "" + request.Params = ¶ms + seen := make(map[string]bool) + var result mcp.ListResourcesResult + for { + if err := ctx.Err(); err != nil { + return nil, err + } + response, err := next(ctx, method, &request) + if err != nil { + return nil, err + } + listed, ok := response.(*mcp.ListResourcesResult) + if !ok || listed == nil { + return nil, fmt.Errorf("skills: resources/list returned an invalid result") + } + if params.Cursor == "" { + result = *listed + } + for _, resource := range listed.Resources { + if resource == nil { + return nil, fmt.Errorf("skills: resources/list returned a nil resource") + } + // Exact registrations also take precedence over templates on reads. + byURI[resource.URI] = resource + } + if listed.NextCursor == "" { + break + } + if seen[listed.NextCursor] { + return nil, fmt.Errorf("skills: resources/list repeated a cursor") + } + seen[listed.NextCursor] = true + params.Cursor = listed.NextCursor + } + catalog, err := p.catalog(ctx, catalogMetadata) + if err != nil { + return nil, err + } + for _, resource := range catalog.resources { + if _, exists := byURI[resource.URI]; !exists { + byURI[resource.URI] = resource + } + } + result.Resources, result.NextCursor, err = PaginateDirectoryResources(slices.Collect(maps.Values(byURI)), cursor, p.pageSize) + if err != nil { + return nil, invalidParams(err.Error()) + } + // Filesystem changes are discovered on request, without notifications. + // Do not inherit a TTL intended only for the registered resources. + result.Cacheable = mcp.Cacheable{CacheScope: "private"} + return &result, nil + } +} diff --git a/skills/resources_test.go b/skills/resources_test.go new file mode 100644 index 00000000..763c8c25 --- /dev/null +++ b/skills/resources_test.go @@ -0,0 +1,240 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + "testing/fstest" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func connectResourceClient(t *testing.T, server *mcp.Server) *mcp.ClientSession { + t.Helper() + client := mcp.NewClient(&mcp.Implementation{Name: "resources-test", Version: "v1"}, nil) + if err := AddClient(client); err != nil { + t.Fatal(err) + } + ct, st := mcp.NewInMemoryTransports() + ss, err := server.Connect(t.Context(), st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := client.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +func TestResourceListingLiveAndMerged(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", map[string]string{"reference notes.md": "notes"}) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v1"}, &mcp.ServerOptions{PageSize: 1}) + readStatic := func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: req.Params.URI, Text: "registered"}}}, nil + } + server.AddResource(&mcp.Resource{URI: "config://one", Name: "one"}, readStatic) + server.AddResource(&mcp.Resource{URI: "config://two", Name: "two"}, readStatic) + if err := AddDirectory(server, dir, &DirectoryOptions{PageSize: 1}); err != nil { + t.Fatal(err) + } + cs := connectResourceClient(t, server) + list := func() []*mcp.Resource { + t.Helper() + params := &mcp.ListResourcesParams{} + var resources []*mcp.Resource + for range 20 { + page, err := cs.ListResources(t.Context(), params) + if err != nil { + t.Fatal(err) + } + if len(page.Resources) > 1 { + t.Fatalf("page has %d resources, want at most 1", len(page.Resources)) + } + resources = append(resources, page.Resources...) + if page.NextCursor == "" { + return resources + } + params.Cursor = page.NextCursor + } + t.Fatal("resources/list did not terminate") + return nil + } + check := func(want []string, description string) { + t.Helper() + var uris []string + for _, resource := range list() { + uris = append(uris, resource.URI) + if resource.URI == "skill://alpha/SKILL.md" { + if resource.Name != "alpha" || resource.Description != description || resource.MIMEType != "text/markdown" { + t.Fatalf("incorrect SKILL.md metadata: %+v", resource) + } + } + } + if !slices.Equal(uris, want) { + t.Fatalf("resources/list = %v, want %v", uris, want) + } + } + check([]string{"config://one", "config://two", "skill://alpha/SKILL.md", "skill://alpha/reference%20notes.md"}, "Alpha skill.") + writeSkill(t, dir, "beta", "Beta skill.", nil) + writeSkill(t, dir, "alpha", "Updated description.", nil) + if err := os.Remove(filepath.Join(dir, "alpha", "reference notes.md")); err != nil { + t.Fatal(err) + } + server.RemoveResources("config://one") + check([]string{"config://two", "skill://alpha/SKILL.md", "skill://beta/SKILL.md"}, "Updated description.") + + // Exact registrations win for both listing and reading, even when added later. + server.AddResource(&mcp.Resource{URI: "skill://beta/SKILL.md", Name: "override"}, readStatic) + resources := list() + if len(resources) != 3 || resources[2].Name != "override" { + t.Fatalf("duplicate URI not resolved to exact registration: %+v", resources) + } + read, err := cs.ReadResource(t.Context(), &mcp.ReadResourceParams{URI: "skill://beta/SKILL.md"}) + if err != nil || read.Contents[0].Text != "registered" { + t.Fatalf("ReadResource = %v, %v", read, err) + } + if _, err := cs.ListResources(t.Context(), &mcp.ListResourcesParams{Cursor: "%%%"}); err == nil { + t.Fatal("accepted invalid cursor") + } +} + +func TestResourceAndDirectoryMetadataWithoutHashing(t *testing.T) { + files := fstest.MapFS{ + "parent/SKILL.md": {Data: []byte("---\nname: parent\ndescription: Parent.\n---\n")}, + "parent/child/SKILL.md": {Data: []byte("---\nname: child\ndescription: Child.\n---\n")}, + "parent/child/info.bin": {Data: []byte{0xff, 0, 1}}, + "unpublished.txt": {Data: []byte("outside skills")}, + } + fsys := &countingFS{FS: files, opens: make(map[string]int)} + p, err := NewFSProvider(fsys, &DirectoryOptions{PageSize: 1}) + if err != nil { + t.Fatal(err) + } + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v1"}, nil) + if err := p.AddTo(server); err != nil { + t.Fatal(err) + } + cs := connectResourceClient(t, server) + for _, description := range []string{"Child.", "Updated child."} { + files["parent/child/SKILL.md"].Data = fmt.Appendf(nil, "---\nname: child\ndescription: %s\n---\n", description) + var uris []string + for resource, err := range cs.Resources(t.Context(), nil) { + if err != nil { + t.Fatal(err) + } + uris = append(uris, resource.URI) + } + want := []string{"skill://parent/SKILL.md", "skill://parent/child/SKILL.md", "skill://parent/child/info.bin"} + if !slices.Equal(uris, want) { + t.Fatalf("listed %v, want %v", uris, want) + } + for resource, err := range DirectoryEntries(t.Context(), cs, &ReadDirectoryParams{URI: "skill://parent/child"}) { + if err != nil { + t.Fatal(err) + } + if resource.URI == "skill://parent/child/SKILL.md" && (resource.Name != "child" || resource.Description != description) { + t.Fatalf("directory metadata = %+v", resource) + } + } + } + if got := fsys.opens["parent/child/info.bin"]; got != 0 { + t.Fatalf("listing read supporting bytes %d times", got) + } + delete(files, "parent/child/info.bin") + files["parent/child/new.txt"] = &fstest.MapFile{Data: []byte("new")} + var children []string + for resource, err := range DirectoryEntries(t.Context(), cs, &ReadDirectoryParams{URI: "skill://parent/child"}) { + if err != nil { + t.Fatal(err) + } + children = append(children, resource.URI) + } + if want := []string{"skill://parent/child/SKILL.md", "skill://parent/child/new.txt"}; !slices.Equal(children, want) { + t.Fatalf("live directory = %v, want %v", children, want) + } +} + +func TestCatalogRefreshFailureRetries(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(fmt.Sprintf("explicit=%v", explicit), func(t *testing.T) { + files := fstest.MapFS{"demo/SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo.\n---\n")}} + invalidate := make(chan struct{}, 1) + p, err := NewFSProvider(files, &DirectoryOptions{Cache: &DirectoryCacheOptions{Preload: true, Invalidate: invalidate}}) + if err != nil { + t.Fatal(err) + } + previous := p.cached + files["new/SKILL.md"] = &fstest.MapFile{Data: []byte("incomplete write")} + if explicit { + err = p.Refresh(t.Context()) + } else { + invalidate <- struct{}{} + _, err = p.ListResources(t.Context(), nil) + } + if err == nil || p.cached != previous { + t.Fatalf("failed refresh = %v; previous cache retained = %v", err, p.cached == previous) + } + if _, err := p.ListResources(t.Context(), nil); err == nil { + t.Fatal("silently reused stale cache after a failed refresh") + } + files["new/SKILL.md"].Data = []byte("---\nname: new\ndescription: New.\n---\n") + result, err := p.ListResources(t.Context(), nil) + if err != nil || len(result.Resources) != 2 { + t.Fatalf("retry without another invalidation = %v, %v", result, err) + } + }) + } +} + +func TestResourceListingMiddlewareComposition(t *testing.T) { + p, err := NewFSProvider(fstest.MapFS{}, nil) + if err != nil { + t.Fatal(err) + } + params := &mcp.ListResourcesParams{Meta: mcp.Meta{"caller": "original"}} + req := &mcp.ListResourcesRequest{Params: params, Extra: &mcp.RequestExtra{}} + upstream := &mcp.ListResourcesResult{Meta: mcp.Meta{"source": "registered"}, Cacheable: mcp.Cacheable{TTLMs: 60000, CacheScope: "public"}} + handler := p.listResourcesMiddleware(func(_ context.Context, _ string, got mcp.Request) (mcp.Result, error) { + if got.GetExtra() != req.Extra || got.GetSession() != req.Session { + t.Fatal("lost request context") + } + got.GetParams().GetMeta()["caller"] = "modified" + return upstream, nil + }) + result, err := handler(t.Context(), "resources/list", req) + if err != nil { + t.Fatal(err) + } + listed := result.(*mcp.ListResourcesResult) + if listed.TTLMs != 0 || listed.CacheScope != "private" || listed.Meta["source"] != "registered" { + t.Fatalf("merged cache policy and metadata = %+v", listed) + } + if upstream.TTLMs != 60000 || params.Meta["caller"] != "original" || params.Cursor != "" { + t.Fatal("mutated upstream result or caller params") + } + upstreamErr := errors.New("access denied") + handler = p.listResourcesMiddleware(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, upstreamErr + }) + if _, err := handler(t.Context(), "resources/list", req); !errors.Is(err, upstreamErr) { + t.Fatalf("lost upstream error: %v", err) + } + handler = p.listResourcesMiddleware(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.ListResourcesResult{NextCursor: "loop"}, nil + }) + if _, err := handler(t.Context(), "resources/list", req); err == nil { + t.Fatal("accepted a repeated upstream cursor") + } +} diff --git a/skills/server.go b/skills/server.go new file mode 100644 index 00000000..6b0d4a13 --- /dev/null +++ b/skills/server.go @@ -0,0 +1,259 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ListSkillsHandler handles skills/list requests. +type ListSkillsHandler func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) + +// GetSkillHandler handles skills/get requests. +type GetSkillHandler func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) + +// ReadDirectoryHandler handles resources/directory/read requests. +type ReadDirectoryHandler func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) + +// UnsafeOptions permits behavior that may not interoperate with conforming hosts. +type UnsafeOptions struct { + DisableDefaultValidation bool + Limits *Limits +} + +// ServerOptions configures handler validation. +type ServerOptions struct { + SkillValidators []func(context.Context, *Skill) error + ListValidators []func(context.Context, *ListSkillsResult) error + DirectoryValidators []func(context.Context, *ReadDirectoryResult) error + Unsafe *UnsafeOptions +} + +// Handlers contains the required and optional Skills extension handlers. +type Handlers struct { + List ListSkillsHandler + Get GetSkillHandler + ReadDirectory ReadDirectoryHandler +} + +// AddHandlers registers the Skills extension handlers on server. +func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) error { + if server == nil { + return fmt.Errorf("skills: nil server") + } + if handlers == nil || handlers.List == nil || handlers.Get == nil { + return fmt.Errorf("skills: list and get handlers are required") + } + handlers = &Handlers{List: handlers.List, Get: handlers.Get, ReadDirectory: handlers.ReadDirectory} + options = cloneServerOptions(options) + if err := mcp.AddReceivingCustomMethod(server, MethodList, + func(ctx context.Context, session *mcp.ServerSession, params *ListSkillsParams) (*ListSkillsResult, error) { + if params == nil { + params = &ListSkillsParams{} + } + result, err := handlers.List(ctx, session, params) + if err != nil { + return nil, err + } + if err := validateListResult(ctx, result, options); err != nil { + return nil, fmt.Errorf("skills/list handler returned an invalid result: %w", err) + } + if supportsListCaching(session, params.Meta) { + if result.CacheScope == "" { + result.CacheScope = "public" + } + } else { + result.omitCache = true + } + return result, nil + }); err != nil { + return err + } + if err := mcp.AddReceivingCustomMethod(server, MethodGet, + func(ctx context.Context, session *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { + if params == nil || params.URI == "" { + return nil, invalidParams("missing required uri") + } + if _, err := skillNameFromURI(params.URI); err != nil { + return nil, invalidParams(err.Error()) + } + result, err := handlers.Get(ctx, session, params) + if err != nil { + return nil, err + } + if result == nil || result.Skill == nil { + return nil, fmt.Errorf("skills/get handler returned a nil skill") + } + if result.Skill.URI != params.URI { + return nil, fmt.Errorf("skills/get handler returned URI %q for %q", result.Skill.URI, params.URI) + } + if err := validateSkillResult(ctx, result.Skill, options); err != nil { + return nil, fmt.Errorf("skills/get handler returned an invalid result: %w", err) + } + result.ResultType = "complete" + return result, nil + }); err != nil { + return err + } + settings := map[string]any{} + if handlers.ReadDirectory != nil { + if err := mcp.AddReceivingCustomMethod(server, MethodReadDirectory, + func(ctx context.Context, session *mcp.ServerSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if params == nil || params.URI == "" { + return nil, invalidParams("missing required uri") + } + if _, err := parseDirectoryURI(params.URI); err != nil { + return nil, invalidParams(err.Error()) + } + result, err := handlers.ReadDirectory(ctx, session, params) + if err != nil { + return nil, err + } + if err := validateDirectoryResult(ctx, params.URI, result, options); err != nil { + return nil, fmt.Errorf("resources/directory/read handler returned an invalid result: %w", err) + } + return result, nil + }); err != nil { + return err + } + settings["directoryRead"] = true + } + server.AddExtension(ExtensionID, settings) + return nil +} + +func cloneServerOptions(options *ServerOptions) *ServerOptions { + if options == nil { + return nil + } + cloned := *options + cloned.SkillValidators = append([]func(context.Context, *Skill) error(nil), options.SkillValidators...) + cloned.ListValidators = append([]func(context.Context, *ListSkillsResult) error(nil), options.ListValidators...) + cloned.DirectoryValidators = append([]func(context.Context, *ReadDirectoryResult) error(nil), options.DirectoryValidators...) + if options.Unsafe != nil { + unsafe := *options.Unsafe + if options.Unsafe.Limits != nil { + limits := *options.Unsafe.Limits + unsafe.Limits = &limits + } + cloned.Unsafe = &unsafe + } + return &cloned +} + +func validateListResponse(ctx context.Context, result *ListSkillsResult) error { + if result == nil { + return fmt.Errorf("result is nil") + } + if result.Skills == nil { + return fmt.Errorf("skills is missing or null") + } + seen := make(map[string]bool, len(result.Skills)) + for _, skill := range result.Skills { + if err := validateSkillResult(ctx, skill, nil); err != nil { + return err + } + if seen[skill.URI] { + return fmt.Errorf("skill URI %q occurs more than once", skill.URI) + } + seen[skill.URI] = true + } + return nil +} + +func supportsListCaching(session *mcp.ServerSession, meta mcp.Meta) bool { + version, _ := meta[mcp.MetaKeyProtocolVersion].(string) + if version == "" && session != nil { + if params := session.InitializeParams(); params != nil { + version = params.ProtocolVersion + } + } + return version >= "2026-07-28" +} + +func validateListResult(ctx context.Context, result *ListSkillsResult, options *ServerOptions) error { + if result == nil { + return fmt.Errorf("result is nil") + } + if result.Skills == nil { + result.Skills = []*Skill{} + } + seen := make(map[string]bool, len(result.Skills)) + for _, skill := range result.Skills { + if err := validateSkillResult(ctx, skill, options); err != nil { + return err + } + if seen[skill.URI] { + return fmt.Errorf("skill URI %q occurs more than once", skill.URI) + } + seen[skill.URI] = true + } + if options != nil { + if err := runValidators(ctx, options.ListValidators, result); err != nil { + return err + } + } + result.ResultType = "complete" + return nil +} + +func validateSkillResult(ctx context.Context, skill *Skill, options *ServerOptions) error { + defaultValidation, limits := validationSettings(options) + if defaultValidation { + if err := ValidateSkillWithLimits(skill, limits); err != nil { + return err + } + } + if options != nil { + return runValidators(ctx, options.SkillValidators, skill) + } + return nil +} + +func validationSettings(options *ServerOptions) (bool, Limits) { + limits := DefaultLimits() + if options == nil || options.Unsafe == nil { + return true, limits + } + if options.Unsafe.Limits != nil { + limits = *options.Unsafe.Limits + } + return !options.Unsafe.DisableDefaultValidation, limits +} + +func validateDirectoryResult(ctx context.Context, uri string, result *ReadDirectoryResult, options *ServerOptions) error { + defaultValidation, _ := validationSettings(options) + if defaultValidation { + if err := ValidateDirectoryResult(uri, result); err != nil { + return err + } + } + if result != nil { + result.ResultType = "complete" + } + if options != nil { + return runValidators(ctx, options.DirectoryValidators, result) + } + return nil +} + +func runValidators[T any](ctx context.Context, validators []func(context.Context, T) error, value T) error { + for _, validate := range validators { + if validate != nil { + if err := validate(ctx, value); err != nil { + return err + } + } + } + return nil +} + +func invalidParams(message string) error { + return &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: message} +} diff --git a/skills/skills_test.go b/skills/skills_test.go new file mode 100644 index 00000000..8c498d5a --- /dev/null +++ b/skills/skills_test.go @@ -0,0 +1,258 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "slices" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestResourcesJSON(t *testing.T) { + static := StaticResources(&Resource{URI: "skill://a/SKILL.md", Digest: "sha256:" + fmt.Sprintf("%064x", 1), Size: 1}) + data, err := json.Marshal(static) + if err != nil { + t.Fatal(err) + } + if got, want := string(data), `[{"uri":"skill://a/SKILL.md","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000001","size":1}]`; got != want { + t.Fatalf("Marshal() = %s, want %s", got, want) + } + data, err = json.Marshal(DynamicResources()) + if err != nil { + t.Fatal(err) + } + if string(data) != `"dynamic"` { + t.Fatalf("Marshal() = %s", data) + } + var resources Resources + if err := json.Unmarshal([]byte(`"dynamic"`), &resources); err != nil { + t.Fatal(err) + } + if !resources.IsDynamic() { + t.Fatal("dynamic resources were not preserved") + } + if err := json.Unmarshal([]byte(`null`), &resources); err == nil { + t.Fatal("unmarshaling null resources succeeded") + } +} + +func TestValidateAndVerifySkill(t *testing.T) { + content := []byte("---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\n---\n# Demo\n") + digest := sha256.Sum256(content) + skill := &Skill{ + URI: "skill://demo/SKILL.md", + Frontmatter: Frontmatter{ + "name": "demo", "description": "A demo skill.", + "metadata": map[string]any{"author": "go-sdk"}, + }, + Resources: StaticResources(&Resource{ + URI: "skill://demo/SKILL.md", Digest: fmt.Sprintf("sha256:%x", digest), Size: int64(len(content)), + }), + } + if err := ValidateSkill(skill); err != nil { + t.Fatal(err) + } + if err := VerifySkillMD(skill, content); err != nil { + t.Fatal(err) + } + parsed, err := parseFrontmatter(content) + if err != nil { + t.Fatal(err) + } + if _, ok := parsed["metadata"].(map[string]any); !ok { + t.Fatalf("metadata has type %T, want map[string]any", parsed["metadata"]) + } + + bad := *skill + bad.Frontmatter = Frontmatter{"name": "Demo", "description": "A demo skill."} + if err := ValidateSkill(&bad); err == nil { + t.Fatal("ValidateSkill accepted an uppercase name") + } + bad = *skill + bad.Resources = StaticResources() + if err := ValidateSkill(&bad); err == nil { + t.Fatal("ValidateSkill accepted a manifest without SKILL.md") + } + + dynamic := &Skill{ + URI: "skill://generated/SKILL.md", + Frontmatter: Frontmatter{"name": "generated", "description": "Generated on demand."}, + Resources: DynamicResources(), + } + if err := ValidateSkill(dynamic); err != nil { + t.Fatalf("ValidateSkill rejected dynamic resources: %v", err) + } + if err := VerifyResource(dynamic, dynamic.URI, content); !errors.Is(err, ErrDynamicResources) { + t.Fatalf("VerifyResource(dynamic) = %v, want ErrDynamicResources", err) + } +} + +func TestPaginateSkills(t *testing.T) { + input := []*Skill{{URI: "skill://c/SKILL.md"}, {URI: "skill://a/SKILL.md"}, {URI: "skill://b/SKILL.md"}} + first, cursor, err := PaginateSkills(input, "", 2) + if err != nil { + t.Fatal(err) + } + if got, want := []string{first[0].URI, first[1].URI}, []string{"skill://a/SKILL.md", "skill://b/SKILL.md"}; !slices.Equal(got, want) { + t.Fatalf("first page = %v, want %v", got, want) + } + second, next, err := PaginateSkills(input, cursor, 2) + if err != nil { + t.Fatal(err) + } + if len(second) != 1 || second[0].URI != "skill://c/SKILL.md" || next != "" { + t.Fatalf("second page = %v, cursor %q", second, next) + } + if input[0].URI != "skill://c/SKILL.md" { + t.Fatal("PaginateSkills modified its input") + } +} + +func TestAllPagesReusable(t *testing.T) { + seq := allPages("", func(cursor string) ([]string, string, error) { + switch cursor { + case "": + return []string{"a"}, "next", nil + case "next": + return []string{"b"}, "", nil + default: + return nil, "", fmt.Errorf("unexpected cursor %q", cursor) + } + }) + for range 2 { + var got []string + for value, err := range seq { + if err != nil { + t.Fatal(err) + } + got = append(got, value) + } + if !slices.Equal(got, []string{"a", "b"}) { + t.Fatalf("iteration yielded %v", got) + } + } +} + +func TestGenericHandlersSupportDynamicResources(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "dynamic", Version: "v1"}, nil) + skill := &Skill{ + URI: "skill://generated/SKILL.md", + Frontmatter: Frontmatter{"name": "generated", "description": "Generated on demand."}, + Resources: DynamicResources(), + } + err := AddHandlers(server, &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, nil + }, + Get: func(_ context.Context, _ *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { + if params.URI != skill.URI { + return nil, mcp.ResourceNotFoundError(params.URI) + } + return &GetSkillResult{Skill: skill}, nil + }, + }, nil) + if err != nil { + t.Fatal(err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1"}, nil) + if err := AddClient(client); err != nil { + t.Fatal(err) + } + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + result, err := List(ctx, cs, nil) + if err != nil { + t.Fatal(err) + } + if len(result.Skills) != 1 || !result.Skills[0].Resources.IsDynamic() { + t.Fatalf("List() = %+v", result) + } + if result.ResultType != "complete" { + t.Fatalf("List() resultType = %q, want complete", result.ResultType) + } +} + +func TestValidateListResponseRejectsMissingSkills(t *testing.T) { + if err := validateListResponse(context.Background(), &ListSkillsResult{}); err == nil { + t.Fatal("validateListResponse accepted missing skills") + } +} + +func TestValidateDirectoryResultAllowsDisplayName(t *testing.T) { + result := &ReadDirectoryResult{Resources: []*mcp.Resource{{ + URI: "skill://demo/SKILL.md", Name: "demo", MIMEType: "text/markdown", + }}} + if err := ValidateDirectoryResult("skill://demo", result); err != nil { + t.Fatal(err) + } +} + +func TestListSkillsResultOmitsLegacyCacheFields(t *testing.T) { + result := &ListSkillsResult{Skills: []*Skill{}, omitCache: true} + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + if _, ok := fields["ttlMs"]; ok { + t.Fatalf("legacy result contains ttlMs: %s", data) + } + if _, ok := fields["cacheScope"]; ok { + t.Fatalf("legacy result contains cacheScope: %s", data) + } +} + +func TestCustomAndUnsafeValidation(t *testing.T) { + resources := make([]*Resource, DefaultMaxResourcesPerSkill+1) + for i := range resources { + uri := fmt.Sprintf("skill://large/%03d.txt", i) + if i == 0 { + uri = "skill://large/SKILL.md" + } + resources[i] = &Resource{URI: uri, Digest: "sha256:" + fmt.Sprintf("%064x", i), Size: 1} + } + skill := &Skill{ + URI: "skill://large/SKILL.md", + Frontmatter: Frontmatter{"name": "large", "description": "A large skill."}, + Resources: StaticResources(resources...), + } + if err := ValidateSkill(skill); err == nil { + t.Fatal("default validation accepted too many resources") + } + called := false + options := &ServerOptions{ + Unsafe: &UnsafeOptions{Limits: &Limits{MaxResourcesPerSkill: len(resources), MaxTotalSize: 1024}}, + SkillValidators: []func(context.Context, *Skill) error{func(context.Context, *Skill) error { + called = true + return nil + }}, + } + if err := validateSkillResult(context.Background(), skill, options); err != nil { + t.Fatal(err) + } + if !called { + t.Fatal("custom validator was not called") + } +} diff --git a/skills/types.go b/skills/types.go new file mode 100644 index 00000000..0096a028 --- /dev/null +++ b/skills/types.go @@ -0,0 +1,156 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package skills implements the MCP Skills extension defined by SEP-2640. +package skills + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + // ExtensionID is the capability identifier for the Skills extension. + ExtensionID = "io.modelcontextprotocol/skills" + // MethodList is the skills/list method name. + MethodList = "skills/list" + // MethodGet is the skills/get method name. + MethodGet = "skills/get" + // MethodReadDirectory is the resources/directory/read method name. + MethodReadDirectory = "resources/directory/read" +) + +// Frontmatter is the verbatim YAML frontmatter of a SKILL.md represented as JSON values. +type Frontmatter map[string]any + +// Resource identifies and fingerprints one file in a skill. +type Resource struct { + URI string `json:"uri"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +// Resources is either a complete static resource manifest or the dynamic marker. +type Resources struct { + dynamic bool + entries []*Resource +} + +// StaticResources constructs a complete static resource manifest. +func StaticResources(resources ...*Resource) Resources { + if resources == nil { + resources = []*Resource{} + } + return Resources{entries: resources} +} + +// DynamicResources constructs the marker used when stable digests cannot be published. +func DynamicResources() Resources { return Resources{dynamic: true} } + +// IsDynamic reports whether r contains the dynamic marker. +func (r Resources) IsDynamic() bool { return r.dynamic } + +// List returns the static manifest and true, or nil and false for dynamic or unset resources. +func (r Resources) List() ([]*Resource, bool) { + if r.entries == nil || r.dynamic { + return nil, false + } + return r.entries, true +} + +func (r Resources) MarshalJSON() ([]byte, error) { + if r.entries == nil && !r.dynamic { + return nil, fmt.Errorf("skills: resources is not set") + } + if r.dynamic { + return []byte(`"dynamic"`), nil + } + return json.Marshal(r.entries) +} + +func (r *Resources) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if bytes.Equal(data, []byte(`"dynamic"`)) { + *r = DynamicResources() + return nil + } + var entries []*Resource + if err := json.Unmarshal(data, &entries); err != nil { + return fmt.Errorf("skills: resources must be an array or %q: %w", "dynamic", err) + } + if entries == nil { + return fmt.Errorf("skills: resources must not be null") + } + *r = StaticResources(entries...) + return nil +} + +// Skill is an entry returned by skills/list or skills/get. +type Skill struct { + URI string `json:"uri"` + Frontmatter Frontmatter `json:"frontmatter"` + Resources Resources `json:"resources"` +} + +// ListSkillsParams contains parameters for skills/list. +type ListSkillsParams struct { + mcp.ParamsBase + Cursor string `json:"cursor,omitempty"` +} + +// ListSkillsResult is the result of skills/list. +type ListSkillsResult struct { + mcp.ResultBase + mcp.Cacheable + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Skills []*Skill `json:"skills"` + omitCache bool +} + +func (r *ListSkillsResult) MarshalJSON() ([]byte, error) { + type alias ListSkillsResult + data, err := json.Marshal((*alias)(r)) + if err != nil || !r.omitCache { + return data, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return nil, err + } + delete(fields, "ttlMs") + delete(fields, "cacheScope") + return json.Marshal(fields) +} + +// GetSkillParams contains parameters for skills/get. +type GetSkillParams struct { + mcp.ParamsBase + URI string `json:"uri"` +} + +// GetSkillResult is the result of skills/get. +type GetSkillResult struct { + mcp.ResultBase + ResultType string `json:"resultType,omitempty"` + Skill *Skill `json:"skill"` +} + +// ReadDirectoryParams contains parameters for resources/directory/read. +type ReadDirectoryParams struct { + mcp.ParamsBase + URI string `json:"uri"` + Cursor string `json:"cursor,omitempty"` +} + +// ReadDirectoryResult is the result of resources/directory/read. +type ReadDirectoryResult struct { + mcp.ResultBase + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Resources []*mcp.Resource `json:"resources"` +} diff --git a/skills/validation.go b/skills/validation.go new file mode 100644 index 00000000..6ef93e04 --- /dev/null +++ b/skills/validation.go @@ -0,0 +1,337 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +func parseFrontmatter(data []byte) (Frontmatter, error) { + normalized := bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + if !bytes.HasPrefix(normalized, []byte("---\n")) { + return nil, fmt.Errorf("SKILL.md must begin with YAML frontmatter") + } + end := bytes.Index(normalized[4:], []byte("\n---\n")) + if end < 0 { + return nil, fmt.Errorf("SKILL.md frontmatter has no closing delimiter") + } + var fields map[string]any + if err := yaml.Unmarshal(normalized[4:4+end], &fields); err != nil { + return nil, err + } + if fields == nil { + return nil, fmt.Errorf("SKILL.md frontmatter is empty") + } + frontmatter := Frontmatter(fields) + for key, value := range frontmatter { + normalized, err := normalizeYAML(value) + if err != nil { + return nil, fmt.Errorf("frontmatter field %q: %w", key, err) + } + frontmatter[key] = normalized + } + return frontmatter, nil +} + +func normalizeYAML(value any) (any, error) { + switch value := value.(type) { + case map[string]any: + for key, item := range value { + normalized, err := normalizeYAML(item) + if err != nil { + return nil, err + } + value[key] = normalized + } + return value, nil + case map[any]any: + result := make(map[string]any, len(value)) + for key, item := range value { + name, ok := key.(string) + if !ok { + return nil, fmt.Errorf("mapping key must be a string") + } + normalized, err := normalizeYAML(item) + if err != nil { + return nil, err + } + result[name] = normalized + } + return result, nil + case []any: + for i, item := range value { + normalized, err := normalizeYAML(item) + if err != nil { + return nil, err + } + value[i] = normalized + } + return value, nil + default: + return value, nil + } +} + +const ( + // DefaultMaxResourcesPerSkill is the SEP-2640 per-skill resource limit. + DefaultMaxResourcesPerSkill = 512 + // DefaultMaxTotalSize is the SEP-2640 per-skill byte limit. + DefaultMaxTotalSize = 16 * 1024 * 1024 +) + +// Limits controls the limits applied to a static skill manifest. +type Limits struct { + MaxResourcesPerSkill int + MaxTotalSize int64 +} + +var skillNameRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// DefaultLimits returns the limits required by SEP-2640. +func DefaultLimits() Limits { + return Limits{ + MaxResourcesPerSkill: DefaultMaxResourcesPerSkill, + MaxTotalSize: DefaultMaxTotalSize, + } +} + +// ValidateSkill validates a skill using the Agent Skills and SEP-2640 defaults. +func ValidateSkill(skill *Skill) error { + return ValidateSkillWithLimits(skill, DefaultLimits()) +} + +// ValidateSkillWithLimits validates a skill using the supplied manifest limits. +func ValidateSkillWithLimits(skill *Skill, limits Limits) error { + if skill == nil { + return fmt.Errorf("skill is nil") + } + name, err := skillNameFromURI(skill.URI) + if err != nil { + return err + } + if skill.Frontmatter == nil { + return fmt.Errorf("skill %q has no frontmatter", skill.URI) + } + if _, err := json.Marshal(skill.Frontmatter); err != nil { + return fmt.Errorf("skill %q frontmatter is not JSON-compatible: %w", skill.URI, err) + } + frontmatterName, ok := skill.Frontmatter["name"].(string) + if !ok { + return fmt.Errorf("skill %q frontmatter name must be a string", skill.URI) + } + if err := validateName(frontmatterName); err != nil { + return fmt.Errorf("skill %q: %w", skill.URI, err) + } + if frontmatterName != name { + return fmt.Errorf("skill %q frontmatter name %q does not match URI name %q", skill.URI, frontmatterName, name) + } + description, ok := skill.Frontmatter["description"].(string) + if !ok || utf8.RuneCountInString(description) < 1 || utf8.RuneCountInString(description) > 1024 { + return fmt.Errorf("skill %q frontmatter description must contain 1 to 1024 characters", skill.URI) + } + if compatibility, ok := skill.Frontmatter["compatibility"]; ok { + s, ok := compatibility.(string) + if !ok || utf8.RuneCountInString(s) < 1 || utf8.RuneCountInString(s) > 500 { + return fmt.Errorf("skill %q frontmatter compatibility must contain 1 to 500 characters", skill.URI) + } + } + if license, ok := skill.Frontmatter["license"]; ok { + if _, ok := license.(string); !ok { + return fmt.Errorf("skill %q frontmatter license must be a string", skill.URI) + } + } + if metadata, ok := skill.Frontmatter["metadata"]; ok { + var m map[string]any + switch metadata := metadata.(type) { + case map[string]any: + m = metadata + case map[string]string: + m = make(map[string]any, len(metadata)) + for key, value := range metadata { + m[key] = value + } + default: + return fmt.Errorf("skill %q frontmatter metadata must be an object", skill.URI) + } + for key, value := range m { + if _, ok := value.(string); !ok { + return fmt.Errorf("skill %q frontmatter metadata value %q must be a string", skill.URI, key) + } + } + } + if allowedTools, ok := skill.Frontmatter["allowed-tools"]; ok { + if _, ok := allowedTools.(string); !ok { + return fmt.Errorf("skill %q frontmatter allowed-tools must be a string", skill.URI) + } + } + + resources, static := skill.Resources.List() + if skill.Resources.IsDynamic() { + return nil + } + if !static { + return fmt.Errorf("skill %q resources is not set", skill.URI) + } + if limits.MaxResourcesPerSkill > 0 && len(resources) > limits.MaxResourcesPerSkill { + return fmt.Errorf("skill %q has %d resources, exceeding the limit of %d", skill.URI, len(resources), limits.MaxResourcesPerSkill) + } + seen := make(map[string]bool, len(resources)) + var total int64 + for i, resource := range resources { + if resource == nil { + return fmt.Errorf("skill %q resource %d is nil", skill.URI, i) + } + if err := validateResourceURI(skill.URI, resource.URI); err != nil { + return fmt.Errorf("skill %q resource %q: %w", skill.URI, resource.URI, err) + } + if seen[resource.URI] { + return fmt.Errorf("skill %q lists resource %q more than once", skill.URI, resource.URI) + } + seen[resource.URI] = true + if !digestRE.MatchString(resource.Digest) { + return fmt.Errorf("skill %q resource %q has invalid SHA-256 digest", skill.URI, resource.URI) + } + if resource.Size < 0 { + return fmt.Errorf("skill %q resource %q has a negative size", skill.URI, resource.URI) + } + if resource.Size > math.MaxInt64-total { + return fmt.Errorf("skill %q resource sizes overflow int64", skill.URI) + } + total += resource.Size + } + if !seen[skill.URI] { + return fmt.Errorf("skill %q resources does not include its SKILL.md", skill.URI) + } + if limits.MaxTotalSize > 0 && total > limits.MaxTotalSize { + return fmt.Errorf("skill %q has %d bytes, exceeding the limit of %d", skill.URI, total, limits.MaxTotalSize) + } + return nil +} + +// ValidateDirectoryResult validates that result contains direct children of uri. +func ValidateDirectoryResult(uri string, result *ReadDirectoryResult) error { + if result == nil { + return fmt.Errorf("directory result is nil") + } + parent, err := parseDirectoryURI(uri) + if err != nil { + return err + } + if result.Resources == nil { + return fmt.Errorf("directory %q returned a null resources array", uri) + } + seenNames := make(map[string]bool, len(result.Resources)) + seenURIs := make(map[string]bool, len(result.Resources)) + for i, resource := range result.Resources { + if resource == nil { + return fmt.Errorf("directory %q resource %d is nil", uri, i) + } + child, err := url.Parse(resource.URI) + if err != nil || child.Scheme == "" { + return fmt.Errorf("directory %q child has invalid URI %q", uri, resource.URI) + } + if child.Scheme != parent.Scheme || child.Host != parent.Host || child.RawQuery != "" || child.Fragment != "" { + return fmt.Errorf("resource %q is not a child of directory %q", resource.URI, uri) + } + parentPath := strings.TrimSuffix(parent.Path, "/") + childPath := child.Path + prefix := parentPath + "/" + if parentPath == "" { + prefix = "/" + } + rel := strings.TrimPrefix(childPath, prefix) + if rel == childPath || rel == "" || strings.Contains(rel, "/") { + return fmt.Errorf("resource %q is not a direct child of directory %q", resource.URI, uri) + } + if strings.HasSuffix(resource.URI, "/") { + return fmt.Errorf("resource %q has a trailing slash", resource.URI) + } + if seenNames[resource.Name] || seenURIs[resource.URI] { + return fmt.Errorf("directory %q contains a duplicate child %q", uri, resource.URI) + } + seenNames[resource.Name] = true + seenURIs[resource.URI] = true + } + return nil +} + +func validateName(name string) error { + if len(name) < 1 || len(name) > 64 || !skillNameRE.MatchString(name) { + return fmt.Errorf("name %q must contain 1 to 64 lowercase ASCII letters, digits, or non-consecutive hyphens", name) + } + return nil +} + +func skillNameFromURI(rawURI string) (string, error) { + u, err := url.Parse(rawURI) + if err != nil || u.Scheme == "" || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("skill URI %q is not a valid absolute resource URI", rawURI) + } + if u.Scheme == "skill" && (u.Host == "" || u.User != nil || u.Port() != "") { + return "", fmt.Errorf("skill URI %q must use a host without userinfo or a port", rawURI) + } + if !strings.HasSuffix(u.Path, "/SKILL.md") { + return "", fmt.Errorf("skill URI %q must end in /SKILL.md", rawURI) + } + dir := strings.Trim(strings.TrimSuffix(u.Path, "/SKILL.md"), "/") + if dir == "" { + dir = u.Hostname() + } else { + parts := strings.Split(dir, "/") + dir = parts[len(parts)-1] + } + if dir == "" { + return "", fmt.Errorf("skill URI %q has no skill name", rawURI) + } + return dir, nil +} + +func validateResourceURI(skillURI, resourceURI string) error { + skillURL, _ := url.Parse(skillURI) + resourceURL, err := url.Parse(resourceURI) + if err != nil || resourceURL.Scheme == "" || resourceURL.RawQuery != "" || resourceURL.Fragment != "" { + return fmt.Errorf("invalid resource URI") + } + if resourceURL.Scheme == "skill" && (resourceURL.Host == "" || resourceURL.User != nil || resourceURL.Port() != "") { + return fmt.Errorf("invalid skill resource authority") + } + if skillURL.Scheme != resourceURL.Scheme || skillURL.Host != resourceURL.Host { + return fmt.Errorf("URI is outside the skill root") + } + rootPath := strings.TrimSuffix(skillURL.Path, "/SKILL.md") + if resourceURL.Path != skillURL.Path && !strings.HasPrefix(resourceURL.Path, rootPath+"/") { + return fmt.Errorf("URI is outside the skill root") + } + for _, segment := range strings.Split(resourceURL.Path, "/") { + if segment == "." || segment == ".." { + return fmt.Errorf("URI contains a traversal segment") + } + } + return nil +} + +func parseDirectoryURI(rawURI string) (*url.URL, error) { + if strings.HasSuffix(rawURI, "/") { + return nil, fmt.Errorf("directory URI %q must not have a trailing slash", rawURI) + } + u, err := url.Parse(rawURI) + if err != nil || u.Scheme == "" || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("directory URI %q is invalid", rawURI) + } + if u.Scheme == "skill" && (u.Host == "" || u.User != nil || u.Port() != "") { + return nil, fmt.Errorf("directory URI %q has an invalid skill authority", rawURI) + } + return u, nil +} diff --git a/skills/verify.go b/skills/verify.go new file mode 100644 index 00000000..62028d7f --- /dev/null +++ b/skills/verify.go @@ -0,0 +1,69 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" +) + +// ErrDynamicResources reports that content cannot be integrity-verified because +// the skill declares dynamic resources. +var ErrDynamicResources = errors.New("skills: dynamic resources cannot be integrity-verified") + +// VerifyResource checks content against a resource in the held skill entry. +func VerifyResource(skill *Skill, uri string, content []byte) error { + if err := ValidateSkillWithLimits(skill, Limits{}); err != nil { + return err + } + if err := validateResourceURI(skill.URI, uri); err != nil { + return err + } + if skill.Resources.IsDynamic() { + return ErrDynamicResources + } + resources, _ := skill.Resources.List() + for _, resource := range resources { + if resource.URI != uri { + continue + } + if int64(len(content)) != resource.Size { + return fmt.Errorf("skills: resource %q has size %d, expected %d", uri, len(content), resource.Size) + } + digest := sha256.Sum256(content) + got := fmt.Sprintf("sha256:%x", digest) + if got != resource.Digest { + return fmt.Errorf("skills: resource %q has digest %q, expected %q", uri, got, resource.Digest) + } + return nil + } + return fmt.Errorf("skills: resource %q is not in the held skill manifest", uri) +} + +// VerifySkillMD verifies both the content digest and the advertised frontmatter. +func VerifySkillMD(skill *Skill, content []byte) error { + if err := VerifyResource(skill, skill.URI, content); err != nil { + return err + } + frontmatter, err := parseFrontmatter(content) + if err != nil { + return err + } + want, err := json.Marshal(skill.Frontmatter) + if err != nil { + return fmt.Errorf("skills: marshaling listed frontmatter: %w", err) + } + got, err := json.Marshal(frontmatter) + if err != nil { + return fmt.Errorf("skills: marshaling resource frontmatter: %w", err) + } + if !bytes.Equal(got, want) { + return fmt.Errorf("skills: SKILL.md frontmatter does not match the skill entry") + } + return nil +}