diff --git a/README.md b/README.md index baeb3e7f7..bcefe4b43 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ The SDK consists of several importable packages: - 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 e8b00a842..8cdbc0262 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,11 @@ The SDK consists of several importable packages: [`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/2026-07-28). Use the index below to learn how the SDK implements a particular aspect of the @@ -41,12 +46,14 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Skills extension](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Skills extension](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 23d57be1d..5837fd990 100644 --- a/docs/client.md +++ b/docs/client.md @@ -544,5 +544,90 @@ 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](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Call package-level functions with 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) + +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() + +for skill, err := range skills.All(ctx, session, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) +} +``` + +`List`, `Get`, and `All` validate structure without imposing count or size caps. +They support the required baseline of 512 files and 16 MiB per skill, as well as +larger manifests. Application download budgets for dynamic content remain the +host's responsibility. + +List calls reject invalid entries by default. To retain valid entries from a +malformed page, pass `skills.ListOptions{SkipInvalidSkills: true}` to `List` or +`All`. `List` reports skipped entries in `InvalidSkills`; `OnInvalidSkill` can +report them during iteration. Missing/null lists, invalid cache hints, and +invalid result types still fail. All entries with a duplicate URI are skipped, +so no duplicate silently replaces another. Page cursors are preserved. + +`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. + +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 := skills.Get(ctx, session, &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. 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. 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 +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 764c96bf1..4d4ad1cf9 100644 --- a/docs/server.md +++ b/docs/server.md @@ -220,7 +220,7 @@ The SDK ensures that a read succeeds only if the URI matches a registered resour or matches the URI pattern of a resource template. To list a server's resources and resource templates, use the -[`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) +[`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) and [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterators, or the lower-level `ListXXX` calls (see [pagination](#pagination)). @@ -1203,7 +1203,95 @@ 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](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`. + +Before calling `AddHandlers`, register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. Registration fails if the effective resources capability is absent. +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. 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 +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 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. Clients accept larger structurally valid manifests; these +server caps govern publication policy only. + +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, get, and directory responses carry +`ttlMs` and `cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints +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/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/README.src.md b/internal/docs/README.src.md index dc2407dc1..01e728938 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -16,6 +16,11 @@ The SDK consists of several importable packages: [`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/2026-07-28). Use the index below to learn how the SDK implements a particular aspect of the @@ -40,12 +45,14 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Skills extension](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Skills extension](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 c0f64b4bc..ba7edb9dd 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -233,5 +233,52 @@ 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](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Call package-level functions with the connected session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: + +%include ../../skills/example_test.go skillsclient - + +`List`, `Get`, and `All` validate structure without imposing count or size caps. +They support the required baseline of 512 files and 16 MiB per skill, as well as +larger manifests. Application download budgets for dynamic content remain the +host's responsibility. + +List calls reject invalid entries by default. To retain valid entries from a +malformed page, pass `skills.ListOptions{SkipInvalidSkills: true}` to `List` or +`All`. `List` reports skipped entries in `InvalidSkills`; `OnInvalidSkill` can +report them during iteration. Missing/null lists, invalid cache hints, and +invalid result types still fail. All entries with a duplicate URI are skipped, +so no duplicate silently replaces another. Page cursors are preserved. + +`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. + +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. 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. 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 +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 1b24c1d6f..3b091f90a 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -66,7 +66,7 @@ The SDK ensures that a read succeeds only if the URI matches a registered resour or matches the URI pattern of a resource template. To list a server's resources and resource templates, use the -[`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) +[`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) and [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterators, or the lower-level `ListXXX` calls (see [pagination](#pagination)). @@ -517,7 +517,59 @@ 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](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`. + +Before calling `AddHandlers`, register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. Registration fails if the effective resources capability is absent. +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. 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 +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 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. Clients accept larger structurally valid manifests; these +server caps govern publication policy only. + +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, get, and directory responses carry +`ttlMs` and `cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints +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/README.src.md b/internal/readme/README.src.md index ce1f6fc3e..e0921d4ab 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -25,6 +25,11 @@ The SDK consists of several importable packages: - 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/mcp/client.go b/mcp/client.go index 44be0ef6a..cc358f2b0 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1212,7 +1212,7 @@ var clientMethodInfosLegacyParams = func() map[string]methodInfo { func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { cs.client.mu.Lock() defer cs.client.mu.Unlock() - return cs.client.sendMethods + return maps.Clone(cs.client.sendMethods) } func (cs *ClientSession) receivingMethodInfos() map[string]methodInfo { @@ -1745,21 +1745,33 @@ func AddSendingCustomMethod[P paramsPtr[PT], R Result, PT any]( // CallCustomMethod sends a custom (non-standard) JSON-RPC method to the // server and decodes the response into R. // -// The method must have been registered on the session's client via -// [AddSendingCustomMethod]. +// The method is registered automatically on first use. An existing registration +// is preserved and must decode into R. Standard MCP methods are rejected. func CallCustomMethod[P paramsPtr[PT], R Result, PT any]( ctx context.Context, cs *ClientSession, method string, params P, ) (R, error) { + var zero R + if cs == nil { + return zero, fmt.Errorf("mcp: CallCustomMethod: nil session") + } + if _, ok := serverMethodInfos[method]; ok { + return zero, fmt.Errorf("mcp: CallCustomMethod: %q shadows a standard MCP method", method) + } c := cs.client c.mu.Lock() - _, ok := c.sendMethods[method] - c.mu.Unlock() + mi, ok := c.sendMethods[method] if !ok { - var zero R - return zero, fmt.Errorf("mcp: CallCustomMethod: %q is not registered; call AddSendingCustomMethod first", method) + mi = methodInfo{newResult: func() Result { + return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) + }} + c.sendMethods[method] = mi + } + c.mu.Unlock() + if _, ok := mi.newResult().(R); !ok { + return zero, fmt.Errorf("mcp: CallCustomMethod: %q is registered with a different result type", method) } if cs.usesNewProtocol() { params = injectRequestMeta(cs, params) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 6fb2d2c7a..f87624f31 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3892,9 +3892,6 @@ func TestCustomMethods(t *testing.T) { t.Cleanup(func() { _ = ss.Close() }) c := NewClient(testImpl, nil) - if err := AddSendingCustomMethod[*searchParams, *searchResult](c, "acme/search"); err != nil { - t.Fatal(err) - } cs, err := c.Connect(ctx, ct, nil) if err != nil { @@ -3927,7 +3924,7 @@ func TestCustomMethods(t *testing.T) { t.Errorf("CallCustomMethod: unexpected total: %d", result2.Total) } - // CallCustomMethod must reject methods that were never registered. + // Unknown methods still return the server's Method Not Found error. if _, err := CallCustomMethod[*searchParams, *searchResult]( ctx, cs, "acme/unregistered", &searchParams{Query: "x"}); err == nil { t.Error("CallCustomMethod: expected error for unregistered method, got nil") @@ -4273,3 +4270,58 @@ func TestResourceSubscriptions_GenGuard(t *testing.T) { t.Fatal("clearing an absent URI reported a deletion") } } + +func TestCallCustomMethodAutomaticRegistration(t *testing.T) { + type params struct{ ParamsBase } + type result struct { + ResultBase + Value string `json:"value"` + } + type otherResult struct{ ResultBase } + server := NewServer(testImpl, nil) + if err := AddReceivingCustomMethod(server, "example/auto", func(context.Context, *ServerSession, *params) (*result, error) { return &result{Value: "ok"}, nil }); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + st, ct := NewInMemoryTransports() + ss, err := server.Connect(t.Context(), st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := client.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + r, err := CallCustomMethod[*params, *result](t.Context(), cs, "example/auto", nil) + if err != nil { + t.Error(err) + } else if r.Value != "ok" { + t.Errorf("result = %+v", r) + } + }) + } + wg.Wait() + for _, method := range []string{"tools/call", "resources/read"} { + if _, err := CallCustomMethod[*params, *result](t.Context(), cs, method, nil); err == nil { + t.Errorf("accepted standard method %s", method) + } + } + if _, err := CallCustomMethod[*params, *otherResult](t.Context(), cs, "example/auto", nil); err == nil { + t.Fatal("accepted incompatible result type") + } + // The failed call must preserve the original registration. + if _, err := CallCustomMethod[*params, *result](t.Context(), cs, "example/auto", nil); err != nil { + t.Fatal(err) + } + if err := AddSendingCustomMethod[*params, *result](client, "example/auto"); err != nil { + t.Fatal(err) + } + if _, err := CallCustomMethod[*params, *result](t.Context(), cs, "example/auto", nil); err != nil { + t.Fatal(err) + } +} diff --git a/mcp/server.go b/mcp/server.go index 16aad6a94..937c167a0 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -200,6 +200,23 @@ 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. 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() + if s.opts.Capabilities == nil { + s.opts.Capabilities = defaultCapabilities() + } 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. // @@ -657,6 +674,19 @@ 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{}, + } +} + +// Capabilities returns a snapshot of the server's effective capabilities, +// including capabilities inferred from registered handlers and resources. +// Changes to the returned value do not affect the server. +func (s *Server) Capabilities() *ServerCapabilities { return s.capabilities() } + func (s *Server) capabilities() *ServerCapabilities { s.mu.Lock() defer s.mu.Unlock() @@ -667,10 +697,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/mcp/server_test.go b/mcp/server_test.go index 64e7de0d7..c5c130063 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -478,6 +478,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 @@ -2692,3 +2717,19 @@ func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) { }) } } + +func TestServerCapabilitiesSnapshot(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddResource(&Resource{URI: "example://resource", Name: "resource"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { return nil, nil }) + server.AddExtension("example/extension", map[string]any{"enabled": true}) + caps := server.Capabilities() + if caps.Resources == nil { + t.Fatal("resource capability was not inferred") + } + caps.Resources.ListChanged = false + delete(caps.Extensions, "example/extension") + fresh := server.Capabilities() + if !fresh.Resources.ListChanged || fresh.Extensions["example/extension"] == nil { + t.Fatal("snapshot modified the server") + } +} diff --git a/skills/client.go b/skills/client.go new file mode 100644 index 000000000..62224eb22 --- /dev/null +++ b/skills/client.go @@ -0,0 +1,211 @@ +// 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" +) + +// ListOptions controls how list calls handle malformed entries. +type ListOptions struct { + // SkipInvalidSkills omits invalid entries instead of rejecting the page. + // Invalid response envelopes always fail. Skipped entries are reported in + // ListSkillsResult.InvalidSkills and, if set, to OnInvalidSkill. + SkipInvalidSkills bool + // OnInvalidSkill receives each skipped entry, including during All. + OnInvalidSkill func(InvalidSkill) +} + +// List calls skills/list and validates the response without imposing manifest size caps. +// If params is nil, List requests the first page. +func List(ctx context.Context, session *mcp.ClientSession, params *ListSkillsParams, options ...ListOptions) (*ListSkillsResult, error) { + if _, err := requireExtension(session); err != nil { + return nil, err + } + if len(options) > 1 { + return nil, fmt.Errorf("skills: at most one ListOptions is allowed") + } + var opts ListOptions + if len(options) == 1 { + opts = options[0] + } + if params == nil { + params = &ListSkillsParams{} + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ListSkillsParams, *ListSkillsResult](ctx, session, MethodList, &request) + if err != nil { + return nil, err + } + if err := validateEnvelope(session, result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + if err := validateClientList(result, opts); 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 without imposing manifest size caps. +// The URI in params must identify a SKILL.md, whether or not it was listed. +func Get(ctx context.Context, session *mcp.ClientSession, params *GetSkillParams) (*GetSkillResult, error) { + if _, err := requireExtension(session); err != nil { + return nil, err + } + limits := Limits{} + if params == nil || params.URI == "" { + return nil, fmt.Errorf("skills: get requires a URI") + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*GetSkillParams, *GetSkillResult](ctx, session, MethodGet, &request) + if err != nil { + return nil, err + } + if err := validateGetResult(params.URI, result, limits); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid skill: %w", err) + } + if err := validateEnvelope(session, result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + return result, nil +} + +// ReadDirectory calls resources/directory/read and validates the response. +// The server must advertise directoryRead, and params must specify a directory URI. +func ReadDirectory(ctx context.Context, session *mcp.ClientSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if err := requireDirectoryRead(session); err != nil { + return nil, err + } + if params == nil || params.URI == "" { + return nil, fmt.Errorf("skills: directory read requires a URI") + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](ctx, 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 := validateEnvelope(session, result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + return result, nil +} + +// 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 [List]. +// The session, options, and parameters are captured when All is called. +// The iterator stops after yielding its first error. +func All(ctx context.Context, session *mcp.ClientSession, params *ListSkillsParams, options ...ListOptions) iter.Seq2[*Skill, error] { + options = append([]ListOptions(nil), options...) + 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, options...) + if err != nil { + return nil, "", err + } + return result.Skills, result.NextCursor, nil + })(yield) + } +} + +// DirectoryEntries returns an iterator over a directory read, starting at params.Cursor. +// Each page is validated as in [ReadDirectory]. +// The iterator stops after yielding its first error. +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) + } +} + +// requireExtension reports the settings the server advertised for the Skills +// extension, or an error explaining which capability is missing. +func requireExtension(session *mcp.ClientSession) (map[string]any, error) { + if session == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") + } + init := 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 nil, fmt.Errorf("skills: server does not advertise %s", ExtensionID) + } + m, ok := settings.(map[string]any) + if !ok { + return nil, fmt.Errorf("skills: server advertised invalid extension settings") + } + if init.Capabilities.Resources == nil { + return nil, fmt.Errorf("skills: server does not advertise resources") + } + return m, nil +} + +func requireDirectoryRead(session *mcp.ClientSession) error { + settings, err := requireExtension(session) + if err != nil { + return err + } + if enabled, _ := settings[capabilityDirectoryRead].(bool); !enabled { + return fmt.Errorf("skills: server does not advertise directoryRead") + } + return nil +} + +// usesCaching reports whether the negotiated protocol version requires a result +// type, and with it the cache hints on skills/list and skills/get. +func usesCaching(session *mcp.ClientSession) bool { + return session.InitializeResult().ProtocolVersion >= protocolVersionCaching +} + +func validateResultType(session *mcp.ClientSession, resultType string) error { + if usesCaching(session) && resultType != resultTypeComplete { + return fmt.Errorf("skills: expected complete result, got %q", resultType) + } + return nil +} + +func validateEnvelope(session *mcp.ClientSession, resultType string, cache mcp.Cacheable, cachePresent bool) error { + if err := validateResultType(session, resultType); err != nil { + return err + } + if !usesCaching(session) { + return nil + } + if !cachePresent { + return fmt.Errorf("skills: missing ttlMs or cacheScope") + } + return validateCache(cache) +} diff --git a/skills/example_test.go b/skills/example_test.go new file mode 100644 index 000000000..52f13d8fc --- /dev/null +++ b/skills/example_test.go @@ -0,0 +1,101 @@ +// 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" + "crypto/sha256" + "fmt" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +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: 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) + } + // !-skillsserver + + // !+skillsclient + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1.0.0"}, nil) + + 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() + + for skill, err := range skills.All(ctx, session, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) + } + // !-skillsclient + + // !+skillsverify + result, err := skills.Get(ctx, session, &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/limits_test.go b/skills/limits_test.go new file mode 100644 index 000000000..ec850c190 --- /dev/null +++ b/skills/limits_test.go @@ -0,0 +1,169 @@ +// 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 ( + "fmt" + "math" + "testing" + + "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 *mcp.ClientSession, skill *Skill, wantOK bool) { + t.Helper() + _, listErr := List(t.Context(), client, nil) + _, getErr := Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + var allErr error + count := 0 + for _, err := range All(t.Context(), client, 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) + } +} + +// TestValidateSkillWithLimits covers the limit matrix by calling validation +// directly. Limits are SDK policy rather than protocol, so the conformance suite +// cannot observe them; TestServerLimits checks that requests reach this code. +func TestValidateSkillWithLimits(t *testing.T) { + baseline := BaselineLimits() + for _, kind := range []string{"count", "bytes"} { + 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) + } + }) + } + 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 TestServerLimits(t *testing.T) { + for _, kind := range []string{"count", "bytes"} { + for _, limited := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/limited=%v", kind, limited), func(t *testing.T) { + server := testServer() + var options *ServerOptions + if limited { + options = &ServerOptions{Limits: BaselineLimits()} + } + skill := oversized(kind) + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + checkLimitCalls(t, connectSkills(t, server, protocolVersionCaching), skill, !limited) + }) + } + } + for _, limits := range []Limits{{MaxResourcesPerSkill: -1}, {MaxTotalSize: -1}} { + if err := AddHandlers(testServer(), fixedHandlers(testSkill()), &ServerOptions{Limits: limits}); err == nil { + t.Fatal("accepted negative server limits") + } + } +} + +func TestServerLimitOwnership(t *testing.T) { + server := testServer() + options := &ServerOptions{Limits: BaselineLimits()} + skill := testSkill() + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + options.Limits = Limits{MaxTotalSize: -1} + checkLimitCalls(t, connectSkills(t, server, protocolVersionCaching), skill, true) +} + +func TestClientSupportsBaselineAndLargerSkills(t *testing.T) { + for _, version := range []string{"2025-11-25", protocolVersionCaching} { + for _, kind := range []string{"count", "bytes"} { + for _, above := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/above=%v", version, kind, above), func(t *testing.T) { + skill := oversized(kind) + if !above { + entries, _ := skill.Resources.List() + if kind == "count" { + skill.Resources = StaticResources(entries[:512]...) + } else { + entries[0].Size = 16 << 20 + } + } + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { + t.Fatal(err) + } + checkLimitCalls(t, connectSkills(t, server, version), skill, true) + }) + } + } + } +} diff --git a/skills/pagination.go b/skills/pagination.go new file mode 100644 index 000000000..ed596cc70 --- /dev/null +++ b/skills/pagination.go @@ -0,0 +1,120 @@ +// 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" + "iter" + "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) { + if pageSize < 0 { + return nil, "", fmt.Errorf("skills: invalid page size %d", pageSize) + } + if pageSize == 0 { + pageSize = mcp.DefaultPageSize + } + 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]) { + return nil, "", fmt.Errorf("skills: missing or duplicate pagination key %q", uri) + } + } + start := 0 + if cursor != "" { + decoded, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil || len(decoded) == 0 { + return nil, "", invalidParams("invalid cursor") + } + // Resume at the first key after the cursor. + last := string(decoded) + 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 := min(start+pageSize, len(items)) + 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. 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 { + return "" + } + return skill.URI + }) +} + +// 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) { + 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 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 { + var zero T + yield(zero, err) + return + } + for _, item := range items { + if !yield(item, nil) { + return + } + } + if next == "" { + return + } + 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/protocol_test.go b/skills/protocol_test.go new file mode 100644 index 000000000..e7202e164 --- /dev/null +++ b/skills/protocol_test.go @@ -0,0 +1,506 @@ +// 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" + "reflect" + "strings" + "sync" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// 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() + 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 + } + if rpc.Code != code { + t.Errorf("%s: error code = %d (%v), want %d", context, rpc.Code, err, code) + } + return rpc +} + +// 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() + 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 + } + 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 + } + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(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 := Get(t.Context(), client, &GetSkillParams{URI: test.uri}) + wantRPCCode(t, test.uri, err, jsonrpc.CodeInvalidParams) + }) + } + }) + + t.Run("directory", func(t *testing.T) { + if _, err := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://missing"}); err != nil { + wantRPCCode(t, "unknown directory", err, jsonrpc.CodeInvalidParams) + } else { + t.Error("unknown directory accepted") + } + // An empty directory is a success with an empty, non-null array. + empty, err := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://empty"}) + if err != nil || empty.Resources == nil || len(empty.Resources) != 0 { + t.Errorf("empty directory = %+v, %v", empty, err) + } + }) + + t.Run("invalid cursor", func(t *testing.T) { + _, listErr := List(t.Context(), client, &ListSkillsParams{Cursor: "%"}) + _, directoryErr := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://demo", Cursor: "%"}) + for method, err := range map[string]error{MethodList: listErr, MethodReadDirectory: directoryErr} { + wantRPCCode(t, method, err, jsonrpc.CodeInvalidParams) + } + }) +} + +// 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 _, 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 := List(t.Context(), client, nil) + _, getErr := Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + _, directoryErr := ReadDirectory(t.Context(), client, &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 := List(t.Context(), connectSkills(t, server, protocolVersionCaching), 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 := List(ctx, nil, nil); err == nil { + t.Error("client without a session accepted List") + } + // A server with no Skills handlers does not advertise the extension. + if _, err := List(ctx, connectSkills(t, testServer(), protocolVersionCaching), 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 := ReadDirectory(ctx, client, &ReadDirectoryParams{URI: "skill://demo"}); err == nil { + t.Error("client called resources/directory/read without the capability") + } + for _, params := range []*GetSkillParams{nil, {}} { + if _, err := Get(ctx, client, 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{Cacheable: mcp.Cacheable{TTLMs: 456, CacheScope: "private"}} + handlers := &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 + }, + } + 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) + } + // The legacy version omits the cache hints; the modern one requires them. + clients := map[string]*mcp.ClientSession{"2025-11-25": connectSkills(t, server, "2025-11-25"), protocolVersionCaching: connectSkills(t, server, protocolVersionCaching)} + var wg sync.WaitGroup + for version, client := range clients { + modern := version == protocolVersionCaching + wg.Go(func() { + for range 4 { + params := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"owner": "caller"}}} + listResult, err := List(t.Context(), client, params) + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(params.Meta, mcp.Meta{"owner": "caller"}) { + t.Errorf("%s: request metadata mutated", version) + } + // 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) + } + getResult, err := Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + if err != nil { + t.Error(err) + return + } + if getResult.cachePresent != modern { + t.Errorf("%s: get cachePresent = %v", version, getResult.cachePresent) + } + if modern && (getResult.TTLMs != 123 || getResult.CacheScope != "private") { + t.Errorf("%s: cache hints lost", version) + } + dirResult, err := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://demo"}) + if err != nil { + t.Error(err) + return + } + if dirResult.cachePresent != modern { + t.Errorf("%s: directory cachePresent = %v", version, dirResult.cachePresent) + } + if modern && (dirResult.TTLMs != 456 || dirResult.CacheScope != "private") { + t.Errorf("%s: directory cache hints lost", version) + } + } + }) + } + wg.Wait() + 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 rawResult struct { + mcp.ResultBase + data json.RawMessage +} + +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) { + 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"}`}, + {"dir/missing-ttl", MethodReadDirectory, `{"resources":[],"resultType":"complete","cacheScope":"public"}`}, + {"dir/missing-scope", MethodReadDirectory, `{"resources":[],"resultType":"complete","ttlMs":0}`}, + } { + t.Run(test.name, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, map[string]any{capabilityDirectoryRead: true}) + 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 MethodReadDirectory: + err = mcp.AddReceivingCustomMethod(server, MethodReadDirectory, func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*rawResult, error) { return raw, nil }) + 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) + } + client := connectSkills(t, server, protocolVersionCaching) + switch test.method { + case MethodReadDirectory: + _, err = ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://demo"}) + case MethodGet: + _, err = Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + default: + _, err = List(t.Context(), client, nil) + } + if err == nil { + t.Fatalf("accepted malformed %s response", test.method) + } + }) + } +} + +// 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 := 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, handlers, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + params := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"key": "value"}}} + seq := All(t.Context(), client, params) + for range 2 { + count := 0 + for _, err := range seq { + if err != nil { + t.Fatal(err) + } + count++ + } + if count != 2 { + t.Fatalf("iterator yielded %d skills across pages, want 2", count) + } + } + if params.Cursor != "" || !reflect.DeepEqual(params.Meta, mcp.Meta{"key": "value"}) { + t.Fatal("iterator mutated its parameters") + } +} + +func TestListSkipsInvalidSkills(t *testing.T) { + valid, _ := json.Marshal(testSkill()) + for _, version := range []string{"2025-11-25", protocolVersionCaching} { + for _, bad := range []string{ + `null`, `42`, `{"uri":"skill://broken/SKILL.md","frontmatter":{},"resources":"dynamic"}`, + `{"uri":"skill://broken/SKILL.md","frontmatter":{"name":"broken","description":"Broken"},"resources":[{"uri":"skill://broken/SKILL.md","digest":"` + testDigest + `"}]}`, + `{"uri":"skill://broken/SKILL.md","frontmatter":{"name":"broken","description":"Broken"},"resources":"invalid"}`, + } { + t.Run(version+"/"+bad, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*rawResult, error) { + envelope := `,"resultType":"complete","ttlMs":0,"cacheScope":"public"` + if version < protocolVersionCaching { + envelope = "" + } + if p.Cursor == "next" { + return &rawResult{data: json.RawMessage(fmt.Sprintf(`{"skills":[%s]%s}`, valid, envelope))}, nil + } + return &rawResult{data: json.RawMessage(fmt.Sprintf(`{"skills":[%s,%s],"nextCursor":"next"%s}`, bad, valid, envelope))}, nil + }); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, version) + if result, err := List(t.Context(), client, nil); err == nil || result != nil { + t.Fatalf("strict List = %+v, %v", result, err) + } + var rejected []InvalidSkill + options := ListOptions{SkipInvalidSkills: true, OnInvalidSkill: func(s InvalidSkill) { rejected = append(rejected, s) }} + result, err := List(t.Context(), client, nil, options) + if err != nil { + t.Fatal(err) + } + if len(result.Skills) != 1 || result.Skills[0].URI != testSkill().URI || result.NextCursor != "next" || len(result.InvalidSkills) != 1 || result.InvalidSkills[0].Index != 0 || result.InvalidSkills[0].Err == nil || len(rejected) != 1 { + t.Fatalf("filtered result = %+v, diagnostics = %+v", result, rejected) + } + rejected = nil + count := 0 + for skill, err := range All(t.Context(), client, nil, options) { + if err != nil { + t.Fatal(err) + } + if skill.URI != testSkill().URI { + t.Fatal(skill.URI) + } + count++ + } + if count != 2 || len(rejected) != 1 { + t.Fatalf("All returned %d valid, %d invalid", count, len(rejected)) + } + }) + } + } +} + +func TestSkipRejectsInvalidEnvelopesAndDuplicateIdentities(t *testing.T) { + valid, _ := json.Marshal(testSkill()) + for _, body := range []string{ + `{"skills":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, + `{"skills":[],"resultType":"complete","cacheScope":"public"}`, + `{"skills":[],"resultType":"input_required","ttlMs":0,"cacheScope":"public"}`, + `{"skills":{},"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, + fmt.Sprintf(`{"skills":[%s,%s],"nextCursor":"next","resultType":"complete","ttlMs":0,"cacheScope":"public"}`, valid, valid), + } { + t.Run(body, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*rawResult, error) { + return &rawResult{data: json.RawMessage(body)}, nil + }); err != nil { + t.Fatal(err) + } + result, err := List(t.Context(), connectSkills(t, server, protocolVersionCaching), nil, ListOptions{SkipInvalidSkills: true}) + if strings.Contains(body, `"nextCursor"`) { + if err != nil || len(result.Skills) != 0 || len(result.InvalidSkills) != 2 || result.NextCursor != "next" { + t.Fatalf("duplicate identities = %+v, %v", result, err) + } + } else if err == nil { + t.Fatalf("accepted invalid envelope: %+v", result) + } + }) + } +} + +func TestAddHandlersRequiresResources(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err == nil { + t.Fatal("registered without resources") + } + if _, ok := server.Capabilities().Extensions[ExtensionID]; ok { + t.Fatal("failed registration advertised extension") + } + server.AddResourceTemplate(&mcp.ResourceTemplate{Name: "skills", URITemplate: "skill://{authority}/{+path}"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { return nil, nil }) + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err != nil { + t.Fatal(err) + } + checkLimitCalls(t, connectSkills(t, server, protocolVersionCaching), testSkill(), true) +} + +func TestAllContinuesAfterEntirelyInvalidPage(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + valid, _ := json.Marshal(testSkill()) + var cursors []string + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*rawResult, error) { + cursors = append(cursors, p.Cursor) + if p.Cursor == "" { + return &rawResult{data: json.RawMessage(`{"skills":[null],"nextCursor":"next","resultType":"complete","ttlMs":0,"cacheScope":"public"}`)}, nil + } + return &rawResult{data: json.RawMessage(fmt.Sprintf(`{"skills":[%s],"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, valid))}, nil + }); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + var rejected []InvalidSkill + options := []ListOptions{{SkipInvalidSkills: true, OnInvalidSkill: func(s InvalidSkill) { rejected = append(rejected, s) }}} + params := &ListSkillsParams{} + seq := All(t.Context(), client, params, options...) + options[0].SkipInvalidSkills = false + count := 0 + for skill, err := range seq { + if err != nil { + t.Fatal(err) + } + if skill.URI != testSkill().URI { + t.Fatal(skill.URI) + } + count++ + } + if count != 1 || len(rejected) != 1 || !reflect.DeepEqual(cursors, []string{"", "next"}) || params.Cursor != "" { + t.Fatalf("count=%d rejected=%+v cursors=%v params=%+v", count, rejected, cursors, params) + } +} diff --git a/skills/server.go b/skills/server.go new file mode 100644 index 000000000..1f604ea21 --- /dev/null +++ b/skills/server.go @@ -0,0 +1,199 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "errors" + "fmt" + "maps" + + "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. Return (nil, nil) for an unknown skill; +// [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 +// 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) + +// ServerOptions configures the per-skill limits. Protocol validation always +// runs; applications can perform additional checks in their handlers. +type ServerOptions struct { + // 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 +} + +// Handlers contains the Skills extension handlers. +// List and Get are required; ReadDirectory is optional. +type Handlers struct { + List ListSkillsHandler + Get GetSkillHandler + ReadDirectory ReadDirectoryHandler +} + +// AddHandlers registers the Skills extension. Register skill content separately +// with [mcp.Server.AddResource] or [mcp.Server.AddResourceTemplate] before calling +// AddHandlers. These registrations also advertise +// the required resources capability. Configure the server before connecting. +// +// 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]. +// +// 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") + } + if handlers == nil || handlers.List == nil || handlers.Get == nil { + return fmt.Errorf("skills: list and get handlers are required") + } + if server.Capabilities().Resources == nil { + return fmt.Errorf("skills: register resources or advertise the resources capability before AddHandlers") + } + h := *handlers + var limits Limits + if options != nil { + limits = options.Limits + } + if err := limits.validate(); 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 := h.List(ctx, session, params) + if err != nil { + return nil, internalError(err) + } + if result == nil { + return nil, internalError(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, internalError(fmt.Errorf("skills/list handler returned an invalid result: %w", err)) + } + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { + return nil, internalError(err) + } + 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 { + return nil, invalidParams("missing required uri") + } + if _, err := skillNameFromURI(params.URI); err != nil { + return nil, invalidParams(err.Error()) + } + result, err := h.Get(ctx, session, params) + if err != nil { + 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, internalError(fmt.Errorf("skills/get handler returned an invalid result: %w", err)) + } + out := *result + out.Meta = maps.Clone(result.Meta) + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { + return nil, internalError(err) + } + return &out, nil + }); err != nil { + return err + } + settings := map[string]any{} + if h.ReadDirectory != nil { + if err := mcp.AddReceivingCustomMethod(server, MethodReadDirectory, + func(ctx context.Context, session *mcp.ServerSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + if _, err := parseDirectoryURI(params.URI); err != nil { + return nil, invalidParams(err.Error()) + } + result, err := h.ReadDirectory(ctx, session, params) + if err != nil { + return nil, internalError(err) + } + 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, internalError(fmt.Errorf("resources/directory/read handler returned an invalid result: %w", err)) + } + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { + return nil, internalError(err) + } + return &out, nil + }); err != nil { + return err + } + settings[capabilityDirectoryRead] = true + } + server.AddExtension(ExtensionID, settings) + return nil +} + +// 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) + return version >= protocolVersionCaching +} + +// 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 = cacheScopePublic + } + return validateCache(*cache) +} + +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/skills_test.go b/skills/skills_test.go new file mode 100644 index 000000000..50a18359e --- /dev/null +++ b/skills/skills_test.go @@ -0,0 +1,283 @@ +// 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/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "slices" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// 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 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})} +} + +// 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 +} + +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) *mcp.ClientSession { + 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) + 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 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 TestResourcesJSON(t *testing.T) { + for _, test := range []struct { + name string + resources Resources + want string // "" means Marshal must fail + }{ + {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("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 err == nil && string(data) != test.want { + t.Fatalf("Marshal() = %s, want %s", data, test.want) + } + }) + } + + 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) + } + if err == nil && resources.IsDynamic() != test.wantDyn { + t.Fatalf("IsDynamic() = %v, want %v", resources.IsDynamic(), test.wantDyn) + } + }) + } +} + +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} + + 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) + } + }) + } + if unsorted[0] != c { + t.Error("PaginateSkills modified its input") + } + + // 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 _, _, err := PaginateDirectoryResources([]*mcp.Resource{nil}, "", 1); err == nil { + t.Error("PaginateDirectoryResources accepted a nil resource") + } +} + +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) + } + }) + + // 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 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) + } + } + }) + } +} + +func TestSkillMetaRoundTrips(t *testing.T) { + skill := skillWith(func(s *Skill) { s.Meta = mcp.Meta{"io.modelcontextprotocol.skills/x": float64(1)} }) + data, err := json.Marshal(skill) + if err != nil { + t.Fatal(err) + } + var got Skill + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Meta, skill.Meta) { + t.Fatalf("_meta = %v, want %v", got.Meta, skill.Meta) + } + plain, _ := json.Marshal(testSkill()) + if strings.Contains(string(plain), "_meta") { + t.Fatalf("empty _meta serialized: %s", plain) + } +} diff --git a/skills/types.go b/skills/types.go new file mode 100644 index 000000000..7abe01010 --- /dev/null +++ b/skills/types.go @@ -0,0 +1,349 @@ +// 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]. +// +// Servers opt in with [AddHandlers] and serve skill files through the ordinary +// MCP resource APIs. Clients use [List], [Get], and [All] on a connected session +// 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 ( + "bytes" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +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. + 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 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"` + // Digest is the SHA-256 hash of the raw file bytes, as "sha256:" followed + // by 64 lowercase hexadecimal digits. + Digest string `json:"digest"` + // Size is the length of the raw file content in bytes. + 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 { + 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{} + } + 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. +// The returned slice and entries are shared with r. +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 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 + } + 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"` + // Meta is reserved for protocol metadata. Keys defined by the Skills + // extension use the io.modelcontextprotocol.skills/ prefix. + Meta mcp.Meta `json:"_meta,omitempty"` +} + +// ListSkillsParams contains parameters for skills/list. +type ListSkillsParams struct { + mcp.ParamsBase + Cursor string `json:"cursor,omitempty"` +} + +// InvalidSkill describes an entry omitted from a list response. +type InvalidSkill struct { + Index int + URI string + Err error +} + +// 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 + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Skills []*Skill `json:"skills"` + // InvalidSkills reports entries skipped by List with SkipInvalidSkills enabled. + InvalidSkills []InvalidSkill `json:"-"` + entryErrors map[int]InvalidSkill + omitCache bool + 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 { + return json.Marshal((*wire)(r)) + } + return json.Marshal(struct { + *wire + omittedCache + }{wire: (*wire)(r)}) +} + +// GetSkillParams contains parameters for skills/get. +type GetSkillParams struct { + mcp.ParamsBase + URI string `json:"uri"` +} + +// 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 + ResultType string `json:"resultType,omitempty"` + Skill *Skill `json:"skill"` + omitCache bool + cachePresent bool +} + +func (r *GetSkillResult) MarshalJSON() ([]byte, error) { + type wire GetSkillResult + if !r.omitCache { + return json.Marshal((*wire)(r)) + } + return json.Marshal(struct { + *wire + omittedCache + }{wire: (*wire)(r)}) +} + +// 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. +// Its result type and cache hints are handled as in [ListSkillsResult]. The +// hints describe the freshness of the listing, not of a held skill entry. +type ReadDirectoryResult struct { + mcp.ResultBase + mcp.Cacheable + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Resources []*mcp.Resource `json:"resources"` + omitCache bool + cachePresent bool +} + +func (r *ReadDirectoryResult) MarshalJSON() ([]byte, error) { + type wire ReadDirectoryResult + if !r.omitCache { + return json.Marshal((*wire)(r)) + } + return json.Marshal(struct { + *wire + omittedCache + }{wire: (*wire)(r)}) +} + +func (r *ReadDirectoryResult) UnmarshalJSON(data []byte) error { + type wire ReadDirectoryResult + var decoded struct { + wire + decodedCache + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = ReadDirectoryResult(decoded.wire) + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) + return nil +} + +func (r *ListSkillsResult) UnmarshalJSON(data []byte) error { + type wire ListSkillsResult + var decoded struct { + wire + decodedCache + Skills []json.RawMessage `json:"skills"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = ListSkillsResult(decoded.wire) + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) + if decoded.Skills != nil { + r.Skills = make([]*Skill, len(decoded.Skills)) + } + for i, raw := range decoded.Skills { + var skill *Skill + if err := json.Unmarshal(raw, &skill); err != nil { + var identity struct { + URI string `json:"uri"` + } + _ = json.Unmarshal(raw, &identity) + if r.entryErrors == nil { + r.entryErrors = make(map[int]InvalidSkill) + } + r.entryErrors[i] = InvalidSkill{Index: i, URI: identity.URI, Err: err} + } else { + r.Skills[i] = skill + } + } + return nil +} + +func (r *GetSkillResult) UnmarshalJSON(data []byte) error { + type wire GetSkillResult + var decoded struct { + wire + decodedCache + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = GetSkillResult(decoded.wire) + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) + return nil +} diff --git a/skills/validation.go b/skills/validation.go new file mode 100644 index 000000000..6a8ca0577 --- /dev/null +++ b/skills/validation.go @@ -0,0 +1,456 @@ +// 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" + "net/url" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "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 && bytes.HasSuffix(normalized, []byte("\n---")) { + end = len(normalized) - 8 + } + 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 + } +} + +// 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. +// +// 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 + // MaxTotalSize limits the sum of the files' raw byte lengths. + MaxTotalSize int64 +} + +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// 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: 512, + MaxTotalSize: 16 * 1024 * 1024, + } +} + +// ValidateSkill checks a skill's structure without imposing manifest caps. +func ValidateSkill(skill *Skill) error { + 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 { + if err := limits.validate(); err != nil { + return err + } + return validateSkill(skill, limits) +} + +func (l Limits) validate() error { + if l.MaxResourcesPerSkill < 0 || l.MaxTotalSize < 0 { + return fmt.Errorf("skills: limits must not be negative") + } + return nil +} + +func validateSkill(skill *Skill, limits Limits) error { + if skill == nil { + return fmt.Errorf("skill is nil") + } + name, skillURL, err := parseSkillURI(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 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 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 length := utf8.RuneCountInString(s); !ok || length < 1 || length > 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) + } + } + + if skill.Resources.IsDynamic() { + return nil + } + resources, static := skill.Resources.List() + 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(skillURL, 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 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 + } + } + if !seen[skill.URI] { + return fmt.Errorf("skill %q resources does not include its SKILL.md", skill.URI) + } + 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) + } + 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 := 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.User.String() != parent.User.String() { + return fmt.Errorf("resource %q is not a child of directory %q", resource.URI, uri) + } + 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 resource.Name == "" { + return fmt.Errorf("directory %q child has no name", uri) + } + if seenURIs[resource.URI] { + return fmt.Errorf("directory %q contains a duplicate child %q", uri, resource.URI) + } + seenURIs[resource.URI] = true + } + return nil +} + +func validateName(name string) error { + 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) { + return invalid + } + } + 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 "", nil, err + } + if !strings.HasSuffix(u.Path, "/SKILL.md") { + return "", nil, fmt.Errorf("skill URI %q must end in /SKILL.md", rawURI) + } + dir := strings.TrimPrefix(strings.TrimSuffix(u.Path, "/SKILL.md"), "/") + if dir == "" { + dir = u.Hostname() + } else { + parts := strings.Split(dir, "/") + dir = parts[len(parts)-1] + } + if dir == "" { + return "", nil, fmt.Errorf("skill URI %q has no skill name", rawURI) + } + if err := validateName(dir); err != nil { + return "", nil, err + } + return dir, u, nil +} + +// 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 + } + 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 !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) { + 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) + // 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.SplitSeq(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 { + 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 validateClientList(result *ListSkillsResult, options ListOptions) error { + if result == nil || result.Skills == nil { + return fmt.Errorf("skills is missing or null") + } + counts := make(map[string]int) + for _, skill := range result.Skills { + if skill != nil { + counts[skill.URI]++ + } + } + for _, invalid := range result.entryErrors { + if invalid.URI != "" { + counts[invalid.URI]++ + } + } + valid := make([]*Skill, 0, len(result.Skills)) + for i, skill := range result.Skills { + invalid, decodeFailed := result.entryErrors[i] + if !decodeFailed { + invalid.Index = i + if skill != nil { + invalid.URI = skill.URI + } + invalid.Err = ValidateSkill(skill) + if invalid.Err == nil && counts[skill.URI] > 1 { + invalid.Err = fmt.Errorf("skill URI %q occurs more than once", skill.URI) + } + } + if invalid.Err != nil { + if !options.SkipInvalidSkills { + return fmt.Errorf("entry %d (%q): %w", i, invalid.URI, invalid.Err) + } + result.InvalidSkills = append(result.InvalidSkills, invalid) + if options.OnInvalidSkill != nil { + options.OnInvalidSkill(invalid) + } + continue + } + valid = append(valid, skill) + } + result.Skills = valid + 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 validateSkill(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 != cacheScopePublic && cache.CacheScope != cacheScopePrivate { + return fmt.Errorf("skills: invalid cacheScope %q", cache.CacheScope) + } + return nil +} diff --git a/skills/validation_test.go b/skills/validation_test.go new file mode 100644 index 000000000..3e6c2cb81 --- /dev/null +++ b/skills/validation_test.go @@ -0,0 +1,372 @@ +// 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" + + "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 + 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":"` + testDigest + `"` + 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 TestParseFrontmatter(t *testing.T) { + for _, test := range []struct { + name, content string + wantErr bool + }{ + {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) { + 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 == 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) { + 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 new file mode 100644 index 000000000..49638fb96 --- /dev/null +++ b/skills/verify.go @@ -0,0 +1,129 @@ +// 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" + "math/big" + "reflect" + "strings" +) + +// 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 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 { + // Verification does not reapply discovery-time limits. + if err := validateSkill(skill, Limits{}); err != nil { + return err + } + _, skillURL, err := parseSkillURI(skill.URI) + if err != nil { + return err + } + if err := validateResourceURI(skillURL, 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 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") + } + verificationErr := VerifyResource(skill, skill.URI, content) + if verificationErr != nil && !errors.Is(verificationErr, ErrDynamicResources) { + return verificationErr + } + frontmatter, err := parseFrontmatter(content) + if err != nil { + return err + } + want, err := comparableFrontmatter(skill.Frontmatter) + if err != nil { + return fmt.Errorf("skills: marshaling listed frontmatter: %w", err) + } + got, err := comparableFrontmatter(frontmatter) + if err != nil { + return fmt.Errorf("skills: marshaling resource frontmatter: %w", err) + } + 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 +}