From db8cd34c75893b44f85fe27b9ccfb5cc239df996 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 4 Sep 2026 17:00:42 +0100 Subject: [PATCH 01/11] 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 +} From e1f0246eee612d5721bf494d1410abbf6ed26034 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 04:00:18 +0100 Subject: [PATCH 02/11] skills: simplify validation and client configuration, align current protocol --- conformance/skills-server/main.go | 96 +++++++ docs/client.md | 32 ++- docs/server.md | 28 +- internal/docs/client.src.md | 32 ++- internal/docs/server.src.md | 28 +- skills/client.go | 140 +++++----- skills/example_test.go | 12 +- skills/pagination.go | 80 ++++-- skills/protocol_test.go | 417 ++++++++++++++++++++++++++++++ skills/server.go | 233 ++++++----------- skills/skills_test.go | 45 +--- skills/types.go | 91 +++++-- skills/validation.go | 154 ++++++++--- skills/verify.go | 6 +- 14 files changed, 1035 insertions(+), 359 deletions(-) create mode 100644 conformance/skills-server/main.go create mode 100644 skills/protocol_test.go diff --git a/conformance/skills-server/main.go b/conformance/skills-server/main.go new file mode 100644 index 000000000..d44f1f91e --- /dev/null +++ b/conformance/skills-server/main.go @@ -0,0 +1,96 @@ +// 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. + +// This fixture exercises the generic Skills API without a filesystem provider. +// Run it against the three sep-2640-skills-* server conformance scenarios. +package main + +import ( + "context" + "crypto/sha256" + "flag" + "fmt" + "log" + "net/http" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +func main() { + addr := flag.String("http", "localhost:18299", "HTTP listen address") + stateless := flag.Bool("stateless", true, "Use the modern stateless protocol") + flag.Parse() + server := mcp.NewServer(&mcp.Implementation{Name: "skills-conformance", Version: "v1"}, nil) + files := map[string]string{ + "skill://demo/SKILL.md": "---\nname: demo\ndescription: A demonstration skill.\nmetadata:\n author: go-sdk\n---\n# Demo\nRead references/guide.md as needed.\n", + "skill://demo/references/guide.md": "# Guide\nSupporting content.\n", + "skill://demo/nested/SKILL.md": "---\nname: nested\ndescription: A nested skill.\n---\n# Nested\n", + "skill://other/SKILL.md": "---\nname: other\ndescription: Another skill.\n---\n# Other\n", + } + var entries []*skills.Skill + for _, item := range []struct{ uri, name, description string }{ + {"skill://demo/SKILL.md", "demo", "A demonstration skill."}, + {"skill://demo/nested/SKILL.md", "nested", "A nested skill."}, + {"skill://other/SKILL.md", "other", "Another skill."}, + } { + var resources []*skills.Resource + prefix := strings.TrimSuffix(item.uri, "SKILL.md") + for uri, content := range files { + if strings.HasPrefix(uri, prefix) { + resources = append(resources, &skills.Resource{URI: uri, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), Size: int64(len(content))}) + } + } + entry := &skills.Skill{URI: item.uri, Frontmatter: skills.Frontmatter{"name": item.name, "description": item.description}, Resources: skills.StaticResources(resources...)} + if item.name == "demo" { + entry.Frontmatter["metadata"] = map[string]string{"author": "go-sdk"} + } + entries = append(entries, entry) + } + directories := map[string][]*mcp.Resource{"skill://demo/empty": {}} + for uri, content := range files { + resource := &mcp.Resource{URI: uri, Name: uri[strings.LastIndex(uri, "/")+1:], MIMEType: "text/markdown"} + for _, entry := range entries { + if entry.URI == uri { + resource.Name = entry.Frontmatter["name"].(string) + resource.Description = entry.Frontmatter["description"].(string) + } + } + server.AddResource(resource, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: uri, MIMEType: "text/markdown", Text: content}}}, nil + }) + parent := uri[:strings.LastIndex(uri, "/")] + directories[parent] = append(directories[parent], resource) + } + for _, uri := range []string{"skill://demo/references", "skill://demo/nested", "skill://demo/empty"} { + directories["skill://demo"] = append(directories["skill://demo"], &mcp.Resource{URI: uri, Name: uri[strings.LastIndex(uri, "/")+1:], MIMEType: "inode/directory"}) + } + if err := skills.AddHandlers(server, &skills.Handlers{ + List: func(_ context.Context, _ *mcp.ServerSession, p *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + page, next, err := skills.PaginateSkills(entries, p.Cursor, 1) + return &skills.ListSkillsResult{Skills: page, NextCursor: next}, err + }, + Get: func(_ context.Context, _ *mcp.ServerSession, p *skills.GetSkillParams) (*skills.GetSkillResult, error) { + for _, entry := range entries { + if entry.URI == p.URI { + return &skills.GetSkillResult{Skill: entry}, nil + } + } + return nil, nil + }, + ReadDirectory: func(_ context.Context, _ *mcp.ServerSession, p *skills.ReadDirectoryParams) (*skills.ReadDirectoryResult, error) { + children, ok := directories[p.URI] + if !ok { + return nil, nil + } + page, next, err := skills.PaginateDirectoryResources(children, p.Cursor, 1) + return &skills.ReadDirectoryResult{Resources: page, NextCursor: next}, err + }, + }, nil); err != nil { + log.Fatal(err) + } + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: *stateless}) + log.Fatal(http.ListenAndServe(*addr, handler)) +} diff --git a/docs/client.md b/docs/client.md index d60cffe1b..1d7ca7aa2 100644 --- a/docs/client.md +++ b/docs/client.md @@ -547,7 +547,31 @@ 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. +package provides typed calls for the Skills extension. Register methods before +connecting, then bind the skills client to the connected session: + +```go +if err := skills.AddMethods(client); err != nil { + return err +} +// Connect client using the usual MCP transport, obtaining session. +skillClient := &skills.Client{Session: session} +for skill, err := range skillClient.All(ctx, nil) { + if err != nil { + return err + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) +} +``` + +`List`, `Get`, and `All` share `skillClient.Limits`. Zero fields use the standard +512-resource and 16 MiB per-skill defaults; larger values allow larger skills +without disabling protocol validation. Servers and clients configure these +limits independently. `ReadDirectory` and `DirectoryEntries` expose optional +directory browsing. Iterators follow cursors without modifying request parameters. + +Listing does not fetch content. Read files on demand with `session.ReadResource` +and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. +Keep skill entries scoped to their originating session: equal URIs from different +servers are different skills. Dynamic manifests return `skills.ErrDynamicResources` +from verification; the application must decide whether to accept unverifiable content. diff --git a/docs/server.md b/docs/server.md index 54efc7614..def9575b0 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1202,16 +1202,30 @@ 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 +package implements the Skills extension. Use `skills.AddHandlers` for request-time `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. +Register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. An entry's manifest includes every file, including `SKILL.md` and +nested skills. Use `skills.DynamicResources()` when stable digests cannot be +published, not simply because the catalog changes over time. + +Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK +returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result +with an empty resource list. Other handler errors pass through unchanged. + +`skills.ServerOptions.Limits` configures manifest limits. Zero fields use the +512-resource and 16 MiB per-skill defaults. A server serving larger skills is not +guaranteed to interoperate with default clients. Structural validation always +runs; put additional application policy in the handlers themselves. The SDK +copies options and prepares outgoing results without mutating handler-owned data. + +On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and +`cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints +through the result's `mcp.Cacheable` field. Earlier protocols omit cache fields +and `resultType`. The extension does not prefetch files or start background work. ### Pagination diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 788404dc0..80b27fc7d 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -236,7 +236,31 @@ 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. +package provides typed calls for the Skills extension. Register methods before +connecting, then bind the skills client to the connected session: + +```go +if err := skills.AddMethods(client); err != nil { + return err +} +// Connect client using the usual MCP transport, obtaining session. +skillClient := &skills.Client{Session: session} +for skill, err := range skillClient.All(ctx, nil) { + if err != nil { + return err + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) +} +``` + +`List`, `Get`, and `All` share `skillClient.Limits`. Zero fields use the standard +512-resource and 16 MiB per-skill defaults; larger values allow larger skills +without disabling protocol validation. Servers and clients configure these +limits independently. `ReadDirectory` and `DirectoryEntries` expose optional +directory browsing. Iterators follow cursors without modifying request parameters. + +Listing does not fetch content. Read files on demand with `session.ReadResource` +and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. +Keep skill entries scoped to their originating session: equal URIs from different +servers are different skills. Dynamic manifests return `skills.ErrDynamicResources` +from verification; the application must decide whether to accept unverifiable content. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 06877b59d..b55027a8d 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -516,16 +516,30 @@ 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 +package implements the Skills extension. Use `skills.AddHandlers` for request-time `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. +Register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. An entry's manifest includes every file, including `SKILL.md` and +nested skills. Use `skills.DynamicResources()` when stable digests cannot be +published, not simply because the catalog changes over time. + +Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK +returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result +with an empty resource list. Other handler errors pass through unchanged. + +`skills.ServerOptions.Limits` configures manifest limits. Zero fields use the +512-resource and 16 MiB per-skill defaults. A server serving larger skills is not +guaranteed to interoperate with default clients. Structural validation always +runs; put additional application policy in the handlers themselves. The SDK +copies options and prepares outgoing results without mutating handler-owned data. + +On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and +`cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints +through the result's `mcp.Cacheable` field. Earlier protocols omit cache fields +and `resultType`. The extension does not prefetch files or start background work. ### Pagination diff --git a/skills/client.go b/skills/client.go index a8f18097e..97334b401 100644 --- a/skills/client.go +++ b/skills/client.go @@ -13,8 +13,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// AddClient registers the Skills extension methods that client may send. -func AddClient(client *mcp.Client) error { +// AddMethods registers the Skills extension methods that client may send. +func AddMethods(client *mcp.Client) error { if client == nil { return fmt.Errorf("skills: nil client") } @@ -27,68 +27,92 @@ func AddClient(client *mcp.Client) error { return mcp.AddSendingCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](client, MethodReadDirectory) } +// Client calls the Skills extension on Session. Configure Limits before use; +// its zero value uses the standard per-skill limits. Call AddMethods on the +// underlying mcp.Client before connecting. +// +// Client does not prefetch content or cache entries. Keep entries scoped to +// their originating session and verify resource bytes before using them. +type Client struct { + Session *mcp.ClientSession + Limits Limits +} + // 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 { +func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkillsResult, error) { + if err := c.requireCapability(false); err != nil { return nil, err } if params == nil { params = &ListSkillsParams{} } - result, err := mcp.CallCustomMethod[*ListSkillsParams, *ListSkillsResult](ctx, session, MethodList, params) + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ListSkillsParams, *ListSkillsResult](ctx, c.Session, MethodList, &request) if err != nil { return nil, err } - if err := validateListResponse(ctx, result); err != nil { + if err := validateListResult(result, c.Limits); err != nil { return nil, fmt.Errorf("skills: server returned an invalid skills/list result: %w", err) } + if err := c.validateEnvelope(result.ResultType, &result.Cacheable, result.cachePresent); err != nil { + return nil, 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 { +func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResult, error) { + if err := c.requireCapability(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) + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*GetSkillParams, *GetSkillResult](ctx, c.Session, MethodGet, &request) 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 { + if err := validateGetResult(params.URI, result, c.Limits); err != nil { return nil, fmt.Errorf("skills: server returned an invalid skill: %w", err) } + if err := c.validateEnvelope(result.ResultType, &result.Cacheable, result.cachePresent); err != nil { + return nil, 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 { +func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if err := c.requireCapability(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) + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](ctx, c.Session, MethodReadDirectory, &request) 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) } + if err := c.validateEnvelope(result.ResultType, nil, false); err != nil { + return nil, 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] { +func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*Skill, error] { + client := Client{} + if c != nil { + client = *c + } var initial ListSkillsParams if params != nil { initial = *params @@ -96,9 +120,10 @@ func All(ctx context.Context, session *mcp.ClientSession, params *ListSkillsPara } return func(yield func(*Skill, error) bool) { request := initial + request.Meta = maps.Clone(initial.Meta) allPages(initial.Cursor, func(cursor string) ([]*Skill, string, error) { request.Cursor = cursor - result, err := List(ctx, session, &request) + result, err := client.List(ctx, &request) if err != nil { return nil, "", err } @@ -108,7 +133,11 @@ func All(ctx context.Context, session *mcp.ClientSession, params *ListSkillsPara } // 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] { +func (c *Client) DirectoryEntries(ctx context.Context, params *ReadDirectoryParams) iter.Seq2[*mcp.Resource, error] { + client := Client{} + if c != nil { + client = *c + } var initial ReadDirectoryParams if params != nil { initial = *params @@ -116,9 +145,10 @@ func DirectoryEntries(ctx context.Context, session *mcp.ClientSession, params *R } return func(yield func(*mcp.Resource, error) bool) { request := initial + request.Meta = maps.Clone(initial.Meta) allPages(initial.Cursor, func(cursor string) ([]*mcp.Resource, string, error) { request.Cursor = cursor - result, err := ReadDirectory(ctx, session, &request) + result, err := client.ReadDirectory(ctx, &request) if err != nil { return nil, "", err } @@ -127,40 +157,11 @@ func DirectoryEntries(ctx context.Context, session *mcp.ClientSession, params *R } } -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 (c *Client) requireCapability(directoryRead bool) error { + if c == nil { + return fmt.Errorf("skills: nil client") } -} - -func requireCapability(session *mcp.ClientSession, directoryRead bool) error { + session := c.Session if session == nil || session.InitializeResult() == nil || session.InitializeResult().Capabilities == nil { return fmt.Errorf("skills: session has no server capabilities") } @@ -168,16 +169,35 @@ func requireCapability(session *mcp.ClientSession, directoryRead bool) error { 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 session.InitializeResult().Capabilities.Resources == nil { + return fmt.Errorf("skills: server does not advertise resources") + } + if !directoryRead { + return nil + } + enabled, _ := m[capabilityDirectoryRead].(bool) if !enabled { return fmt.Errorf("skills: server does not advertise directoryRead") } return nil } + +func (c *Client) validateEnvelope(resultType string, cache *mcp.Cacheable, cachePresent bool) error { + if c.Session.InitializeResult().ProtocolVersion < "2026-07-28" { + return nil + } + if resultType != "complete" { + return fmt.Errorf("skills: expected complete result, got %q", resultType) + } + if cache != nil { + if !cachePresent { + return fmt.Errorf("skills: missing ttlMs or cacheScope") + } + return validateCache(*cache) + } + return nil +} diff --git a/skills/example_test.go b/skills/example_test.go index 3cf266cea..96bdf5ff5 100644 --- a/skills/example_test.go +++ b/skills/example_test.go @@ -8,7 +8,6 @@ import ( "context" "log" - "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/skills" ) @@ -22,13 +21,22 @@ func ExampleAddHandlers() { }, Resources: skills.DynamicResources(), } + + server.AddResource(&mcp.Resource{ + URI: entry.URI, Name: "generated", Description: "Instructions generated on demand.", MIMEType: "text/markdown", + }, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: entry.URI, MIMEType: "text/markdown", + Text: "---\nname: generated\ndescription: Instructions generated on demand.\n---\n# Generated\n", + }}}, nil + }) 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 nil, nil } return &skills.GetSkillResult{Skill: entry}, nil }, diff --git a/skills/pagination.go b/skills/pagination.go index 20d64e65f..383b33b1f 100644 --- a/skills/pagination.go +++ b/skills/pagination.go @@ -7,6 +7,7 @@ package skills import ( "encoding/base64" "fmt" + "iter" "slices" "strings" @@ -14,6 +15,20 @@ import ( ) func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) ([]T, string, error) { + if pageSize < 0 { + return nil, "", fmt.Errorf("skills: invalid page size %d", pageSize) + } + if pageSize == 0 { + pageSize = mcp.DefaultPageSize + } + items = slices.Clone(items) + slices.SortFunc(items, func(a, b T) int { return strings.Compare(key(a), key(b)) }) + for i, item := range items { + uri := key(item) + if uri == "" || i > 0 && uri == key(items[i-1]) { + return nil, "", fmt.Errorf("skills: missing or duplicate pagination key %q", uri) + } + } start := 0 if cursor != "" { decoded, err := base64.RawURLEncoding.DecodeString(cursor) @@ -44,27 +59,54 @@ func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) // 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 }) + return paginate(skills, cursor, pageSize, func(skill *Skill) string { + if skill == nil { + return "" + } + return skill.URI + }) } -// PaginateDirectoryResources returns one URI-ordered directory page and an -// opaque cursor for the next page. It does not modify resources. +// PaginateDirectoryResources returns one URI-ordered directory page without +// modifying resources. A zero page size uses mcp.DefaultPageSize. 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 + return paginate(resources, cursor, pageSize, func(resource *mcp.Resource) string { + if resource == nil { + return "" + } + return resource.URI + }) +} + +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 + } } - 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/protocol_test.go b/skills/protocol_test.go new file mode 100644 index 000000000..62a38933d --- /dev/null +++ b/skills/protocol_test.go @@ -0,0 +1,417 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func testSkill() *Skill { + return &Skill{URI: "skill://demo/SKILL.md", Frontmatter: Frontmatter{"name": "demo", "description": "Demo"}, + Resources: StaticResources(&Resource{URI: "skill://demo/SKILL.md", Digest: "sha256:" + strings.Repeat("0", 64), Size: 1})} +} + +func testServer() *mcp.Server { + return mcp.NewServer(&mcp.Implementation{Name: "skills-test", Version: "v1"}, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}}, + }) +} + +func connectSkills(t *testing.T, server *mcp.Server, version string) *Client { + t.Helper() + httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: version >= "2026-07-28"})) + t.Cleanup(httpServer.Close) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + if err := AddMethods(client); err != nil { + t.Fatal(err) + } + session, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, &mcp.ClientSessionOptions{ProtocolVersion: version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return &Client{Session: session} +} + +func fixedHandlers(skill *Skill) *Handlers { + return &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, nil + }, + Get: func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + if p.URI != skill.URI { + return nil, nil + } + return &GetSkillResult{Skill: skill}, nil + }, + } +} + +func TestLimitsRoundTrip(t *testing.T) { + for _, kind := range []string{"count", "bytes"} { + t.Run(kind, func(t *testing.T) { + skill := testSkill() + limits := DefaultLimits() + if kind == "count" { + limits.MaxResourcesPerSkill++ + entries, _ := skill.Resources.List() + for i := 1; i < limits.MaxResourcesPerSkill; i++ { + entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: entries[0].Digest, Size: 1}) + } + skill.Resources = StaticResources(entries...) + } else { + limits.MaxTotalSize++ + entries, _ := skill.Resources.List() + entries[0].Size = limits.MaxTotalSize + } + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: limits}); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, "2026-07-28") + if _, err := client.List(t.Context(), nil); err == nil { + t.Fatal("default client accepted oversized list") + } + if _, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}); err == nil { + t.Fatal("default client accepted oversized get") + } + client.Limits = limits + if _, err := client.List(t.Context(), nil); err != nil { + t.Fatal(err) + } + if _, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}); err != nil { + t.Fatal(err) + } + count := 0 + for _, err := range client.All(t.Context(), nil) { + if err != nil { + t.Fatal(err) + } + count++ + } + if count != 1 { + t.Fatalf("count=%d", count) + } + // A higher budget never bypasses structural checks. + malformed := *skill + malformed.Frontmatter = Frontmatter{"name": "BAD", "description": "Demo"} + if err := ValidateSkillWithLimits(&malformed, limits); err == nil { + t.Fatal("accepted malformed skill") + } + // The server also enforces its own defaults. + if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { + t.Fatal(err) + } + if _, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}); err == nil { + t.Fatal("default server served oversized skill") + } + }) + } + if err := ValidateSkillWithLimits(testSkill(), Limits{MaxTotalSize: -1}); err == nil { + t.Fatal("negative limit accepted") + } + if err := AddHandlers(testServer(), fixedHandlers(testSkill()), &ServerOptions{Limits: Limits{MaxResourcesPerSkill: -1}}); err == nil { + t.Fatal("negative server limit accepted") + } + entries, _ := testSkill().Resources.List() + skill := testSkill() + entries[0].Size = DefaultMaxTotalSize + skill.Resources = StaticResources(entries...) + if err := ValidateSkillWithLimits(skill, Limits{MaxResourcesPerSkill: 1}); err != nil { + t.Fatal(err) + } + entries[0].Size++ + if err := ValidateSkillWithLimits(skill, Limits{MaxResourcesPerSkill: 1}); err == nil { + t.Fatal("zero byte limit disabled the default") + } +} + +func TestUnknownURIsAndHandlerErrors(t *testing.T) { + server := testServer() + skill := testSkill() + h := fixedHandlers(skill) + h.Get = func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + switch p.URI { + case "skill://nil/SKILL.md": + return &GetSkillResult{}, nil + case "skill://backend/SKILL.md": + return nil, errors.New("backend unavailable") + case "skill://wrong/SKILL.md": + return &GetSkillResult{Skill: skill}, nil + default: + return nil, nil + } + } + h.ReadDirectory = func(_ context.Context, _ *mcp.ServerSession, p *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if p.URI == "skill://empty" { + return &ReadDirectoryResult{}, nil + } + return nil, nil + } + if err := AddHandlers(server, h, nil); err != nil { + t.Fatal(err) + } + c := connectSkills(t, server, "2026-07-28") + for _, uri := range []string{"skill://unknown/SKILL.md", "skill://nil/SKILL.md", "malformed"} { + _, err := c.Get(t.Context(), &GetSkillParams{URI: uri}) + var rpc *jsonrpc.Error + if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInvalidParams { + t.Fatalf("%s: %v", uri, err) + } + } + for _, uri := range []string{"skill://backend/SKILL.md", "skill://wrong/SKILL.md"} { + _, err := c.Get(t.Context(), &GetSkillParams{URI: uri}) + var rpc *jsonrpc.Error + if err == nil || errors.As(err, &rpc) && rpc.Code == jsonrpc.CodeInvalidParams { + t.Fatalf("handler bug mislabeled: %v", err) + } + } + if _, err := c.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://missing"}); err == nil { + t.Fatal("unknown directory accepted") + } else { + var rpc *jsonrpc.Error + if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInvalidParams { + t.Fatal(err) + } + } + empty, err := c.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://empty"}) + if err != nil || empty.Resources == nil || len(empty.Resources) != 0 { + t.Fatalf("empty directory: %+v, %v", empty, err) + } +} + +func TestResponsesAndParamsAreNotMutated(t *testing.T) { + server := testServer() + skill := testSkill() + list := &ListSkillsResult{Skills: []*Skill{skill}, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}} + get := &GetSkillResult{Skill: skill, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}, Cacheable: mcp.Cacheable{TTLMs: 123, CacheScope: "private"}} + dir := &ReadDirectoryResult{} + h := &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return list, nil + }, + Get: func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) { return get, nil }, + ReadDirectory: func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) { + return dir, nil + }, + } + beforeList, _ := json.Marshal(list) + beforeGet, _ := json.Marshal(get) + beforeDir, _ := json.Marshal(dir) + if err := AddHandlers(server, h, nil); err != nil { + t.Fatal(err) + } + legacy := connectSkills(t, server, "2025-11-25") + modern := connectSkills(t, server, "2026-07-28") + var wg sync.WaitGroup + for _, client := range []*Client{legacy, modern} { + wg.Go(func() { + for range 4 { + p := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"owner": "caller"}}} + res, err := client.List(t.Context(), p) + if err != nil { + t.Error(err) + return + } + if len(p.Meta) != 1 { + t.Error("request metadata mutated") + } + // Re-encoding received legacy results need not preserve wire omission; + // the decoder tracks actual presence separately. + if res.cachePresent != (client == modern) { + t.Error("wrong cache fields on wire") + } + gr, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + if err != nil { + t.Error(err) + return + } + if gr.cachePresent != (client == modern) { + t.Error("wrong get cache fields") + } + if client == modern && (gr.TTLMs != 123 || gr.CacheScope != "private") { + t.Error("cache hints lost") + } + if _, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}); err != nil { + t.Error(err) + } + } + }) + } + wg.Wait() + afterList, _ := json.Marshal(list) + afterGet, _ := json.Marshal(get) + afterDir, _ := json.Marshal(dir) + if string(beforeList) != string(afterList) || string(beforeGet) != string(afterGet) || string(beforeDir) != string(afterDir) { + t.Fatal("handler-owned result changed") + } +} + +type rawSkillResult struct { + mcp.ResultBase + data json.RawMessage +} + +func (r *rawSkillResult) MarshalJSON() ([]byte, error) { return r.data, nil } + +func TestClientRejectsMalformedResponses(t *testing.T) { + good := testSkill() + encoded, _ := json.Marshal(good) + for _, tc := range []struct{ name, body string }{ + {"null-list", `{"skills":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`}, + {"duplicate", fmt.Sprintf(`{"skills":[%s,%s],"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, encoded, encoded)}, + {"missing-ttl", `{"skills":[],"resultType":"complete","cacheScope":"public"}`}, + {"missing-scope", `{"skills":[],"resultType":"complete","ttlMs":0}`}, + {"negative-ttl", `{"skills":[],"resultType":"complete","ttlMs":-1,"cacheScope":"public"}`}, + {"bad-scope", `{"skills":[],"resultType":"complete","ttlMs":0,"cacheScope":"unknown"}`}, + {"wrong-result-type", `{"skills":[],"resultType":"input_required","ttlMs":0,"cacheScope":"public"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*rawSkillResult, error) { + return &rawSkillResult{data: json.RawMessage(tc.body)}, nil + }); err != nil { + t.Fatal(err) + } + c := connectSkills(t, server, "2026-07-28") + if _, err := c.List(t.Context(), nil); err == nil { + t.Fatal("accepted malformed response") + } + }) + } + for _, body := range []string{ + fmt.Sprintf(`{"skill":%s,"resultType":"complete","ttlMs":0}`, encoded), + fmt.Sprintf(`{"skill":%s,"resultType":"complete","cacheScope":"public"}`, encoded), + `{"skill":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, + } { + server := testServer() + server.AddExtension(ExtensionID, nil) + if err := mcp.AddReceivingCustomMethod(server, MethodGet, func(context.Context, *mcp.ServerSession, *GetSkillParams) (*rawSkillResult, error) { + return &rawSkillResult{data: json.RawMessage(body)}, nil + }); err != nil { + t.Fatal(err) + } + c := connectSkills(t, server, "2026-07-28") + if _, err := c.Get(t.Context(), &GetSkillParams{URI: good.URI}); err == nil { + t.Fatal("accepted malformed get") + } + } +} + +func TestPaginationAndIteratorOwnership(t *testing.T) { + server := testServer() + one := testSkill() + two := *one + two.URI = "skill://other/SKILL.md" + two.Frontmatter = Frontmatter{"name": "other", "description": "Other"} + two.Resources = DynamicResources() + h := fixedHandlers(one) + h.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + page, next, err := PaginateSkills([]*Skill{&two, one}, p.Cursor, 1) + return &ListSkillsResult{Skills: page, NextCursor: next}, err + } + if err := AddHandlers(server, h, nil); err != nil { + t.Fatal(err) + } + c := connectSkills(t, server, "2026-07-28") + p := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"key": "value"}}} + seq := c.All(t.Context(), p) + for range 2 { + count := 0 + for _, err := range seq { + if err != nil { + t.Fatal(err) + } + count++ + } + if count != 2 { + t.Fatalf("count=%d", count) + } + } + if p.Cursor != "" || !reflect.DeepEqual(p.Meta, mcp.Meta{"key": "value"}) { + t.Fatal("iterator mutated parameters") + } + calls := 0 + for _, err := range allPages("", func(string) ([]int, string, error) { calls++; return []int{1}, "repeat", nil }) { + if err != nil { + break + } + } + if calls != 2 { + t.Fatalf("repeated cursor: %d calls", calls) + } + calls = 0 + for range allPages("", func(string) ([]int, string, error) { calls++; return []int{1}, "next", nil }) { + break + } + if calls != 1 { + t.Fatal("iterator fetched after early stop") + } + for _, items := range [][]*Skill{{nil}, {one, one}} { + if _, _, err := PaginateSkills(items, "", 1); err == nil { + t.Fatal("invalid pagination input accepted") + } + } +} + +func TestURIAndVerificationBoundaries(t *testing.T) { + for _, uri := range []string{"skill://demo/../bad", "skill://demo/%2e%2e", "skill://demo/%2e", "skill://demo/file?", "skill://demo/file#", "skill:opaque"} { + if _, err := parseURI(uri); err == nil { + t.Fatalf("accepted %s", uri) + } + } + for _, uri := range []string{"skill://demo/%2e%2e", "skill://other/a", "skill://demo/a/b", "skill://demo/a%2fb", "skill://demo/sub%2f"} { + if err := ValidateDirectoryResult("skill://demo", &ReadDirectoryResult{Resources: []*mcp.Resource{{URI: uri, Name: "child"}}}); err == nil { + t.Fatalf("accepted child %s", uri) + } + } + if err := VerifySkillMD(nil, nil); err == nil { + t.Fatal("nil skill accepted") + } + skill := testSkill() + data := []byte("tampered") + if err := VerifyResource(skill, "skill://demo/unlisted", data); err == nil { + t.Fatal("unlisted file accepted") + } + if err := VerifyResource(skill, skill.URI, data); err == nil { + t.Fatal("wrong content accepted") + } +} + +func TestFrontmatterAtEOF(t *testing.T) { + for _, content := range []string{ + "---\nname: demo\ndescription: Demo\n---", + "---\r\nname: demo\r\ndescription: Demo\r\n---", + "---\nname: demo\ndescription: Demo\n---\n", + } { + got, err := parseFrontmatter([]byte(content)) + if err != nil || got["name"] != "demo" { + t.Fatalf("frontmatter: %v, %v", got, err) + } + } +} + +func TestDirectoryDisplayNamesNeedNotBeUnique(t *testing.T) { + result := &ReadDirectoryResult{Resources: []*mcp.Resource{ + {URI: "skill://demo/a", Name: "Same display name"}, + {URI: "skill://demo/b", Name: "Same display name"}, + }} + if err := ValidateDirectoryResult("skill://demo", result); err != nil { + t.Fatal(err) + } +} diff --git a/skills/server.go b/skills/server.go index 6b0d4a131..af55a1550 100644 --- a/skills/server.go +++ b/skills/server.go @@ -7,6 +7,7 @@ package skills import ( "context" "fmt" + "maps" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -15,24 +16,18 @@ import ( // ListSkillsHandler handles skills/list requests. type ListSkillsHandler func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) -// GetSkillHandler handles skills/get requests. +// GetSkillHandler handles skills/get. Return (nil, nil) for an unknown skill; +// AddHandlers translates it to JSON-RPC Invalid Params. Other errors pass through. type GetSkillHandler func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) -// ReadDirectoryHandler handles resources/directory/read requests. +// ReadDirectoryHandler handles resources/directory/read. Return (nil, nil) if +// the URI does not exist or is not a directory. An empty directory has a non-nil result. 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. +// ServerOptions configures the per-skill limits. Protocol validation always +// runs; applications can perform additional checks in their handlers. type ServerOptions struct { - SkillValidators []func(context.Context, *Skill) error - ListValidators []func(context.Context, *ListSkillsResult) error - DirectoryValidators []func(context.Context, *ReadDirectoryResult) error - Unsafe *UnsafeOptions + Limits Limits } // Handlers contains the required and optional Skills extension handlers. @@ -42,7 +37,12 @@ type Handlers struct { ReadDirectory ReadDirectoryHandler } -// AddHandlers registers the Skills extension handlers on server. +// AddHandlers registers the Skills extension. Register skill content separately +// with Server.AddResource or Server.AddResourceTemplate, which also advertises +// the required resources capability. Configure the server before connecting. +// +// Options and handler functions are copied. Results are validated without +// modifying handler-owned values; handlers must synchronize their own state. func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) error { if server == nil { return fmt.Errorf("skills: nil server") @@ -50,208 +50,129 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) 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) + h := *handlers + var limits Limits + if options != nil { + limits = options.Limits + } + limits, err := limits.resolve() + if err != nil { + return err + } 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) + result, err := h.List(ctx, session, params) if err != nil { return nil, err } - if err := validateListResult(ctx, result, options); err != nil { + if result == nil { + return nil, fmt.Errorf("skills/list handler returned a nil result") + } + out := *result + out.Meta = maps.Clone(result.Meta) + if out.Skills == nil { + out.Skills = []*Skill{} + } + if err := validateListResult(&out, limits); 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 + out.omitCache = !supportsCaching(params.Meta) + out.ResultType = resultType(params.Meta) + normalizeCache(&out.Cacheable) + if err := validateCache(out.Cacheable); err != nil { + return nil, err } - return result, nil + return &out, 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 == "" { + if params == nil { 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) + result, err := h.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") + return nil, invalidParams("unknown skill: " + params.URI) } - 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 { + if err := validateGetResult(params.URI, result, limits); err != nil { return nil, fmt.Errorf("skills/get handler returned an invalid result: %w", err) } - result.ResultType = "complete" - return result, nil + out := *result + out.Meta = maps.Clone(result.Meta) + out.omitCache = !supportsCaching(params.Meta) + out.ResultType = resultType(params.Meta) + normalizeCache(&out.Cacheable) + if err := validateCache(out.Cacheable); err != nil { + return nil, err + } + return &out, nil }); err != nil { return err } settings := map[string]any{} - if handlers.ReadDirectory != nil { + if h.ReadDirectory != nil { if err := mcp.AddReceivingCustomMethod(server, MethodReadDirectory, func(ctx context.Context, session *mcp.ServerSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { - if params == nil || params.URI == "" { + if params == nil { 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) + result, err := h.ReadDirectory(ctx, session, params) if err != nil { return nil, err } - if err := validateDirectoryResult(ctx, params.URI, result, options); err != nil { + if result == nil { + return nil, invalidParams("unknown directory: " + params.URI) + } + out := *result + out.Meta = maps.Clone(result.Meta) + if out.Resources == nil { + out.Resources = []*mcp.Resource{} + } + if err := ValidateDirectoryResult(params.URI, &out); err != nil { return nil, fmt.Errorf("resources/directory/read handler returned an invalid result: %w", err) } - return result, nil + out.ResultType = resultType(params.Meta) + return &out, nil }); err != nil { return err } - settings["directoryRead"] = true + settings[capabilityDirectoryRead] = 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 { +// Modern requests carry the validated protocol version in _meta. InitializeParams +// contains the client's proposal, which can differ from the negotiated version. +func supportsCaching(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) +func resultType(meta mcp.Meta) string { + if supportsCaching(meta) { + return "complete" } - return nil + return "" } -func validationSettings(options *ServerOptions) (bool, Limits) { - limits := DefaultLimits() - if options == nil || options.Unsafe == nil { - return true, limits +func normalizeCache(cache *mcp.Cacheable) { + if cache.CacheScope == "" { + cache.CacheScope = "public" } - 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 { diff --git a/skills/skills_test.go b/skills/skills_test.go index 8c498d5a3..437b37ab3 100644 --- a/skills/skills_test.go +++ b/skills/skills_test.go @@ -142,7 +142,7 @@ func TestAllPagesReusable(t *testing.T) { } func TestGenericHandlersSupportDynamicResources(t *testing.T) { - server := mcp.NewServer(&mcp.Implementation{Name: "dynamic", Version: "v1"}, nil) + server := mcp.NewServer(&mcp.Implementation{Name: "dynamic", Version: "v1"}, &mcp.ServerOptions{Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}}}) skill := &Skill{ URI: "skill://generated/SKILL.md", Frontmatter: Frontmatter{"name": "generated", "description": "Generated on demand."}, @@ -164,7 +164,7 @@ func TestGenericHandlersSupportDynamicResources(t *testing.T) { } client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1"}, nil) - if err := AddClient(client); err != nil { + if err := AddMethods(client); err != nil { t.Fatal(err) } ctx := context.Background() @@ -179,20 +179,20 @@ func TestGenericHandlersSupportDynamicResources(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = cs.Close() }) - result, err := List(ctx, cs, nil) + result, err := (&Client{Session: cs}).List(ctx, 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) + if result.ResultType != resultType(mcp.Meta{mcp.MetaKeyProtocolVersion: cs.InitializeResult().ProtocolVersion}) { + t.Fatalf("List() resultType = %q, unexpected for protocol", result.ResultType) } } func TestValidateListResponseRejectsMissingSkills(t *testing.T) { - if err := validateListResponse(context.Background(), &ListSkillsResult{}); err == nil { + if err := validateListResult(&ListSkillsResult{}, Limits{}); err == nil { t.Fatal("validateListResponse accepted missing skills") } } @@ -223,36 +223,3 @@ func TestListSkillsResultOmitsLegacyCacheFields(t *testing.T) { 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 index 0096a028a..bba71dd36 100644 --- a/skills/types.go +++ b/skills/types.go @@ -13,6 +13,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +const capabilityDirectoryRead = "directoryRead" + const ( // ExtensionID is the capability identifier for the Skills extension. ExtensionID = "io.modelcontextprotocol/skills" @@ -106,25 +108,23 @@ type ListSkillsParams struct { type ListSkillsResult struct { mcp.ResultBase mcp.Cacheable - ResultType string `json:"resultType,omitempty"` - NextCursor string `json:"nextCursor,omitempty"` - Skills []*Skill `json:"skills"` - omitCache bool + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Skills []*Skill `json:"skills"` + omitCache bool + cachePresent 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 + type wire ListSkillsResult + if !r.omitCache { + return json.Marshal((*wire)(r)) } - delete(fields, "ttlMs") - delete(fields, "cacheScope") - return json.Marshal(fields) + return json.Marshal(struct { + *wire + TTLMs *int `json:"ttlMs,omitempty"` + CacheScope *string `json:"cacheScope,omitempty"` + }{wire: (*wire)(r)}) } // GetSkillParams contains parameters for skills/get. @@ -136,8 +136,23 @@ type GetSkillParams struct { // GetSkillResult is the result of skills/get. type GetSkillResult struct { mcp.ResultBase - ResultType string `json:"resultType,omitempty"` - Skill *Skill `json:"skill"` + mcp.Cacheable + omitCache bool + cachePresent bool + ResultType string `json:"resultType,omitempty"` + Skill *Skill `json:"skill"` +} + +func (r *GetSkillResult) MarshalJSON() ([]byte, error) { + type wire GetSkillResult + if !r.omitCache { + return json.Marshal((*wire)(r)) + } + return json.Marshal(struct { + *wire + TTLMs *int `json:"ttlMs,omitempty"` + CacheScope *string `json:"cacheScope,omitempty"` + }{wire: (*wire)(r)}) } // ReadDirectoryParams contains parameters for resources/directory/read. @@ -154,3 +169,45 @@ type ReadDirectoryResult struct { NextCursor string `json:"nextCursor,omitempty"` Resources []*mcp.Resource `json:"resources"` } + +func (r *ListSkillsResult) UnmarshalJSON(data []byte) error { + type wire ListSkillsResult + var decoded struct { + wire + TTLMs *int `json:"ttlMs"` + CacheScope *string `json:"cacheScope"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = ListSkillsResult(decoded.wire) + r.cachePresent = decoded.TTLMs != nil && decoded.CacheScope != nil + if decoded.TTLMs != nil { + r.TTLMs = *decoded.TTLMs + } + if decoded.CacheScope != nil { + r.CacheScope = *decoded.CacheScope + } + return nil +} + +func (r *GetSkillResult) UnmarshalJSON(data []byte) error { + type wire GetSkillResult + var decoded struct { + wire + TTLMs *int `json:"ttlMs"` + CacheScope *string `json:"cacheScope"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = GetSkillResult(decoded.wire) + r.cachePresent = decoded.TTLMs != nil && decoded.CacheScope != nil + if decoded.TTLMs != nil { + r.TTLMs = *decoded.TTLMs + } + if decoded.CacheScope != nil { + r.CacheScope = *decoded.CacheScope + } + return nil +} diff --git a/skills/validation.go b/skills/validation.go index 6ef93e04f..739444064 100644 --- a/skills/validation.go +++ b/skills/validation.go @@ -14,6 +14,7 @@ import ( "strings" "unicode/utf8" + "github.com/modelcontextprotocol/go-sdk/mcp" "gopkg.in/yaml.v3" ) @@ -23,6 +24,9 @@ func parseFrontmatter(data []byte) (Frontmatter, error) { return nil, fmt.Errorf("SKILL.md must begin with YAML frontmatter") } end := bytes.Index(normalized[4:], []byte("\n---\n")) + if end < 0 && bytes.HasSuffix(normalized, []byte("\n---")) { + end = len(normalized) - 8 + } if end < 0 { return nil, fmt.Errorf("SKILL.md frontmatter has no closing delimiter") } @@ -90,7 +94,9 @@ const ( DefaultMaxTotalSize = 16 * 1024 * 1024 ) -// Limits controls the limits applied to a static skill manifest. +// Limits bounds a static skill manifest. Zero fields use the standard defaults; +// negative fields are invalid. Larger limits must be explicitly configured on +// both the server and client. Limits never disable structural validation. type Limits struct { MaxResourcesPerSkill int MaxTotalSize int64 @@ -109,11 +115,32 @@ func DefaultLimits() Limits { // ValidateSkill validates a skill using the Agent Skills and SEP-2640 defaults. func ValidateSkill(skill *Skill) error { - return ValidateSkillWithLimits(skill, DefaultLimits()) + return ValidateSkillWithLimits(skill, Limits{}) } // ValidateSkillWithLimits validates a skill using the supplied manifest limits. func ValidateSkillWithLimits(skill *Skill, limits Limits) error { + limits, err := limits.resolve() + if err != nil { + return err + } + return validateSkill(skill, limits) +} + +func (limits Limits) resolve() (Limits, error) { + if limits.MaxResourcesPerSkill < 0 || limits.MaxTotalSize < 0 { + return Limits{}, fmt.Errorf("skills: limits must not be negative") + } + if limits.MaxResourcesPerSkill == 0 { + limits.MaxResourcesPerSkill = DefaultMaxResourcesPerSkill + } + if limits.MaxTotalSize == 0 { + limits.MaxTotalSize = DefaultMaxTotalSize + } + return limits, nil +} + +func validateSkill(skill *Skill, limits Limits) error { if skill == nil { return fmt.Errorf("skill is nil") } @@ -232,36 +259,28 @@ func ValidateDirectoryResult(uri string, result *ReadDirectoryResult) error { 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 == "" { + child, err := parseURI(resource.URI) + if err != nil { 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 != "" { + if child.Scheme != parent.Scheme || child.Host != parent.Host || child.User.String() != parent.User.String() { 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, "/") { + rel := strings.TrimPrefix(child.Path, parent.Path+"/") + if rel == child.Path || 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 resource.Name == "" { + return fmt.Errorf("directory %q child has no name", uri) } - if seenNames[resource.Name] || seenURIs[resource.URI] { + if 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 @@ -275,17 +294,14 @@ func validateName(name string) error { } 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) + u, err := parseURI(rawURI) + if err != nil { + return "", err } 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"), "/") + dir := strings.TrimPrefix(strings.TrimSuffix(u.Path, "/SKILL.md"), "/") if dir == "" { dir = u.Hostname() } else { @@ -295,43 +311,95 @@ func skillNameFromURI(rawURI string) (string, error) { if dir == "" { return "", fmt.Errorf("skill URI %q has no skill name", rawURI) } + if err := validateName(dir); err != nil { + return "", err + } 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") + skillURL, err := parseURI(skillURI) + if err != nil { + return err } - if resourceURL.Scheme == "skill" && (resourceURL.Host == "" || resourceURL.User != nil || resourceURL.Port() != "") { - return fmt.Errorf("invalid skill resource authority") + resourceURL, err := parseURI(resourceURI) + if err != nil { + return err } - if skillURL.Scheme != resourceURL.Scheme || skillURL.Host != resourceURL.Host { + if skillURL.Scheme != resourceURL.Scheme || skillURL.Host != resourceURL.Host || skillURL.User.String() != resourceURL.User.String() { 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") - } + if !strings.HasPrefix(resourceURL.Path, rootPath+"/") || strings.HasSuffix(resourceURL.Path, "/") { + return fmt.Errorf("URI is outside the skill root or is not a file") } return nil } func parseDirectoryURI(rawURI string) (*url.URL, error) { - if strings.HasSuffix(rawURI, "/") { + u, err := parseURI(rawURI) + if err != nil { + return nil, err + } + if strings.HasSuffix(u.Path, "/") { return nil, fmt.Errorf("directory URI %q must not have a trailing slash", rawURI) } + return u, nil +} + +func parseURI(rawURI string) (*url.URL, error) { 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 err != nil || u.Scheme == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(rawURI, "#") { + return nil, fmt.Errorf("invalid resource URI %q", 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 nil, fmt.Errorf("invalid skill authority in %q", rawURI) + } + for _, segment := range strings.Split(u.Path, "/") { + if segment == "." || segment == ".." { + return nil, fmt.Errorf("URI %q contains a traversal segment", rawURI) + } } return u, nil } + +func validateListResult(result *ListSkillsResult, limits Limits) error { + limits, err := limits.resolve() + if err != nil { + return err + } + if result == nil || 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 := validateSkill(skill, limits); 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 validateGetResult(uri string, result *GetSkillResult, limits Limits) error { + if result == nil || result.Skill == nil { + return fmt.Errorf("skill is missing or null") + } + if result.Skill.URI != uri { + return fmt.Errorf("returned URI %q for %q", result.Skill.URI, uri) + } + return ValidateSkillWithLimits(result.Skill, limits) +} + +func validateCache(cache mcp.Cacheable) error { + if cache.TTLMs < 0 { + return fmt.Errorf("skills: ttlMs must not be negative") + } + if cache.CacheScope != "public" && cache.CacheScope != "private" { + return fmt.Errorf("skills: invalid cacheScope %q", cache.CacheScope) + } + return nil +} diff --git a/skills/verify.go b/skills/verify.go index 62028d7f9..3d75f8f8d 100644 --- a/skills/verify.go +++ b/skills/verify.go @@ -18,7 +18,8 @@ var ErrDynamicResources = errors.New("skills: dynamic resources cannot be integr // 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 { + // Content verification must not reimpose default limits on an accepted entry. + if err := validateSkill(skill, Limits{}); err != nil { return err } if err := validateResourceURI(skill.URI, uri); err != nil { @@ -47,6 +48,9 @@ func VerifyResource(skill *Skill, uri string, content []byte) error { // VerifySkillMD verifies both the content digest and the advertised frontmatter. func VerifySkillMD(skill *Skill, content []byte) error { + if skill == nil { + return fmt.Errorf("skills: nil skill") + } if err := VerifyResource(skill, skill.URI, content); err != nil { return err } From 284092ec0fa0239fc1ff628f04dc17c1bc007306 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 04:25:08 +0100 Subject: [PATCH 03/11] skills: align docs, examples, and verification semantics --- CONTRIBUTING.md | 2 +- README.md | 8 +++ docs/README.md | 19 ++++++-- docs/client.md | 76 ++++++++++++++++++++++++----- docs/server.md | 60 +++++++++++++++++++++-- internal/docs/README.src.md | 19 ++++++-- internal/docs/client.src.md | 48 ++++++++++-------- internal/docs/server.src.md | 24 +++++++-- internal/readme/README.src.md | 8 +++ internal/readme/contributing.src.md | 2 +- mcp/server.go | 3 +- skills/client.go | 29 +++++++---- skills/example_test.go | 74 +++++++++++++++++++++++++--- skills/pagination.go | 6 +-- skills/protocol_test.go | 30 ++++++++++++ skills/server.go | 12 +++-- skills/skills_test.go | 33 ++++++++++++- skills/types.go | 34 ++++++++++--- skills/verify.go | 15 ++++-- 19 files changed, 414 insertions(+), 88 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d8b0904b..2b071f56a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -200,7 +200,7 @@ change therefore cannot reach existing users by accident; they have to change their import path to receive one. This policy covers the exported API of the SDK's importable packages — `mcp`, -`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +`jsonrpc`, `auth`, `auth/extauth`, `oauthex` and `skills`. Everything under `internal/` is not importable outside the module and may change in any release. Which MCP spec revisions each SDK version speaks is documented in the diff --git a/README.md b/README.md index baeb3e7f7..a02d65143 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,17 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. See the [client](docs/client.md#skills-extension) + and [server](docs/server.md#skills-extension) examples. The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. diff --git a/docs/README.md b/docs/README.md index d07cfa0fa..c7f2355ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,14 +13,23 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. -These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). -Use the index below to learn how the SDK implements a particular aspect of the -protocol. +These docs describe the SDK's implementation of the +[MCP specification](https://modelcontextprotocol.io/specification/2026-07-28) +and optional extensions. See the [version compatibility table](../README.md#version-compatibility) +for supported protocol revisions. Use the index below to learn how the SDK +implements a particular feature. ## Base Protocol @@ -41,12 +50,16 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Extensions](client.md#extensions) + 1. [Skills](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Extensions](server.md#extensions) + 1. [Skills](server.md#skills-extension) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/docs/client.md b/docs/client.md index 1d7ca7aa2..0b80d04b8 100644 --- a/docs/client.md +++ b/docs/client.md @@ -544,23 +544,42 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so 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. +are per-extension settings objects. Extensions require explicit opt-in. + +#### Skills extension The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) -package provides typed calls for the Skills extension. Register methods before -connecting, then bind the skills client to the connected session: +package provides typed calls for the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Register methods before connecting, then bind the skills client to the connected +session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: ```go +ctx := context.Background() +client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1.0.0"}, nil) if err := skills.AddMethods(client); err != nil { - return err + log.Fatal(err) +} + +serverTransport, clientTransport := mcp.NewInMemoryTransports() +serverSession, err := server.Connect(ctx, serverTransport, nil) +if err != nil { + log.Fatal(err) +} +defer serverSession.Close() +session, err := client.Connect(ctx, clientTransport, nil) +if err != nil { + log.Fatal(err) } -// Connect client using the usual MCP transport, obtaining session. +defer session.Close() + skillClient := &skills.Client{Session: session} for skill, err := range skillClient.All(ctx, nil) { - if err != nil { - return err - } - fmt.Println(skill.URI, skill.Frontmatter["description"]) + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) } ``` @@ -568,10 +587,43 @@ for skill, err := range skillClient.All(ctx, nil) { 512-resource and 16 MiB per-skill defaults; larger values allow larger skills without disabling protocol validation. Servers and clients configure these limits independently. `ReadDirectory` and `DirectoryEntries` expose optional -directory browsing. Iterators follow cursors without modifying request parameters. +directory browsing when the server advertises `directoryRead: true`. Calls fail +if the required server capabilities are absent. Iterators follow cursors without +modifying request parameters and stop after the first error. Configure the +client's session and limits before concurrent use. Listing does not fetch content. Read files on demand with `session.ReadResource` and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. +A listed entry is complete; `Get` also retrieves a skill directly by URI even +when it was not listed. For example, when the user chooses to load a known skill: + +```go +result, err := skillClient.Get(ctx, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) +if err != nil { + log.Fatal(err) +} +resource, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: result.Skill.URI}) +if err != nil { + log.Fatal(err) +} +if len(resource.Contents) != 1 || resource.Contents[0] == nil || resource.Contents[0].URI != result.Skill.URI || resource.Contents[0].Blob != nil { + log.Fatal("expected one text resource for SKILL.md") +} +if err := skills.VerifySkillMD(result.Skill, []byte(resource.Contents[0].Text)); err != nil { + log.Fatal(err) +} +fmt.Println("verified", result.Skill.URI) +``` + Keep skill entries scoped to their originating session: equal URIs from different -servers are different skills. Dynamic manifests return `skills.ErrDynamicResources` -from verification; the application must decide whether to accept unverifiable content. +servers are different skills. Use a host-assigned server identity when persisting +entries or approvals. Directory results are live observations; they do not expand +the files authorized by a held manifest. + +`VerifyResource` checks manifest membership, byte length, and SHA-256 digest. +`VerifySkillMD` also compares every frontmatter field. For dynamic manifests, +`VerifySkillMD` still checks frontmatter and returns `skills.ErrDynamicResources` +only when it matches; malformed or mismatched frontmatter returns a different error. +Applications decide whether to accept content without integrity verification and +own skill approval and execution policy. A digest match alone does not make remote +instructions trustworthy. diff --git a/docs/server.md b/docs/server.md index def9575b0..15c33c29c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1197,12 +1197,14 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ adds an `extensions` map to `ServerCapabilities` so 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. +per-extension settings objects. Extensions require explicit opt-in. #### Skills extension The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) -package implements the Skills extension. Use `skills.AddHandlers` for request-time +package implements the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Use `skills.AddHandlers` for request-time `skills/list` and `skills/get` handlers. An optional directory handler enables `resources/directory/read` and advertises `directoryRead: true`. @@ -1212,10 +1214,58 @@ capability. An entry's manifest includes every file, including `SKILL.md` and nested skills. Use `skills.DynamicResources()` when stable digests cannot be published, not simply because the catalog changes over time. +This example serves a complete static manifest and its content. The +[client example](client.md#skills-extension) connects to this server and verifies +the resource bytes: + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) +const uri = "skill://greeting/SKILL.md" +const content = "---\nname: greeting\ndescription: Greet the user.\n---\n# Greeting\nSay hello to the user.\n" +entry := &skills.Skill{ + URI: uri, + Frontmatter: skills.Frontmatter{ + "name": "greeting", "description": "Greet the user.", + }, + Resources: skills.StaticResources(&skills.Resource{ + URI: uri, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), Size: int64(len(content)), + }), +} + +server.AddResource(&mcp.Resource{ + URI: uri, Name: "greeting", Description: "Greet the user.", MIMEType: "text/markdown", +}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: uri, MIMEType: "text/markdown", Text: content, + }}}, nil +}) +err := skills.AddHandlers(server, &skills.Handlers{ + List: func(_ context.Context, _ *mcp.ServerSession, params *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + page, next, err := skills.PaginateSkills([]*skills.Skill{entry}, params.Cursor, 0) + return &skills.ListSkillsResult{Skills: page, NextCursor: next}, err + }, + Get: func(_ context.Context, _ *mcp.ServerSession, params *skills.GetSkillParams) (*skills.GetSkillResult, error) { + if params.URI != entry.URI { + return nil, nil + } + return &skills.GetSkillResult{Skill: entry}, nil + }, +}, nil) +if err != nil { + log.Fatal(err) +} +``` + Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result with an empty resource list. Other handler errors pass through unchanged. +Handlers own pagination. `skills.PaginateSkills` and +`skills.PaginateDirectoryResources` sort by URI and return one page without +modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; +`mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill +entry contains its complete manifest, which is never split across pages. + `skills.ServerOptions.Limits` configures manifest limits. Zero fields use the 512-resource and 16 MiB per-skill defaults. A server serving larger skills is not guaranteed to interoperate with default clients. Structural validation always @@ -1240,7 +1290,7 @@ indicates whether page retrieval failed. - [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) iterates prompts. -- [`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) +- [`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) iterates resources. - [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterates resource templates. @@ -1250,7 +1300,7 @@ indicates whether page retrieval failed. The `ClientSession` also exposes `ListXXX` methods for fine-grained control over pagination. -**Server-side**: pagination is on by default, so in general nothing is required -server-side. However, you may use +**Server-side**: pagination is on by default for core feature lists, so in general +nothing is required server-side. However, you may use [`ServerOptions.PageSize`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.PageSize) to customize the page size. diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index 3283efaf8..180bb3a7d 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -12,14 +12,23 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. -These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). -Use the index below to learn how the SDK implements a particular aspect of the -protocol. +These docs describe the SDK's implementation of the +[MCP specification](https://modelcontextprotocol.io/specification/2026-07-28) +and optional extensions. See the [version compatibility table](../README.md#version-compatibility) +for supported protocol revisions. Use the index below to learn how the SDK +implements a particular feature. ## Base Protocol @@ -40,12 +49,16 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Extensions](client.md#extensions) + 1. [Skills](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Extensions](server.md#extensions) + 1. [Skills](server.md#skills-extension) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 80b27fc7d..068ee4f7b 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -233,34 +233,44 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so 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. +are per-extension settings objects. Extensions require explicit opt-in. + +#### Skills extension The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) -package provides typed calls for the Skills extension. Register methods before -connecting, then bind the skills client to the connected session: +package provides typed calls for the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Register methods before connecting, then bind the skills client to the connected +session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: -```go -if err := skills.AddMethods(client); err != nil { - return err -} -// Connect client using the usual MCP transport, obtaining session. -skillClient := &skills.Client{Session: session} -for skill, err := range skillClient.All(ctx, nil) { - if err != nil { - return err - } - fmt.Println(skill.URI, skill.Frontmatter["description"]) -} -``` +%include ../../skills/example_test.go skillsclient - `List`, `Get`, and `All` share `skillClient.Limits`. Zero fields use the standard 512-resource and 16 MiB per-skill defaults; larger values allow larger skills without disabling protocol validation. Servers and clients configure these limits independently. `ReadDirectory` and `DirectoryEntries` expose optional -directory browsing. Iterators follow cursors without modifying request parameters. +directory browsing when the server advertises `directoryRead: true`. Calls fail +if the required server capabilities are absent. Iterators follow cursors without +modifying request parameters and stop after the first error. Configure the +client's session and limits before concurrent use. Listing does not fetch content. Read files on demand with `session.ReadResource` and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. +A listed entry is complete; `Get` also retrieves a skill directly by URI even +when it was not listed. For example, when the user chooses to load a known skill: + +%include ../../skills/example_test.go skillsverify - + Keep skill entries scoped to their originating session: equal URIs from different -servers are different skills. Dynamic manifests return `skills.ErrDynamicResources` -from verification; the application must decide whether to accept unverifiable content. +servers are different skills. Use a host-assigned server identity when persisting +entries or approvals. Directory results are live observations; they do not expand +the files authorized by a held manifest. + +`VerifyResource` checks manifest membership, byte length, and SHA-256 digest. +`VerifySkillMD` also compares every frontmatter field. For dynamic manifests, +`VerifySkillMD` still checks frontmatter and returns `skills.ErrDynamicResources` +only when it matches; malformed or mismatched frontmatter returns a different error. +Applications decide whether to accept content without integrity verification and +own skill approval and execution policy. A digest match alone does not make remote +instructions trustworthy. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index b55027a8d..d690bed16 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -511,12 +511,14 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ adds an `extensions` map to `ServerCapabilities` so 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. +per-extension settings objects. Extensions require explicit opt-in. #### Skills extension The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) -package implements the Skills extension. Use `skills.AddHandlers` for request-time +package implements the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Use `skills.AddHandlers` for request-time `skills/list` and `skills/get` handlers. An optional directory handler enables `resources/directory/read` and advertises `directoryRead: true`. @@ -526,10 +528,22 @@ capability. An entry's manifest includes every file, including `SKILL.md` and nested skills. Use `skills.DynamicResources()` when stable digests cannot be published, not simply because the catalog changes over time. +This example serves a complete static manifest and its content. The +[client example](client.md#skills-extension) connects to this server and verifies +the resource bytes: + +%include ../../skills/example_test.go skillsserver - + Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result with an empty resource list. Other handler errors pass through unchanged. +Handlers own pagination. `skills.PaginateSkills` and +`skills.PaginateDirectoryResources` sort by URI and return one page without +modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; +`mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill +entry contains its complete manifest, which is never split across pages. + `skills.ServerOptions.Limits` configures manifest limits. Zero fields use the 512-resource and 16 MiB per-skill defaults. A server serving larger skills is not guaranteed to interoperate with default clients. Structural validation always @@ -554,7 +568,7 @@ indicates whether page retrieval failed. - [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) iterates prompts. -- [`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) +- [`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) iterates resources. - [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterates resource templates. @@ -564,7 +578,7 @@ indicates whether page retrieval failed. The `ClientSession` also exposes `ListXXX` methods for fine-grained control over pagination. -**Server-side**: pagination is on by default, so in general nothing is required -server-side. However, you may use +**Server-side**: pagination is on by default for core feature lists, so in general +nothing is required server-side. However, you may use [`ServerOptions.PageSize`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.PageSize) to customize the page size. diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index ce1f6fc3e..7b534dfa4 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -22,9 +22,17 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. See the [client](docs/client.md#skills-extension) + and [server](docs/server.md#skills-extension) examples. The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index f2e931ba1..5f714998c 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -182,7 +182,7 @@ change therefore cannot reach existing users by accident; they have to change their import path to receive one. This policy covers the exported API of the SDK's importable packages — `mcp`, -`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +`jsonrpc`, `auth`, `auth/extauth`, `oauthex` and `skills`. Everything under `internal/` is not importable outside the module and may change in any release. Which MCP spec revisions each SDK version speaks is documented in the diff --git a/mcp/server.go b/mcp/server.go index 00ab506ef..0f1f70d47 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -204,7 +204,8 @@ type ServerOptions struct { // // 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. +// nil, an empty object is advertised. The settings map is copied shallowly; +// nested maps, slices, and pointers must not be modified after the call. func (s *Server) AddExtension(name string, settings map[string]any) { s.mu.Lock() defer s.mu.Unlock() diff --git a/skills/client.go b/skills/client.go index 97334b401..35639a2a1 100644 --- a/skills/client.go +++ b/skills/client.go @@ -14,6 +14,7 @@ import ( ) // AddMethods registers the Skills extension methods that client may send. +// Call it before connecting the client to a server. func AddMethods(client *mcp.Client) error { if client == nil { return fmt.Errorf("skills: nil client") @@ -27,18 +28,22 @@ func AddMethods(client *mcp.Client) error { return mcp.AddSendingCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](client, MethodReadDirectory) } -// Client calls the Skills extension on Session. Configure Limits before use; -// its zero value uses the standard per-skill limits. Call AddMethods on the -// underlying mcp.Client before connecting. +// Client calls the Skills extension on a connected [mcp.ClientSession]. +// Call [AddMethods] on the underlying [mcp.Client] before connecting. +// A Client may be used concurrently; do not modify its fields during use. // // Client does not prefetch content or cache entries. Keep entries scoped to // their originating session and verify resource bytes before using them. type Client struct { + // Session is the connected MCP session. It must be non-nil. Session *mcp.ClientSession - Limits Limits + // Limits bounds each manifest returned by List, Get, or All. + // Zero fields use the standard per-skill defaults. + Limits Limits } -// List calls skills/list and validates the response. +// List calls skills/list and validates the response using c.Limits. +// If params is nil, List requests the first page. func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkillsResult, error) { if err := c.requireCapability(false); err != nil { return nil, err @@ -61,7 +66,8 @@ func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkill return result, nil } -// Get calls skills/get and validates the response. +// Get calls skills/get and validates the response using c.Limits. +// The URI in params must identify a SKILL.md, whether or not it was listed. func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResult, error) { if err := c.requireCapability(false); err != nil { return nil, err @@ -85,6 +91,7 @@ func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResu } // ReadDirectory calls resources/directory/read and validates the response. +// The server must advertise directoryRead, and params must specify a directory URI. func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { if err := c.requireCapability(true); err != nil { return nil, err @@ -107,7 +114,9 @@ func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) return result, nil } -// All returns an iterator that follows every page of skills/list. +// All returns an iterator over skills/list, starting at params.Cursor. +// A nil params starts at the first page. Each page is validated as in [Client.List]. +// The iterator stops after yielding its first error. func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*Skill, error] { client := Client{} if c != nil { @@ -120,7 +129,6 @@ func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*S } return func(yield func(*Skill, error) bool) { request := initial - request.Meta = maps.Clone(initial.Meta) allPages(initial.Cursor, func(cursor string) ([]*Skill, string, error) { request.Cursor = cursor result, err := client.List(ctx, &request) @@ -132,7 +140,9 @@ func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*S } } -// DirectoryEntries returns an iterator that follows every page of a directory read. +// DirectoryEntries returns an iterator over a directory read, starting at params.Cursor. +// Each page is validated as in [Client.ReadDirectory]. +// The iterator stops after yielding its first error. func (c *Client) DirectoryEntries(ctx context.Context, params *ReadDirectoryParams) iter.Seq2[*mcp.Resource, error] { client := Client{} if c != nil { @@ -145,7 +155,6 @@ func (c *Client) DirectoryEntries(ctx context.Context, params *ReadDirectoryPara } return func(yield func(*mcp.Resource, error) bool) { request := initial - request.Meta = maps.Clone(initial.Meta) allPages(initial.Cursor, func(cursor string) ([]*mcp.Resource, string, error) { request.Cursor = cursor result, err := client.ReadDirectory(ctx, &request) diff --git a/skills/example_test.go b/skills/example_test.go index 96bdf5ff5..b000efc9d 100644 --- a/skills/example_test.go +++ b/skills/example_test.go @@ -6,6 +6,8 @@ package skills_test import ( "context" + "crypto/sha256" + "fmt" "log" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -13,26 +15,31 @@ import ( ) func ExampleAddHandlers() { + // !+skillsserver server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) + const uri = "skill://greeting/SKILL.md" + const content = "---\nname: greeting\ndescription: Greet the user.\n---\n# Greeting\nSay hello to the user.\n" entry := &skills.Skill{ - URI: "skill://generated/SKILL.md", + URI: uri, Frontmatter: skills.Frontmatter{ - "name": "generated", "description": "Instructions generated on demand.", + "name": "greeting", "description": "Greet the user.", }, - Resources: skills.DynamicResources(), + Resources: skills.StaticResources(&skills.Resource{ + URI: uri, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), Size: int64(len(content)), + }), } server.AddResource(&mcp.Resource{ - URI: entry.URI, Name: "generated", Description: "Instructions generated on demand.", MIMEType: "text/markdown", + URI: uri, Name: "greeting", Description: "Greet the user.", MIMEType: "text/markdown", }, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ - URI: entry.URI, MIMEType: "text/markdown", - Text: "---\nname: generated\ndescription: Instructions generated on demand.\n---\n# Generated\n", + URI: uri, MIMEType: "text/markdown", Text: content, }}}, nil }) 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 + List: func(_ context.Context, _ *mcp.ServerSession, params *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + page, next, err := skills.PaginateSkills([]*skills.Skill{entry}, params.Cursor, 0) + return &skills.ListSkillsResult{Skills: page, NextCursor: next}, err }, Get: func(_ context.Context, _ *mcp.ServerSession, params *skills.GetSkillParams) (*skills.GetSkillResult, error) { if params.URI != entry.URI { @@ -44,4 +51,55 @@ func ExampleAddHandlers() { if err != nil { log.Fatal(err) } + // !-skillsserver + + // !+skillsclient + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1.0.0"}, nil) + if err := skills.AddMethods(client); err != nil { + log.Fatal(err) + } + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + log.Fatal(err) + } + defer serverSession.Close() + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + skillClient := &skills.Client{Session: session} + for skill, err := range skillClient.All(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) + } + // !-skillsclient + + // !+skillsverify + result, err := skillClient.Get(ctx, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) + if err != nil { + log.Fatal(err) + } + resource, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: result.Skill.URI}) + if err != nil { + log.Fatal(err) + } + if len(resource.Contents) != 1 || resource.Contents[0] == nil || resource.Contents[0].URI != result.Skill.URI || resource.Contents[0].Blob != nil { + log.Fatal("expected one text resource for SKILL.md") + } + if err := skills.VerifySkillMD(result.Skill, []byte(resource.Contents[0].Text)); err != nil { + log.Fatal(err) + } + fmt.Println("verified", result.Skill.URI) + // !-skillsverify + + // Output: + // skill://greeting/SKILL.md Greet the user. + // verified skill://greeting/SKILL.md } diff --git a/skills/pagination.go b/skills/pagination.go index 383b33b1f..0df0ffe9a 100644 --- a/skills/pagination.go +++ b/skills/pagination.go @@ -33,7 +33,7 @@ func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) if cursor != "" { decoded, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil || len(decoded) == 0 { - return nil, "", fmt.Errorf("invalid cursor") + return nil, "", invalidParams("invalid cursor") } last := string(decoded) start = len(items) @@ -57,7 +57,7 @@ func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) } // PaginateSkills returns one URI-ordered page and an opaque cursor for the next page. -// It does not modify skills. +// It does not modify skills. A zero page size uses [mcp.DefaultPageSize]. func PaginateSkills(skills []*Skill, cursor string, pageSize int) ([]*Skill, string, error) { return paginate(skills, cursor, pageSize, func(skill *Skill) string { if skill == nil { @@ -68,7 +68,7 @@ func PaginateSkills(skills []*Skill, cursor string, pageSize int) ([]*Skill, str } // PaginateDirectoryResources returns one URI-ordered directory page without -// modifying resources. A zero page size uses mcp.DefaultPageSize. +// modifying resources. A zero page size uses [mcp.DefaultPageSize]. func PaginateDirectoryResources(resources []*mcp.Resource, cursor string, pageSize int) ([]*mcp.Resource, string, error) { return paginate(resources, cursor, pageSize, func(resource *mcp.Resource) string { if resource == nil { diff --git a/skills/protocol_test.go b/skills/protocol_test.go index 62a38933d..09b77e3ea 100644 --- a/skills/protocol_test.go +++ b/skills/protocol_test.go @@ -369,6 +369,36 @@ func TestPaginationAndIteratorOwnership(t *testing.T) { } } +func TestInvalidPaginationCursors(t *testing.T) { + for _, version := range []string{"2025-11-25", "2026-07-28"} { + t.Run(version, func(t *testing.T) { + server := testServer() + skill := testSkill() + h := fixedHandlers(skill) + h.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + page, next, err := PaginateSkills([]*Skill{skill}, p.Cursor, 0) + return &ListSkillsResult{Skills: page, NextCursor: next}, err + } + h.ReadDirectory = func(_ context.Context, _ *mcp.ServerSession, p *ReadDirectoryParams) (*ReadDirectoryResult, error) { + page, next, err := PaginateDirectoryResources([]*mcp.Resource{{URI: skill.URI, Name: "demo"}}, p.Cursor, 0) + return &ReadDirectoryResult{Resources: page, NextCursor: next}, err + } + if err := AddHandlers(server, h, nil); err != nil { + t.Fatal(err) + } + c := connectSkills(t, server, version) + _, listErr := c.List(t.Context(), &ListSkillsParams{Cursor: "%"}) + _, directoryErr := c.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo", Cursor: "%"}) + for method, err := range map[string]error{MethodList: listErr, MethodReadDirectory: directoryErr} { + var rpc *jsonrpc.Error + if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInvalidParams { + t.Errorf("%s: got %v, want JSON-RPC Invalid Params", method, err) + } + } + }) + } +} + func TestURIAndVerificationBoundaries(t *testing.T) { for _, uri := range []string{"skill://demo/../bad", "skill://demo/%2e%2e", "skill://demo/%2e", "skill://demo/file?", "skill://demo/file#", "skill:opaque"} { if _, err := parseURI(uri); err == nil { diff --git a/skills/server.go b/skills/server.go index af55a1550..8d4ca0996 100644 --- a/skills/server.go +++ b/skills/server.go @@ -17,7 +17,7 @@ import ( type ListSkillsHandler func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) // GetSkillHandler handles skills/get. Return (nil, nil) for an unknown skill; -// AddHandlers translates it to JSON-RPC Invalid Params. Other errors pass through. +// [AddHandlers] translates it to JSON-RPC Invalid Params. Other errors pass through. type GetSkillHandler func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) // ReadDirectoryHandler handles resources/directory/read. Return (nil, nil) if @@ -30,7 +30,8 @@ type ServerOptions struct { Limits Limits } -// Handlers contains the required and optional Skills extension handlers. +// Handlers contains the Skills extension handlers. +// List and Get are required; ReadDirectory is optional. type Handlers struct { List ListSkillsHandler Get GetSkillHandler @@ -38,9 +39,14 @@ type Handlers struct { } // AddHandlers registers the Skills extension. Register skill content separately -// with Server.AddResource or Server.AddResourceTemplate, which also advertises +// with [mcp.Server.AddResource] or [mcp.Server.AddResourceTemplate], which also advertises // the required resources capability. Configure the server before connecting. // +// If options is nil, the default limits apply. Handlers own pagination; use +// [PaginateSkills] or [PaginateDirectoryResources] to paginate in-memory slices. +// AddHandlers supplies resultType and default cache hints for the request's +// protocol version. See [ListSkillsResult] and [GetSkillResult]. +// // Options and handler functions are copied. Results are validated without // modifying handler-owned values; handlers must synchronize their own state. func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) error { diff --git a/skills/skills_test.go b/skills/skills_test.go index 437b37ab3..b988aa37e 100644 --- a/skills/skills_test.go +++ b/skills/skills_test.go @@ -116,6 +116,35 @@ func TestPaginateSkills(t *testing.T) { } } +func TestVerifyDynamicSkillMD(t *testing.T) { + skill := &Skill{ + URI: "skill://demo/SKILL.md", + Frontmatter: Frontmatter{"name": "demo", "description": "A demo skill.", "metadata": map[string]string{"author": "go-sdk"}}, + Resources: DynamicResources(), + } + for _, test := range []struct { + name string + content string + matches bool + }{ + {"matching", "---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\n---\n# Demo\n", true}, + {"changed-description", "---\nname: demo\ndescription: Different instructions.\nmetadata:\n author: go-sdk\n---\n", false}, + {"missing-metadata", "---\nname: demo\ndescription: A demo skill.\n---\n", false}, + {"extra-field", "---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\nallowed-tools: Bash\n---\n", false}, + {"malformed", "---\nname: [\n---\n", false}, + } { + t.Run(test.name, func(t *testing.T) { + err := VerifySkillMD(skill, []byte(test.content)) + if err == nil { + t.Fatal("dynamic content passed integrity verification") + } + if got := errors.Is(err, ErrDynamicResources); got != test.matches { + t.Fatalf("VerifySkillMD() = %v; want ErrDynamicResources only for matching frontmatter", err) + } + }) + } +} + func TestAllPagesReusable(t *testing.T) { seq := allPages("", func(cursor string) ([]string, string, error) { switch cursor { @@ -191,9 +220,9 @@ func TestGenericHandlersSupportDynamicResources(t *testing.T) { } } -func TestValidateListResponseRejectsMissingSkills(t *testing.T) { +func TestValidateListResultRejectsMissingSkills(t *testing.T) { if err := validateListResult(&ListSkillsResult{}, Limits{}); err == nil { - t.Fatal("validateListResponse accepted missing skills") + t.Fatal("validateListResult accepted missing skills") } } diff --git a/skills/types.go b/skills/types.go index bba71dd36..bb498a780 100644 --- a/skills/types.go +++ b/skills/types.go @@ -2,7 +2,14 @@ // 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 implements the MCP [Skills extension]. +// +// Servers opt in with [AddHandlers] and serve skill files through the ordinary +// MCP resource APIs. Clients register [AddMethods] before connecting, then use +// [Client] to discover entries. [VerifySkillMD] and [VerifyResource] check content +// retrieved on demand against an entry from the same server. +// +// [Skills extension]: https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx package skills import ( @@ -26,23 +33,29 @@ const ( MethodReadDirectory = "resources/directory/read" ) -// Frontmatter is the verbatim YAML frontmatter of a SKILL.md represented as JSON values. +// Frontmatter is all of a SKILL.md's YAML frontmatter represented as JSON-compatible values. type Frontmatter map[string]any // Resource identifies and fingerprints one file in a skill. type Resource struct { - URI string `json:"uri"` + URI string `json:"uri"` + // Digest is the SHA-256 hash of the raw file bytes, as "sha256:" followed + // by 64 lowercase hexadecimal digits. Digest string `json:"digest"` - Size int64 `json:"size"` + // Size is the length of the raw file content in bytes. + Size int64 `json:"size"` } // Resources is either a complete static resource manifest or the dynamic marker. +// Its zero value is invalid; use [StaticResources] or [DynamicResources]. type Resources struct { dynamic bool entries []*Resource } // StaticResources constructs a complete static resource manifest. +// It must include SKILL.md and every supporting file, including nested skills. +// The resource slice and its entries are retained, not copied. func StaticResources(resources ...*Resource) Resources { if resources == nil { resources = []*Resource{} @@ -57,6 +70,7 @@ func DynamicResources() Resources { return Resources{dynamic: true} } func (r Resources) IsDynamic() bool { return r.dynamic } // List returns the static manifest and true, or nil and false for dynamic or unset resources. +// The returned slice and entries are shared with r. func (r Resources) List() ([]*Resource, bool) { if r.entries == nil || r.dynamic { return nil, false @@ -104,7 +118,11 @@ type ListSkillsParams struct { Cursor string `json:"cursor,omitempty"` } -// ListSkillsResult is the result of skills/list. +// ListSkillsResult is one page of skills/list. Each Skill is a complete entry; +// its manifest is never split across pages. +// [AddHandlers] sets ResultType and defaults CacheScope to "public"; a zero TTLMs +// marks the response immediately stale. These fields are omitted before protocol +// version 2026-07-28. type ListSkillsResult struct { mcp.ResultBase mcp.Cacheable @@ -134,13 +152,14 @@ type GetSkillParams struct { } // GetSkillResult is the result of skills/get. +// Its result type and cache hints are handled as in [ListSkillsResult]. type GetSkillResult struct { mcp.ResultBase mcp.Cacheable - omitCache bool - cachePresent bool ResultType string `json:"resultType,omitempty"` Skill *Skill `json:"skill"` + omitCache bool + cachePresent bool } func (r *GetSkillResult) MarshalJSON() ([]byte, error) { @@ -163,6 +182,7 @@ type ReadDirectoryParams struct { } // ReadDirectoryResult is the result of resources/directory/read. +// [AddHandlers] sets ResultType for the request's protocol version. type ReadDirectoryResult struct { mcp.ResultBase ResultType string `json:"resultType,omitempty"` diff --git a/skills/verify.go b/skills/verify.go index 3d75f8f8d..1259bf1d6 100644 --- a/skills/verify.go +++ b/skills/verify.go @@ -16,7 +16,9 @@ import ( // 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. +// VerifyResource checks membership, size, and digest against the held skill entry. +// It returns [ErrDynamicResources] for a dynamic manifest. It checks the entry's +// structure without reapplying size limits configured during discovery. func VerifyResource(skill *Skill, uri string, content []byte) error { // Content verification must not reimpose default limits on an accepted entry. if err := validateSkill(skill, Limits{}); err != nil { @@ -46,13 +48,16 @@ func VerifyResource(skill *Skill, uri string, content []byte) error { return fmt.Errorf("skills: resource %q is not in the held skill manifest", uri) } -// VerifySkillMD verifies both the content digest and the advertised frontmatter. +// VerifySkillMD verifies SKILL.md with [VerifyResource] and compares every +// frontmatter field with the held entry. For a dynamic manifest it still checks +// frontmatter, returning [ErrDynamicResources] only if the frontmatter matches. func VerifySkillMD(skill *Skill, content []byte) error { if skill == nil { return fmt.Errorf("skills: nil skill") } - if err := VerifyResource(skill, skill.URI, content); err != nil { - return err + verificationErr := VerifyResource(skill, skill.URI, content) + if verificationErr != nil && !errors.Is(verificationErr, ErrDynamicResources) { + return verificationErr } frontmatter, err := parseFrontmatter(content) if err != nil { @@ -69,5 +74,5 @@ func VerifySkillMD(skill *Skill, content []byte) error { if !bytes.Equal(got, want) { return fmt.Errorf("skills: SKILL.md frontmatter does not match the skill entry") } - return nil + return verificationErr } From 26a90b23f192b767a7d0539be5d5ca982ea4cde8 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 04:42:07 +0100 Subject: [PATCH 04/11] skills: distinguish default and explicit manifest limits --- docs/client.md | 36 ++++++-- docs/server.md | 17 +++- internal/docs/client.src.md | 32 +++++-- internal/docs/server.src.md | 17 +++- skills/client.go | 25 ++++- skills/example_test.go | 6 ++ skills/limits_test.go | 176 ++++++++++++++++++++++++++++++++++++ skills/protocol_test.go | 79 ---------------- skills/server.go | 11 ++- skills/validation.go | 41 +++++---- 10 files changed, 310 insertions(+), 130 deletions(-) create mode 100644 skills/limits_test.go diff --git a/docs/client.md b/docs/client.md index 0b80d04b8..8cf635f9c 100644 --- a/docs/client.md +++ b/docs/client.md @@ -583,14 +583,38 @@ for skill, err := range skillClient.All(ctx, nil) { } ``` -`List`, `Get`, and `All` share `skillClient.Limits`. Zero fields use the standard -512-resource and 16 MiB per-skill defaults; larger values allow larger skills -without disabling protocol validation. Servers and clients configure these -limits independently. `ReadDirectory` and `DirectoryEntries` expose optional +`List`, `Get`, and `All` share `skillClient.Limits`: + +| Configuration | Manifest limits | +| --- | --- | +| `Limits: nil` | SDK defaults: currently 512 resources and 16 MiB per skill | +| `Limits: &skills.Limits{}` | No count or size caps | +| Positive fields in a supplied `Limits` | Exact caps for those dimensions | +| Zero fields in a supplied `Limits` | Those dimensions are unlimited | +| Negative fields | Configuration error | + +Structural validation always runs. To change one default while retaining the +others, start from `skills.DefaultLimits()`, which returns a fresh value: + +```go +limits := skills.DefaultLimits() +limits.MaxTotalSize = 32 << 20 +skillClient = &skills.Client{Session: session, Limits: &limits} +``` + +A literal containing only `MaxTotalSize` leaves resource count unlimited. +`DefaultLimits()` and the exported `DefaultMaxResourcesPerSkill` and +`DefaultMaxTotalSize` constants follow the installed SDK version. Supply explicit +numeric values for all desired caps to pin application policy across upgrades. + +Servers and clients configure these limits independently. Each call captures the +configured limits before sending its request, and `All` captures them when the +iterator is created. Do not mutate the client or its referenced limits during use. + +`ReadDirectory` and `DirectoryEntries` expose optional directory browsing when the server advertises `directoryRead: true`. Calls fail if the required server capabilities are absent. Iterators follow cursors without -modifying request parameters and stop after the first error. Configure the -client's session and limits before concurrent use. +modifying request parameters and stop after the first error. Listing does not fetch content. Read files on demand with `session.ReadResource` and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. diff --git a/docs/server.md b/docs/server.md index 15c33c29c..7c5ec1258 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1266,11 +1266,18 @@ modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; `mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill entry contains its complete manifest, which is never split across pages. -`skills.ServerOptions.Limits` configures manifest limits. Zero fields use the -512-resource and 16 MiB per-skill defaults. A server serving larger skills is not -guaranteed to interoperate with default clients. Structural validation always -runs; put additional application policy in the handlers themselves. The SDK -copies options and prepares outgoing results without mutating handler-owned data. +`skills.ServerOptions.Limits` is an optional `*skills.Limits`. Nil uses the SDK +defaults, currently 512 resources and 16 MiB per skill. A supplied value uses exact +caps: zero fields are unlimited and negative fields are invalid. Pass +`&skills.Limits{}` for no manifest caps, or modify a value from +`skills.DefaultLimits()` to retain defaults for fields you do not override. +The [client documentation](client.md#skills-extension) shows the configuration +semantics and how to pin values across SDK upgrades. + +A server serving larger skills is not guaranteed to interoperate with default +clients. Structural validation always runs; put additional application policy +in the handlers themselves. The SDK copies the limits during registration and +prepares outgoing results without mutating handler-owned data. On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and `cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 068ee4f7b..75a45521d 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -246,14 +246,34 @@ session. This example connects to the server from the %include ../../skills/example_test.go skillsclient - -`List`, `Get`, and `All` share `skillClient.Limits`. Zero fields use the standard -512-resource and 16 MiB per-skill defaults; larger values allow larger skills -without disabling protocol validation. Servers and clients configure these -limits independently. `ReadDirectory` and `DirectoryEntries` expose optional +`List`, `Get`, and `All` share `skillClient.Limits`: + +| Configuration | Manifest limits | +| --- | --- | +| `Limits: nil` | SDK defaults: currently 512 resources and 16 MiB per skill | +| `Limits: &skills.Limits{}` | No count or size caps | +| Positive fields in a supplied `Limits` | Exact caps for those dimensions | +| Zero fields in a supplied `Limits` | Those dimensions are unlimited | +| Negative fields | Configuration error | + +Structural validation always runs. To change one default while retaining the +others, start from `skills.DefaultLimits()`, which returns a fresh value: + +%include ../../skills/example_test.go skillslimits - + +A literal containing only `MaxTotalSize` leaves resource count unlimited. +`DefaultLimits()` and the exported `DefaultMaxResourcesPerSkill` and +`DefaultMaxTotalSize` constants follow the installed SDK version. Supply explicit +numeric values for all desired caps to pin application policy across upgrades. + +Servers and clients configure these limits independently. Each call captures the +configured limits before sending its request, and `All` captures them when the +iterator is created. Do not mutate the client or its referenced limits during use. + +`ReadDirectory` and `DirectoryEntries` expose optional directory browsing when the server advertises `directoryRead: true`. Calls fail if the required server capabilities are absent. Iterators follow cursors without -modifying request parameters and stop after the first error. Configure the -client's session and limits before concurrent use. +modifying request parameters and stop after the first error. Listing does not fetch content. Read files on demand with `session.ReadResource` and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index d690bed16..478483902 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -544,11 +544,18 @@ modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; `mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill entry contains its complete manifest, which is never split across pages. -`skills.ServerOptions.Limits` configures manifest limits. Zero fields use the -512-resource and 16 MiB per-skill defaults. A server serving larger skills is not -guaranteed to interoperate with default clients. Structural validation always -runs; put additional application policy in the handlers themselves. The SDK -copies options and prepares outgoing results without mutating handler-owned data. +`skills.ServerOptions.Limits` is an optional `*skills.Limits`. Nil uses the SDK +defaults, currently 512 resources and 16 MiB per skill. A supplied value uses exact +caps: zero fields are unlimited and negative fields are invalid. Pass +`&skills.Limits{}` for no manifest caps, or modify a value from +`skills.DefaultLimits()` to retain defaults for fields you do not override. +The [client documentation](client.md#skills-extension) shows the configuration +semantics and how to pin values across SDK upgrades. + +A server serving larger skills is not guaranteed to interoperate with default +clients. Structural validation always runs; put additional application policy +in the handlers themselves. The SDK copies the limits during registration and +prepares outgoing results without mutating handler-owned data. On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and `cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints diff --git a/skills/client.go b/skills/client.go index 35639a2a1..6839d55d8 100644 --- a/skills/client.go +++ b/skills/client.go @@ -30,7 +30,8 @@ func AddMethods(client *mcp.Client) error { // Client calls the Skills extension on a connected [mcp.ClientSession]. // Call [AddMethods] on the underlying [mcp.Client] before connecting. -// A Client may be used concurrently; do not modify its fields during use. +// A Client may be used concurrently; do not modify its fields or the referenced +// Limits value during use. // // Client does not prefetch content or cache entries. Keep entries scoped to // their originating session and verify resource bytes before using them. @@ -38,8 +39,9 @@ type Client struct { // Session is the connected MCP session. It must be non-nil. Session *mcp.ClientSession // Limits bounds each manifest returned by List, Get, or All. - // Zero fields use the standard per-skill defaults. - Limits Limits + // Nil uses [DefaultLimits]; a non-nil value supplies exact caps, with + // zero fields meaning unlimited. + Limits *Limits } // List calls skills/list and validates the response using c.Limits. @@ -48,6 +50,10 @@ func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkill if err := c.requireCapability(false); err != nil { return nil, err } + limits, err := c.Limits.resolve() + if err != nil { + return nil, err + } if params == nil { params = &ListSkillsParams{} } @@ -57,7 +63,7 @@ func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkill if err != nil { return nil, err } - if err := validateListResult(result, c.Limits); err != nil { + if err := validateListResult(result, limits); err != nil { return nil, fmt.Errorf("skills: server returned an invalid skills/list result: %w", err) } if err := c.validateEnvelope(result.ResultType, &result.Cacheable, result.cachePresent); err != nil { @@ -72,6 +78,10 @@ func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResu if err := c.requireCapability(false); err != nil { return nil, err } + limits, err := c.Limits.resolve() + if err != nil { + return nil, err + } if params == nil || params.URI == "" { return nil, fmt.Errorf("skills: get requires a URI") } @@ -81,7 +91,7 @@ func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResu if err != nil { return nil, err } - if err := validateGetResult(params.URI, result, c.Limits); err != nil { + if err := validateGetResult(params.URI, result, limits); err != nil { return nil, fmt.Errorf("skills: server returned an invalid skill: %w", err) } if err := c.validateEnvelope(result.ResultType, &result.Cacheable, result.cachePresent); err != nil { @@ -116,11 +126,16 @@ func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) // All returns an iterator over skills/list, starting at params.Cursor. // A nil params starts at the first page. Each page is validated as in [Client.List]. +// The session, limits, and parameters are captured when All is called. // The iterator stops after yielding its first error. func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*Skill, error] { client := Client{} if c != nil { client = *c + if c.Limits != nil { + limits := *c.Limits + client.Limits = &limits + } } var initial ListSkillsParams if params != nil { diff --git a/skills/example_test.go b/skills/example_test.go index b000efc9d..34a494e1c 100644 --- a/skills/example_test.go +++ b/skills/example_test.go @@ -81,6 +81,12 @@ func ExampleAddHandlers() { } // !-skillsclient + // !+skillslimits + limits := skills.DefaultLimits() + limits.MaxTotalSize = 32 << 20 + skillClient = &skills.Client{Session: session, Limits: &limits} + // !-skillslimits + // !+skillsverify result, err := skillClient.Get(ctx, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) if err != nil { diff --git a/skills/limits_test.go b/skills/limits_test.go new file mode 100644 index 000000000..c7db2ea15 --- /dev/null +++ b/skills/limits_test.go @@ -0,0 +1,176 @@ +// 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" + "sync/atomic" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func checkLimitCalls(t *testing.T, client *Client, skill *Skill, wantOK bool) { + t.Helper() + _, listErr := client.List(t.Context(), nil) + _, getErr := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + var allErr error + count := 0 + for _, err := range client.All(t.Context(), nil) { + if err != nil { + allErr = err + break + } + count++ + } + for method, err := range map[string]error{"List": listErr, "Get": getErr, "All": allErr} { + if (err == nil) != wantOK { + t.Errorf("%s: error = %v, want success = %v", method, err, wantOK) + } + } + if wantOK && count != 1 { + t.Errorf("All yielded %d skills, want 1", count) + } +} + +func TestLimitsRoundTrip(t *testing.T) { + defaults := DefaultLimits() + for _, kind := range []string{"count", "bytes"} { + t.Run(kind, func(t *testing.T) { + skill := testSkill() + entries, _ := skill.Resources.List() + if kind == "count" { + for i := 1; i <= DefaultMaxResourcesPerSkill; i++ { + entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: entries[0].Digest, Size: 1}) + } + } else { + entries[0].Size = DefaultMaxTotalSize + 1 + } + skill.Resources = StaticResources(entries...) + for _, test := range []struct { + name string + limits *Limits + wantOK bool + }{ + {"defaults", nil, false}, + {"explicit-defaults", &defaults, false}, + {"unlimited", &Limits{}, true}, + {"count-only", &Limits{MaxResourcesPerSkill: DefaultMaxResourcesPerSkill}, kind == "bytes"}, + {"bytes-only", &Limits{MaxTotalSize: DefaultMaxTotalSize}, kind == "count"}, + {"raised-count", &Limits{MaxResourcesPerSkill: DefaultMaxResourcesPerSkill + 1}, true}, + {"raised-bytes", &Limits{MaxTotalSize: DefaultMaxTotalSize + 1}, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Run("client", func(t *testing.T) { + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: &Limits{}}); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, "2026-07-28") + client.Limits = test.limits + checkLimitCalls(t, client, skill, test.wantOK) + }) + t.Run("server", func(t *testing.T) { + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: test.limits}); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, "2026-07-28") + client.Limits = &Limits{} + checkLimitCalls(t, client, skill, test.wantOK) + }) + }) + } + if err := ValidateSkill(skill); err == nil { + t.Fatal("ValidateSkill accepted an oversized manifest") + } + if err := ValidateSkillWithLimits(skill, Limits{}); err != nil { + t.Fatalf("explicit unlimited validation: %v", err) + } + }) + } +} + +func TestNegativeLimits(t *testing.T) { + server := testServer() + skill := testSkill() + handlers := fixedHandlers(skill) + var calls atomic.Int32 + list, get := handlers.List, handlers.Get + handlers.List = func(ctx context.Context, session *mcp.ServerSession, params *ListSkillsParams) (*ListSkillsResult, error) { + calls.Add(1) + return list(ctx, session, params) + } + handlers.Get = func(ctx context.Context, session *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { + calls.Add(1) + return get(ctx, session, params) + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, "2026-07-28") + for _, limits := range []Limits{{MaxResourcesPerSkill: -1}, {MaxTotalSize: -1}} { + if err := ValidateSkillWithLimits(skill, limits); err == nil { + t.Fatal("negative validation limit accepted") + } + if err := AddHandlers(testServer(), handlers, &ServerOptions{Limits: &limits}); err == nil { + t.Fatal("negative server limit accepted") + } + client.Limits = &limits + checkLimitCalls(t, client, skill, false) + } + if got := calls.Load(); got != 0 { + t.Fatalf("invalid client limits sent %d requests", got) + } +} + +func TestLimitOwnership(t *testing.T) { + skill := testSkill() + entries, _ := skill.Resources.List() + skill.Resources = StaticResources(entries[0], &Resource{URI: "skill://demo/helper.txt", Digest: entries[0].Digest, Size: 1}) + server := testServer() + serverLimits := Limits{MaxResourcesPerSkill: 2} + options := &ServerOptions{Limits: &serverLimits} + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + serverLimits.MaxResourcesPerSkill = 1 + options.Limits = &Limits{MaxTotalSize: 1} + client := connectSkills(t, server, "2026-07-28") + clientLimits := Limits{MaxResourcesPerSkill: 2} + client.Limits = &clientLimits + checkLimitCalls(t, client, skill, true) + seq := client.All(t.Context(), nil) + clientLimits.MaxResourcesPerSkill = 1 + checkLimitCalls(t, client, skill, false) + for range 2 { + count := 0 + for _, err := range seq { + if err != nil { + t.Fatal(err) + } + count++ + } + if count != 1 { + t.Fatalf("captured iterator yielded %d skills, want 1", count) + } + } +} + +func TestUnlimitedLimitsKeepStructuralValidation(t *testing.T) { + skill := testSkill() + skill.Frontmatter["name"] = "BAD" + if err := ValidateSkillWithLimits(skill, Limits{}); err == nil { + t.Fatal("unlimited validation accepted malformed frontmatter") + } + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: &Limits{}}); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, "2026-07-28") + client.Limits = &Limits{} + checkLimitCalls(t, client, skill, false) +} diff --git a/skills/protocol_test.go b/skills/protocol_test.go index 09b77e3ea..2a2cf96cf 100644 --- a/skills/protocol_test.go +++ b/skills/protocol_test.go @@ -61,85 +61,6 @@ func fixedHandlers(skill *Skill) *Handlers { } } -func TestLimitsRoundTrip(t *testing.T) { - for _, kind := range []string{"count", "bytes"} { - t.Run(kind, func(t *testing.T) { - skill := testSkill() - limits := DefaultLimits() - if kind == "count" { - limits.MaxResourcesPerSkill++ - entries, _ := skill.Resources.List() - for i := 1; i < limits.MaxResourcesPerSkill; i++ { - entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: entries[0].Digest, Size: 1}) - } - skill.Resources = StaticResources(entries...) - } else { - limits.MaxTotalSize++ - entries, _ := skill.Resources.List() - entries[0].Size = limits.MaxTotalSize - } - server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: limits}); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, "2026-07-28") - if _, err := client.List(t.Context(), nil); err == nil { - t.Fatal("default client accepted oversized list") - } - if _, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}); err == nil { - t.Fatal("default client accepted oversized get") - } - client.Limits = limits - if _, err := client.List(t.Context(), nil); err != nil { - t.Fatal(err) - } - if _, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}); err != nil { - t.Fatal(err) - } - count := 0 - for _, err := range client.All(t.Context(), nil) { - if err != nil { - t.Fatal(err) - } - count++ - } - if count != 1 { - t.Fatalf("count=%d", count) - } - // A higher budget never bypasses structural checks. - malformed := *skill - malformed.Frontmatter = Frontmatter{"name": "BAD", "description": "Demo"} - if err := ValidateSkillWithLimits(&malformed, limits); err == nil { - t.Fatal("accepted malformed skill") - } - // The server also enforces its own defaults. - if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { - t.Fatal(err) - } - if _, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}); err == nil { - t.Fatal("default server served oversized skill") - } - }) - } - if err := ValidateSkillWithLimits(testSkill(), Limits{MaxTotalSize: -1}); err == nil { - t.Fatal("negative limit accepted") - } - if err := AddHandlers(testServer(), fixedHandlers(testSkill()), &ServerOptions{Limits: Limits{MaxResourcesPerSkill: -1}}); err == nil { - t.Fatal("negative server limit accepted") - } - entries, _ := testSkill().Resources.List() - skill := testSkill() - entries[0].Size = DefaultMaxTotalSize - skill.Resources = StaticResources(entries...) - if err := ValidateSkillWithLimits(skill, Limits{MaxResourcesPerSkill: 1}); err != nil { - t.Fatal(err) - } - entries[0].Size++ - if err := ValidateSkillWithLimits(skill, Limits{MaxResourcesPerSkill: 1}); err == nil { - t.Fatal("zero byte limit disabled the default") - } -} - func TestUnknownURIsAndHandlerErrors(t *testing.T) { server := testServer() skill := testSkill() diff --git a/skills/server.go b/skills/server.go index 8d4ca0996..06093b9fe 100644 --- a/skills/server.go +++ b/skills/server.go @@ -27,7 +27,10 @@ type ReadDirectoryHandler func(context.Context, *mcp.ServerSession, *ReadDirecto // ServerOptions configures the per-skill limits. Protocol validation always // runs; applications can perform additional checks in their handlers. type ServerOptions struct { - Limits Limits + // Limits bounds static manifests. Nil uses [DefaultLimits]; a non-nil + // value supplies exact caps, with zero fields meaning unlimited. + // AddHandlers copies the value during registration. + Limits *Limits } // Handlers contains the Skills extension handlers. @@ -57,11 +60,11 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) return fmt.Errorf("skills: list and get handlers are required") } h := *handlers - var limits Limits + var configuredLimits *Limits if options != nil { - limits = options.Limits + configuredLimits = options.Limits } - limits, err := limits.resolve() + limits, err := configuredLimits.resolve() if err != nil { return err } diff --git a/skills/validation.go b/skills/validation.go index 739444064..360b9c026 100644 --- a/skills/validation.go +++ b/skills/validation.go @@ -94,18 +94,25 @@ const ( DefaultMaxTotalSize = 16 * 1024 * 1024 ) -// Limits bounds a static skill manifest. Zero fields use the standard defaults; -// negative fields are invalid. Larger limits must be explicitly configured on -// both the server and client. Limits never disable structural validation. +// Limits bounds a static skill manifest. Positive fields are exact caps; zero +// fields are unlimited, and negative fields are invalid. The zero value imposes +// no manifest caps. Limits never disable structural validation. +// +// A nil *Limits in [Client] or [ServerOptions] uses [DefaultLimits]. To customize +// one default while retaining the others, start with the value from DefaultLimits. type Limits struct { + // MaxResourcesPerSkill limits the number of files, including SKILL.md. MaxResourcesPerSkill int - MaxTotalSize int64 + // MaxTotalSize limits the sum of the files' raw byte lengths. + 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. +// DefaultLimits returns a fresh value containing the SDK's current defaults: +// [DefaultMaxResourcesPerSkill] and [DefaultMaxTotalSize]. To pin application +// policy across SDK upgrades, supply explicit numeric limits instead. func DefaultLimits() Limits { return Limits{ MaxResourcesPerSkill: DefaultMaxResourcesPerSkill, @@ -115,10 +122,11 @@ func DefaultLimits() Limits { // ValidateSkill validates a skill using the Agent Skills and SEP-2640 defaults. func ValidateSkill(skill *Skill) error { - return ValidateSkillWithLimits(skill, Limits{}) + return ValidateSkillWithLimits(skill, DefaultLimits()) } -// ValidateSkillWithLimits validates a skill using the supplied manifest limits. +// ValidateSkillWithLimits validates a skill using exactly the supplied limits. +// Zero fields impose no cap on that dimension; structural validation always runs. func ValidateSkillWithLimits(skill *Skill, limits Limits) error { limits, err := limits.resolve() if err != nil { @@ -127,17 +135,14 @@ func ValidateSkillWithLimits(skill *Skill, limits Limits) error { return validateSkill(skill, limits) } -func (limits Limits) resolve() (Limits, error) { +func (limits *Limits) resolve() (Limits, error) { + if limits == nil { + return DefaultLimits(), nil + } if limits.MaxResourcesPerSkill < 0 || limits.MaxTotalSize < 0 { return Limits{}, fmt.Errorf("skills: limits must not be negative") } - if limits.MaxResourcesPerSkill == 0 { - limits.MaxResourcesPerSkill = DefaultMaxResourcesPerSkill - } - if limits.MaxTotalSize == 0 { - limits.MaxTotalSize = DefaultMaxTotalSize - } - return limits, nil + return *limits, nil } func validateSkill(skill *Skill, limits Limits) error { @@ -364,10 +369,6 @@ func parseURI(rawURI string) (*url.URL, error) { } func validateListResult(result *ListSkillsResult, limits Limits) error { - limits, err := limits.resolve() - if err != nil { - return err - } if result == nil || result.Skills == nil { return fmt.Errorf("skills is missing or null") } @@ -391,7 +392,7 @@ func validateGetResult(uri string, result *GetSkillResult, limits Limits) error if result.Skill.URI != uri { return fmt.Errorf("returned URI %q for %q", result.Skill.URI, uri) } - return ValidateSkillWithLimits(result.Skill, limits) + return validateSkill(result.Skill, limits) } func validateCache(cache mcp.Cacheable) error { From 458f497ac896537c8c4f135493d6231b6e019099 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 09:47:08 +0100 Subject: [PATCH 05/11] skills: make manifest caps opt-in and fix protocol validation --- .github/workflows/conformance.yml | 31 +++++++ CONTRIBUTING.md | 27 ++++-- docs/client.md | 28 ++++--- docs/server.md | 31 +++---- internal/docs/client.src.md | 24 ++++-- internal/docs/server.src.md | 31 +++---- internal/readme/contributing.src.md | 27 ++++-- scripts/skills-conformance.sh | 81 ++++++++++++++++++ skills/client.go | 21 ++--- skills/example_test.go | 4 +- skills/limits_test.go | 81 +++++++++++------- skills/protocol_test.go | 52 +++++++++++- skills/server.go | 45 ++++++---- skills/types.go | 38 ++++++++- skills/validation.go | 73 ++++++++--------- skills/validation_test.go | 122 ++++++++++++++++++++++++++++ skills/verify.go | 57 +++++++++++-- 17 files changed, 602 insertions(+), 171 deletions(-) create mode 100755 scripts/skills-conformance.sh create mode 100644 skills/validation_test.go diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 6f54df4e0..5a6cb8636 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -17,6 +17,37 @@ env: CONFORMANCE_VERSION: "0.2.0-alpha.11" jobs: + skills-conformance: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "^1.26" + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + # Skills scenarios are pending in modelcontextprotocol/conformance#330. + # Replace this checkout with a pinned npm release once they are released. + - name: Check out Skills conformance scenarios + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: panyam/mcpconformance + ref: 73ac2c4d0f40505fbd597c23399aebd7545900ed + path: _conformance-skills + persist-credentials: false + - name: Build conformance runner + working-directory: _conformance-skills + run: npm ci --ignore-scripts && npm run build + - name: Run Skills conformance tests + run: >- + bash ./scripts/skills-conformance.sh + --conformance_repo "$GITHUB_WORKSPACE/_conformance-skills" + --result_dir "$RUNNER_TEMP/skills-conformance" + server-conformance: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b071f56a..98a4ccbd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,18 +49,17 @@ go work init ./project ./go-sdk ### Conformance tests -The SDK includes a script to run the official MCP conformance tests against the -SDK's conformance server: +The SDK includes scripts to run the official MCP server and client conformance tests: ```sh -./scripts/conformance.sh +./scripts/server-conformance.sh +./scripts/client-conformance.sh ``` -By default, results are cleaned up after the script runs. To save results to a -specific directory: +To save server results to a specific directory: ```sh -./scripts/conformance.sh --result_dir ./conformance-results +./scripts/server-conformance.sh --result_dir ./conformance-results ``` To run against a local checkout of the @@ -68,12 +67,24 @@ To run against a local checkout of the of the latest npm release: ```sh -./scripts/conformance.sh --conformance_repo ~/src/conformance +./scripts/server-conformance.sh --conformance_repo ~/src/conformance ``` Note: you must run `npm install` in the conformance repo first. -Run `./scripts/conformance.sh --help` for more options. +Run either script with `--help` for more options. + +Skills has a separate fixture and CI job covering enumeration, manifests, and +directory reads on both `2025-11-25` stateful and `2026-07-28` stateless transports. +Its scenarios are pending in [conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). +Until they are released, CI pins the checkout below. To reproduce that run: + +```sh +git clone https://github.com/panyam/mcpconformance.git ../skills-conformance +git -C ../skills-conformance checkout 73ac2c4d0f40505fbd597c23399aebd7545900ed +(cd ../skills-conformance && npm ci --ignore-scripts && npm run build) +./scripts/skills-conformance.sh --conformance_repo ../skills-conformance --result_dir /tmp/skills-conformance-results +``` ## Filing issues diff --git a/docs/client.md b/docs/client.md index 8cf635f9c..6ffca2774 100644 --- a/docs/client.md +++ b/docs/client.md @@ -587,29 +587,34 @@ for skill, err := range skillClient.All(ctx, nil) { | Configuration | Manifest limits | | --- | --- | -| `Limits: nil` | SDK defaults: currently 512 resources and 16 MiB per skill | -| `Limits: &skills.Limits{}` | No count or size caps | +| Omitted or `Limits: skills.Limits{}` | No count or size caps | +| `Limits: skills.BaselineLimits()` | 512 resources and 16 MiB per skill | | Positive fields in a supplied `Limits` | Exact caps for those dimensions | | Zero fields in a supplied `Limits` | Those dimensions are unlimited | | Negative fields | Configuration error | -Structural validation always runs. To change one default while retaining the -others, start from `skills.DefaultLimits()`, which returns a fresh value: +Structural validation always runs. The spec's limits are an interoperability +baseline: hosts must support at least that much and may support more. They are +not mandatory rejection thresholds. To opt into caps based on that baseline: ```go -limits := skills.DefaultLimits() +limits := skills.BaselineLimits() limits.MaxTotalSize = 32 << 20 -skillClient = &skills.Client{Session: session, Limits: &limits} +skillClient = &skills.Client{Session: session, Limits: limits} ``` A literal containing only `MaxTotalSize` leaves resource count unlimited. -`DefaultLimits()` and the exported `DefaultMaxResourcesPerSkill` and -`DefaultMaxTotalSize` constants follow the installed SDK version. Supply explicit -numeric values for all desired caps to pin application policy across upgrades. +`BaselineLimits()` follows the spec supported by the installed SDK version. +Supply explicit numeric values to pin application policy across upgrades. +Caps below the baseline reduce what the host can accept. Servers and clients configure these limits independently. Each call captures the configured limits before sending its request, and `All` captures them when the -iterator is created. Do not mutate the client or its referenced limits during use. +iterator is created. Do not mutate the client during use. + +These caps apply to static manifests. For dynamic skills, applications manage +their own download, storage, and context budgets; the SDK does not retrieve files +or maintain cumulative size or file counts. `ReadDirectory` and `DirectoryEntries` expose optional directory browsing when the server advertises `directoryRead: true`. Calls fail @@ -645,7 +650,8 @@ entries or approvals. Directory results are live observations; they do not expan the files authorized by a held manifest. `VerifyResource` checks manifest membership, byte length, and SHA-256 digest. -`VerifySkillMD` also compares every frontmatter field. For dynamic manifests, +`VerifySkillMD` also compares every frontmatter field. JSON frontmatter numbers +are decoded as `json.Number` to preserve integer precision. For dynamic manifests, `VerifySkillMD` still checks frontmatter and returns `skills.ErrDynamicResources` only when it matches; malformed or mismatched frontmatter returns a different error. Applications decide whether to accept content without integrity verification and diff --git a/docs/server.md b/docs/server.md index 7c5ec1258..677e4e60c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1258,7 +1258,8 @@ if err != nil { Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result -with an empty resource list. Other handler errors pass through unchanged. +with an empty resource list. Explicit JSON-RPC errors retain their code and data; +other handler errors and invalid results become Internal Error (`-32603`). Handlers own pagination. `skills.PaginateSkills` and `skills.PaginateDirectoryResources` sort by URI and return one page without @@ -1266,23 +1267,25 @@ modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; `mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill entry contains its complete manifest, which is never split across pages. -`skills.ServerOptions.Limits` is an optional `*skills.Limits`. Nil uses the SDK -defaults, currently 512 resources and 16 MiB per skill. A supplied value uses exact -caps: zero fields are unlimited and negative fields are invalid. Pass -`&skills.Limits{}` for no manifest caps, or modify a value from -`skills.DefaultLimits()` to retain defaults for fields you do not override. -The [client documentation](client.md#skills-extension) shows the configuration -semantics and how to pin values across SDK upgrades. - -A server serving larger skills is not guaranteed to interoperate with default -clients. Structural validation always runs; put additional application policy -in the handlers themselves. The SDK copies the limits during registration and +`skills.ServerOptions.Limits` is a `skills.Limits` value. By default it imposes no +manifest caps. Positive fields set exact caps, zero fields are unlimited, and +negative fields are invalid. Set `Limits: skills.BaselineLimits()` to opt into +the spec's interoperability baseline of 512 resources and 16 MiB per skill. +Servers should stay within this baseline for broad compatibility; serving larger +skills is allowed but some clients may decline them. The +[client documentation](client.md#skills-extension) explains how to customize caps +and pin application policy across SDK upgrades. + +Structural validation always runs; put additional application policy in the +handlers themselves. Dynamic content budgets belong to the application; the SDK +does not accumulate sizes across resource reads. It copies options at registration and prepares outgoing results without mutating handler-owned data. On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and `cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints -through the result's `mcp.Cacheable` field. Earlier protocols omit cache fields -and `resultType`. The extension does not prefetch files or start background work. +through the result's `mcp.Cacheable` field. The SDK also supports the extension on +earlier protocols as a compatibility backport, omitting cache fields and +`resultType`. The extension does not prefetch files or start background work. ### Pagination diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index 75a45521d..118976e8e 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -250,25 +250,30 @@ session. This example connects to the server from the | Configuration | Manifest limits | | --- | --- | -| `Limits: nil` | SDK defaults: currently 512 resources and 16 MiB per skill | -| `Limits: &skills.Limits{}` | No count or size caps | +| Omitted or `Limits: skills.Limits{}` | No count or size caps | +| `Limits: skills.BaselineLimits()` | 512 resources and 16 MiB per skill | | Positive fields in a supplied `Limits` | Exact caps for those dimensions | | Zero fields in a supplied `Limits` | Those dimensions are unlimited | | Negative fields | Configuration error | -Structural validation always runs. To change one default while retaining the -others, start from `skills.DefaultLimits()`, which returns a fresh value: +Structural validation always runs. The spec's limits are an interoperability +baseline: hosts must support at least that much and may support more. They are +not mandatory rejection thresholds. To opt into caps based on that baseline: %include ../../skills/example_test.go skillslimits - A literal containing only `MaxTotalSize` leaves resource count unlimited. -`DefaultLimits()` and the exported `DefaultMaxResourcesPerSkill` and -`DefaultMaxTotalSize` constants follow the installed SDK version. Supply explicit -numeric values for all desired caps to pin application policy across upgrades. +`BaselineLimits()` follows the spec supported by the installed SDK version. +Supply explicit numeric values to pin application policy across upgrades. +Caps below the baseline reduce what the host can accept. Servers and clients configure these limits independently. Each call captures the configured limits before sending its request, and `All` captures them when the -iterator is created. Do not mutate the client or its referenced limits during use. +iterator is created. Do not mutate the client during use. + +These caps apply to static manifests. For dynamic skills, applications manage +their own download, storage, and context budgets; the SDK does not retrieve files +or maintain cumulative size or file counts. `ReadDirectory` and `DirectoryEntries` expose optional directory browsing when the server advertises `directoryRead: true`. Calls fail @@ -288,7 +293,8 @@ entries or approvals. Directory results are live observations; they do not expan the files authorized by a held manifest. `VerifyResource` checks manifest membership, byte length, and SHA-256 digest. -`VerifySkillMD` also compares every frontmatter field. For dynamic manifests, +`VerifySkillMD` also compares every frontmatter field. JSON frontmatter numbers +are decoded as `json.Number` to preserve integer precision. For dynamic manifests, `VerifySkillMD` still checks frontmatter and returns `skills.ErrDynamicResources` only when it matches; malformed or mismatched frontmatter returns a different error. Applications decide whether to accept content without integrity verification and diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 478483902..8aba84b39 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -536,7 +536,8 @@ the resource bytes: Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result -with an empty resource list. Other handler errors pass through unchanged. +with an empty resource list. Explicit JSON-RPC errors retain their code and data; +other handler errors and invalid results become Internal Error (`-32603`). Handlers own pagination. `skills.PaginateSkills` and `skills.PaginateDirectoryResources` sort by URI and return one page without @@ -544,23 +545,25 @@ modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; `mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill entry contains its complete manifest, which is never split across pages. -`skills.ServerOptions.Limits` is an optional `*skills.Limits`. Nil uses the SDK -defaults, currently 512 resources and 16 MiB per skill. A supplied value uses exact -caps: zero fields are unlimited and negative fields are invalid. Pass -`&skills.Limits{}` for no manifest caps, or modify a value from -`skills.DefaultLimits()` to retain defaults for fields you do not override. -The [client documentation](client.md#skills-extension) shows the configuration -semantics and how to pin values across SDK upgrades. - -A server serving larger skills is not guaranteed to interoperate with default -clients. Structural validation always runs; put additional application policy -in the handlers themselves. The SDK copies the limits during registration and +`skills.ServerOptions.Limits` is a `skills.Limits` value. By default it imposes no +manifest caps. Positive fields set exact caps, zero fields are unlimited, and +negative fields are invalid. Set `Limits: skills.BaselineLimits()` to opt into +the spec's interoperability baseline of 512 resources and 16 MiB per skill. +Servers should stay within this baseline for broad compatibility; serving larger +skills is allowed but some clients may decline them. The +[client documentation](client.md#skills-extension) explains how to customize caps +and pin application policy across SDK upgrades. + +Structural validation always runs; put additional application policy in the +handlers themselves. Dynamic content budgets belong to the application; the SDK +does not accumulate sizes across resource reads. It copies options at registration and prepares outgoing results without mutating handler-owned data. On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and `cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints -through the result's `mcp.Cacheable` field. Earlier protocols omit cache fields -and `resultType`. The extension does not prefetch files or start background work. +through the result's `mcp.Cacheable` field. The SDK also supports the extension on +earlier protocols as a compatibility backport, omitting cache fields and +`resultType`. The extension does not prefetch files or start background work. ### Pagination diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index 5f714998c..24f46d294 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -31,18 +31,17 @@ go work init ./project ./go-sdk ### Conformance tests -The SDK includes a script to run the official MCP conformance tests against the -SDK's conformance server: +The SDK includes scripts to run the official MCP server and client conformance tests: ```sh -./scripts/conformance.sh +./scripts/server-conformance.sh +./scripts/client-conformance.sh ``` -By default, results are cleaned up after the script runs. To save results to a -specific directory: +To save server results to a specific directory: ```sh -./scripts/conformance.sh --result_dir ./conformance-results +./scripts/server-conformance.sh --result_dir ./conformance-results ``` To run against a local checkout of the @@ -50,12 +49,24 @@ To run against a local checkout of the of the latest npm release: ```sh -./scripts/conformance.sh --conformance_repo ~/src/conformance +./scripts/server-conformance.sh --conformance_repo ~/src/conformance ``` Note: you must run `npm install` in the conformance repo first. -Run `./scripts/conformance.sh --help` for more options. +Run either script with `--help` for more options. + +Skills has a separate fixture and CI job covering enumeration, manifests, and +directory reads on both `2025-11-25` stateful and `2026-07-28` stateless transports. +Its scenarios are pending in [conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). +Until they are released, CI pins the checkout below. To reproduce that run: + +```sh +git clone https://github.com/panyam/mcpconformance.git ../skills-conformance +git -C ../skills-conformance checkout 73ac2c4d0f40505fbd597c23399aebd7545900ed +(cd ../skills-conformance && npm ci --ignore-scripts && npm run build) +./scripts/skills-conformance.sh --conformance_repo ../skills-conformance --result_dir /tmp/skills-conformance-results +``` ## Filing issues diff --git a/scripts/skills-conformance.sh b/scripts/skills-conformance.sh new file mode 100755 index 000000000..581fa1613 --- /dev/null +++ b/scripts/skills-conformance.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# 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. + +set -euo pipefail + +usage() { + echo "Usage: $0 --conformance_repo [--result_dir ]" + echo "Run the Skills server scenarios on both supported protocol transports." + echo "The checkout must contain the scenarios from conformance PR #330." +} + +CONFORMANCE_REPO="" +RESULT_DIR="" +SERVER_PID="" +PORT="${PORT:-18301}" +while [[ $# -gt 0 ]]; do + case "$1" in + --conformance_repo) CONFORMANCE_REPO="$2"; shift 2 ;; + --result_dir) RESULT_DIR="$2"; shift 2 ;; + --help) usage; exit 0 ;; + *) usage >&2; exit 1 ;; + esac +done +if [[ -z "$CONFORMANCE_REPO" || ! -f "$CONFORMANCE_REPO/dist/index.js" ]]; then + usage >&2 + exit 1 +fi +CONFORMANCE_REPO=$(cd "$CONFORMANCE_REPO" && pwd) +if [[ -z "$RESULT_DIR" ]]; then + RESULT_DIR=$(mktemp -d) +fi +mkdir -p "$RESULT_DIR" +RESULT_DIR=$(cd "$RESULT_DIR" && pwd) +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) + +stop_server() { + if [[ -n "$SERVER_PID" ]]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + SERVER_PID="" + fi +} +trap stop_server EXIT + +go -C "$REPO_ROOT" build -o "$RESULT_DIR/skills-server" ./conformance/skills-server +STATUS=0 +for version in 2025-11-25 2026-07-28; do + stateless=false + if [[ "$version" == 2026-07-28 ]]; then + stateless=true + fi + "$RESULT_DIR/skills-server" -http="localhost:$PORT" -stateless="$stateless" > "$RESULT_DIR/$version-server.log" 2>&1 & + SERVER_PID=$! + ready=false + for ((attempt = 0; attempt < 30; attempt++)); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + if curl --silent --max-time 1 --output /dev/null "http://localhost:$PORT"; then + ready=true + break + fi + sleep 0.5 + done + if [[ "$ready" != true ]]; then + cat "$RESULT_DIR/$version-server.log" >&2 + exit 1 + fi + for scenario in enumeration manifest directory; do + node "$CONFORMANCE_REPO/dist/index.js" server \ + --url "http://localhost:$PORT" \ + --scenario "sep-2640-skills-$scenario" \ + --spec-version "$version" --force \ + --output-dir "$RESULT_DIR/$version/$scenario" || STATUS=1 + done + stop_server +done +echo "Skills conformance results: $RESULT_DIR" +exit "$STATUS" diff --git a/skills/client.go b/skills/client.go index 6839d55d8..c5b2e0d9b 100644 --- a/skills/client.go +++ b/skills/client.go @@ -30,8 +30,7 @@ func AddMethods(client *mcp.Client) error { // Client calls the Skills extension on a connected [mcp.ClientSession]. // Call [AddMethods] on the underlying [mcp.Client] before connecting. -// A Client may be used concurrently; do not modify its fields or the referenced -// Limits value during use. +// A Client may be used concurrently; do not modify its fields during use. // // Client does not prefetch content or cache entries. Keep entries scoped to // their originating session and verify resource bytes before using them. @@ -39,9 +38,9 @@ type Client struct { // Session is the connected MCP session. It must be non-nil. Session *mcp.ClientSession // Limits bounds each manifest returned by List, Get, or All. - // Nil uses [DefaultLimits]; a non-nil value supplies exact caps, with - // zero fields meaning unlimited. - Limits *Limits + // The zero value imposes no caps. Use [BaselineLimits] to opt into + // the spec's interoperability baseline. + Limits Limits } // List calls skills/list and validates the response using c.Limits. @@ -50,8 +49,8 @@ func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkill if err := c.requireCapability(false); err != nil { return nil, err } - limits, err := c.Limits.resolve() - if err != nil { + limits := c.Limits + if err := limits.validate(); err != nil { return nil, err } if params == nil { @@ -78,8 +77,8 @@ func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResu if err := c.requireCapability(false); err != nil { return nil, err } - limits, err := c.Limits.resolve() - if err != nil { + limits := c.Limits + if err := limits.validate(); err != nil { return nil, err } if params == nil || params.URI == "" { @@ -132,10 +131,6 @@ func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*S client := Client{} if c != nil { client = *c - if c.Limits != nil { - limits := *c.Limits - client.Limits = &limits - } } var initial ListSkillsParams if params != nil { diff --git a/skills/example_test.go b/skills/example_test.go index 34a494e1c..e449514d1 100644 --- a/skills/example_test.go +++ b/skills/example_test.go @@ -82,9 +82,9 @@ func ExampleAddHandlers() { // !-skillsclient // !+skillslimits - limits := skills.DefaultLimits() + limits := skills.BaselineLimits() limits.MaxTotalSize = 32 << 20 - skillClient = &skills.Client{Session: session, Limits: &limits} + skillClient = &skills.Client{Session: session, Limits: limits} // !-skillslimits // !+skillsverify diff --git a/skills/limits_test.go b/skills/limits_test.go index c7db2ea15..d8b990ff9 100644 --- a/skills/limits_test.go +++ b/skills/limits_test.go @@ -7,6 +7,7 @@ package skills import ( "context" "fmt" + "math" "sync/atomic" "testing" @@ -37,36 +38,35 @@ func checkLimitCalls(t *testing.T, client *Client, skill *Skill, wantOK bool) { } func TestLimitsRoundTrip(t *testing.T) { - defaults := DefaultLimits() + baseline := BaselineLimits() for _, kind := range []string{"count", "bytes"} { t.Run(kind, func(t *testing.T) { skill := testSkill() entries, _ := skill.Resources.List() if kind == "count" { - for i := 1; i <= DefaultMaxResourcesPerSkill; i++ { + for i := 1; i <= baseline.MaxResourcesPerSkill; i++ { entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: entries[0].Digest, Size: 1}) } } else { - entries[0].Size = DefaultMaxTotalSize + 1 + entries[0].Size = baseline.MaxTotalSize + 1 } skill.Resources = StaticResources(entries...) for _, test := range []struct { name string - limits *Limits + limits Limits wantOK bool }{ - {"defaults", nil, false}, - {"explicit-defaults", &defaults, false}, - {"unlimited", &Limits{}, true}, - {"count-only", &Limits{MaxResourcesPerSkill: DefaultMaxResourcesPerSkill}, kind == "bytes"}, - {"bytes-only", &Limits{MaxTotalSize: DefaultMaxTotalSize}, kind == "count"}, - {"raised-count", &Limits{MaxResourcesPerSkill: DefaultMaxResourcesPerSkill + 1}, true}, - {"raised-bytes", &Limits{MaxTotalSize: DefaultMaxTotalSize + 1}, true}, + {"zero-value", Limits{}, true}, + {"baseline", baseline, false}, + {"count-only", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill}, kind == "bytes"}, + {"bytes-only", Limits{MaxTotalSize: baseline.MaxTotalSize}, kind == "count"}, + {"raised-count", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill + 1}, true}, + {"raised-bytes", Limits{MaxTotalSize: baseline.MaxTotalSize + 1}, true}, } { t.Run(test.name, func(t *testing.T) { t.Run("client", func(t *testing.T) { server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: &Limits{}}); err != nil { + if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { t.Fatal(err) } client := connectSkills(t, server, "2026-07-28") @@ -79,16 +79,16 @@ func TestLimitsRoundTrip(t *testing.T) { t.Fatal(err) } client := connectSkills(t, server, "2026-07-28") - client.Limits = &Limits{} + client.Limits = Limits{} checkLimitCalls(t, client, skill, test.wantOK) }) }) } - if err := ValidateSkill(skill); err == nil { - t.Fatal("ValidateSkill accepted an oversized manifest") + if err := ValidateSkill(skill); err != nil { + t.Fatalf("ValidateSkill imposed a manifest cap: %v", err) } - if err := ValidateSkillWithLimits(skill, Limits{}); err != nil { - t.Fatalf("explicit unlimited validation: %v", err) + if err := ValidateSkillWithLimits(skill, baseline); err == nil { + t.Fatal("baseline validation accepted an oversized manifest") } }) } @@ -116,10 +116,10 @@ func TestNegativeLimits(t *testing.T) { if err := ValidateSkillWithLimits(skill, limits); err == nil { t.Fatal("negative validation limit accepted") } - if err := AddHandlers(testServer(), handlers, &ServerOptions{Limits: &limits}); err == nil { + if err := AddHandlers(testServer(), handlers, &ServerOptions{Limits: limits}); err == nil { t.Fatal("negative server limit accepted") } - client.Limits = &limits + client.Limits = limits checkLimitCalls(t, client, skill, false) } if got := calls.Load(); got != 0 { @@ -132,19 +132,16 @@ func TestLimitOwnership(t *testing.T) { entries, _ := skill.Resources.List() skill.Resources = StaticResources(entries[0], &Resource{URI: "skill://demo/helper.txt", Digest: entries[0].Digest, Size: 1}) server := testServer() - serverLimits := Limits{MaxResourcesPerSkill: 2} - options := &ServerOptions{Limits: &serverLimits} + options := &ServerOptions{Limits: Limits{MaxResourcesPerSkill: 2}} if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { t.Fatal(err) } - serverLimits.MaxResourcesPerSkill = 1 - options.Limits = &Limits{MaxTotalSize: 1} + options.Limits = Limits{MaxTotalSize: 1} client := connectSkills(t, server, "2026-07-28") - clientLimits := Limits{MaxResourcesPerSkill: 2} - client.Limits = &clientLimits + client.Limits = Limits{MaxResourcesPerSkill: 2} checkLimitCalls(t, client, skill, true) seq := client.All(t.Context(), nil) - clientLimits.MaxResourcesPerSkill = 1 + client.Limits.MaxResourcesPerSkill = 1 checkLimitCalls(t, client, skill, false) for range 2 { count := 0 @@ -167,10 +164,38 @@ func TestUnlimitedLimitsKeepStructuralValidation(t *testing.T) { t.Fatal("unlimited validation accepted malformed frontmatter") } server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: &Limits{}}); err != nil { + if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { t.Fatal(err) } client := connectSkills(t, server, "2026-07-28") - client.Limits = &Limits{} + client.Limits = Limits{} checkLimitCalls(t, client, skill, false) } + +func TestUnlimitedTotalSize(t *testing.T) { + skill := testSkill() + entries, _ := skill.Resources.List() + entries[0].Size = math.MaxInt64 + skill.Resources = StaticResources(entries[0], &Resource{ + URI: "skill://demo/helper.txt", Digest: entries[0].Digest, Size: 1, + }) + if err := ValidateSkill(skill); err != nil { + t.Fatalf("unlimited validation accumulated a total size: %v", err) + } + if err := ValidateSkillWithLimits(skill, Limits{MaxTotalSize: math.MaxInt64}); err == nil { + t.Fatal("total size overflow bypassed the configured cap") + } +} + +func TestDynamicLimits(t *testing.T) { + skill := testSkill() + skill.Resources = DynamicResources() + limits := Limits{MaxResourcesPerSkill: 1, MaxTotalSize: 1} + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: limits}); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, "2026-07-28") + client.Limits = limits + checkLimitCalls(t, client, skill, true) +} diff --git a/skills/protocol_test.go b/skills/protocol_test.go index 2a2cf96cf..06121d0ca 100644 --- a/skills/protocol_test.go +++ b/skills/protocol_test.go @@ -97,7 +97,7 @@ func TestUnknownURIsAndHandlerErrors(t *testing.T) { for _, uri := range []string{"skill://backend/SKILL.md", "skill://wrong/SKILL.md"} { _, err := c.Get(t.Context(), &GetSkillParams{URI: uri}) var rpc *jsonrpc.Error - if err == nil || errors.As(err, &rpc) && rpc.Code == jsonrpc.CodeInvalidParams { + if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInternalError { t.Fatalf("handler bug mislabeled: %v", err) } } @@ -115,6 +115,56 @@ func TestUnknownURIsAndHandlerErrors(t *testing.T) { } } +func TestHandlerErrorCodes(t *testing.T) { + coded := &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "invalid request", Data: json.RawMessage(`{"reason":"test"}`)} + for _, version := range []string{"2025-11-25", "2026-07-28"} { + for _, test := range []struct { + name string + err error + code int64 + }{ + {"backend", errors.New("backend unavailable"), jsonrpc.CodeInternalError}, + {"invalid-result", nil, jsonrpc.CodeInternalError}, + {"coded", coded, jsonrpc.CodeInvalidParams}, + {"wrapped-coded", fmt.Errorf("handler: %w", coded), jsonrpc.CodeInvalidParams}, + } { + t.Run(version+"/"+test.name, func(t *testing.T) { + server := testServer() + skill := testSkill() + skill.Frontmatter["description"] = false + h := &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, test.err + }, + Get: func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) { + return &GetSkillResult{Skill: skill}, test.err + }, + ReadDirectory: func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) { + return &ReadDirectoryResult{Resources: []*mcp.Resource{{URI: skill.URI}}}, test.err + }, + } + if err := AddHandlers(server, h, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, version) + _, listErr := client.List(t.Context(), nil) + _, getErr := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + _, directoryErr := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}) + for method, err := range map[string]error{MethodList: listErr, MethodGet: getErr, MethodReadDirectory: directoryErr} { + var rpc *jsonrpc.Error + if !errors.As(err, &rpc) || rpc.Code != test.code { + t.Errorf("%s: error = %v, want code %d", method, err, test.code) + continue + } + if test.code == coded.Code && string(rpc.Data) != string(coded.Data) { + t.Errorf("%s: error data = %s, want %s", method, rpc.Data, coded.Data) + } + } + }) + } + } +} + func TestResponsesAndParamsAreNotMutated(t *testing.T) { server := testServer() skill := testSkill() diff --git a/skills/server.go b/skills/server.go index 06093b9fe..ff92491bc 100644 --- a/skills/server.go +++ b/skills/server.go @@ -6,6 +6,7 @@ package skills import ( "context" + "errors" "fmt" "maps" @@ -17,7 +18,8 @@ import ( type ListSkillsHandler func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) // GetSkillHandler handles skills/get. Return (nil, nil) for an unknown skill; -// [AddHandlers] translates it to JSON-RPC Invalid Params. Other errors pass through. +// [AddHandlers] translates it to JSON-RPC Invalid Params. Explicit JSON-RPC errors +// are preserved; other errors become Internal Error. type GetSkillHandler func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) // ReadDirectoryHandler handles resources/directory/read. Return (nil, nil) if @@ -27,10 +29,10 @@ type ReadDirectoryHandler func(context.Context, *mcp.ServerSession, *ReadDirecto // ServerOptions configures the per-skill limits. Protocol validation always // runs; applications can perform additional checks in their handlers. type ServerOptions struct { - // Limits bounds static manifests. Nil uses [DefaultLimits]; a non-nil - // value supplies exact caps, with zero fields meaning unlimited. + // Limits optionally bounds static manifests; zero fields impose no caps. + // Use [BaselineLimits] to opt into the spec's interoperability baseline. // AddHandlers copies the value during registration. - Limits *Limits + Limits Limits } // Handlers contains the Skills extension handlers. @@ -45,7 +47,7 @@ type Handlers struct { // with [mcp.Server.AddResource] or [mcp.Server.AddResourceTemplate], which also advertises // the required resources capability. Configure the server before connecting. // -// If options is nil, the default limits apply. Handlers own pagination; use +// If options is nil, no manifest caps apply. Handlers own pagination; use // [PaginateSkills] or [PaginateDirectoryResources] to paginate in-memory slices. // AddHandlers supplies resultType and default cache hints for the request's // protocol version. See [ListSkillsResult] and [GetSkillResult]. @@ -60,12 +62,11 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) return fmt.Errorf("skills: list and get handlers are required") } h := *handlers - var configuredLimits *Limits + var limits Limits if options != nil { - configuredLimits = options.Limits + limits = options.Limits } - limits, err := configuredLimits.resolve() - if err != nil { + if err := limits.validate(); err != nil { return err } if err := mcp.AddReceivingCustomMethod(server, MethodList, @@ -75,10 +76,10 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) } result, err := h.List(ctx, session, params) if err != nil { - return nil, err + return nil, internalError(err) } if result == nil { - return nil, fmt.Errorf("skills/list handler returned a nil result") + return nil, internalError(fmt.Errorf("skills/list handler returned a nil result")) } out := *result out.Meta = maps.Clone(result.Meta) @@ -86,13 +87,13 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) out.Skills = []*Skill{} } if err := validateListResult(&out, limits); err != nil { - return nil, fmt.Errorf("skills/list handler returned an invalid result: %w", err) + return nil, internalError(fmt.Errorf("skills/list handler returned an invalid result: %w", err)) } out.omitCache = !supportsCaching(params.Meta) out.ResultType = resultType(params.Meta) normalizeCache(&out.Cacheable) if err := validateCache(out.Cacheable); err != nil { - return nil, err + return nil, internalError(err) } return &out, nil }); err != nil { @@ -108,13 +109,13 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) } result, err := h.Get(ctx, session, params) if err != nil { - return nil, err + return nil, internalError(err) } if result == nil || result.Skill == nil { return nil, invalidParams("unknown skill: " + params.URI) } if err := validateGetResult(params.URI, result, limits); err != nil { - return nil, fmt.Errorf("skills/get handler returned an invalid result: %w", err) + return nil, internalError(fmt.Errorf("skills/get handler returned an invalid result: %w", err)) } out := *result out.Meta = maps.Clone(result.Meta) @@ -122,7 +123,7 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) out.ResultType = resultType(params.Meta) normalizeCache(&out.Cacheable) if err := validateCache(out.Cacheable); err != nil { - return nil, err + return nil, internalError(err) } return &out, nil }); err != nil { @@ -140,7 +141,7 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) } result, err := h.ReadDirectory(ctx, session, params) if err != nil { - return nil, err + return nil, internalError(err) } if result == nil { return nil, invalidParams("unknown directory: " + params.URI) @@ -151,7 +152,7 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) out.Resources = []*mcp.Resource{} } if err := ValidateDirectoryResult(params.URI, &out); err != nil { - return nil, fmt.Errorf("resources/directory/read handler returned an invalid result: %w", err) + return nil, internalError(fmt.Errorf("resources/directory/read handler returned an invalid result: %w", err)) } out.ResultType = resultType(params.Meta) return &out, nil @@ -187,3 +188,11 @@ func normalizeCache(cache *mcp.Cacheable) { func invalidParams(message string) error { return &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: message} } + +func internalError(err error) error { + var rpc *jsonrpc.Error + if errors.As(err, &rpc) { + return &jsonrpc.Error{Code: rpc.Code, Message: err.Error(), Data: rpc.Data} + } + return &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: err.Error()} +} diff --git a/skills/types.go b/skills/types.go index bb498a780..4372f62e4 100644 --- a/skills/types.go +++ b/skills/types.go @@ -34,8 +34,20 @@ const ( ) // Frontmatter is all of a SKILL.md's YAML frontmatter represented as JSON-compatible values. +// JSON numbers are decoded as [json.Number] to preserve their precision. type Frontmatter map[string]any +func (f *Frontmatter) UnmarshalJSON(data []byte) error { + var fields map[string]any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&fields); err != nil { + return err + } + *f = fields + return nil +} + // Resource identifies and fingerprints one file in a skill. type Resource struct { URI string `json:"uri"` @@ -46,6 +58,23 @@ type Resource struct { Size int64 `json:"size"` } +func (r *Resource) UnmarshalJSON(data []byte) error { + type wire Resource + var decoded struct { + wire + Size *int64 `json:"size"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + if decoded.Size == nil { + return fmt.Errorf("skills: resource size is missing or null") + } + *r = Resource(decoded.wire) + r.Size = *decoded.Size + return nil +} + // Resources is either a complete static resource manifest or the dynamic marker. // Its zero value is invalid; use [StaticResources] or [DynamicResources]. type Resources struct { @@ -90,7 +119,14 @@ func (r Resources) MarshalJSON() ([]byte, error) { func (r *Resources) UnmarshalJSON(data []byte) error { data = bytes.TrimSpace(data) - if bytes.Equal(data, []byte(`"dynamic"`)) { + if len(data) > 0 && data[0] == '"' { + var marker string + if err := json.Unmarshal(data, &marker); err != nil { + return err + } + if marker != "dynamic" { + return fmt.Errorf("skills: unknown resources marker %q", marker) + } *r = DynamicResources() return nil } diff --git a/skills/validation.go b/skills/validation.go index 360b9c026..cc6364229 100644 --- a/skills/validation.go +++ b/skills/validation.go @@ -8,10 +8,10 @@ import ( "bytes" "encoding/json" "fmt" - "math" "net/url" "regexp" "strings" + "unicode" "unicode/utf8" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -87,19 +87,12 @@ func normalizeYAML(value any) (any, error) { } } -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 bounds a static skill manifest. Positive fields are exact caps; zero // fields are unlimited, and negative fields are invalid. The zero value imposes // no manifest caps. Limits never disable structural validation. // -// A nil *Limits in [Client] or [ServerOptions] uses [DefaultLimits]. To customize -// one default while retaining the others, start with the value from DefaultLimits. +// Limits are optional application policy. [BaselineLimits] provides the spec's +// interoperability baseline. Applications manage budgets for dynamic content. type Limits struct { // MaxResourcesPerSkill limits the number of files, including SKILL.md. MaxResourcesPerSkill int @@ -107,42 +100,39 @@ type Limits struct { MaxTotalSize int64 } -var skillNameRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) -// DefaultLimits returns a fresh value containing the SDK's current defaults: -// [DefaultMaxResourcesPerSkill] and [DefaultMaxTotalSize]. To pin application -// policy across SDK upgrades, supply explicit numeric limits instead. -func DefaultLimits() Limits { +// BaselineLimits returns the Skills spec's interoperability baseline: 512 files +// and 16 MiB per skill. Hosts must support at least this much and may support +// more; servers should stay within it for broad compatibility. The SDK does not +// impose these caps by default. Use explicit numeric limits to pin application +// policy independently of future spec revisions. +func BaselineLimits() Limits { return Limits{ - MaxResourcesPerSkill: DefaultMaxResourcesPerSkill, - MaxTotalSize: DefaultMaxTotalSize, + MaxResourcesPerSkill: 512, + MaxTotalSize: 16 * 1024 * 1024, } } -// ValidateSkill validates a skill using the Agent Skills and SEP-2640 defaults. +// ValidateSkill checks a skill's structure without imposing manifest caps. func ValidateSkill(skill *Skill) error { - return ValidateSkillWithLimits(skill, DefaultLimits()) + return validateSkill(skill, Limits{}) } // ValidateSkillWithLimits validates a skill using exactly the supplied limits. // Zero fields impose no cap on that dimension; structural validation always runs. func ValidateSkillWithLimits(skill *Skill, limits Limits) error { - limits, err := limits.resolve() - if err != nil { + if err := limits.validate(); err != nil { return err } return validateSkill(skill, limits) } -func (limits *Limits) resolve() (Limits, error) { - if limits == nil { - return DefaultLimits(), nil +func (l Limits) validate() error { + if l.MaxResourcesPerSkill < 0 || l.MaxTotalSize < 0 { + return fmt.Errorf("skills: limits must not be negative") } - if limits.MaxResourcesPerSkill < 0 || limits.MaxTotalSize < 0 { - return Limits{}, fmt.Errorf("skills: limits must not be negative") - } - return *limits, nil + return nil } func validateSkill(skill *Skill, limits Limits) error { @@ -163,9 +153,6 @@ func validateSkill(skill *Skill, limits Limits) error { 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) } @@ -238,17 +225,16 @@ func validateSkill(skill *Skill, limits Limits) error { 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) + if limits.MaxTotalSize > 0 { + if resource.Size > limits.MaxTotalSize-total { + return fmt.Errorf("skill %q resource sizes exceed the limit of %d bytes", skill.URI, limits.MaxTotalSize) + } + total += resource.Size } - 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 } @@ -292,8 +278,17 @@ func ValidateDirectoryResult(uri string, result *ReadDirectoryResult) error { } 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) + length := utf8.RuneCountInString(name) + valid := utf8.ValidString(name) && length >= 1 && length <= 64 && name == strings.ToLower(name) && + !strings.HasPrefix(name, "-") && !strings.HasSuffix(name, "-") && !strings.Contains(name, "--") + for _, r := range name { + if r != '-' && !unicode.IsLetter(r) && !unicode.IsNumber(r) { + valid = false + break + } + } + if !valid { + return fmt.Errorf("name %q must contain 1 to 64 lowercase Unicode letters, numbers, or non-consecutive hyphens, with no leading or trailing hyphen", name) } return nil } diff --git a/skills/validation_test.go b/skills/validation_test.go new file mode 100644 index 000000000..c3f6544e9 --- /dev/null +++ b/skills/validation_test.go @@ -0,0 +1,122 @@ +// 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 ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +func TestResourceSizeRequired(t *testing.T) { + for _, test := range []struct { + name string + size string + ok bool + }{ + {"missing", "", false}, + {"null", `,"size":null`, false}, + {"zero", `,"size":0`, true}, + {"positive", `,"size":1`, true}, + {"negative", `,"size":-1`, false}, + {"fraction", `,"size":0.5`, false}, + } { + t.Run(test.name, func(t *testing.T) { + data := `{"uri":"skill://demo/SKILL.md","frontmatter":{"name":"demo","description":"Demo"},"resources":[{"uri":"skill://demo/SKILL.md","digest":"sha256:` + strings.Repeat("0", 64) + `"` + test.size + `}]}` + var skill Skill + err := json.Unmarshal([]byte(data), &skill) + if err == nil { + err = ValidateSkill(&skill) + } + if (err == nil) != test.ok { + t.Fatalf("decode and validate: %v, want success = %v", err, test.ok) + } + }) + } +} + +func TestDynamicMarkerJSON(t *testing.T) { + for _, data := range []string{`"dynamic"`, `"dyn\u0061mic"`, ` "\u0064ynamic" `} { + var resources Resources + if err := json.Unmarshal([]byte(data), &resources); err != nil || !resources.IsDynamic() { + t.Errorf("decode %s = %+v, %v", data, resources, err) + } + } + for _, data := range []string{`"other"`, `null`, `42`, `{}`} { + var resources Resources + if err := json.Unmarshal([]byte(data), &resources); err == nil { + t.Errorf("decode %s succeeded", data) + } + } +} + +func TestSkillNames(t *testing.T) { + for _, test := range []struct { + name string + ok bool + }{ + {"demo", true}, {"café", true}, {"中文", true}, {"résumé-٢", true}, + {strings.Repeat("é", 64), true}, {strings.Repeat("é", 65), false}, + {"", false}, {"CAFÉ", false}, {"-demo", false}, {"demo-", false}, + {"demo--test", false}, {"demo_test", false}, {"demo/test", false}, + {"demo\xff", false}, + } { + t.Run(test.name, func(t *testing.T) { + skill := &Skill{ + URI: "skill://org/" + test.name + "/SKILL.md", + Frontmatter: Frontmatter{"name": test.name, "description": "Demo"}, + Resources: DynamicResources(), + } + if err := ValidateSkill(skill); (err == nil) != test.ok { + t.Fatalf("ValidateSkill() = %v, want success = %v", err, test.ok) + } + }) + } +} + +func TestVerifyFrontmatterNumbers(t *testing.T) { + for _, test := range []struct { + name, advertised, yaml string + matches bool + }{ + {"large-integer", "9007199254740993", "9007199254740993", true}, + {"changed-large-integer", "9007199254740992", "9007199254740993", false}, + {"uint64", "18446744073709551615", "18446744073709551615", true}, + {"exponent", "9007199254740993e0", "9007199254740993", true}, + {"decimal", "1.00", "1", true}, + {"fraction", "0.00100", "0.001", true}, + {"negative", "-12.30", "-12.3", true}, + {"zero", "-0.00e1000000000", "0", true}, + {"large-exponent", "1e1000000000", "1", false}, + {"small-exponent", "1e-1000000000", "0", false}, + {"string-is-not-number", `"1"`, "1", false}, + } { + t.Run(test.name, func(t *testing.T) { + content := []byte("---\nname: demo\ndescription: Demo\nextra:\n values: [" + test.yaml + "]\n---\nBody\n") + data := `{"name":"demo","description":"Demo","extra":{"values":[` + test.advertised + `]}}` + var fields Frontmatter + if err := json.Unmarshal([]byte(data), &fields); err != nil { + t.Fatal(err) + } + skill := &Skill{URI: "skill://demo/SKILL.md", Frontmatter: fields} + for _, dynamic := range []bool{false, true} { + skill.Resources = StaticResources(&Resource{ + URI: skill.URI, Size: int64(len(content)), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(content)), + }) + if dynamic { + skill.Resources = DynamicResources() + } + err := VerifySkillMD(skill, content) + matched := err == nil || dynamic && errors.Is(err, ErrDynamicResources) + if matched != test.matches { + t.Fatalf("dynamic=%v: VerifySkillMD() = %v, want match = %v", dynamic, err, test.matches) + } + } + }) + } +} diff --git a/skills/verify.go b/skills/verify.go index 1259bf1d6..d3cf9def0 100644 --- a/skills/verify.go +++ b/skills/verify.go @@ -5,11 +5,13 @@ package skills import ( - "bytes" "crypto/sha256" "encoding/json" "errors" "fmt" + "math/big" + "reflect" + "strings" ) // ErrDynamicResources reports that content cannot be integrity-verified because @@ -20,7 +22,7 @@ var ErrDynamicResources = errors.New("skills: dynamic resources cannot be integr // It returns [ErrDynamicResources] for a dynamic manifest. It checks the entry's // structure without reapplying size limits configured during discovery. func VerifyResource(skill *Skill, uri string, content []byte) error { - // Content verification must not reimpose default limits on an accepted entry. + // Verification does not reapply discovery-time limits. if err := validateSkill(skill, Limits{}); err != nil { return err } @@ -63,16 +65,61 @@ func VerifySkillMD(skill *Skill, content []byte) error { if err != nil { return err } - want, err := json.Marshal(skill.Frontmatter) + want, err := comparableFrontmatter(skill.Frontmatter) if err != nil { return fmt.Errorf("skills: marshaling listed frontmatter: %w", err) } - got, err := json.Marshal(frontmatter) + got, err := comparableFrontmatter(frontmatter) if err != nil { return fmt.Errorf("skills: marshaling resource frontmatter: %w", err) } - if !bytes.Equal(got, want) { + if !reflect.DeepEqual(got, want) { return fmt.Errorf("skills: SKILL.md frontmatter does not match the skill entry") } return verificationErr } + +func comparableFrontmatter(fields Frontmatter) (any, error) { + data, err := json.Marshal(fields) + if err != nil { + return nil, err + } + var decoded Frontmatter + if err := json.Unmarshal(data, &decoded); err != nil { + return nil, err + } + return normalizeNumbers(map[string]any(decoded)), nil +} + +func normalizeNumbers(value any) any { + switch value := value.(type) { + case map[string]any: + for key, item := range value { + value[key] = normalizeNumbers(item) + } + case []any: + for i, item := range value { + value[i] = normalizeNumbers(item) + } + case json.Number: + // Compare decimal values exactly without expanding potentially huge exponents. + mantissa, power, _ := strings.Cut(strings.ToLower(string(value)), "e") + mantissa, negative := strings.CutPrefix(mantissa, "-") + integer, fraction, _ := strings.Cut(mantissa, ".") + digits := strings.TrimLeft(integer+fraction, "0") + coefficient := strings.TrimRight(digits, "0") + if coefficient == "" { + return json.Number("0") + } + var exponent big.Int + if power != "" { + exponent.SetString(power, 10) + } + exponent.Add(&exponent, big.NewInt(int64(len(digits)-len(coefficient)-len(fraction)))) + if negative { + coefficient = "-" + coefficient + } + return json.Number(coefficient + "e" + exponent.String()) + } + return value +} From 64566ad60304e7459cedc70bf95bd85b3297008f Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 09:52:15 +0100 Subject: [PATCH 06/11] skills: keep pending conformance scenarios local --- .github/workflows/conformance.yml | 31 ----------------------------- CONTRIBUTING.md | 8 ++++---- internal/readme/contributing.src.md | 8 ++++---- 3 files changed, 8 insertions(+), 39 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 5a6cb8636..6f54df4e0 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -17,37 +17,6 @@ env: CONFORMANCE_VERSION: "0.2.0-alpha.11" jobs: - skills-conformance: - runs-on: ubuntu-latest - steps: - - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version: "^1.26" - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - # Skills scenarios are pending in modelcontextprotocol/conformance#330. - # Replace this checkout with a pinned npm release once they are released. - - name: Check out Skills conformance scenarios - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: panyam/mcpconformance - ref: 73ac2c4d0f40505fbd597c23399aebd7545900ed - path: _conformance-skills - persist-credentials: false - - name: Build conformance runner - working-directory: _conformance-skills - run: npm ci --ignore-scripts && npm run build - - name: Run Skills conformance tests - run: >- - bash ./scripts/skills-conformance.sh - --conformance_repo "$GITHUB_WORKSPACE/_conformance-skills" - --result_dir "$RUNNER_TEMP/skills-conformance" - server-conformance: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98a4ccbd2..23a2eccac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,10 +74,10 @@ Note: you must run `npm install` in the conformance repo first. Run either script with `--help` for more options. -Skills has a separate fixture and CI job covering enumeration, manifests, and -directory reads on both `2025-11-25` stateful and `2026-07-28` stateless transports. -Its scenarios are pending in [conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). -Until they are released, CI pins the checkout below. To reproduce that run: +Run Skills conformance locally with the scenarios from +[conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). +The runner covers enumeration, manifests, and directory reads on both +`2025-11-25` stateful and `2026-07-28` stateless transports: ```sh git clone https://github.com/panyam/mcpconformance.git ../skills-conformance diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index 24f46d294..7be153148 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -56,10 +56,10 @@ Note: you must run `npm install` in the conformance repo first. Run either script with `--help` for more options. -Skills has a separate fixture and CI job covering enumeration, manifests, and -directory reads on both `2025-11-25` stateful and `2026-07-28` stateless transports. -Its scenarios are pending in [conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). -Until they are released, CI pins the checkout below. To reproduce that run: +Run Skills conformance locally with the scenarios from +[conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). +The runner covers enumeration, manifests, and directory reads on both +`2025-11-25` stateful and `2026-07-28` stateless transports: ```sh git clone https://github.com/panyam/mcpconformance.git ../skills-conformance From a7e923a4b3e059c091459876783d61bb091c2487 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 09:55:47 +0100 Subject: [PATCH 07/11] skills: keep conformance instructions in the PR description --- CONTRIBUTING.md | 12 ------------ internal/readme/contributing.src.md | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a2eccac..4c96dbde8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,18 +74,6 @@ Note: you must run `npm install` in the conformance repo first. Run either script with `--help` for more options. -Run Skills conformance locally with the scenarios from -[conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). -The runner covers enumeration, manifests, and directory reads on both -`2025-11-25` stateful and `2026-07-28` stateless transports: - -```sh -git clone https://github.com/panyam/mcpconformance.git ../skills-conformance -git -C ../skills-conformance checkout 73ac2c4d0f40505fbd597c23399aebd7545900ed -(cd ../skills-conformance && npm ci --ignore-scripts && npm run build) -./scripts/skills-conformance.sh --conformance_repo ../skills-conformance --result_dir /tmp/skills-conformance-results -``` - ## Filing issues This project uses the [GitHub issue diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index 7be153148..e6486c338 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -56,18 +56,6 @@ Note: you must run `npm install` in the conformance repo first. Run either script with `--help` for more options. -Run Skills conformance locally with the scenarios from -[conformance #330](https://github.com/modelcontextprotocol/conformance/pull/330). -The runner covers enumeration, manifests, and directory reads on both -`2025-11-25` stateful and `2026-07-28` stateless transports: - -```sh -git clone https://github.com/panyam/mcpconformance.git ../skills-conformance -git -C ../skills-conformance checkout 73ac2c4d0f40505fbd597c23399aebd7545900ed -(cd ../skills-conformance && npm ci --ignore-scripts && npm run build) -./scripts/skills-conformance.sh --conformance_repo ../skills-conformance --result_dir /tmp/skills-conformance-results -``` - ## Filing issues This project uses the [GitHub issue From 32871f2c82d59ead7641163cec9e23c50b6ffbf8 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 10:54:21 +0100 Subject: [PATCH 08/11] scripts: support conformance repository revisions in the server runner --- scripts/server-conformance.sh | 115 +++++++++++++++++++++++++++------- scripts/skills-conformance.sh | 81 ------------------------ 2 files changed, 92 insertions(+), 104 deletions(-) delete mode 100755 scripts/skills-conformance.sh diff --git a/scripts/server-conformance.sh b/scripts/server-conformance.sh index e8fe6e188..e3e3c6238 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -12,22 +12,41 @@ SERVER_PID="" RESULT_DIR="" WORKDIR="" CONFORMANCE_REPO="" +CONFORMANCE_REF="" +CHECKOUT_DIR="" +SERVER_PACKAGE="./conformance/everything-server" +STATELESS=false +CONFORMANCE_ARGS=(--spec-version 2025-11-25) FINAL_EXIT_CODE=0 usage() { - echo "Usage: $0 [options]" + echo "Usage: $0 [options] [-- ]" echo "" echo "Run MCP conformance tests against the Go SDK conformance server." echo "" echo "Options:" echo " --result_dir Save results to the specified directory" - echo " --conformance_repo Run conformance tests from a local checkout" - echo " instead of using the latest npm release" + echo " --conformance_repo Use a local checkout or clone a Git repository" + echo " instead of using the latest npm release" + echo " --conformance_ref Check out a branch, commit, or tag in a temporary clone" + echo " (requires --conformance_repo)" + echo " --server Server to build (default: ./conformance/everything-server)" + echo " --stateless Run the server in stateless mode" + echo " -- Replace the default conformance arguments:" + echo " --spec-version 2025-11-25" echo " --help Show this help message" } # Parse arguments. while [[ $# -gt 0 ]]; do + case $1 in + --result_dir|--conformance_repo|--conformance_ref|--server) + if [[ $# -lt 2 || -z "$2" || "$2" == --* ]]; then + echo "Missing value for $1" >&2 + exit 1 + fi + ;; + esac case $1 in --result_dir) RESULT_DIR="$2" @@ -37,6 +56,23 @@ while [[ $# -gt 0 ]]; do CONFORMANCE_REPO="$2" shift 2 ;; + --conformance_ref) + CONFORMANCE_REF="$2" + shift 2 + ;; + --server) + SERVER_PACKAGE="$2" + shift 2 + ;; + --stateless) + STATELESS=true + shift + ;; + --) + shift + CONFORMANCE_ARGS=("$@") + break + ;; --help) usage exit 0 @@ -49,12 +85,20 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -n "$CONFORMANCE_REF" && -z "$CONFORMANCE_REPO" ]]; then + echo "--conformance_ref requires --conformance_repo" >&2 + exit 1 +fi + cleanup() { if [ -n "$SERVER_PID" ]; then echo "Stopping server..." kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi + if [[ -n "$CHECKOUT_DIR" ]]; then + rm -rf -- "$CHECKOUT_DIR" + fi } trap cleanup EXIT @@ -65,45 +109,70 @@ if [ -n "$RESULT_DIR" ]; then else WORKDIR=$(mktemp -d) fi +WORKDIR=$(cd "$WORKDIR" && pwd) +OUTPUT_ARGS=() +if [[ -n "$RESULT_DIR" ]]; then + RESULT_DIR="$WORKDIR" + OUTPUT_ARGS=(--output-dir "$RESULT_DIR") +fi + +if [[ -n "$CONFORMANCE_REPO" ]]; then + if [[ -d "$CONFORMANCE_REPO" ]]; then + CONFORMANCE_REPO=$(cd "$CONFORMANCE_REPO" && pwd) + fi + if [[ -n "$CONFORMANCE_REF" || ! -d "$CONFORMANCE_REPO" ]]; then + CHECKOUT_DIR=$(mktemp -d) + git clone --quiet --no-checkout -- "$CONFORMANCE_REPO" "$CHECKOUT_DIR" + git -C "$CHECKOUT_DIR" fetch --quiet origin "${CONFORMANCE_REF:-HEAD}" + git -C "$CHECKOUT_DIR" checkout --quiet --detach FETCH_HEAD + CONFORMANCE_REPO="$CHECKOUT_DIR" + npm --prefix "$CONFORMANCE_REPO" ci --ignore-scripts + fi + npm --prefix "$CONFORMANCE_REPO" run build + RUNNER=(node "$CONFORMANCE_REPO/dist/index.js") +else + RUNNER=(npx @modelcontextprotocol/conformance@latest) +fi # Build the conformance server. -go build -o "$WORKDIR/conformance-server" ./conformance/everything-server +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +go -C "$REPO_ROOT" build -o "$WORKDIR/conformance-server" "$SERVER_PACKAGE" # Start the server in the background. -# -stateless=false pins the server to the stateful transport so that +# Stateful transport is the default so that # server-initiated sampling/elicitation scenarios (which are not supported on # stateless streamable HTTP) work against the current @latest conformance -# suite. Drop this flag once the 0.2.x line is promoted to the @latest +# suite. Change the default once the 0.2.x line is promoted to the @latest # dist-tag on npm and the stateless leg becomes viable. echo "Starting conformance server on localhost:$PORT..." -"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless=false & +"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless="$STATELESS" & SERVER_PID=$! echo "Server pid is $SERVER_PID" # Wait for server to be ready echo "Waiting for server to be ready..." -if ! timeout 15 bash -c "until curl -s http://localhost:$PORT > /dev/null 2>&1; do sleep 0.5; done"; then - echo "Server failed to start within 15 seconds." +READY=false +for ((attempt = 0; attempt < 30; attempt++)); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + if curl --silent --max-time 1 --output /dev/null "http://localhost:$PORT"; then + READY=true + break + fi + sleep 0.5 +done +if [[ "$READY" != true ]]; then + echo "Server failed to become ready." exit 1 fi # Run conformance tests from the work directory to avoid writing results to the repo. echo "Running conformance tests..." -if [ -n "$CONFORMANCE_REPO" ]; then - # Run from local checkout using npm run start. - (cd "$WORKDIR" && \ - npm --prefix "$CONFORMANCE_REPO" run start -- \ - server --url "http://localhost:$PORT" \ - --spec-version 2025-11-25 \ - ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? -else - (cd "$WORKDIR" && \ - npx @modelcontextprotocol/conformance@latest \ - server --url "http://localhost:$PORT" \ - --spec-version 2025-11-25 \ - ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? -fi +(cd "$WORKDIR" && \ + "${RUNNER[@]}" server --url "http://localhost:$PORT" \ + "${CONFORMANCE_ARGS[@]}" "${OUTPUT_ARGS[@]}") || FINAL_EXIT_CODE=$? echo "" if [ -n "$RESULT_DIR" ]; then diff --git a/scripts/skills-conformance.sh b/scripts/skills-conformance.sh deleted file mode 100755 index 581fa1613..000000000 --- a/scripts/skills-conformance.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/bash -# 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. - -set -euo pipefail - -usage() { - echo "Usage: $0 --conformance_repo [--result_dir ]" - echo "Run the Skills server scenarios on both supported protocol transports." - echo "The checkout must contain the scenarios from conformance PR #330." -} - -CONFORMANCE_REPO="" -RESULT_DIR="" -SERVER_PID="" -PORT="${PORT:-18301}" -while [[ $# -gt 0 ]]; do - case "$1" in - --conformance_repo) CONFORMANCE_REPO="$2"; shift 2 ;; - --result_dir) RESULT_DIR="$2"; shift 2 ;; - --help) usage; exit 0 ;; - *) usage >&2; exit 1 ;; - esac -done -if [[ -z "$CONFORMANCE_REPO" || ! -f "$CONFORMANCE_REPO/dist/index.js" ]]; then - usage >&2 - exit 1 -fi -CONFORMANCE_REPO=$(cd "$CONFORMANCE_REPO" && pwd) -if [[ -z "$RESULT_DIR" ]]; then - RESULT_DIR=$(mktemp -d) -fi -mkdir -p "$RESULT_DIR" -RESULT_DIR=$(cd "$RESULT_DIR" && pwd) -REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) - -stop_server() { - if [[ -n "$SERVER_PID" ]]; then - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - SERVER_PID="" - fi -} -trap stop_server EXIT - -go -C "$REPO_ROOT" build -o "$RESULT_DIR/skills-server" ./conformance/skills-server -STATUS=0 -for version in 2025-11-25 2026-07-28; do - stateless=false - if [[ "$version" == 2026-07-28 ]]; then - stateless=true - fi - "$RESULT_DIR/skills-server" -http="localhost:$PORT" -stateless="$stateless" > "$RESULT_DIR/$version-server.log" 2>&1 & - SERVER_PID=$! - ready=false - for ((attempt = 0; attempt < 30; attempt++)); do - if ! kill -0 "$SERVER_PID" 2>/dev/null; then - break - fi - if curl --silent --max-time 1 --output /dev/null "http://localhost:$PORT"; then - ready=true - break - fi - sleep 0.5 - done - if [[ "$ready" != true ]]; then - cat "$RESULT_DIR/$version-server.log" >&2 - exit 1 - fi - for scenario in enumeration manifest directory; do - node "$CONFORMANCE_REPO/dist/index.js" server \ - --url "http://localhost:$PORT" \ - --scenario "sep-2640-skills-$scenario" \ - --spec-version "$version" --force \ - --output-dir "$RESULT_DIR/$version/$scenario" || STATUS=1 - done - stop_server -done -echo "Skills conformance results: $RESULT_DIR" -exit "$STATUS" From 5b4b3773e95f8de249b862360d55312d856ed620 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 14:29:52 +0100 Subject: [PATCH 09/11] skills: simplify envelope stamping, pagination, and URI parsing Replace the repeated result-type and cache-hint assignments in AddHandlers with a single stampEnvelope helper that also validates the hints it settles on, and route the protocol version, result type, and cache scope through named constants instead of repeated literals. paginate now skips the clone-and-sort when the catalog is already ordered, and resolves a cursor with a binary search rather than a linear scan. allPages allocates its seen set only once a server hands out a second cursor, and rejects a cursor equal to the initial one. parseSkillURI returns the parsed URL so validateResourceURI can reuse it instead of reparsing the skill root for every resource. mcp: factor the SDK default capabilities into defaultCapabilities so that AddExtension and capabilities cannot drift apart. conformance/skills-server: index skills by URI instead of rescanning the entry slice, and use path.Base for display names. scripts: factor the repeated argument check into require_value and simplify the work-directory setup. Co-Authored-By: Claude Opus 5 (1M context) --- conformance/skills-server/main.go | 16 ++--- mcp/server.go | 15 +++-- scripts/server-conformance.sh | 34 +++++----- skills/client.go | 100 ++++++++++++++++++------------ skills/pagination.go | 36 ++++++----- skills/server.go | 26 ++++---- skills/types.go | 65 ++++++++++++------- skills/validation.go | 63 +++++++++++-------- skills/verify.go | 6 +- 9 files changed, 218 insertions(+), 143 deletions(-) diff --git a/conformance/skills-server/main.go b/conformance/skills-server/main.go index d44f1f91e..09bdb6bd6 100644 --- a/conformance/skills-server/main.go +++ b/conformance/skills-server/main.go @@ -13,6 +13,7 @@ import ( "fmt" "log" "net/http" + "path" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -31,6 +32,7 @@ func main() { "skill://other/SKILL.md": "---\nname: other\ndescription: Another skill.\n---\n# Other\n", } var entries []*skills.Skill + byURI := map[string]*skills.Skill{} for _, item := range []struct{ uri, name, description string }{ {"skill://demo/SKILL.md", "demo", "A demonstration skill."}, {"skill://demo/nested/SKILL.md", "nested", "A nested skill."}, @@ -48,24 +50,24 @@ func main() { entry.Frontmatter["metadata"] = map[string]string{"author": "go-sdk"} } entries = append(entries, entry) + byURI[item.uri] = entry } directories := map[string][]*mcp.Resource{"skill://demo/empty": {}} for uri, content := range files { - resource := &mcp.Resource{URI: uri, Name: uri[strings.LastIndex(uri, "/")+1:], MIMEType: "text/markdown"} - for _, entry := range entries { - if entry.URI == uri { - resource.Name = entry.Frontmatter["name"].(string) - resource.Description = entry.Frontmatter["description"].(string) - } + resource := &mcp.Resource{URI: uri, Name: path.Base(uri), MIMEType: "text/markdown"} + if entry, ok := byURI[uri]; ok { + resource.Name = entry.Frontmatter["name"].(string) + resource.Description = entry.Frontmatter["description"].(string) } server.AddResource(resource, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: uri, MIMEType: "text/markdown", Text: content}}}, nil }) + // path.Dir would clean "skill://" down to "skill:/". parent := uri[:strings.LastIndex(uri, "/")] directories[parent] = append(directories[parent], resource) } for _, uri := range []string{"skill://demo/references", "skill://demo/nested", "skill://demo/empty"} { - directories["skill://demo"] = append(directories["skill://demo"], &mcp.Resource{URI: uri, Name: uri[strings.LastIndex(uri, "/")+1:], MIMEType: "inode/directory"}) + directories["skill://demo"] = append(directories["skill://demo"], &mcp.Resource{URI: uri, Name: path.Base(uri), MIMEType: "inode/directory"}) } if err := skills.AddHandlers(server, &skills.Handlers{ List: func(_ context.Context, _ *mcp.ServerSession, p *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { diff --git a/mcp/server.go b/mcp/server.go index 0f1f70d47..dcd8d145a 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -210,7 +210,7 @@ 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{}} + s.opts.Capabilities = defaultCapabilities() } else { s.opts.Capabilities = s.opts.Capabilities.clone() } @@ -671,6 +671,14 @@ func (s *Server) RemoveResourceTemplates(uriTemplates ...string) { s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resourceTemplates.remove(uriTemplates...) }) } +// defaultCapabilities returns the capabilities of a server whose options do not +// set any: only logging. +func defaultCapabilities() *ServerCapabilities { + return &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } +} + func (s *Server) capabilities() *ServerCapabilities { s.mu.Lock() defer s.mu.Unlock() @@ -681,10 +689,7 @@ func (s *Server) capabilities() *ServerCapabilities { // Deep copy the user-provided capabilities to avoid mutation. caps = s.opts.Capabilities.clone() } else { - // SDK defaults: only logging capability. - caps = &ServerCapabilities{ - Logging: &LoggingCapabilities{}, - } + caps = defaultCapabilities() } // Augment with tools capability if tools exist or legacy HasTools is set. diff --git a/scripts/server-conformance.sh b/scripts/server-conformance.sh index e3e3c6238..3ac8bce9f 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -37,30 +37,34 @@ usage() { echo " --help Show this help message" } +# require_value exits unless $1 was given a usable value in $2. +require_value() { + if [[ $# -lt 2 || -z "$2" || "$2" == --* ]]; then + echo "Missing value for $1" >&2 + exit 1 + fi +} + # Parse arguments. while [[ $# -gt 0 ]]; do - case $1 in - --result_dir|--conformance_repo|--conformance_ref|--server) - if [[ $# -lt 2 || -z "$2" || "$2" == --* ]]; then - echo "Missing value for $1" >&2 - exit 1 - fi - ;; - esac case $1 in --result_dir) + require_value "$@" RESULT_DIR="$2" shift 2 ;; --conformance_repo) + require_value "$@" CONFORMANCE_REPO="$2" shift 2 ;; --conformance_ref) + require_value "$@" CONFORMANCE_REF="$2" shift 2 ;; --server) + require_value "$@" SERVER_PACKAGE="$2" shift 2 ;; @@ -102,19 +106,17 @@ cleanup() { } trap cleanup EXIT -# Set up the work directory. -if [ -n "$RESULT_DIR" ]; then +# Set up the work directory. Results are written to an absolute path, so that +# the conformance runner can be started from the work directory. +OUTPUT_ARGS=() +if [[ -n "$RESULT_DIR" ]]; then mkdir -p "$RESULT_DIR" + RESULT_DIR=$(cd "$RESULT_DIR" && pwd) WORKDIR="$RESULT_DIR" + OUTPUT_ARGS=(--output-dir "$RESULT_DIR") else WORKDIR=$(mktemp -d) fi -WORKDIR=$(cd "$WORKDIR" && pwd) -OUTPUT_ARGS=() -if [[ -n "$RESULT_DIR" ]]; then - RESULT_DIR="$WORKDIR" - OUTPUT_ARGS=(--output-dir "$RESULT_DIR") -fi if [[ -n "$CONFORMANCE_REPO" ]]; then if [[ -d "$CONFORMANCE_REPO" ]]; then diff --git a/skills/client.go b/skills/client.go index c5b2e0d9b..46c681728 100644 --- a/skills/client.go +++ b/skills/client.go @@ -46,7 +46,7 @@ type Client struct { // List calls skills/list and validates the response using c.Limits. // If params is nil, List requests the first page. func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkillsResult, error) { - if err := c.requireCapability(false); err != nil { + if _, err := c.requireExtension(); err != nil { return nil, err } limits := c.Limits @@ -65,7 +65,7 @@ func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkill if err := validateListResult(result, limits); err != nil { return nil, fmt.Errorf("skills: server returned an invalid skills/list result: %w", err) } - if err := c.validateEnvelope(result.ResultType, &result.Cacheable, result.cachePresent); err != nil { + if err := c.validateEnvelope(result.ResultType, result.Cacheable, result.cachePresent); err != nil { return nil, err } return result, nil @@ -74,7 +74,7 @@ func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkill // Get calls skills/get and validates the response using c.Limits. // The URI in params must identify a SKILL.md, whether or not it was listed. func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResult, error) { - if err := c.requireCapability(false); err != nil { + if _, err := c.requireExtension(); err != nil { return nil, err } limits := c.Limits @@ -93,7 +93,7 @@ func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResu if err := validateGetResult(params.URI, result, limits); err != nil { return nil, fmt.Errorf("skills: server returned an invalid skill: %w", err) } - if err := c.validateEnvelope(result.ResultType, &result.Cacheable, result.cachePresent); err != nil { + if err := c.validateEnvelope(result.ResultType, result.Cacheable, result.cachePresent); err != nil { return nil, err } return result, nil @@ -102,7 +102,7 @@ func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResu // ReadDirectory calls resources/directory/read and validates the response. // The server must advertise directoryRead, and params must specify a directory URI. func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { - if err := c.requireCapability(true); err != nil { + if err := c.requireDirectoryRead(); err != nil { return nil, err } if params == nil || params.URI == "" { @@ -117,7 +117,7 @@ func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) if err := ValidateDirectoryResult(params.URI, result); err != nil { return nil, fmt.Errorf("skills: server returned an invalid directory result: %w", err) } - if err := c.validateEnvelope(result.ResultType, nil, false); err != nil { + if err := c.validateResultType(result.ResultType); err != nil { return nil, err } return result, nil @@ -128,10 +128,7 @@ func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) // The session, limits, and parameters are captured when All is called. // The iterator stops after yielding its first error. func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*Skill, error] { - client := Client{} - if c != nil { - client = *c - } + client := c.snapshot() var initial ListSkillsParams if params != nil { initial = *params @@ -154,10 +151,7 @@ func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*S // Each page is validated as in [Client.ReadDirectory]. // The iterator stops after yielding its first error. func (c *Client) DirectoryEntries(ctx context.Context, params *ReadDirectoryParams) iter.Seq2[*mcp.Resource, error] { - client := Client{} - if c != nil { - client = *c - } + client := c.snapshot() var initial ReadDirectoryParams if params != nil { initial = *params @@ -176,47 +170,75 @@ func (c *Client) DirectoryEntries(ctx context.Context, params *ReadDirectoryPara } } -func (c *Client) requireCapability(directoryRead bool) error { +// snapshot copies c so that an iterator keeps using the session and limits that +// were configured when it was created. +func (c *Client) snapshot() Client { if c == nil { - return fmt.Errorf("skills: nil client") + return Client{} + } + return *c +} + +// requireExtension reports the settings the server advertised for the Skills +// extension, or an error explaining which capability is missing. +func (c *Client) requireExtension() (map[string]any, error) { + if c == nil { + return nil, fmt.Errorf("skills: nil client") } - session := c.Session - if session == nil || session.InitializeResult() == nil || session.InitializeResult().Capabilities == nil { - return fmt.Errorf("skills: session has no server capabilities") + if c.Session == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") } - settings, ok := session.InitializeResult().Capabilities.Extensions[ExtensionID] + init := c.Session.InitializeResult() + if init == nil || init.Capabilities == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") + } + settings, ok := init.Capabilities.Extensions[ExtensionID] if !ok { - return fmt.Errorf("skills: server does not advertise %s", ExtensionID) + return nil, fmt.Errorf("skills: server does not advertise %s", ExtensionID) } m, ok := settings.(map[string]any) if !ok { - return fmt.Errorf("skills: server advertised invalid extension settings") + return nil, fmt.Errorf("skills: server advertised invalid extension settings") } - if session.InitializeResult().Capabilities.Resources == nil { - return fmt.Errorf("skills: server does not advertise resources") + if init.Capabilities.Resources == nil { + return nil, fmt.Errorf("skills: server does not advertise resources") } - if !directoryRead { - return nil + return m, nil +} + +func (c *Client) requireDirectoryRead() error { + settings, err := c.requireExtension() + if err != nil { + return err } - enabled, _ := m[capabilityDirectoryRead].(bool) - if !enabled { + if enabled, _ := settings[capabilityDirectoryRead].(bool); !enabled { return fmt.Errorf("skills: server does not advertise directoryRead") } return nil } -func (c *Client) validateEnvelope(resultType string, cache *mcp.Cacheable, cachePresent bool) error { - if c.Session.InitializeResult().ProtocolVersion < "2026-07-28" { - return nil - } - if resultType != "complete" { +// usesCaching reports whether the negotiated protocol version requires a result +// type, and with it the cache hints on skills/list and skills/get. +func (c *Client) usesCaching() bool { + return c.Session.InitializeResult().ProtocolVersion >= protocolVersionCaching +} + +func (c *Client) validateResultType(resultType string) error { + if c.usesCaching() && resultType != resultTypeComplete { return fmt.Errorf("skills: expected complete result, got %q", resultType) } - if cache != nil { - if !cachePresent { - return fmt.Errorf("skills: missing ttlMs or cacheScope") - } - return validateCache(*cache) - } return nil } + +func (c *Client) validateEnvelope(resultType string, cache mcp.Cacheable, cachePresent bool) error { + if err := c.validateResultType(resultType); err != nil { + return err + } + if !c.usesCaching() { + return nil + } + if !cachePresent { + return fmt.Errorf("skills: missing ttlMs or cacheScope") + } + return validateCache(cache) +} diff --git a/skills/pagination.go b/skills/pagination.go index 0df0ffe9a..ed596cc70 100644 --- a/skills/pagination.go +++ b/skills/pagination.go @@ -21,8 +21,13 @@ func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) if pageSize == 0 { pageSize = mcp.DefaultPageSize } - items = slices.Clone(items) - slices.SortFunc(items, func(a, b T) int { return strings.Compare(key(a), key(b)) }) + cmp := func(a, b T) int { return strings.Compare(key(a), key(b)) } + // An already-ordered catalog is the common case, and is the fast path: + // paginating it costs one comparison pass and no allocation. + if !slices.IsSortedFunc(items, cmp) { + items = slices.Clone(items) + slices.SortFunc(items, cmp) + } for i, item := range items { uri := key(item) if uri == "" || i > 0 && uri == key(items[i-1]) { @@ -35,16 +40,17 @@ func paginate[T any](items []T, cursor string, pageSize int, key func(T) string) if err != nil || len(decoded) == 0 { return nil, "", invalidParams("invalid cursor") } + // Resume at the first key after the cursor. last := string(decoded) - start = len(items) - for i, item := range items { - if key(item) > last { - start = i - break - } + var found bool + start, found = slices.BinarySearchFunc(items, last, func(item T, last string) int { + return strings.Compare(key(item), last) + }) + if found { + start++ } } - end := start + min(pageSize, len(items)-start) + end := min(start+pageSize, len(items)) page := slices.Clone(items[start:end]) if page == nil { page = []T{} @@ -81,10 +87,9 @@ func PaginateDirectoryResources(resources []*mcp.Resource, cursor string, pageSi 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 - } + // seen is populated only once a server hands out a second cursor, + // so the common single-page walk allocates nothing. + var seen map[string]bool for { items, next, err := fetch(cursor) if err != nil { @@ -100,11 +105,14 @@ func allPages[T any](initialCursor string, fetch func(string) ([]T, string, erro if next == "" { return } - if seen[next] { + if next == initialCursor || seen[next] { var zero T yield(zero, fmt.Errorf("skills: server repeated pagination cursor %q", next)) return } + if seen == nil { + seen = map[string]bool{} + } seen[next] = true cursor = next } diff --git a/skills/server.go b/skills/server.go index ff92491bc..fb30ae822 100644 --- a/skills/server.go +++ b/skills/server.go @@ -89,10 +89,7 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) if err := validateListResult(&out, limits); err != nil { return nil, internalError(fmt.Errorf("skills/list handler returned an invalid result: %w", err)) } - out.omitCache = !supportsCaching(params.Meta) - out.ResultType = resultType(params.Meta) - normalizeCache(&out.Cacheable) - if err := validateCache(out.Cacheable); err != nil { + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { return nil, internalError(err) } return &out, nil @@ -119,10 +116,7 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) } out := *result out.Meta = maps.Clone(result.Meta) - out.omitCache = !supportsCaching(params.Meta) - out.ResultType = resultType(params.Meta) - normalizeCache(&out.Cacheable) - if err := validateCache(out.Cacheable); err != nil { + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { return nil, internalError(err) } return &out, nil @@ -169,20 +163,28 @@ func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) // contains the client's proposal, which can differ from the negotiated version. func supportsCaching(meta mcp.Meta) bool { version, _ := meta[mcp.MetaKeyProtocolVersion].(string) - return version >= "2026-07-28" + return version >= protocolVersionCaching } func resultType(meta mcp.Meta) string { if supportsCaching(meta) { - return "complete" + return resultTypeComplete } return "" } -func normalizeCache(cache *mcp.Cacheable) { +// stampEnvelope fills in the result type and cache hints that the request's +// protocol version calls for, and validates the hints it settled on. +func stampEnvelope(meta mcp.Meta, resultType *string, omitCache *bool, cache *mcp.Cacheable) error { + caching := supportsCaching(meta) + *omitCache = !caching + if caching { + *resultType = resultTypeComplete + } if cache.CacheScope == "" { - cache.CacheScope = "public" + cache.CacheScope = cacheScopePublic } + return validateCache(*cache) } func invalidParams(message string) error { diff --git a/skills/types.go b/skills/types.go index 4372f62e4..f14d6411a 100644 --- a/skills/types.go +++ b/skills/types.go @@ -20,7 +20,15 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -const capabilityDirectoryRead = "directoryRead" +const ( + capabilityDirectoryRead = "directoryRead" + // protocolVersionCaching is the first protocol version whose results carry + // a result type and cache hints. + protocolVersionCaching = "2026-07-28" + resultTypeComplete = "complete" + cacheScopePublic = "public" + cacheScopePrivate = "private" +) const ( // ExtensionID is the capability identifier for the Skills extension. @@ -169,6 +177,33 @@ type ListSkillsResult struct { cachePresent bool } +// omittedCache shadows the [mcp.Cacheable] hints with nil pointers, dropping +// them from the wire form of a result marshaled before protocol version +// 2026-07-28. +type omittedCache struct { + TTLMs *int `json:"ttlMs,omitempty"` + CacheScope *string `json:"cacheScope,omitempty"` +} + +// decodedCache captures the cache hints as they appeared on the wire, so that a +// result can distinguish an absent hint from a zero-valued one. +type decodedCache struct { + TTLMs *int `json:"ttlMs"` + CacheScope *string `json:"cacheScope"` +} + +// apply copies the hints that were present into cache, and reports whether the +// response carried both of them. +func (d decodedCache) apply(cache *mcp.Cacheable) bool { + if d.TTLMs != nil { + cache.TTLMs = *d.TTLMs + } + if d.CacheScope != nil { + cache.CacheScope = *d.CacheScope + } + return d.TTLMs != nil && d.CacheScope != nil +} + func (r *ListSkillsResult) MarshalJSON() ([]byte, error) { type wire ListSkillsResult if !r.omitCache { @@ -176,8 +211,7 @@ func (r *ListSkillsResult) MarshalJSON() ([]byte, error) { } return json.Marshal(struct { *wire - TTLMs *int `json:"ttlMs,omitempty"` - CacheScope *string `json:"cacheScope,omitempty"` + omittedCache }{wire: (*wire)(r)}) } @@ -205,8 +239,7 @@ func (r *GetSkillResult) MarshalJSON() ([]byte, error) { } return json.Marshal(struct { *wire - TTLMs *int `json:"ttlMs,omitempty"` - CacheScope *string `json:"cacheScope,omitempty"` + omittedCache }{wire: (*wire)(r)}) } @@ -230,20 +263,13 @@ func (r *ListSkillsResult) UnmarshalJSON(data []byte) error { type wire ListSkillsResult var decoded struct { wire - TTLMs *int `json:"ttlMs"` - CacheScope *string `json:"cacheScope"` + decodedCache } if err := json.Unmarshal(data, &decoded); err != nil { return err } *r = ListSkillsResult(decoded.wire) - r.cachePresent = decoded.TTLMs != nil && decoded.CacheScope != nil - if decoded.TTLMs != nil { - r.TTLMs = *decoded.TTLMs - } - if decoded.CacheScope != nil { - r.CacheScope = *decoded.CacheScope - } + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) return nil } @@ -251,19 +277,12 @@ func (r *GetSkillResult) UnmarshalJSON(data []byte) error { type wire GetSkillResult var decoded struct { wire - TTLMs *int `json:"ttlMs"` - CacheScope *string `json:"cacheScope"` + decodedCache } if err := json.Unmarshal(data, &decoded); err != nil { return err } *r = GetSkillResult(decoded.wire) - r.cachePresent = decoded.TTLMs != nil && decoded.CacheScope != nil - if decoded.TTLMs != nil { - r.TTLMs = *decoded.TTLMs - } - if decoded.CacheScope != nil { - r.CacheScope = *decoded.CacheScope - } + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) return nil } diff --git a/skills/validation.go b/skills/validation.go index cc6364229..de8fe46ee 100644 --- a/skills/validation.go +++ b/skills/validation.go @@ -139,7 +139,7 @@ func validateSkill(skill *Skill, limits Limits) error { if skill == nil { return fmt.Errorf("skill is nil") } - name, err := skillNameFromURI(skill.URI) + name, skillURL, err := parseSkillURI(skill.URI) if err != nil { return err } @@ -157,12 +157,12 @@ func validateSkill(skill *Skill, limits Limits) error { 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 { + if length := utf8.RuneCountInString(description); !ok || length < 1 || length > 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 { + if length := utf8.RuneCountInString(s); !ok || length < 1 || length > 500 { return fmt.Errorf("skill %q frontmatter compatibility must contain 1 to 500 characters", skill.URI) } } @@ -196,10 +196,10 @@ func validateSkill(skill *Skill, limits Limits) error { } } - resources, static := skill.Resources.List() if skill.Resources.IsDynamic() { return nil } + resources, static := skill.Resources.List() if !static { return fmt.Errorf("skill %q resources is not set", skill.URI) } @@ -212,7 +212,7 @@ func validateSkill(skill *Skill, limits Limits) error { if resource == nil { return fmt.Errorf("skill %q resource %d is nil", skill.URI, i) } - if err := validateResourceURI(skill.URI, resource.URI); err != nil { + if err := validateResourceURI(skillURL, resource.URI); err != nil { return fmt.Errorf("skill %q resource %q: %w", skill.URI, resource.URI, err) } if seen[resource.URI] { @@ -278,28 +278,39 @@ func ValidateDirectoryResult(uri string, result *ReadDirectoryResult) error { } func validateName(name string) error { - length := utf8.RuneCountInString(name) - valid := utf8.ValidString(name) && length >= 1 && length <= 64 && name == strings.ToLower(name) && - !strings.HasPrefix(name, "-") && !strings.HasSuffix(name, "-") && !strings.Contains(name, "--") + invalid := fmt.Errorf("name %q must contain 1 to 64 lowercase Unicode letters, numbers, or non-consecutive hyphens, with no leading or trailing hyphen", name) + if length := utf8.RuneCountInString(name); length < 1 || length > 64 { + return invalid + } + if name != strings.ToLower(name) { + return invalid + } + if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") || strings.Contains(name, "--") { + return invalid + } + // Invalid UTF-8 decodes to U+FFFD, which is neither a letter nor a number. for _, r := range name { if r != '-' && !unicode.IsLetter(r) && !unicode.IsNumber(r) { - valid = false - break + return invalid } } - if !valid { - return fmt.Errorf("name %q must contain 1 to 64 lowercase Unicode letters, numbers, or non-consecutive hyphens, with no leading or trailing hyphen", name) - } return nil } func skillNameFromURI(rawURI string) (string, error) { + name, _, err := parseSkillURI(rawURI) + return name, err +} + +// parseSkillURI validates a SKILL.md URI, returning the skill name and the +// parsed URI so that callers checking many resources parse the root only once. +func parseSkillURI(rawURI string) (string, *url.URL, error) { u, err := parseURI(rawURI) if err != nil { - return "", err + return "", nil, err } if !strings.HasSuffix(u.Path, "/SKILL.md") { - return "", fmt.Errorf("skill URI %q must end in /SKILL.md", rawURI) + return "", nil, fmt.Errorf("skill URI %q must end in /SKILL.md", rawURI) } dir := strings.TrimPrefix(strings.TrimSuffix(u.Path, "/SKILL.md"), "/") if dir == "" { @@ -309,19 +320,17 @@ func skillNameFromURI(rawURI string) (string, error) { dir = parts[len(parts)-1] } if dir == "" { - return "", fmt.Errorf("skill URI %q has no skill name", rawURI) + return "", nil, fmt.Errorf("skill URI %q has no skill name", rawURI) } if err := validateName(dir); err != nil { - return "", err + return "", nil, err } - return dir, nil + return dir, u, nil } -func validateResourceURI(skillURI, resourceURI string) error { - skillURL, err := parseURI(skillURI) - if err != nil { - return err - } +// validateResourceURI checks that resourceURI names a file under the skill root +// described by the already-parsed skillURL. +func validateResourceURI(skillURL *url.URL, resourceURI string) error { resourceURL, err := parseURI(resourceURI) if err != nil { return err @@ -349,13 +358,15 @@ func parseDirectoryURI(rawURI string) (*url.URL, error) { func parseURI(rawURI string) (*url.URL, error) { u, err := url.Parse(rawURI) - if err != nil || u.Scheme == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(rawURI, "#") { + // A non-empty fragment always leaves a "#" in the raw URI, so the raw check + // covers both a parsed fragment and an empty one. + if err != nil || u.Scheme == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || strings.Contains(rawURI, "#") { return nil, fmt.Errorf("invalid resource URI %q", rawURI) } if u.Scheme == "skill" && (u.Host == "" || u.User != nil || u.Port() != "") { return nil, fmt.Errorf("invalid skill authority in %q", rawURI) } - for _, segment := range strings.Split(u.Path, "/") { + for segment := range strings.SplitSeq(u.Path, "/") { if segment == "." || segment == ".." { return nil, fmt.Errorf("URI %q contains a traversal segment", rawURI) } @@ -394,7 +405,7 @@ func validateCache(cache mcp.Cacheable) error { if cache.TTLMs < 0 { return fmt.Errorf("skills: ttlMs must not be negative") } - if cache.CacheScope != "public" && cache.CacheScope != "private" { + if cache.CacheScope != cacheScopePublic && cache.CacheScope != cacheScopePrivate { return fmt.Errorf("skills: invalid cacheScope %q", cache.CacheScope) } return nil diff --git a/skills/verify.go b/skills/verify.go index d3cf9def0..49638fb96 100644 --- a/skills/verify.go +++ b/skills/verify.go @@ -26,7 +26,11 @@ func VerifyResource(skill *Skill, uri string, content []byte) error { if err := validateSkill(skill, Limits{}); err != nil { return err } - if err := validateResourceURI(skill.URI, uri); err != nil { + _, skillURL, err := parseSkillURI(skill.URI) + if err != nil { + return err + } + if err := validateResourceURI(skillURL, uri); err != nil { return err } if skill.Resources.IsDynamic() { From 7732974af9331921115201f72fb63a846d5ded4e Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 14:30:15 +0100 Subject: [PATCH 10/11] go.mod: mark golang.org/x/sync as a direct dependency mcp/mrtr.go imports golang.org/x/sync/errgroup directly, so the "// indirect" marking was stale and `go mod tidy -diff` reported a pending change. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index f2f70f10a..325d40714 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/segmentio/encoding v0.5.4 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.35.0 + golang.org/x/sync v0.20.0 golang.org/x/time v0.15.0 golang.org/x/tools v0.42.0 gopkg.in/yaml.v3 v3.0.1 @@ -16,6 +17,5 @@ require ( require ( github.com/segmentio/asm v1.1.3 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect ) From fade7b3028802b3c1cbc945a51bf80a032ac8d08 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 11 Sep 2026 14:30:15 +0100 Subject: [PATCH 11/11] skills: restructure the tests as tables Group the package tests by what they exercise: validation_test.go holds the pure unit tables, protocol_test.go the wire behavior, skills_test.go the shared helpers and API contracts, and limits_test.go the manifest caps. The limits matrix now calls ValidateSkillWithLimits directly instead of standing up a streamable HTTP server per combination, taking that test from 24 connections to 4. TestLimitsArePlumbed keeps one end-to-end case per side to prove Client.Limits and ServerOptions.Limits reach validation. TestSpecErrorScenarios collects the cases that the sep-2640-skills-* conformance scenarios also cover behind a single server, and carries a note to delete it once those scenarios run in CI against ./conformance/skills-server. Statement coverage rises from 82.2% to 90.8%. Co-Authored-By: Claude Opus 5 (1M context) --- skills/limits_test.go | 259 +++++++++--------- skills/protocol_test.go | 536 +++++++++++++++++--------------------- skills/skills_test.go | 412 +++++++++++++++-------------- skills/validation_test.go | 310 +++++++++++++++++++--- 4 files changed, 865 insertions(+), 652 deletions(-) diff --git a/skills/limits_test.go b/skills/limits_test.go index d8b990ff9..f79a72cfa 100644 --- a/skills/limits_test.go +++ b/skills/limits_test.go @@ -14,6 +14,22 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// oversized returns a skill that exceeds exactly one baseline dimension. +func oversized(kind string) *Skill { + return skillWith(func(s *Skill) { + entries, _ := s.Resources.List() + if kind == "count" { + for i := range BaselineLimits().MaxResourcesPerSkill { + entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: testDigest, Size: 1}) + } + } else { + entries[0].Size = BaselineLimits().MaxTotalSize + 1 + } + s.Resources = StaticResources(entries...) + }) +} + +// checkLimitCalls exercises every client entry point that validates a manifest. func checkLimitCalls(t *testing.T, client *Client, skill *Skill, wantOK bool) { t.Helper() _, listErr := client.List(t.Context(), nil) @@ -37,109 +53,149 @@ func checkLimitCalls(t *testing.T, client *Client, skill *Skill, wantOK bool) { } } -func TestLimitsRoundTrip(t *testing.T) { +// TestValidateSkillWithLimits covers the limit matrix by calling validation +// directly. Limits are SDK policy rather than protocol, so the conformance suite +// cannot observe them; TestLimitsArePlumbed checks that requests reach this code. +func TestValidateSkillWithLimits(t *testing.T) { baseline := BaselineLimits() for _, kind := range []string{"count", "bytes"} { - t.Run(kind, func(t *testing.T) { - skill := testSkill() - entries, _ := skill.Resources.List() - if kind == "count" { - for i := 1; i <= baseline.MaxResourcesPerSkill; i++ { - entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: entries[0].Digest, Size: 1}) + skill := oversized(kind) + for _, test := range []struct { + name string + limits Limits + wantOK bool + }{ + {"zero value imposes no caps", Limits{}, true}, + {"baseline", baseline, false}, + {"count only", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill}, kind == "bytes"}, + {"bytes only", Limits{MaxTotalSize: baseline.MaxTotalSize}, kind == "count"}, + {"raised count", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill + 1}, true}, + {"raised bytes", Limits{MaxTotalSize: baseline.MaxTotalSize + 1}, true}, + {"negative count", Limits{MaxResourcesPerSkill: -1}, false}, + {"negative bytes", Limits{MaxTotalSize: -1}, false}, + } { + t.Run(kind+"/"+test.name, func(t *testing.T) { + if err := ValidateSkillWithLimits(skill, test.limits); (err == nil) != test.wantOK { + t.Fatalf("ValidateSkillWithLimits() = %v, want success = %v", err, test.wantOK) } - } else { - entries[0].Size = baseline.MaxTotalSize + 1 - } - skill.Resources = StaticResources(entries...) - for _, test := range []struct { - name string - limits Limits - wantOK bool - }{ - {"zero-value", Limits{}, true}, - {"baseline", baseline, false}, - {"count-only", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill}, kind == "bytes"}, - {"bytes-only", Limits{MaxTotalSize: baseline.MaxTotalSize}, kind == "count"}, - {"raised-count", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill + 1}, true}, - {"raised-bytes", Limits{MaxTotalSize: baseline.MaxTotalSize + 1}, true}, - } { - t.Run(test.name, func(t *testing.T) { - t.Run("client", func(t *testing.T) { - server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, "2026-07-28") - client.Limits = test.limits - checkLimitCalls(t, client, skill, test.wantOK) - }) - t.Run("server", func(t *testing.T) { - server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: test.limits}); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, "2026-07-28") - client.Limits = Limits{} - checkLimitCalls(t, client, skill, test.wantOK) - }) - }) - } - if err := ValidateSkill(skill); err != nil { - t.Fatalf("ValidateSkill imposed a manifest cap: %v", err) - } - if err := ValidateSkillWithLimits(skill, baseline); err == nil { - t.Fatal("baseline validation accepted an oversized manifest") + }) + } + if err := ValidateSkill(skill); err != nil { + t.Errorf("%s: ValidateSkill imposed a manifest cap: %v", kind, err) + } + } + + for _, test := range []struct { + name string + skill *Skill + limits Limits + wantOK bool + }{ + // Structural validation runs whatever the limits are. + {"unlimited still validates structure", skillWith(func(s *Skill) { s.Frontmatter["name"] = "BAD" }), Limits{}, false}, + // A dynamic manifest has no countable resources, so caps do not apply. + {"dynamic is exempt", skillWith(func(s *Skill) { s.Resources = DynamicResources() }), Limits{MaxResourcesPerSkill: 1, MaxTotalSize: 1}, true}, + // Sizes are compared against the remaining budget so the sum cannot overflow. + {"total size cannot overflow", skillWith(func(s *Skill) { + s.Resources = StaticResources( + &Resource{URI: "skill://demo/SKILL.md", Digest: testDigest, Size: math.MaxInt64}, + &Resource{URI: "skill://demo/helper.txt", Digest: testDigest, Size: 1}) + }), Limits{MaxTotalSize: math.MaxInt64}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if err := ValidateSkillWithLimits(test.skill, test.limits); (err == nil) != test.wantOK { + t.Fatalf("ValidateSkillWithLimits() = %v, want success = %v", err, test.wantOK) } }) } } -func TestNegativeLimits(t *testing.T) { - server := testServer() - skill := testSkill() - handlers := fixedHandlers(skill) +// TestLimitsArePlumbed checks that Client.Limits and ServerOptions.Limits reach +// validation over a connection, and that invalid limits fail before a request is +// sent. The matrix itself lives in TestValidateSkillWithLimits. +func TestLimitsArePlumbed(t *testing.T) { + skill := oversized("count") var calls atomic.Int32 - list, get := handlers.List, handlers.Get - handlers.List = func(ctx context.Context, session *mcp.ServerSession, params *ListSkillsParams) (*ListSkillsResult, error) { - calls.Add(1) - return list(ctx, session, params) - } - handlers.Get = func(ctx context.Context, session *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { - calls.Add(1) - return get(ctx, session, params) - } - if err := AddHandlers(server, handlers, nil); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, "2026-07-28") - for _, limits := range []Limits{{MaxResourcesPerSkill: -1}, {MaxTotalSize: -1}} { - if err := ValidateSkillWithLimits(skill, limits); err == nil { - t.Fatal("negative validation limit accepted") + counted := func(skill *Skill) *Handlers { + h := fixedHandlers(skill) + list, get := h.List, h.Get + h.List = func(ctx context.Context, s *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + calls.Add(1) + return list(ctx, s, p) } - if err := AddHandlers(testServer(), handlers, &ServerOptions{Limits: limits}); err == nil { - t.Fatal("negative server limit accepted") + h.Get = func(ctx context.Context, s *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + calls.Add(1) + return get(ctx, s, p) } - client.Limits = limits - checkLimitCalls(t, client, skill, false) + return h } - if got := calls.Load(); got != 0 { - t.Fatalf("invalid client limits sent %d requests", got) + + for _, side := range []string{"client", "server"} { + for _, test := range []struct { + name string + limits Limits + wantOK bool + }{ + {"unlimited", Limits{}, true}, + {"baseline", BaselineLimits(), false}, + } { + t.Run(side+"/"+test.name, func(t *testing.T) { + server := testServer() + var options *ServerOptions + if side == "server" { + options = &ServerOptions{Limits: test.limits} + } + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + if side == "client" { + client.Limits = test.limits + } + checkLimitCalls(t, client, skill, test.wantOK) + }) + } } + + t.Run("negative limits fail before sending", func(t *testing.T) { + valid := testSkill() + server := testServer() + if err := AddHandlers(server, counted(valid), nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + for _, limits := range []Limits{{MaxResourcesPerSkill: -1}, {MaxTotalSize: -1}} { + if err := AddHandlers(testServer(), counted(valid), &ServerOptions{Limits: limits}); err == nil { + t.Error("AddHandlers accepted a negative limit") + } + client.Limits = limits + checkLimitCalls(t, client, valid, false) + } + if got := calls.Load(); got != 0 { + t.Fatalf("invalid client limits sent %d requests", got) + } + }) } +// TestLimitOwnership checks that limits are captured at registration and at +// iterator creation, so later mutation of the caller's value has no effect. func TestLimitOwnership(t *testing.T) { - skill := testSkill() - entries, _ := skill.Resources.List() - skill.Resources = StaticResources(entries[0], &Resource{URI: "skill://demo/helper.txt", Digest: entries[0].Digest, Size: 1}) + skill := skillWith(func(s *Skill) { + s.Resources = StaticResources( + &Resource{URI: "skill://demo/SKILL.md", Digest: testDigest, Size: 1}, + &Resource{URI: "skill://demo/helper.txt", Digest: testDigest, Size: 1}) + }) server := testServer() options := &ServerOptions{Limits: Limits{MaxResourcesPerSkill: 2}} if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { t.Fatal(err) } - options.Limits = Limits{MaxTotalSize: 1} - client := connectSkills(t, server, "2026-07-28") + options.Limits = Limits{MaxTotalSize: 1} // must not affect the registered handlers + + client := connectSkills(t, server, protocolVersionCaching) client.Limits = Limits{MaxResourcesPerSkill: 2} checkLimitCalls(t, client, skill, true) + seq := client.All(t.Context(), nil) client.Limits.MaxResourcesPerSkill = 1 checkLimitCalls(t, client, skill, false) @@ -156,46 +212,3 @@ func TestLimitOwnership(t *testing.T) { } } } - -func TestUnlimitedLimitsKeepStructuralValidation(t *testing.T) { - skill := testSkill() - skill.Frontmatter["name"] = "BAD" - if err := ValidateSkillWithLimits(skill, Limits{}); err == nil { - t.Fatal("unlimited validation accepted malformed frontmatter") - } - server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, "2026-07-28") - client.Limits = Limits{} - checkLimitCalls(t, client, skill, false) -} - -func TestUnlimitedTotalSize(t *testing.T) { - skill := testSkill() - entries, _ := skill.Resources.List() - entries[0].Size = math.MaxInt64 - skill.Resources = StaticResources(entries[0], &Resource{ - URI: "skill://demo/helper.txt", Digest: entries[0].Digest, Size: 1, - }) - if err := ValidateSkill(skill); err != nil { - t.Fatalf("unlimited validation accumulated a total size: %v", err) - } - if err := ValidateSkillWithLimits(skill, Limits{MaxTotalSize: math.MaxInt64}); err == nil { - t.Fatal("total size overflow bypassed the configured cap") - } -} - -func TestDynamicLimits(t *testing.T) { - skill := testSkill() - skill.Resources = DynamicResources() - limits := Limits{MaxResourcesPerSkill: 1, MaxTotalSize: 1} - server := testServer() - if err := AddHandlers(server, fixedHandlers(skill), &ServerOptions{Limits: limits}); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, "2026-07-28") - client.Limits = limits - checkLimitCalls(t, client, skill, true) -} diff --git a/skills/protocol_test.go b/skills/protocol_test.go index 06121d0ca..6016d092f 100644 --- a/skills/protocol_test.go +++ b/skills/protocol_test.go @@ -9,10 +9,7 @@ import ( "encoding/json" "errors" "fmt" - "net/http" - "net/http/httptest" "reflect" - "strings" "sync" "testing" @@ -20,158 +17,186 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -func testSkill() *Skill { - return &Skill{URI: "skill://demo/SKILL.md", Frontmatter: Frontmatter{"name": "demo", "description": "Demo"}, - Resources: StaticResources(&Resource{URI: "skill://demo/SKILL.md", Digest: "sha256:" + strings.Repeat("0", 64), Size: 1})} -} - -func testServer() *mcp.Server { - return mcp.NewServer(&mcp.Implementation{Name: "skills-test", Version: "v1"}, &mcp.ServerOptions{ - Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}}, - }) -} - -func connectSkills(t *testing.T, server *mcp.Server, version string) *Client { +// wantRPCCode reports whether err is a JSON-RPC error with the given code. +func wantRPCCode(t *testing.T, context string, err error, code int64) *jsonrpc.Error { t.Helper() - httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: version >= "2026-07-28"})) - t.Cleanup(httpServer.Close) - client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) - if err := AddMethods(client); err != nil { - t.Fatal(err) - } - session, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, &mcp.ClientSessionOptions{ProtocolVersion: version}) - if err != nil { - t.Fatal(err) + var rpc *jsonrpc.Error + if !errors.As(err, &rpc) { + t.Errorf("%s: error = %v, want a JSON-RPC error with code %d", context, err, code) + return nil } - t.Cleanup(func() { _ = session.Close() }) - return &Client{Session: session} -} - -func fixedHandlers(skill *Skill) *Handlers { - return &Handlers{ - List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { - return &ListSkillsResult{Skills: []*Skill{skill}}, nil - }, - Get: func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { - if p.URI != skill.URI { - return nil, nil - } - return &GetSkillResult{Skill: skill}, nil - }, + if rpc.Code != code { + t.Errorf("%s: error code = %d (%v), want %d", context, rpc.Code, err, code) } + return rpc } -func TestUnknownURIsAndHandlerErrors(t *testing.T) { +// TestSpecErrorScenarios covers wire behavior that the SEP-2640 server +// conformance scenarios (sep-2640-skills-*) also check. Once those scenarios +// run in CI against ./conformance/skills-server, this test can be deleted. +func TestSpecErrorScenarios(t *testing.T) { server := testServer() skill := testSkill() - h := fixedHandlers(skill) - h.Get = func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { - switch p.URI { - case "skill://nil/SKILL.md": - return &GetSkillResult{}, nil - case "skill://backend/SKILL.md": - return nil, errors.New("backend unavailable") - case "skill://wrong/SKILL.md": - return &GetSkillResult{Skill: skill}, nil - default: - return nil, nil - } + handlers := fixedHandlers(skill) + handlers.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + page, next, err := PaginateSkills([]*Skill{skill}, p.Cursor, 0) + return &ListSkillsResult{Skills: page, NextCursor: next}, err } - h.ReadDirectory = func(_ context.Context, _ *mcp.ServerSession, p *ReadDirectoryParams) (*ReadDirectoryResult, error) { - if p.URI == "skill://empty" { + handlers.ReadDirectory = func(_ context.Context, _ *mcp.ServerSession, p *ReadDirectoryParams) (*ReadDirectoryResult, error) { + switch p.URI { + case "skill://demo": + page, next, err := PaginateDirectoryResources([]*mcp.Resource{{URI: skill.URI, Name: "demo"}}, p.Cursor, 0) + return &ReadDirectoryResult{Resources: page, NextCursor: next}, err + case "skill://empty": return &ReadDirectoryResult{}, nil + default: + return nil, nil // unknown directory } - return nil, nil } - if err := AddHandlers(server, h, nil); err != nil { + if err := AddHandlers(server, handlers, nil); err != nil { t.Fatal(err) } - c := connectSkills(t, server, "2026-07-28") - for _, uri := range []string{"skill://unknown/SKILL.md", "skill://nil/SKILL.md", "malformed"} { - _, err := c.Get(t.Context(), &GetSkillParams{URI: uri}) - var rpc *jsonrpc.Error - if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInvalidParams { - t.Fatalf("%s: %v", uri, err) + client := connectSkills(t, server, protocolVersionCaching) + + t.Run("get", func(t *testing.T) { + for _, test := range []struct { + name, uri string + }{ + {"unknown skill", "skill://unknown/SKILL.md"}, + {"malformed uri", "malformed"}, + {"not a SKILL.md", "skill://demo/other.md"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := client.Get(t.Context(), &GetSkillParams{URI: test.uri}) + wantRPCCode(t, test.uri, err, jsonrpc.CodeInvalidParams) + }) } - } - for _, uri := range []string{"skill://backend/SKILL.md", "skill://wrong/SKILL.md"} { - _, err := c.Get(t.Context(), &GetSkillParams{URI: uri}) - var rpc *jsonrpc.Error - if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInternalError { - t.Fatalf("handler bug mislabeled: %v", err) + }) + + t.Run("directory", func(t *testing.T) { + if _, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://missing"}); err != nil { + wantRPCCode(t, "unknown directory", err, jsonrpc.CodeInvalidParams) + } else { + t.Error("unknown directory accepted") } - } - if _, err := c.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://missing"}); err == nil { - t.Fatal("unknown directory accepted") - } else { - var rpc *jsonrpc.Error - if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInvalidParams { - t.Fatal(err) + // An empty directory is a success with an empty, non-null array. + empty, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://empty"}) + if err != nil || empty.Resources == nil || len(empty.Resources) != 0 { + t.Errorf("empty directory = %+v, %v", empty, err) } - } - empty, err := c.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://empty"}) - if err != nil || empty.Resources == nil || len(empty.Resources) != 0 { - t.Fatalf("empty directory: %+v, %v", empty, err) - } + }) + + t.Run("invalid cursor", func(t *testing.T) { + _, listErr := client.List(t.Context(), &ListSkillsParams{Cursor: "%"}) + _, directoryErr := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo", Cursor: "%"}) + for method, err := range map[string]error{MethodList: listErr, MethodReadDirectory: directoryErr} { + wantRPCCode(t, method, err, jsonrpc.CodeInvalidParams) + } + }) } -func TestHandlerErrorCodes(t *testing.T) { +// TestHandlerErrorMapping covers how AddHandlers translates a Go handler's +// return values into JSON-RPC errors. This mapping is SDK behavior and is not +// visible to the conformance suite. +func TestHandlerErrorMapping(t *testing.T) { coded := &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "invalid request", Data: json.RawMessage(`{"reason":"test"}`)} - for _, version := range []string{"2025-11-25", "2026-07-28"} { - for _, test := range []struct { - name string - err error - code int64 - }{ - {"backend", errors.New("backend unavailable"), jsonrpc.CodeInternalError}, - {"invalid-result", nil, jsonrpc.CodeInternalError}, - {"coded", coded, jsonrpc.CodeInvalidParams}, - {"wrapped-coded", fmt.Errorf("handler: %w", coded), jsonrpc.CodeInvalidParams}, - } { - t.Run(version+"/"+test.name, func(t *testing.T) { - server := testServer() - skill := testSkill() - skill.Frontmatter["description"] = false - h := &Handlers{ - List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { - return &ListSkillsResult{Skills: []*Skill{skill}}, test.err - }, - Get: func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) { - return &GetSkillResult{Skill: skill}, test.err - }, - ReadDirectory: func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) { - return &ReadDirectoryResult{Resources: []*mcp.Resource{{URI: skill.URI}}}, test.err - }, - } - if err := AddHandlers(server, h, nil); err != nil { - t.Fatal(err) - } - client := connectSkills(t, server, version) - _, listErr := client.List(t.Context(), nil) - _, getErr := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) - _, directoryErr := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}) - for method, err := range map[string]error{MethodList: listErr, MethodGet: getErr, MethodReadDirectory: directoryErr} { - var rpc *jsonrpc.Error - if !errors.As(err, &rpc) || rpc.Code != test.code { - t.Errorf("%s: error = %v, want code %d", method, err, test.code) - continue - } - if test.code == coded.Code && string(rpc.Data) != string(coded.Data) { - t.Errorf("%s: error data = %s, want %s", method, rpc.Data, coded.Data) - } + for _, test := range []struct { + name string + err error + invalid bool // the handler also returns a structurally invalid result + code int64 + wantData bool + }{ + {name: "plain error becomes internal", err: errors.New("backend unavailable"), code: jsonrpc.CodeInternalError}, + {name: "invalid result becomes internal", invalid: true, code: jsonrpc.CodeInternalError}, + {name: "coded error is preserved", err: coded, code: jsonrpc.CodeInvalidParams, wantData: true}, + {name: "wrapped coded error is preserved", err: fmt.Errorf("handler: %w", coded), code: jsonrpc.CodeInvalidParams, wantData: true}, + } { + t.Run(test.name, func(t *testing.T) { + server := testServer() + skill := testSkill() + if test.invalid || test.err == nil { + skill.Frontmatter["description"] = false // fails validateSkill + } + handlers := &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, test.err + }, + Get: func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) { + return &GetSkillResult{Skill: skill}, test.err + }, + ReadDirectory: func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) { + return &ReadDirectoryResult{Resources: []*mcp.Resource{{URI: skill.URI}}}, test.err + }, + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + _, listErr := client.List(t.Context(), nil) + _, getErr := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + _, directoryErr := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}) + for method, err := range map[string]error{MethodList: listErr, MethodGet: getErr, MethodReadDirectory: directoryErr} { + rpc := wantRPCCode(t, method, err, test.code) + if rpc != nil && test.wantData && string(rpc.Data) != string(coded.Data) { + t.Errorf("%s: error data = %s, want %s", method, rpc.Data, coded.Data) } - }) + } + }) + } + + t.Run("nil list result", func(t *testing.T) { + server := testServer() + handlers := fixedHandlers(testSkill()) + handlers.List = func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return nil, nil + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + _, err := connectSkills(t, server, protocolVersionCaching).List(t.Context(), nil) + wantRPCCode(t, MethodList, err, jsonrpc.CodeInternalError) + }) +} + +// TestClientRequiresCapabilities checks the guards that run before a request is +// sent. A server that advertises neither the extension nor directoryRead must be +// rejected locally rather than called. +func TestClientRequiresCapabilities(t *testing.T) { + ctx := t.Context() + if _, err := (&Client{}).List(ctx, nil); err == nil { + t.Error("client without a session accepted List") + } + // A server with no Skills handlers does not advertise the extension. + if _, err := connectSkills(t, testServer(), protocolVersionCaching).List(ctx, nil); err == nil { + t.Error("client called a server that does not advertise the extension") + } + // Handlers without ReadDirectory advertise the extension but not directoryRead. + server := testServer() + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + if _, err := client.ReadDirectory(ctx, &ReadDirectoryParams{URI: "skill://demo"}); err == nil { + t.Error("client called resources/directory/read without the capability") + } + for _, params := range []*GetSkillParams{nil, {}} { + if _, err := client.Get(ctx, params); err == nil { + t.Errorf("Get accepted %+v", params) } } } +// TestResponsesAndParamsAreNotMutated checks the ownership contract: handler +// results and caller parameters are copied, never modified in place, even under +// concurrent use across two protocol versions. func TestResponsesAndParamsAreNotMutated(t *testing.T) { server := testServer() skill := testSkill() list := &ListSkillsResult{Skills: []*Skill{skill}, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}} get := &GetSkillResult{Skill: skill, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}, Cacheable: mcp.Cacheable{TTLMs: 123, CacheScope: "private"}} dir := &ReadDirectoryResult{} - h := &Handlers{ + handlers := &Handlers{ List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { return list, nil }, @@ -180,42 +205,44 @@ func TestResponsesAndParamsAreNotMutated(t *testing.T) { return dir, nil }, } - beforeList, _ := json.Marshal(list) - beforeGet, _ := json.Marshal(get) - beforeDir, _ := json.Marshal(dir) - if err := AddHandlers(server, h, nil); err != nil { + before := map[string][]byte{} + for name, result := range map[string]any{"list": list, "get": get, "dir": dir} { + before[name], _ = json.Marshal(result) + } + if err := AddHandlers(server, handlers, nil); err != nil { t.Fatal(err) } - legacy := connectSkills(t, server, "2025-11-25") - modern := connectSkills(t, server, "2026-07-28") + // The legacy version omits the cache hints; the modern one requires them. + clients := map[string]*Client{"2025-11-25": connectSkills(t, server, "2025-11-25"), protocolVersionCaching: connectSkills(t, server, protocolVersionCaching)} var wg sync.WaitGroup - for _, client := range []*Client{legacy, modern} { + for version, client := range clients { + modern := version == protocolVersionCaching wg.Go(func() { for range 4 { - p := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"owner": "caller"}}} - res, err := client.List(t.Context(), p) + params := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"owner": "caller"}}} + listResult, err := client.List(t.Context(), params) if err != nil { t.Error(err) return } - if len(p.Meta) != 1 { - t.Error("request metadata mutated") + if !reflect.DeepEqual(params.Meta, mcp.Meta{"owner": "caller"}) { + t.Errorf("%s: request metadata mutated", version) } - // Re-encoding received legacy results need not preserve wire omission; - // the decoder tracks actual presence separately. - if res.cachePresent != (client == modern) { - t.Error("wrong cache fields on wire") + // Re-encoding a received legacy result need not preserve wire + // omission; the decoder tracks actual presence separately. + if listResult.cachePresent != modern { + t.Errorf("%s: list cachePresent = %v", version, listResult.cachePresent) } - gr, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + getResult, err := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) if err != nil { t.Error(err) return } - if gr.cachePresent != (client == modern) { - t.Error("wrong get cache fields") + if getResult.cachePresent != modern { + t.Errorf("%s: get cachePresent = %v", version, getResult.cachePresent) } - if client == modern && (gr.TTLMs != 123 || gr.CacheScope != "private") { - t.Error("cache hints lost") + if modern && (getResult.TTLMs != 123 || getResult.CacheScope != "private") { + t.Errorf("%s: cache hints lost", version) } if _, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}); err != nil { t.Error(err) @@ -224,84 +251,93 @@ func TestResponsesAndParamsAreNotMutated(t *testing.T) { }) } wg.Wait() - afterList, _ := json.Marshal(list) - afterGet, _ := json.Marshal(get) - afterDir, _ := json.Marshal(dir) - if string(beforeList) != string(afterList) || string(beforeGet) != string(afterGet) || string(beforeDir) != string(afterDir) { - t.Fatal("handler-owned result changed") + for name, result := range map[string]any{"list": list, "get": get, "dir": dir} { + after, _ := json.Marshal(result) + if string(after) != string(before[name]) { + t.Errorf("handler-owned %s result changed:\n got %s\nwant %s", name, after, before[name]) + } } } -type rawSkillResult struct { +type rawResult struct { mcp.ResultBase data json.RawMessage } -func (r *rawSkillResult) MarshalJSON() ([]byte, error) { return r.data, nil } +func (r *rawResult) MarshalJSON() ([]byte, error) { return r.data, nil } +// TestClientRejectsMalformedResponses feeds hand-built bodies past the server's +// own validation. The conformance suite drives servers, not clients, so nothing +// else covers these paths. func TestClientRejectsMalformedResponses(t *testing.T) { - good := testSkill() - encoded, _ := json.Marshal(good) - for _, tc := range []struct{ name, body string }{ - {"null-list", `{"skills":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`}, - {"duplicate", fmt.Sprintf(`{"skills":[%s,%s],"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, encoded, encoded)}, - {"missing-ttl", `{"skills":[],"resultType":"complete","cacheScope":"public"}`}, - {"missing-scope", `{"skills":[],"resultType":"complete","ttlMs":0}`}, - {"negative-ttl", `{"skills":[],"resultType":"complete","ttlMs":-1,"cacheScope":"public"}`}, - {"bad-scope", `{"skills":[],"resultType":"complete","ttlMs":0,"cacheScope":"unknown"}`}, - {"wrong-result-type", `{"skills":[],"resultType":"input_required","ttlMs":0,"cacheScope":"public"}`}, + skill := testSkill() + encoded, err := json.Marshal(skill) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, method, body string + }{ + {"list/null-skills", MethodList, `{"skills":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`}, + {"list/duplicate-skill", MethodList, fmt.Sprintf(`{"skills":[%s,%s],"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, encoded, encoded)}, + {"list/missing-ttl", MethodList, `{"skills":[],"resultType":"complete","cacheScope":"public"}`}, + {"list/missing-scope", MethodList, `{"skills":[],"resultType":"complete","ttlMs":0}`}, + {"list/negative-ttl", MethodList, `{"skills":[],"resultType":"complete","ttlMs":-1,"cacheScope":"public"}`}, + {"list/bad-scope", MethodList, `{"skills":[],"resultType":"complete","ttlMs":0,"cacheScope":"unknown"}`}, + {"list/wrong-result-type", MethodList, `{"skills":[],"resultType":"input_required","ttlMs":0,"cacheScope":"public"}`}, + {"list/missing-result-type", MethodList, `{"skills":[],"ttlMs":0,"cacheScope":"public"}`}, + {"get/missing-scope", MethodGet, fmt.Sprintf(`{"skill":%s,"resultType":"complete","ttlMs":0}`, encoded)}, + {"get/missing-ttl", MethodGet, fmt.Sprintf(`{"skill":%s,"resultType":"complete","cacheScope":"public"}`, encoded)}, + {"get/null-skill", MethodGet, `{"skill":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`}, } { - t.Run(tc.name, func(t *testing.T) { + t.Run(test.name, func(t *testing.T) { server := testServer() server.AddExtension(ExtensionID, nil) - if err := mcp.AddReceivingCustomMethod(server, MethodList, func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*rawSkillResult, error) { - return &rawSkillResult{data: json.RawMessage(tc.body)}, nil - }); err != nil { + raw := &rawResult{data: json.RawMessage(test.body)} + // Register only the method under test, bypassing AddHandlers so that + // the body reaches the client exactly as written. + var err error + switch test.method { + case MethodGet: + err = mcp.AddReceivingCustomMethod(server, MethodGet, func(context.Context, *mcp.ServerSession, *GetSkillParams) (*rawResult, error) { return raw, nil }) + default: + err = mcp.AddReceivingCustomMethod(server, MethodList, func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*rawResult, error) { return raw, nil }) + } + if err != nil { t.Fatal(err) } - c := connectSkills(t, server, "2026-07-28") - if _, err := c.List(t.Context(), nil); err == nil { - t.Fatal("accepted malformed response") + client := connectSkills(t, server, protocolVersionCaching) + if test.method == MethodGet { + _, err = client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + } else { + _, err = client.List(t.Context(), nil) + } + if err == nil { + t.Fatalf("accepted malformed %s response", test.method) } }) } - for _, body := range []string{ - fmt.Sprintf(`{"skill":%s,"resultType":"complete","ttlMs":0}`, encoded), - fmt.Sprintf(`{"skill":%s,"resultType":"complete","cacheScope":"public"}`, encoded), - `{"skill":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, - } { - server := testServer() - server.AddExtension(ExtensionID, nil) - if err := mcp.AddReceivingCustomMethod(server, MethodGet, func(context.Context, *mcp.ServerSession, *GetSkillParams) (*rawSkillResult, error) { - return &rawSkillResult{data: json.RawMessage(body)}, nil - }); err != nil { - t.Fatal(err) - } - c := connectSkills(t, server, "2026-07-28") - if _, err := c.Get(t.Context(), &GetSkillParams{URI: good.URI}); err == nil { - t.Fatal("accepted malformed get") - } - } } -func TestPaginationAndIteratorOwnership(t *testing.T) { +// TestIteratorOwnership checks that All captures the session and parameters when +// it is called, and that the returned sequence is reusable. +func TestIteratorOwnership(t *testing.T) { server := testServer() one := testSkill() - two := *one - two.URI = "skill://other/SKILL.md" - two.Frontmatter = Frontmatter{"name": "other", "description": "Other"} - two.Resources = DynamicResources() - h := fixedHandlers(one) - h.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { - page, next, err := PaginateSkills([]*Skill{&two, one}, p.Cursor, 1) + two := skillWith(func(s *Skill) { + s.URI, s.Frontmatter, s.Resources = "skill://other/SKILL.md", Frontmatter{"name": "other", "description": "Other"}, DynamicResources() + }) + handlers := fixedHandlers(one) + handlers.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + page, next, err := PaginateSkills([]*Skill{two, one}, p.Cursor, 1) return &ListSkillsResult{Skills: page, NextCursor: next}, err } - if err := AddHandlers(server, h, nil); err != nil { + if err := AddHandlers(server, handlers, nil); err != nil { t.Fatal(err) } - c := connectSkills(t, server, "2026-07-28") - p := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"key": "value"}}} - seq := c.All(t.Context(), p) + client := connectSkills(t, server, protocolVersionCaching) + params := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"key": "value"}}} + seq := client.All(t.Context(), params) for range 2 { count := 0 for _, err := range seq { @@ -311,108 +347,10 @@ func TestPaginationAndIteratorOwnership(t *testing.T) { count++ } if count != 2 { - t.Fatalf("count=%d", count) - } - } - if p.Cursor != "" || !reflect.DeepEqual(p.Meta, mcp.Meta{"key": "value"}) { - t.Fatal("iterator mutated parameters") - } - calls := 0 - for _, err := range allPages("", func(string) ([]int, string, error) { calls++; return []int{1}, "repeat", nil }) { - if err != nil { - break - } - } - if calls != 2 { - t.Fatalf("repeated cursor: %d calls", calls) - } - calls = 0 - for range allPages("", func(string) ([]int, string, error) { calls++; return []int{1}, "next", nil }) { - break - } - if calls != 1 { - t.Fatal("iterator fetched after early stop") - } - for _, items := range [][]*Skill{{nil}, {one, one}} { - if _, _, err := PaginateSkills(items, "", 1); err == nil { - t.Fatal("invalid pagination input accepted") + t.Fatalf("iterator yielded %d skills across pages, want 2", count) } } -} - -func TestInvalidPaginationCursors(t *testing.T) { - for _, version := range []string{"2025-11-25", "2026-07-28"} { - t.Run(version, func(t *testing.T) { - server := testServer() - skill := testSkill() - h := fixedHandlers(skill) - h.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { - page, next, err := PaginateSkills([]*Skill{skill}, p.Cursor, 0) - return &ListSkillsResult{Skills: page, NextCursor: next}, err - } - h.ReadDirectory = func(_ context.Context, _ *mcp.ServerSession, p *ReadDirectoryParams) (*ReadDirectoryResult, error) { - page, next, err := PaginateDirectoryResources([]*mcp.Resource{{URI: skill.URI, Name: "demo"}}, p.Cursor, 0) - return &ReadDirectoryResult{Resources: page, NextCursor: next}, err - } - if err := AddHandlers(server, h, nil); err != nil { - t.Fatal(err) - } - c := connectSkills(t, server, version) - _, listErr := c.List(t.Context(), &ListSkillsParams{Cursor: "%"}) - _, directoryErr := c.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo", Cursor: "%"}) - for method, err := range map[string]error{MethodList: listErr, MethodReadDirectory: directoryErr} { - var rpc *jsonrpc.Error - if !errors.As(err, &rpc) || rpc.Code != jsonrpc.CodeInvalidParams { - t.Errorf("%s: got %v, want JSON-RPC Invalid Params", method, err) - } - } - }) - } -} - -func TestURIAndVerificationBoundaries(t *testing.T) { - for _, uri := range []string{"skill://demo/../bad", "skill://demo/%2e%2e", "skill://demo/%2e", "skill://demo/file?", "skill://demo/file#", "skill:opaque"} { - if _, err := parseURI(uri); err == nil { - t.Fatalf("accepted %s", uri) - } - } - for _, uri := range []string{"skill://demo/%2e%2e", "skill://other/a", "skill://demo/a/b", "skill://demo/a%2fb", "skill://demo/sub%2f"} { - if err := ValidateDirectoryResult("skill://demo", &ReadDirectoryResult{Resources: []*mcp.Resource{{URI: uri, Name: "child"}}}); err == nil { - t.Fatalf("accepted child %s", uri) - } - } - if err := VerifySkillMD(nil, nil); err == nil { - t.Fatal("nil skill accepted") - } - skill := testSkill() - data := []byte("tampered") - if err := VerifyResource(skill, "skill://demo/unlisted", data); err == nil { - t.Fatal("unlisted file accepted") - } - if err := VerifyResource(skill, skill.URI, data); err == nil { - t.Fatal("wrong content accepted") - } -} - -func TestFrontmatterAtEOF(t *testing.T) { - for _, content := range []string{ - "---\nname: demo\ndescription: Demo\n---", - "---\r\nname: demo\r\ndescription: Demo\r\n---", - "---\nname: demo\ndescription: Demo\n---\n", - } { - got, err := parseFrontmatter([]byte(content)) - if err != nil || got["name"] != "demo" { - t.Fatalf("frontmatter: %v, %v", got, err) - } - } -} - -func TestDirectoryDisplayNamesNeedNotBeUnique(t *testing.T) { - result := &ReadDirectoryResult{Resources: []*mcp.Resource{ - {URI: "skill://demo/a", Name: "Same display name"}, - {URI: "skill://demo/b", Name: "Same display name"}, - }} - if err := ValidateDirectoryResult("skill://demo", result); err != nil { - t.Fatal(err) + if params.Cursor != "" || !reflect.DeepEqual(params.Meta, mcp.Meta{"key": "value"}) { + t.Fatal("iterator mutated its parameters") } } diff --git a/skills/skills_test.go b/skills/skills_test.go index b988aa37e..0d1da5468 100644 --- a/skills/skills_test.go +++ b/skills/skills_test.go @@ -6,249 +6,261 @@ package skills import ( "context" - "crypto/sha256" + "encoding/base64" "encoding/json" - "errors" "fmt" + "net/http" + "net/http/httptest" "slices" + "strings" "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") - } -} +// testDigest is a syntactically valid SHA-256 digest. Tests that check content +// integrity compute a real digest instead. +var testDigest = "sha256:" + strings.Repeat("0", 64) -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"]) - } +func testSkill() *Skill { + return &Skill{URI: "skill://demo/SKILL.md", Frontmatter: Frontmatter{"name": "demo", "description": "Demo"}, + Resources: StaticResources(&Resource{URI: "skill://demo/SKILL.md", Digest: testDigest, Size: 1})} +} - 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") - } +// skillWith returns a valid skill with mutate applied, for table rows that vary +// one field at a time. +func skillWith(mutate func(*Skill)) *Skill { + skill := testSkill() + mutate(skill) + return skill +} - 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 testServer() *mcp.Server { + return mcp.NewServer(&mcp.Implementation{Name: "skills-test", Version: "v1"}, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}}, + }) } -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 { +func connectSkills(t *testing.T, server *mcp.Server, version string) *Client { + t.Helper() + httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: version >= protocolVersionCaching})) + t.Cleanup(httpServer.Close) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + if err := AddMethods(client); 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) + session, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, &mcp.ClientSessionOptions{ProtocolVersion: version}) 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") - } + t.Cleanup(func() { _ = session.Close() }) + return &Client{Session: session} } -func TestVerifyDynamicSkillMD(t *testing.T) { - skill := &Skill{ - URI: "skill://demo/SKILL.md", - Frontmatter: Frontmatter{"name": "demo", "description": "A demo skill.", "metadata": map[string]string{"author": "go-sdk"}}, - Resources: DynamicResources(), +func fixedHandlers(skill *Skill) *Handlers { + return &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, nil + }, + Get: func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + if p.URI != skill.URI { + return nil, nil + } + return &GetSkillResult{Skill: skill}, nil + }, } +} + +func TestResourcesJSON(t *testing.T) { for _, test := range []struct { - name string - content string - matches bool + name string + resources Resources + want string // "" means Marshal must fail }{ - {"matching", "---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\n---\n# Demo\n", true}, - {"changed-description", "---\nname: demo\ndescription: Different instructions.\nmetadata:\n author: go-sdk\n---\n", false}, - {"missing-metadata", "---\nname: demo\ndescription: A demo skill.\n---\n", false}, - {"extra-field", "---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\nallowed-tools: Bash\n---\n", false}, - {"malformed", "---\nname: [\n---\n", false}, + {name: "static", resources: StaticResources(&Resource{URI: "skill://a/SKILL.md", Digest: testDigest, Size: 1}), + want: `[{"uri":"skill://a/SKILL.md","digest":"` + testDigest + `","size":1}]`}, + {name: "empty static", resources: StaticResources(), want: `[]`}, + {name: "dynamic", resources: DynamicResources(), want: `"dynamic"`}, + {name: "unset", resources: Resources{}}, } { - t.Run(test.name, func(t *testing.T) { - err := VerifySkillMD(skill, []byte(test.content)) - if err == nil { - t.Fatal("dynamic content passed integrity verification") + t.Run("encode/"+test.name, func(t *testing.T) { + data, err := json.Marshal(test.resources) + if (err == nil) != (test.want != "") { + t.Fatalf("Marshal() = %s, %v, want %q", data, err, test.want) } - if got := errors.Is(err, ErrDynamicResources); got != test.matches { - t.Fatalf("VerifySkillMD() = %v; want ErrDynamicResources only for matching frontmatter", err) + if err == nil && string(data) != test.want { + t.Fatalf("Marshal() = %s, want %s", data, test.want) } }) } -} -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) + for _, test := range []struct { + data string + wantErr, wantDyn bool + }{ + {data: `"dynamic"`, wantDyn: true}, + {data: `"dynamic"`, wantDyn: true}, + {data: ` "dynamic" `, wantDyn: true}, + {data: `[]`}, + {data: `"other"`, wantErr: true}, + {data: `null`, wantErr: true}, + {data: `42`, wantErr: true}, + {data: `{}`, wantErr: true}, + } { + t.Run("decode/"+test.data, func(t *testing.T) { + var resources Resources + err := json.Unmarshal([]byte(test.data), &resources) + if (err != nil) != test.wantErr { + t.Fatalf("Unmarshal() error = %v, want error = %v", err, test.wantErr) } - got = append(got, value) - } - if !slices.Equal(got, []string{"a", "b"}) { - t.Fatalf("iteration yielded %v", got) - } + if err == nil && resources.IsDynamic() != test.wantDyn { + t.Fatalf("IsDynamic() = %v, want %v", resources.IsDynamic(), test.wantDyn) + } + }) } } -func TestGenericHandlersSupportDynamicResources(t *testing.T) { - server := mcp.NewServer(&mcp.Implementation{Name: "dynamic", Version: "v1"}, &mcp.ServerOptions{Capabilities: &mcp.ServerCapabilities{Resources: &mcp.ResourceCapabilities{}}}) - 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) - } +func TestPaginate(t *testing.T) { + cursor := func(uri string) string { return base64.RawURLEncoding.EncodeToString([]byte(uri)) } + a, b, c := &Skill{URI: "skill://a/SKILL.md"}, &Skill{URI: "skill://b/SKILL.md"}, &Skill{URI: "skill://c/SKILL.md"} + unsorted := []*Skill{c, a, b} - client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v1"}, nil) - if err := AddMethods(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) + for _, test := range []struct { + name string + input []*Skill + cursor string + pageSize int + want []string + wantNext string + wantErr bool + }{ + {name: "sorts by uri", input: unsorted, pageSize: 2, want: []string{a.URI, b.URI}, wantNext: cursor(b.URI)}, + {name: "resumes after cursor", input: unsorted, cursor: cursor(b.URI), pageSize: 2, want: []string{c.URI}}, + {name: "cursor past the end", input: unsorted, cursor: cursor("skill://z/SKILL.md"), pageSize: 2}, + {name: "default page size", input: unsorted, want: []string{a.URI, b.URI, c.URI}}, + {name: "empty input", pageSize: 2}, + {name: "negative page size", input: unsorted, pageSize: -1, wantErr: true}, + {name: "invalid cursor", input: unsorted, cursor: "%", pageSize: 1, wantErr: true}, + {name: "nil entry", input: []*Skill{nil}, pageSize: 1, wantErr: true}, + {name: "duplicate uri", input: []*Skill{a, a}, pageSize: 1, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + page, next, err := PaginateSkills(test.input, test.cursor, test.pageSize) + if (err != nil) != test.wantErr { + t.Fatalf("PaginateSkills() error = %v, want error = %v", err, test.wantErr) + } + if err != nil { + return + } + var got []string + for _, skill := range page { + got = append(got, skill.URI) + } + if !slices.Equal(got, test.want) || next != test.wantNext || page == nil { + t.Fatalf("PaginateSkills() = %v, %q, want %v, %q", got, next, test.want, test.wantNext) + } + }) } - t.Cleanup(func() { _ = cs.Close() }) - result, err := (&Client{Session: cs}).List(ctx, nil) - if err != nil { - t.Fatal(err) + if unsorted[0] != c { + t.Error("PaginateSkills modified its input") } - if len(result.Skills) != 1 || !result.Skills[0].Resources.IsDynamic() { - t.Fatalf("List() = %+v", result) + + // PaginateDirectoryResources shares paginate; check only its key function. + page, next, err := PaginateDirectoryResources([]*mcp.Resource{{URI: "skill://demo/b"}, {URI: "skill://demo/a"}}, "", 1) + if err != nil || len(page) != 1 || page[0].URI != "skill://demo/a" || next != cursor("skill://demo/a") { + t.Fatalf("PaginateDirectoryResources() = %v, %q, %v", page, next, err) } - if result.ResultType != resultType(mcp.Meta{mcp.MetaKeyProtocolVersion: cs.InitializeResult().ProtocolVersion}) { - t.Fatalf("List() resultType = %q, unexpected for protocol", result.ResultType) + if _, _, err := PaginateDirectoryResources([]*mcp.Resource{nil}, "", 1); err == nil { + t.Error("PaginateDirectoryResources accepted a nil resource") } } -func TestValidateListResultRejectsMissingSkills(t *testing.T) { - if err := validateListResult(&ListSkillsResult{}, Limits{}); err == nil { - t.Fatal("validateListResult accepted missing skills") - } -} +func TestAllPages(t *testing.T) { + t.Run("reusable", func(t *testing.T) { + calls := 0 + seq := allPages("", func(cursor string) ([]string, string, error) { + calls++ + if cursor == "" { + return []string{"a"}, "next", nil + } + return []string{"b"}, "", nil + }) + 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) + } + } + if calls != 4 { + t.Fatalf("re-iterating made %d fetches, want 4", calls) + } + }) -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) + // Each of these must stop the walk after a bounded number of fetches: a + // server repeating a cursor would otherwise loop forever, and a consumer + // breaking out must not trigger another fetch. + for _, test := range []struct { + name, initial, next string + fetchErr bool + stopEarly bool + wantCalls int + }{ + {name: "repeated cursor", next: "repeat", wantCalls: 2}, + {name: "cursor equals the initial cursor", initial: "start", next: "start", wantCalls: 1}, + {name: "fetch error", fetchErr: true, wantCalls: 1}, + {name: "consumer stops early", next: "next", stopEarly: true, wantCalls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + calls, failed := 0, false + for _, err := range allPages(test.initial, func(string) ([]int, string, error) { + calls++ + if test.fetchErr { + return nil, "", fmt.Errorf("boom") + } + return []int{1}, test.next, nil + }) { + if err != nil { + failed = true + } + if err != nil || test.stopEarly { + break + } + } + if failed == test.stopEarly { + t.Errorf("yielded an error = %v, want %v", failed, !test.stopEarly) + } + if calls != test.wantCalls { + t.Errorf("made %d fetches, want %d", calls, test.wantCalls) + } + }) } } -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 TestResultCacheFieldsOmittedForLegacyProtocol(t *testing.T) { + for name, result := range map[string]json.Marshaler{ + "list": &ListSkillsResult{Skills: []*Skill{}, omitCache: true}, + "get": &GetSkillResult{Skill: testSkill(), omitCache: true}, + } { + t.Run(name, func(t *testing.T) { + 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) + } + for _, key := range []string{"ttlMs", "cacheScope", "resultType"} { + if _, ok := fields[key]; ok { + t.Errorf("legacy result contains %s: %s", key, data) + } + } + }) } } diff --git a/skills/validation_test.go b/skills/validation_test.go index c3f6544e9..3e6c2cb81 100644 --- a/skills/validation_test.go +++ b/skills/validation_test.go @@ -11,8 +11,186 @@ import ( "fmt" "strings" "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) +func TestValidateSkill(t *testing.T) { + set := func(key string, value any) func(*Skill) { + return func(s *Skill) { s.Frontmatter[key] = value } + } + resources := func(entries ...*Resource) func(*Skill) { + return func(s *Skill) { s.Resources = StaticResources(entries...) } + } + self := &Resource{URI: "skill://demo/SKILL.md", Digest: testDigest, Size: 1} + + for _, test := range []struct { + name string + skill *Skill + wantErr bool + }{ + {name: "valid", skill: testSkill()}, + {name: "dynamic", skill: skillWith(func(s *Skill) { s.Resources = DynamicResources() })}, + {name: "nil", wantErr: true}, + {name: "uri is not a SKILL.md", skill: skillWith(func(s *Skill) { s.URI = "skill://demo/other.md" }), wantErr: true}, + {name: "uri name does not match frontmatter", skill: skillWith(func(s *Skill) { s.URI = "skill://other/SKILL.md" }), wantErr: true}, + {name: "no frontmatter", skill: skillWith(func(s *Skill) { s.Frontmatter = nil }), wantErr: true}, + {name: "frontmatter is not JSON-compatible", skill: skillWith(set("extra", make(chan int))), wantErr: true}, + {name: "name is not a string", skill: skillWith(set("name", 1)), wantErr: true}, + {name: "description is missing", skill: skillWith(func(s *Skill) { delete(s.Frontmatter, "description") }), wantErr: true}, + {name: "description is empty", skill: skillWith(set("description", "")), wantErr: true}, + {name: "description is too long", skill: skillWith(set("description", strings.Repeat("é", 1025))), wantErr: true}, + {name: "compatibility is not a string", skill: skillWith(set("compatibility", 1)), wantErr: true}, + {name: "compatibility is too long", skill: skillWith(set("compatibility", strings.Repeat("é", 501))), wantErr: true}, + {name: "license is not a string", skill: skillWith(set("license", 1)), wantErr: true}, + {name: "metadata as map[string]string", skill: skillWith(set("metadata", map[string]string{"author": "go-sdk"}))}, + {name: "metadata is not an object", skill: skillWith(set("metadata", "author")), wantErr: true}, + {name: "metadata value is not a string", skill: skillWith(set("metadata", map[string]any{"count": 1})), wantErr: true}, + {name: "allowed-tools is not a string", skill: skillWith(set("allowed-tools", []string{"Bash"})), wantErr: true}, + {name: "resources unset", skill: skillWith(func(s *Skill) { s.Resources = Resources{} }), wantErr: true}, + {name: "resources omit SKILL.md", skill: skillWith(resources(&Resource{URI: "skill://demo/a.md", Digest: testDigest, Size: 1})), wantErr: true}, + {name: "nil resource", skill: skillWith(resources(self, nil)), wantErr: true}, + {name: "duplicate resource", skill: skillWith(resources(self, self)), wantErr: true}, + {name: "resource outside the skill root", skill: skillWith(resources(self, &Resource{URI: "skill://other/a.md", Digest: testDigest, Size: 1})), wantErr: true}, + {name: "resource is a directory", skill: skillWith(resources(self, &Resource{URI: "skill://demo/sub/", Digest: testDigest, Size: 1})), wantErr: true}, + {name: "invalid digest", skill: skillWith(resources(&Resource{URI: self.URI, Digest: "sha256:" + strings.Repeat("A", 64), Size: 1})), wantErr: true}, + {name: "negative size", skill: skillWith(resources(&Resource{URI: self.URI, Digest: testDigest, Size: -1})), wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + if err := ValidateSkill(test.skill); (err != nil) != test.wantErr { + t.Fatalf("ValidateSkill() = %v, want error = %v", err, test.wantErr) + } + }) + } +} + +func TestSkillNames(t *testing.T) { + for _, test := range []struct { + name string + ok bool + }{ + {"demo", true}, {"café", true}, {"中文", true}, {"résumé-٢", true}, + {strings.Repeat("é", 64), true}, {strings.Repeat("é", 65), false}, + {"", false}, {"CAFÉ", false}, {"-demo", false}, {"demo-", false}, + {"demo--test", false}, {"demo_test", false}, {"demo/test", false}, + {"demo\xff", false}, + } { + t.Run(test.name, func(t *testing.T) { + skill := &Skill{ + URI: "skill://org/" + test.name + "/SKILL.md", + Frontmatter: Frontmatter{"name": test.name, "description": "Demo"}, + Resources: DynamicResources(), + } + if err := ValidateSkill(skill); (err == nil) != test.ok { + t.Fatalf("ValidateSkill() = %v, want success = %v", err, test.ok) + } + }) + } +} + +func TestParseURI(t *testing.T) { + for _, test := range []struct { + uri string + ok bool + }{ + {"skill://demo/SKILL.md", true}, + {"https://example.com/a", true}, + {"skill://demo/../bad", false}, + {"skill://demo/%2e%2e", false}, + {"skill://demo/%2e", false}, + {"skill://demo/file?", false}, + {"skill://demo/file#", false}, + {"skill:opaque", false}, + {"/no-scheme", false}, + {"skill:///no-host", false}, + {"skill://user@demo/a", false}, + {"skill://demo:8080/a", false}, + } { + t.Run(test.uri, func(t *testing.T) { + if _, err := parseURI(test.uri); (err == nil) != test.ok { + t.Fatalf("parseURI(%q) = %v, want success = %v", test.uri, err, test.ok) + } + }) + } +} + +func TestValidateDirectoryResult(t *testing.T) { + child := func(uri, name string) *mcp.Resource { return &mcp.Resource{URI: uri, Name: name} } + for _, test := range []struct { + name string + uri string + resources []*mcp.Resource + nilResult bool + wantErr bool + }{ + {name: "direct children", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/SKILL.md", "demo"), child("skill://demo/sub", "sub")}}, + {name: "empty", uri: "skill://demo", resources: []*mcp.Resource{}}, + // Display names identify a child to a human, not to the protocol. + {name: "duplicate display names", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/a", "same"), child("skill://demo/b", "same")}}, + {name: "nil result", uri: "skill://demo", nilResult: true, wantErr: true}, + {name: "null resources", uri: "skill://demo", wantErr: true}, + {name: "trailing slash on the directory", uri: "skill://demo/", resources: []*mcp.Resource{}, wantErr: true}, + {name: "traversal child", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/%2e%2e", "child")}, wantErr: true}, + {name: "child of another skill", uri: "skill://demo", resources: []*mcp.Resource{child("skill://other/a", "child")}, wantErr: true}, + {name: "grandchild", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/a/b", "child")}, wantErr: true}, + {name: "encoded separator", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/a%2fb", "child")}, wantErr: true}, + {name: "nil child", uri: "skill://demo", resources: []*mcp.Resource{nil}, wantErr: true}, + {name: "child without a name", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/a", "")}, wantErr: true}, + {name: "duplicate child", uri: "skill://demo", resources: []*mcp.Resource{child("skill://demo/a", "a"), child("skill://demo/a", "a")}, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + var result *ReadDirectoryResult + if !test.nilResult { + result = &ReadDirectoryResult{Resources: test.resources} + } + if err := ValidateDirectoryResult(test.uri, result); (err != nil) != test.wantErr { + t.Fatalf("ValidateDirectoryResult() = %v, want error = %v", err, test.wantErr) + } + }) + } +} + +func TestValidateListAndGetResult(t *testing.T) { + skill := testSkill() + for _, test := range []struct { + name string + result *ListSkillsResult + wantErr bool + }{ + {name: "ok", result: &ListSkillsResult{Skills: []*Skill{skill}}}, + {name: "nil result", wantErr: true}, + {name: "missing skills", result: &ListSkillsResult{}, wantErr: true}, + {name: "invalid skill", result: &ListSkillsResult{Skills: []*Skill{{URI: skill.URI}}}, wantErr: true}, + {name: "duplicate skill", result: &ListSkillsResult{Skills: []*Skill{skill, skill}}, wantErr: true}, + } { + t.Run("list/"+test.name, func(t *testing.T) { + if err := validateListResult(test.result, Limits{}); (err != nil) != test.wantErr { + t.Fatalf("validateListResult() = %v, want error = %v", err, test.wantErr) + } + }) + } + + other := skillWith(func(s *Skill) { + s.URI, s.Frontmatter = "skill://other/SKILL.md", Frontmatter{"name": "other", "description": "Other"} + }) + for _, test := range []struct { + name string + result *GetSkillResult + wantErr bool + }{ + {name: "ok", result: &GetSkillResult{Skill: skill}}, + {name: "nil result", wantErr: true}, + {name: "nil skill", result: &GetSkillResult{}, wantErr: true}, + {name: "different uri", result: &GetSkillResult{Skill: other}, wantErr: true}, + } { + t.Run("get/"+test.name, func(t *testing.T) { + if err := validateGetResult(skill.URI, test.result, Limits{}); (err != nil) != test.wantErr { + t.Fatalf("validateGetResult() = %v, want error = %v", err, test.wantErr) + } + }) + } +} + func TestResourceSizeRequired(t *testing.T) { for _, test := range []struct { name string @@ -27,7 +205,7 @@ func TestResourceSizeRequired(t *testing.T) { {"fraction", `,"size":0.5`, false}, } { t.Run(test.name, func(t *testing.T) { - data := `{"uri":"skill://demo/SKILL.md","frontmatter":{"name":"demo","description":"Demo"},"resources":[{"uri":"skill://demo/SKILL.md","digest":"sha256:` + strings.Repeat("0", 64) + `"` + test.size + `}]}` + data := `{"uri":"skill://demo/SKILL.md","frontmatter":{"name":"demo","description":"Demo"},"resources":[{"uri":"skill://demo/SKILL.md","digest":"` + testDigest + `"` + test.size + `}]}` var skill Skill err := json.Unmarshal([]byte(data), &skill) if err == nil { @@ -40,43 +218,115 @@ func TestResourceSizeRequired(t *testing.T) { } } -func TestDynamicMarkerJSON(t *testing.T) { - for _, data := range []string{`"dynamic"`, `"dyn\u0061mic"`, ` "\u0064ynamic" `} { - var resources Resources - if err := json.Unmarshal([]byte(data), &resources); err != nil || !resources.IsDynamic() { - t.Errorf("decode %s = %+v, %v", data, resources, err) - } - } - for _, data := range []string{`"other"`, `null`, `42`, `{}`} { - var resources Resources - if err := json.Unmarshal([]byte(data), &resources); err == nil { - t.Errorf("decode %s succeeded", data) - } - } -} - -func TestSkillNames(t *testing.T) { +func TestParseFrontmatter(t *testing.T) { for _, test := range []struct { - name string - ok bool + name, content string + wantErr bool }{ - {"demo", true}, {"café", true}, {"中文", true}, {"résumé-٢", true}, - {strings.Repeat("é", 64), true}, {strings.Repeat("é", 65), false}, - {"", false}, {"CAFÉ", false}, {"-demo", false}, {"demo-", false}, - {"demo--test", false}, {"demo_test", false}, {"demo/test", false}, - {"demo\xff", false}, + {name: "closing delimiter at EOF", content: "---\nname: demo\ndescription: Demo\n---"}, + {name: "crlf", content: "---\r\nname: demo\r\ndescription: Demo\r\n---"}, + {name: "trailing newline", content: "---\nname: demo\ndescription: Demo\n---\n"}, + {name: "body", content: "---\nname: demo\ndescription: Demo\n---\n# Demo\n"}, + {name: "no frontmatter", content: "# Demo\n", wantErr: true}, + {name: "no closing delimiter", content: "---\nname: demo\n", wantErr: true}, + {name: "empty frontmatter", content: "---\n\n---\n", wantErr: true}, + {name: "malformed yaml", content: "---\nname: [\n---\n", wantErr: true}, + {name: "non-string mapping key", content: "---\nname: demo\nextra:\n 1: a\n---\n", wantErr: true}, } { t.Run(test.name, func(t *testing.T) { - skill := &Skill{ - URI: "skill://org/" + test.name + "/SKILL.md", - Frontmatter: Frontmatter{"name": test.name, "description": "Demo"}, - Resources: DynamicResources(), + got, err := parseFrontmatter([]byte(test.content)) + if (err != nil) != test.wantErr { + t.Fatalf("parseFrontmatter() = %v, %v, want error = %v", got, err, test.wantErr) } - if err := ValidateSkill(skill); (err == nil) != test.ok { - t.Fatalf("ValidateSkill() = %v, want success = %v", err, test.ok) + if err == nil && got["name"] != "demo" { + t.Fatalf("parseFrontmatter() name = %v", got["name"]) } }) } + + // Nested YAML mappings normalize to map[string]any so that the result is + // JSON-compatible and comparable with a decoded manifest. + got, err := parseFrontmatter([]byte("---\nname: demo\nmetadata:\n author: go-sdk\nlist:\n - key: value\n---\n")) + if err != nil { + t.Fatal(err) + } + if _, ok := got["metadata"].(map[string]any); !ok { + t.Errorf("metadata has type %T, want map[string]any", got["metadata"]) + } + if _, ok := got["list"].([]any)[0].(map[string]any); !ok { + t.Errorf("list item has type %T, want map[string]any", got["list"].([]any)[0]) + } +} + +func TestVerify(t *testing.T) { + content := []byte("---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\n---\n# Demo\n") + frontmatter := Frontmatter{"name": "demo", "description": "A demo skill.", "metadata": map[string]any{"author": "go-sdk"}} + static := &Skill{URI: "skill://demo/SKILL.md", Frontmatter: frontmatter, Resources: StaticResources(&Resource{ + URI: "skill://demo/SKILL.md", Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(content)), Size: int64(len(content)), + })} + dynamic := &Skill{URI: static.URI, Frontmatter: frontmatter, Resources: DynamicResources()} + + t.Run("VerifyResource", func(t *testing.T) { + for _, test := range []struct { + name string + skill *Skill + uri string + content []byte + wantErr bool + }{ + {name: "matching", skill: static, uri: static.URI, content: content}, + {name: "unlisted", skill: static, uri: "skill://demo/unlisted.md", content: content, wantErr: true}, + {name: "outside the root", skill: static, uri: "skill://other/a.md", content: content, wantErr: true}, + {name: "wrong size", skill: static, uri: static.URI, content: content[:len(content)-1], wantErr: true}, + {name: "wrong digest", skill: static, uri: static.URI, content: append(content[:len(content)-1:len(content)-1], '!'), wantErr: true}, + {name: "invalid skill", skill: skillWith(func(s *Skill) { s.Frontmatter = nil }), uri: static.URI, content: content, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + if err := VerifyResource(test.skill, test.uri, test.content); (err != nil) != test.wantErr { + t.Fatalf("VerifyResource() = %v, want error = %v", err, test.wantErr) + } + }) + } + if err := VerifyResource(dynamic, dynamic.URI, content); !errors.Is(err, ErrDynamicResources) { + t.Fatalf("VerifyResource(dynamic) = %v, want ErrDynamicResources", err) + } + }) + + t.Run("VerifySkillMD", func(t *testing.T) { + if err := VerifySkillMD(nil, nil); err == nil { + t.Error("VerifySkillMD accepted a nil skill") + } + if err := VerifySkillMD(static, content); err != nil { + t.Error(err) + } + if err := VerifySkillMD(static, []byte("---\nname: demo\ndescription: Different.\n---\n")); err == nil { + t.Error("VerifySkillMD accepted mismatched content") + } + + // A dynamic manifest cannot be integrity-checked, so VerifySkillMD still + // compares frontmatter and reports ErrDynamicResources only on a match. + for _, test := range []struct { + name string + content string + matches bool + }{ + {"matching", string(content), true}, + {"changed-description", "---\nname: demo\ndescription: Different instructions.\nmetadata:\n author: go-sdk\n---\n", false}, + {"missing-metadata", "---\nname: demo\ndescription: A demo skill.\n---\n", false}, + {"extra-field", "---\nname: demo\ndescription: A demo skill.\nmetadata:\n author: go-sdk\nallowed-tools: Bash\n---\n", false}, + {"malformed", "---\nname: [\n---\n", false}, + } { + t.Run("dynamic/"+test.name, func(t *testing.T) { + err := VerifySkillMD(dynamic, []byte(test.content)) + if err == nil { + t.Fatal("dynamic content passed integrity verification") + } + if got := errors.Is(err, ErrDynamicResources); got != test.matches { + t.Fatalf("VerifySkillMD() = %v; want ErrDynamicResources only for matching frontmatter", err) + } + }) + } + }) } func TestVerifyFrontmatterNumbers(t *testing.T) {