From db8cd34c75893b44f85fe27b9ccfb5cc239df996 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 4 Sep 2026 17:00:42 +0100 Subject: [PATCH] skills: add SEP-2640 protocol support --- docs/client.md | 5 + docs/server.md | 14 ++ go.mod | 1 + go.sum | 4 + internal/docs/client.src.md | 5 + internal/docs/server.src.md | 14 ++ mcp/server.go | 16 ++ mcp/server_test.go | 25 +++ skills/client.go | 183 ++++++++++++++++++++ skills/example_test.go | 39 +++++ skills/pagination.go | 70 ++++++++ skills/server.go | 259 +++++++++++++++++++++++++++ skills/skills_test.go | 258 +++++++++++++++++++++++++++ skills/types.go | 156 +++++++++++++++++ skills/validation.go | 337 ++++++++++++++++++++++++++++++++++++ skills/verify.go | 69 ++++++++ 16 files changed, 1455 insertions(+) create mode 100644 skills/client.go create mode 100644 skills/example_test.go create mode 100644 skills/pagination.go create mode 100644 skills/server.go create mode 100644 skills/skills_test.go create mode 100644 skills/types.go create mode 100644 skills/validation.go create mode 100644 skills/verify.go diff --git a/docs/client.md b/docs/client.md index ad71c119f..d60cffe1b 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 d7bc314ad..54efc7614 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1199,6 +1199,20 @@ 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. + +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 3287a9578..f2f70f10a 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 c13454aad..b67dd1f45 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 fdaef2ae9..788404dc0 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 82b956e98..06877b59d 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -513,6 +513,20 @@ 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. + +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 5014a4f33..00ab506ef 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 11e75e232..2e77e5b15 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 000000000..a8f18097e --- /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/example_test.go b/skills/example_test.go new file mode 100644 index 000000000..3cf266cea --- /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 000000000..20d64e65f --- /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/server.go b/skills/server.go new file mode 100644 index 000000000..6b0d4a131 --- /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 000000000..8c498d5a3 --- /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 000000000..0096a028a --- /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 000000000..6ef93e04f --- /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 000000000..62028d7f9 --- /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 +}