diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d8b0904b..4c96dbde8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,18 +49,17 @@ go work init ./project ./go-sdk ### Conformance tests -The SDK includes a script to run the official MCP conformance tests against the -SDK's conformance server: +The SDK includes scripts to run the official MCP server and client conformance tests: ```sh -./scripts/conformance.sh +./scripts/server-conformance.sh +./scripts/client-conformance.sh ``` -By default, results are cleaned up after the script runs. To save results to a -specific directory: +To save server results to a specific directory: ```sh -./scripts/conformance.sh --result_dir ./conformance-results +./scripts/server-conformance.sh --result_dir ./conformance-results ``` To run against a local checkout of the @@ -68,12 +67,12 @@ To run against a local checkout of the of the latest npm release: ```sh -./scripts/conformance.sh --conformance_repo ~/src/conformance +./scripts/server-conformance.sh --conformance_repo ~/src/conformance ``` Note: you must run `npm install` in the conformance repo first. -Run `./scripts/conformance.sh --help` for more options. +Run either script with `--help` for more options. ## Filing issues @@ -200,7 +199,7 @@ change therefore cannot reach existing users by accident; they have to change their import path to receive one. This policy covers the exported API of the SDK's importable packages — `mcp`, -`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +`jsonrpc`, `auth`, `auth/extauth`, `oauthex` and `skills`. Everything under `internal/` is not importable outside the module and may change in any release. Which MCP spec revisions each SDK version speaks is documented in the diff --git a/README.md b/README.md index baeb3e7f7..a02d65143 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,17 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. See the [client](docs/client.md#skills-extension) + and [server](docs/server.md#skills-extension) examples. The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. diff --git a/conformance/skills-server/main.go b/conformance/skills-server/main.go new file mode 100644 index 000000000..09bdb6bd6 --- /dev/null +++ b/conformance/skills-server/main.go @@ -0,0 +1,98 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// This fixture exercises the generic Skills API without a filesystem provider. +// Run it against the three sep-2640-skills-* server conformance scenarios. +package main + +import ( + "context" + "crypto/sha256" + "flag" + "fmt" + "log" + "net/http" + "path" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +func main() { + addr := flag.String("http", "localhost:18299", "HTTP listen address") + stateless := flag.Bool("stateless", true, "Use the modern stateless protocol") + flag.Parse() + server := mcp.NewServer(&mcp.Implementation{Name: "skills-conformance", Version: "v1"}, nil) + files := map[string]string{ + "skill://demo/SKILL.md": "---\nname: demo\ndescription: A demonstration skill.\nmetadata:\n author: go-sdk\n---\n# Demo\nRead references/guide.md as needed.\n", + "skill://demo/references/guide.md": "# Guide\nSupporting content.\n", + "skill://demo/nested/SKILL.md": "---\nname: nested\ndescription: A nested skill.\n---\n# Nested\n", + "skill://other/SKILL.md": "---\nname: other\ndescription: Another skill.\n---\n# Other\n", + } + var entries []*skills.Skill + byURI := map[string]*skills.Skill{} + for _, item := range []struct{ uri, name, description string }{ + {"skill://demo/SKILL.md", "demo", "A demonstration skill."}, + {"skill://demo/nested/SKILL.md", "nested", "A nested skill."}, + {"skill://other/SKILL.md", "other", "Another skill."}, + } { + var resources []*skills.Resource + prefix := strings.TrimSuffix(item.uri, "SKILL.md") + for uri, content := range files { + if strings.HasPrefix(uri, prefix) { + resources = append(resources, &skills.Resource{URI: uri, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), Size: int64(len(content))}) + } + } + entry := &skills.Skill{URI: item.uri, Frontmatter: skills.Frontmatter{"name": item.name, "description": item.description}, Resources: skills.StaticResources(resources...)} + if item.name == "demo" { + entry.Frontmatter["metadata"] = map[string]string{"author": "go-sdk"} + } + entries = append(entries, entry) + byURI[item.uri] = entry + } + directories := map[string][]*mcp.Resource{"skill://demo/empty": {}} + for uri, content := range files { + resource := &mcp.Resource{URI: uri, Name: path.Base(uri), MIMEType: "text/markdown"} + if entry, ok := byURI[uri]; ok { + resource.Name = entry.Frontmatter["name"].(string) + resource.Description = entry.Frontmatter["description"].(string) + } + server.AddResource(resource, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: uri, MIMEType: "text/markdown", Text: content}}}, nil + }) + // path.Dir would clean "skill://" down to "skill:/". + parent := uri[:strings.LastIndex(uri, "/")] + directories[parent] = append(directories[parent], resource) + } + for _, uri := range []string{"skill://demo/references", "skill://demo/nested", "skill://demo/empty"} { + directories["skill://demo"] = append(directories["skill://demo"], &mcp.Resource{URI: uri, Name: path.Base(uri), MIMEType: "inode/directory"}) + } + if err := skills.AddHandlers(server, &skills.Handlers{ + List: func(_ context.Context, _ *mcp.ServerSession, p *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + page, next, err := skills.PaginateSkills(entries, p.Cursor, 1) + return &skills.ListSkillsResult{Skills: page, NextCursor: next}, err + }, + Get: func(_ context.Context, _ *mcp.ServerSession, p *skills.GetSkillParams) (*skills.GetSkillResult, error) { + for _, entry := range entries { + if entry.URI == p.URI { + return &skills.GetSkillResult{Skill: entry}, nil + } + } + return nil, nil + }, + ReadDirectory: func(_ context.Context, _ *mcp.ServerSession, p *skills.ReadDirectoryParams) (*skills.ReadDirectoryResult, error) { + children, ok := directories[p.URI] + if !ok { + return nil, nil + } + page, next, err := skills.PaginateDirectoryResources(children, p.Cursor, 1) + return &skills.ReadDirectoryResult{Resources: page, NextCursor: next}, err + }, + }, nil); err != nil { + log.Fatal(err) + } + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: *stateless}) + log.Fatal(http.ListenAndServe(*addr, handler)) +} diff --git a/docs/README.md b/docs/README.md index d07cfa0fa..c7f2355ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,14 +13,23 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. -These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). -Use the index below to learn how the SDK implements a particular aspect of the -protocol. +These docs describe the SDK's implementation of the +[MCP specification](https://modelcontextprotocol.io/specification/2026-07-28) +and optional extensions. See the [version compatibility table](../README.md#version-compatibility) +for supported protocol revisions. Use the index below to learn how the SDK +implements a particular feature. ## Base Protocol @@ -41,12 +50,16 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Extensions](client.md#extensions) + 1. [Skills](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Extensions](server.md#extensions) + 1. [Skills](server.md#skills-extension) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/docs/client.md b/docs/client.md index 23d57be1d..14f741f01 100644 --- a/docs/client.md +++ b/docs/client.md @@ -544,5 +544,116 @@ 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). +Register methods before connecting, then bind the skills client to the connected +session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: + +```go +ctx := context.Background() +client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1.0.0"}, nil) +if err := skills.AddMethods(client); err != nil { + log.Fatal(err) +} + +serverTransport, clientTransport := mcp.NewInMemoryTransports() +serverSession, err := server.Connect(ctx, serverTransport, nil) +if err != nil { + log.Fatal(err) +} +defer serverSession.Close() +session, err := client.Connect(ctx, clientTransport, nil) +if err != nil { + log.Fatal(err) +} +defer session.Close() + +skillClient := &skills.Client{Session: session} +for skill, err := range skillClient.All(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) +} +``` + +`List`, `Get`, and `All` share `skillClient.Limits`: + +| Configuration | Manifest limits | +| --- | --- | +| Omitted or `Limits: skills.Limits{}` | No count or size caps | +| `Limits: skills.BaselineLimits()` | 512 resources and 16 MiB per skill | +| Positive fields in a supplied `Limits` | Exact caps for those dimensions | +| Zero fields in a supplied `Limits` | Those dimensions are unlimited | +| Negative fields | Configuration error | + +Structural validation always runs. The spec's limits are an interoperability +baseline: hosts must support at least that much and may support more. They are +not mandatory rejection thresholds. To opt into caps based on that baseline: + +```go +limits := skills.BaselineLimits() +limits.MaxTotalSize = 32 << 20 +skillClient = &skills.Client{Session: session, Limits: limits} +``` + +A literal containing only `MaxTotalSize` leaves resource count unlimited. +`BaselineLimits()` follows the spec supported by the installed SDK version. +Supply explicit numeric values to pin application policy across upgrades. +Caps below the baseline reduce what the host can accept. + +Servers and clients configure these limits independently. Each call captures the +configured limits before sending its request, and `All` captures them when the +iterator is created. Do not mutate the client during use. + +These caps apply to static manifests. For dynamic skills, applications manage +their own download, storage, and context budgets; the SDK does not retrieve files +or maintain cumulative size or file counts. + +`ReadDirectory` and `DirectoryEntries` expose optional +directory browsing when the server advertises `directoryRead: true`. Calls fail +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 := skillClient.Get(ctx, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) +if err != nil { + log.Fatal(err) +} +resource, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: result.Skill.URI}) +if err != nil { + log.Fatal(err) +} +if len(resource.Contents) != 1 || resource.Contents[0] == nil || resource.Contents[0].URI != result.Skill.URI || resource.Contents[0].Blob != nil { + log.Fatal("expected one text resource for SKILL.md") +} +if err := skills.VerifySkillMD(result.Skill, []byte(resource.Contents[0].Text)); err != nil { + log.Fatal(err) +} +fmt.Println("verified", result.Skill.URI) +``` + +Keep skill entries scoped to their originating session: equal URIs from different +servers are different skills. 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 d7bc314ad..677e4e60c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1197,7 +1197,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`. + +Register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. An entry's manifest includes every file, including `SKILL.md` and +nested skills. Use `skills.DynamicResources()` when stable digests cannot be +published, not simply because the catalog changes over time. + +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 but some clients may decline them. The +[client documentation](client.md#skills-extension) explains how to customize caps +and pin application policy across SDK upgrades. + +Structural validation always runs; put additional application policy in the +handlers themselves. Dynamic content budgets belong to the application; the SDK +does not accumulate sizes across resource reads. It copies options at registration and +prepares outgoing results without mutating handler-owned data. + +On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and +`cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints +through the result's `mcp.Cacheable` field. 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 @@ -1212,7 +1300,7 @@ indicates whether page retrieval failed. - [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) iterates prompts. -- [`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) +- [`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) iterates resources. - [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterates resource templates. @@ -1222,7 +1310,7 @@ indicates whether page retrieval failed. The `ClientSession` also exposes `ListXXX` methods for fine-grained control over pagination. -**Server-side**: pagination is on by default, so in general nothing is required -server-side. However, you may use +**Server-side**: pagination is on by default for core feature lists, so in general +nothing is required server-side. However, you may use [`ServerOptions.PageSize`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.PageSize) to customize the page size. diff --git a/go.mod b/go.mod index 3287a9578..325d40714 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,13 @@ require ( github.com/segmentio/encoding v0.5.4 github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/oauth2 v0.35.0 + golang.org/x/sync v0.20.0 golang.org/x/time v0.15.0 golang.org/x/tools v0.42.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/segmentio/asm v1.1.3 // indirect - golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect ) 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 3283efaf8..180bb3a7d 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -12,14 +12,23 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. -These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2025-06-18). -Use the index below to learn how the SDK implements a particular aspect of the -protocol. +These docs describe the SDK's implementation of the +[MCP specification](https://modelcontextprotocol.io/specification/2026-07-28) +and optional extensions. See the [version compatibility table](../README.md#version-compatibility) +for supported protocol revisions. Use the index below to learn how the SDK +implements a particular feature. ## Base Protocol @@ -40,12 +49,16 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Extensions](client.md#extensions) + 1. [Skills](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Extensions](server.md#extensions) + 1. [Skills](server.md#skills-extension) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index c0f64b4bc..f3e062beb 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -233,5 +233,70 @@ 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). +Register methods before connecting, then bind the skills client to the connected +session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: + +%include ../../skills/example_test.go skillsclient - + +`List`, `Get`, and `All` share `skillClient.Limits`: + +| Configuration | Manifest limits | +| --- | --- | +| Omitted or `Limits: skills.Limits{}` | No count or size caps | +| `Limits: skills.BaselineLimits()` | 512 resources and 16 MiB per skill | +| Positive fields in a supplied `Limits` | Exact caps for those dimensions | +| Zero fields in a supplied `Limits` | Those dimensions are unlimited | +| Negative fields | Configuration error | + +Structural validation always runs. The spec's limits are an interoperability +baseline: hosts must support at least that much and may support more. They are +not mandatory rejection thresholds. To opt into caps based on that baseline: + +%include ../../skills/example_test.go skillslimits - + +A literal containing only `MaxTotalSize` leaves resource count unlimited. +`BaselineLimits()` follows the spec supported by the installed SDK version. +Supply explicit numeric values to pin application policy across upgrades. +Caps below the baseline reduce what the host can accept. + +Servers and clients configure these limits independently. Each call captures the +configured limits before sending its request, and `All` captures them when the +iterator is created. Do not mutate the client during use. + +These caps apply to static manifests. For dynamic skills, applications manage +their own download, storage, and context budgets; the SDK does not retrieve files +or maintain cumulative size or file counts. + +`ReadDirectory` and `DirectoryEntries` expose optional +directory browsing when the server advertises `directoryRead: true`. Calls fail +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 82b956e98..8aba84b39 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -511,7 +511,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`. + +Register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. An entry's manifest includes every file, including `SKILL.md` and +nested skills. Use `skills.DynamicResources()` when stable digests cannot be +published, not simply because the catalog changes over time. + +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 but some clients may decline them. The +[client documentation](client.md#skills-extension) explains how to customize caps +and pin application policy across SDK upgrades. + +Structural validation always runs; put additional application policy in the +handlers themselves. Dynamic content budgets belong to the application; the SDK +does not accumulate sizes across resource reads. It copies options at registration and +prepares outgoing results without mutating handler-owned data. + +On protocol `2026-07-28` and later, list and get responses carry `ttlMs` and +`cacheScope`, defaulting to zero and `public`. Handlers can supply explicit hints +through the result's `mcp.Cacheable` field. 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 @@ -526,7 +578,7 @@ indicates whether page retrieval failed. - [`ClientSession.Prompts`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Prompts) iterates prompts. -- [`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) +- [`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) iterates resources. - [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterates resource templates. @@ -536,7 +588,7 @@ indicates whether page retrieval failed. The `ClientSession` also exposes `ListXXX` methods for fine-grained control over pagination. -**Server-side**: pagination is on by default, so in general nothing is required -server-side. However, you may use +**Server-side**: pagination is on by default for core feature lists, so in general +nothing is required server-side. However, you may use [`ServerOptions.PageSize`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.PageSize) to customize the page size. diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index ce1f6fc3e..7b534dfa4 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -22,9 +22,17 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/auth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth) package provides some primitives for supporting OAuth. +- The + [`github.com/modelcontextprotocol/go-sdk/auth/extauth`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/auth/extauth) + package provides OAuth handlers for authorization extensions. - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. See the [client](docs/client.md#skills-extension) + and [server](docs/server.md#skills-extension) examples. The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. diff --git a/internal/readme/contributing.src.md b/internal/readme/contributing.src.md index f2e931ba1..e6486c338 100644 --- a/internal/readme/contributing.src.md +++ b/internal/readme/contributing.src.md @@ -31,18 +31,17 @@ go work init ./project ./go-sdk ### Conformance tests -The SDK includes a script to run the official MCP conformance tests against the -SDK's conformance server: +The SDK includes scripts to run the official MCP server and client conformance tests: ```sh -./scripts/conformance.sh +./scripts/server-conformance.sh +./scripts/client-conformance.sh ``` -By default, results are cleaned up after the script runs. To save results to a -specific directory: +To save server results to a specific directory: ```sh -./scripts/conformance.sh --result_dir ./conformance-results +./scripts/server-conformance.sh --result_dir ./conformance-results ``` To run against a local checkout of the @@ -50,12 +49,12 @@ To run against a local checkout of the of the latest npm release: ```sh -./scripts/conformance.sh --conformance_repo ~/src/conformance +./scripts/server-conformance.sh --conformance_repo ~/src/conformance ``` Note: you must run `npm install` in the conformance repo first. -Run `./scripts/conformance.sh --help` for more options. +Run either script with `--help` for more options. ## Filing issues @@ -182,7 +181,7 @@ change therefore cannot reach existing users by accident; they have to change their import path to receive one. This policy covers the exported API of the SDK's importable packages — `mcp`, -`jsonrpc`, `auth`, `auth/extauth` and `oauthex`. Everything under `internal/` +`jsonrpc`, `auth`, `auth/extauth`, `oauthex` and `skills`. Everything under `internal/` is not importable outside the module and may change in any release. Which MCP spec revisions each SDK version speaks is documented in the diff --git a/mcp/server.go b/mcp/server.go index 442d7bc6a..d925ceed1 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. // @@ -654,6 +671,14 @@ func (s *Server) RemoveResourceTemplates(uriTemplates ...string) { s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resourceTemplates.remove(uriTemplates...) }) } +// defaultCapabilities returns the capabilities of a server whose options do not +// set any: only logging. +func defaultCapabilities() *ServerCapabilities { + return &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } +} + func (s *Server) capabilities() *ServerCapabilities { s.mu.Lock() defer s.mu.Unlock() @@ -664,10 +689,7 @@ func (s *Server) capabilities() *ServerCapabilities { // Deep copy the user-provided capabilities to avoid mutation. caps = s.opts.Capabilities.clone() } else { - // SDK defaults: only logging capability. - caps = &ServerCapabilities{ - Logging: &LoggingCapabilities{}, - } + caps = defaultCapabilities() } // Augment with tools capability if tools exist or legacy HasTools is set. diff --git a/mcp/server_test.go b/mcp/server_test.go index 1544e298d..c2f0aa6f4 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -477,6 +477,31 @@ func TestServerCapabilities(t *testing.T) { } } +func TestServerAddExtension(t *testing.T) { + capabilities := &ServerCapabilities{Tools: &ToolCapabilities{}} + server := NewServer(testImpl, &ServerOptions{Capabilities: capabilities}) + settings := map[string]any{"enabled": true} + server.AddExtension("io.example/test", settings) + settings["enabled"] = false + + got := server.capabilities().Extensions["io.example/test"] + want := map[string]any{"enabled": true} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("extension settings mismatch (-want +got):\n%s", diff) + } + if capabilities.Extensions != nil { + t.Fatal("AddExtension mutated the caller's capabilities") + } +} + +func TestServerAddExtensionPreservesDefaultCapabilities(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddExtension("io.example/test", nil) + if server.capabilities().Logging == nil { + t.Fatal("AddExtension removed the default logging capability") + } +} + func TestServerAddResourceTemplate(t *testing.T) { tests := []struct { name string diff --git a/scripts/server-conformance.sh b/scripts/server-conformance.sh index e8fe6e188..3ac8bce9f 100755 --- a/scripts/server-conformance.sh +++ b/scripts/server-conformance.sh @@ -12,31 +12,71 @@ SERVER_PID="" RESULT_DIR="" WORKDIR="" CONFORMANCE_REPO="" +CONFORMANCE_REF="" +CHECKOUT_DIR="" +SERVER_PACKAGE="./conformance/everything-server" +STATELESS=false +CONFORMANCE_ARGS=(--spec-version 2025-11-25) FINAL_EXIT_CODE=0 usage() { - echo "Usage: $0 [options]" + echo "Usage: $0 [options] [-- ]" echo "" echo "Run MCP conformance tests against the Go SDK conformance server." echo "" echo "Options:" echo " --result_dir Save results to the specified directory" - echo " --conformance_repo Run conformance tests from a local checkout" - echo " instead of using the latest npm release" + echo " --conformance_repo Use a local checkout or clone a Git repository" + echo " instead of using the latest npm release" + echo " --conformance_ref Check out a branch, commit, or tag in a temporary clone" + echo " (requires --conformance_repo)" + echo " --server Server to build (default: ./conformance/everything-server)" + echo " --stateless Run the server in stateless mode" + echo " -- Replace the default conformance arguments:" + echo " --spec-version 2025-11-25" echo " --help Show this help message" } +# require_value exits unless $1 was given a usable value in $2. +require_value() { + if [[ $# -lt 2 || -z "$2" || "$2" == --* ]]; then + echo "Missing value for $1" >&2 + exit 1 + fi +} + # Parse arguments. while [[ $# -gt 0 ]]; do case $1 in --result_dir) + require_value "$@" RESULT_DIR="$2" shift 2 ;; --conformance_repo) + require_value "$@" CONFORMANCE_REPO="$2" shift 2 ;; + --conformance_ref) + require_value "$@" + CONFORMANCE_REF="$2" + shift 2 + ;; + --server) + require_value "$@" + SERVER_PACKAGE="$2" + shift 2 + ;; + --stateless) + STATELESS=true + shift + ;; + --) + shift + CONFORMANCE_ARGS=("$@") + break + ;; --help) usage exit 0 @@ -49,61 +89,92 @@ while [[ $# -gt 0 ]]; do esac done +if [[ -n "$CONFORMANCE_REF" && -z "$CONFORMANCE_REPO" ]]; then + echo "--conformance_ref requires --conformance_repo" >&2 + exit 1 +fi + cleanup() { if [ -n "$SERVER_PID" ]; then echo "Stopping server..." kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi + if [[ -n "$CHECKOUT_DIR" ]]; then + rm -rf -- "$CHECKOUT_DIR" + fi } trap cleanup EXIT -# Set up the work directory. -if [ -n "$RESULT_DIR" ]; then +# Set up the work directory. Results are written to an absolute path, so that +# the conformance runner can be started from the work directory. +OUTPUT_ARGS=() +if [[ -n "$RESULT_DIR" ]]; then mkdir -p "$RESULT_DIR" + RESULT_DIR=$(cd "$RESULT_DIR" && pwd) WORKDIR="$RESULT_DIR" + OUTPUT_ARGS=(--output-dir "$RESULT_DIR") else WORKDIR=$(mktemp -d) fi +if [[ -n "$CONFORMANCE_REPO" ]]; then + if [[ -d "$CONFORMANCE_REPO" ]]; then + CONFORMANCE_REPO=$(cd "$CONFORMANCE_REPO" && pwd) + fi + if [[ -n "$CONFORMANCE_REF" || ! -d "$CONFORMANCE_REPO" ]]; then + CHECKOUT_DIR=$(mktemp -d) + git clone --quiet --no-checkout -- "$CONFORMANCE_REPO" "$CHECKOUT_DIR" + git -C "$CHECKOUT_DIR" fetch --quiet origin "${CONFORMANCE_REF:-HEAD}" + git -C "$CHECKOUT_DIR" checkout --quiet --detach FETCH_HEAD + CONFORMANCE_REPO="$CHECKOUT_DIR" + npm --prefix "$CONFORMANCE_REPO" ci --ignore-scripts + fi + npm --prefix "$CONFORMANCE_REPO" run build + RUNNER=(node "$CONFORMANCE_REPO/dist/index.js") +else + RUNNER=(npx @modelcontextprotocol/conformance@latest) +fi + # Build the conformance server. -go build -o "$WORKDIR/conformance-server" ./conformance/everything-server +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +go -C "$REPO_ROOT" build -o "$WORKDIR/conformance-server" "$SERVER_PACKAGE" # Start the server in the background. -# -stateless=false pins the server to the stateful transport so that +# Stateful transport is the default so that # server-initiated sampling/elicitation scenarios (which are not supported on # stateless streamable HTTP) work against the current @latest conformance -# suite. Drop this flag once the 0.2.x line is promoted to the @latest +# suite. Change the default once the 0.2.x line is promoted to the @latest # dist-tag on npm and the stateless leg becomes viable. echo "Starting conformance server on localhost:$PORT..." -"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless=false & +"$WORKDIR/conformance-server" -http="localhost:$PORT" -stateless="$STATELESS" & SERVER_PID=$! echo "Server pid is $SERVER_PID" # Wait for server to be ready echo "Waiting for server to be ready..." -if ! timeout 15 bash -c "until curl -s http://localhost:$PORT > /dev/null 2>&1; do sleep 0.5; done"; then - echo "Server failed to start within 15 seconds." +READY=false +for ((attempt = 0; attempt < 30; attempt++)); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + if curl --silent --max-time 1 --output /dev/null "http://localhost:$PORT"; then + READY=true + break + fi + sleep 0.5 +done +if [[ "$READY" != true ]]; then + echo "Server failed to become ready." exit 1 fi # Run conformance tests from the work directory to avoid writing results to the repo. echo "Running conformance tests..." -if [ -n "$CONFORMANCE_REPO" ]; then - # Run from local checkout using npm run start. - (cd "$WORKDIR" && \ - npm --prefix "$CONFORMANCE_REPO" run start -- \ - server --url "http://localhost:$PORT" \ - --spec-version 2025-11-25 \ - ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? -else - (cd "$WORKDIR" && \ - npx @modelcontextprotocol/conformance@latest \ - server --url "http://localhost:$PORT" \ - --spec-version 2025-11-25 \ - ${RESULT_DIR:+--output-dir "$RESULT_DIR"}) || FINAL_EXIT_CODE=$? -fi +(cd "$WORKDIR" && \ + "${RUNNER[@]}" server --url "http://localhost:$PORT" \ + "${CONFORMANCE_ARGS[@]}" "${OUTPUT_ARGS[@]}") || FINAL_EXIT_CODE=$? echo "" if [ -n "$RESULT_DIR" ]; then diff --git a/skills/client.go b/skills/client.go new file mode 100644 index 000000000..46c681728 --- /dev/null +++ b/skills/client.go @@ -0,0 +1,244 @@ +// 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" +) + +// AddMethods registers the Skills extension methods that client may send. +// Call it before connecting the client to a server. +func AddMethods(client *mcp.Client) error { + if client == nil { + return fmt.Errorf("skills: nil client") + } + if err := mcp.AddSendingCustomMethod[*ListSkillsParams, *ListSkillsResult](client, MethodList); err != nil { + return err + } + if err := mcp.AddSendingCustomMethod[*GetSkillParams, *GetSkillResult](client, MethodGet); err != nil { + return err + } + return mcp.AddSendingCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](client, MethodReadDirectory) +} + +// Client calls the Skills extension on a connected [mcp.ClientSession]. +// Call [AddMethods] on the underlying [mcp.Client] before connecting. +// A Client may be used concurrently; do not modify its fields during use. +// +// Client does not prefetch content or cache entries. Keep entries scoped to +// their originating session and verify resource bytes before using them. +type Client struct { + // Session is the connected MCP session. It must be non-nil. + Session *mcp.ClientSession + // Limits bounds each manifest returned by List, Get, or All. + // The zero value imposes no caps. Use [BaselineLimits] to opt into + // the spec's interoperability baseline. + Limits Limits +} + +// List calls skills/list and validates the response using c.Limits. +// If params is nil, List requests the first page. +func (c *Client) List(ctx context.Context, params *ListSkillsParams) (*ListSkillsResult, error) { + if _, err := c.requireExtension(); err != nil { + return nil, err + } + limits := c.Limits + if err := limits.validate(); err != nil { + return nil, err + } + if params == nil { + params = &ListSkillsParams{} + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ListSkillsParams, *ListSkillsResult](ctx, c.Session, MethodList, &request) + if err != nil { + return nil, err + } + if err := validateListResult(result, limits); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid skills/list result: %w", err) + } + if err := c.validateEnvelope(result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + return result, nil +} + +// Get calls skills/get and validates the response using c.Limits. +// The URI in params must identify a SKILL.md, whether or not it was listed. +func (c *Client) Get(ctx context.Context, params *GetSkillParams) (*GetSkillResult, error) { + if _, err := c.requireExtension(); err != nil { + return nil, err + } + limits := c.Limits + if err := limits.validate(); err != nil { + return nil, err + } + 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, c.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 := c.validateEnvelope(result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + return result, nil +} + +// ReadDirectory calls resources/directory/read and validates the response. +// The server must advertise directoryRead, and params must specify a directory URI. +func (c *Client) ReadDirectory(ctx context.Context, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if err := c.requireDirectoryRead(); 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, c.Session, MethodReadDirectory, &request) + if err != nil { + return nil, err + } + if err := ValidateDirectoryResult(params.URI, result); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid directory result: %w", err) + } + if err := c.validateResultType(result.ResultType); 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 [Client.List]. +// The session, limits, and parameters are captured when All is called. +// The iterator stops after yielding its first error. +func (c *Client) All(ctx context.Context, params *ListSkillsParams) iter.Seq2[*Skill, error] { + client := c.snapshot() + 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 := client.List(ctx, &request) + 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 [Client.ReadDirectory]. +// The iterator stops after yielding its first error. +func (c *Client) DirectoryEntries(ctx context.Context, params *ReadDirectoryParams) iter.Seq2[*mcp.Resource, error] { + client := c.snapshot() + 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 := client.ReadDirectory(ctx, &request) + if err != nil { + return nil, "", err + } + return result.Resources, result.NextCursor, nil + })(yield) + } +} + +// snapshot copies c so that an iterator keeps using the session and limits that +// were configured when it was created. +func (c *Client) snapshot() Client { + if c == nil { + return Client{} + } + return *c +} + +// requireExtension reports the settings the server advertised for the Skills +// extension, or an error explaining which capability is missing. +func (c *Client) requireExtension() (map[string]any, error) { + if c == nil { + return nil, fmt.Errorf("skills: nil client") + } + if c.Session == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") + } + init := c.Session.InitializeResult() + if init == nil || init.Capabilities == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") + } + settings, ok := init.Capabilities.Extensions[ExtensionID] + if !ok { + return 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 (c *Client) requireDirectoryRead() error { + settings, err := c.requireExtension() + 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 (c *Client) usesCaching() bool { + return c.Session.InitializeResult().ProtocolVersion >= protocolVersionCaching +} + +func (c *Client) validateResultType(resultType string) error { + if c.usesCaching() && resultType != resultTypeComplete { + return fmt.Errorf("skills: expected complete result, got %q", resultType) + } + return nil +} + +func (c *Client) validateEnvelope(resultType string, cache mcp.Cacheable, cachePresent bool) error { + if err := c.validateResultType(resultType); err != nil { + return err + } + if !c.usesCaching() { + return nil + } + if !cachePresent { + return fmt.Errorf("skills: missing ttlMs or cacheScope") + } + return validateCache(cache) +} diff --git a/skills/example_test.go b/skills/example_test.go new file mode 100644 index 000000000..e449514d1 --- /dev/null +++ b/skills/example_test.go @@ -0,0 +1,111 @@ +// 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) + if err := skills.AddMethods(client); err != nil { + log.Fatal(err) + } + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + log.Fatal(err) + } + defer serverSession.Close() + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + skillClient := &skills.Client{Session: session} + for skill, err := range skillClient.All(ctx, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) + } + // !-skillsclient + + // !+skillslimits + limits := skills.BaselineLimits() + limits.MaxTotalSize = 32 << 20 + skillClient = &skills.Client{Session: session, Limits: limits} + // !-skillslimits + + // !+skillsverify + result, err := skillClient.Get(ctx, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) + if err != nil { + 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..f79a72cfa --- /dev/null +++ b/skills/limits_test.go @@ -0,0 +1,214 @@ +// 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" + "math" + "sync/atomic" + "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 *Client, skill *Skill, wantOK bool) { + t.Helper() + _, listErr := client.List(t.Context(), nil) + _, getErr := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + var allErr error + count := 0 + for _, err := range client.All(t.Context(), nil) { + if err != nil { + allErr = err + break + } + count++ + } + for method, err := range map[string]error{"List": listErr, "Get": getErr, "All": allErr} { + if (err == nil) != wantOK { + t.Errorf("%s: error = %v, want success = %v", method, err, wantOK) + } + } + if wantOK && count != 1 { + t.Errorf("All yielded %d skills, want 1", count) + } +} + +// TestValidateSkillWithLimits covers the limit matrix by calling validation +// directly. Limits are SDK policy rather than protocol, so the conformance suite +// cannot observe them; TestLimitsArePlumbed checks that requests reach this code. +func TestValidateSkillWithLimits(t *testing.T) { + baseline := BaselineLimits() + for _, kind := range []string{"count", "bytes"} { + 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) + } + }) + } +} + +// TestLimitsArePlumbed checks that Client.Limits and ServerOptions.Limits reach +// validation over a connection, and that invalid limits fail before a request is +// sent. The matrix itself lives in TestValidateSkillWithLimits. +func TestLimitsArePlumbed(t *testing.T) { + skill := oversized("count") + var calls atomic.Int32 + counted := func(skill *Skill) *Handlers { + h := fixedHandlers(skill) + list, get := h.List, h.Get + h.List = func(ctx context.Context, s *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + calls.Add(1) + return list(ctx, s, p) + } + h.Get = func(ctx context.Context, s *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + calls.Add(1) + return get(ctx, s, p) + } + return h + } + + for _, side := range []string{"client", "server"} { + for _, test := range []struct { + name string + limits Limits + wantOK bool + }{ + {"unlimited", Limits{}, true}, + {"baseline", BaselineLimits(), false}, + } { + t.Run(side+"/"+test.name, func(t *testing.T) { + server := testServer() + var options *ServerOptions + if side == "server" { + options = &ServerOptions{Limits: test.limits} + } + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + if side == "client" { + client.Limits = test.limits + } + checkLimitCalls(t, client, skill, test.wantOK) + }) + } + } + + t.Run("negative limits fail before sending", func(t *testing.T) { + valid := testSkill() + server := testServer() + if err := AddHandlers(server, counted(valid), nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + for _, limits := range []Limits{{MaxResourcesPerSkill: -1}, {MaxTotalSize: -1}} { + if err := AddHandlers(testServer(), counted(valid), &ServerOptions{Limits: limits}); err == nil { + t.Error("AddHandlers accepted a negative limit") + } + client.Limits = limits + checkLimitCalls(t, client, valid, false) + } + if got := calls.Load(); got != 0 { + t.Fatalf("invalid client limits sent %d requests", got) + } + }) +} + +// TestLimitOwnership checks that limits are captured at registration and at +// iterator creation, so later mutation of the caller's value has no effect. +func TestLimitOwnership(t *testing.T) { + skill := skillWith(func(s *Skill) { + s.Resources = StaticResources( + &Resource{URI: "skill://demo/SKILL.md", Digest: testDigest, Size: 1}, + &Resource{URI: "skill://demo/helper.txt", Digest: testDigest, Size: 1}) + }) + server := testServer() + options := &ServerOptions{Limits: Limits{MaxResourcesPerSkill: 2}} + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + options.Limits = Limits{MaxTotalSize: 1} // must not affect the registered handlers + + client := connectSkills(t, server, protocolVersionCaching) + client.Limits = Limits{MaxResourcesPerSkill: 2} + checkLimitCalls(t, client, skill, true) + + seq := client.All(t.Context(), nil) + client.Limits.MaxResourcesPerSkill = 1 + checkLimitCalls(t, client, skill, false) + for range 2 { + count := 0 + for _, err := range seq { + if err != nil { + t.Fatal(err) + } + count++ + } + if count != 1 { + t.Fatalf("captured iterator yielded %d skills, want 1", count) + } + } +} 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..6016d092f --- /dev/null +++ b/skills/protocol_test.go @@ -0,0 +1,356 @@ +// 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" + "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 := client.Get(t.Context(), &GetSkillParams{URI: test.uri}) + wantRPCCode(t, test.uri, err, jsonrpc.CodeInvalidParams) + }) + } + }) + + t.Run("directory", func(t *testing.T) { + if _, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://missing"}); err != nil { + wantRPCCode(t, "unknown directory", err, jsonrpc.CodeInvalidParams) + } else { + t.Error("unknown directory accepted") + } + // An empty directory is a success with an empty, non-null array. + empty, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://empty"}) + if err != nil || empty.Resources == nil || len(empty.Resources) != 0 { + t.Errorf("empty directory = %+v, %v", empty, err) + } + }) + + t.Run("invalid cursor", func(t *testing.T) { + _, listErr := client.List(t.Context(), &ListSkillsParams{Cursor: "%"}) + _, directoryErr := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo", Cursor: "%"}) + for method, err := range map[string]error{MethodList: listErr, MethodReadDirectory: directoryErr} { + wantRPCCode(t, method, err, jsonrpc.CodeInvalidParams) + } + }) +} + +// 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 := client.List(t.Context(), nil) + _, getErr := client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + _, directoryErr := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}) + for method, err := range map[string]error{MethodList: listErr, MethodGet: getErr, MethodReadDirectory: directoryErr} { + rpc := wantRPCCode(t, method, err, test.code) + if rpc != nil && test.wantData && string(rpc.Data) != string(coded.Data) { + t.Errorf("%s: error data = %s, want %s", method, rpc.Data, coded.Data) + } + } + }) + } + + t.Run("nil list result", func(t *testing.T) { + server := testServer() + handlers := fixedHandlers(testSkill()) + handlers.List = func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return nil, nil + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + _, err := connectSkills(t, server, protocolVersionCaching).List(t.Context(), nil) + wantRPCCode(t, MethodList, err, jsonrpc.CodeInternalError) + }) +} + +// TestClientRequiresCapabilities checks the guards that run before a request is +// sent. A server that advertises neither the extension nor directoryRead must be +// rejected locally rather than called. +func TestClientRequiresCapabilities(t *testing.T) { + ctx := t.Context() + if _, err := (&Client{}).List(ctx, nil); err == nil { + t.Error("client without a session accepted List") + } + // A server with no Skills handlers does not advertise the extension. + if _, err := connectSkills(t, testServer(), protocolVersionCaching).List(ctx, nil); err == nil { + t.Error("client called a server that does not advertise the extension") + } + // Handlers without ReadDirectory advertise the extension but not directoryRead. + server := testServer() + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + if _, err := client.ReadDirectory(ctx, &ReadDirectoryParams{URI: "skill://demo"}); err == nil { + t.Error("client called resources/directory/read without the capability") + } + for _, params := range []*GetSkillParams{nil, {}} { + if _, err := client.Get(ctx, params); err == nil { + t.Errorf("Get accepted %+v", params) + } + } +} + +// TestResponsesAndParamsAreNotMutated checks the ownership contract: handler +// results and caller parameters are copied, never modified in place, even under +// concurrent use across two protocol versions. +func TestResponsesAndParamsAreNotMutated(t *testing.T) { + server := testServer() + skill := testSkill() + list := &ListSkillsResult{Skills: []*Skill{skill}, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}} + get := &GetSkillResult{Skill: skill, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}, Cacheable: mcp.Cacheable{TTLMs: 123, CacheScope: "private"}} + dir := &ReadDirectoryResult{} + 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]*Client{"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 := client.List(t.Context(), 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 := client.Get(t.Context(), &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) + } + if _, err := client.ReadDirectory(t.Context(), &ReadDirectoryParams{URI: "skill://demo"}); err != nil { + t.Error(err) + } + } + }) + } + 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"}`}, + } { + t.Run(test.name, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + raw := &rawResult{data: json.RawMessage(test.body)} + // Register only the method under test, bypassing AddHandlers so that + // the body reaches the client exactly as written. + var err error + switch test.method { + case MethodGet: + err = mcp.AddReceivingCustomMethod(server, MethodGet, func(context.Context, *mcp.ServerSession, *GetSkillParams) (*rawResult, error) { return raw, nil }) + default: + err = mcp.AddReceivingCustomMethod(server, MethodList, func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*rawResult, error) { return raw, nil }) + } + if err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + if test.method == MethodGet { + _, err = client.Get(t.Context(), &GetSkillParams{URI: skill.URI}) + } else { + _, err = client.List(t.Context(), nil) + } + if err == nil { + t.Fatalf("accepted malformed %s response", test.method) + } + }) + } +} + +// 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 := client.All(t.Context(), 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") + } +} diff --git a/skills/server.go b/skills/server.go new file mode 100644 index 000000000..fb30ae822 --- /dev/null +++ b/skills/server.go @@ -0,0 +1,200 @@ +// 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], which also advertises +// 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") + } + 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)) + } + out.ResultType = resultType(params.Meta) + 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 +} + +func resultType(meta mcp.Meta) string { + if supportsCaching(meta) { + return resultTypeComplete + } + return "" +} + +// 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..0d1da5468 --- /dev/null +++ b/skills/skills_test.go @@ -0,0 +1,266 @@ +// 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" + "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) *Client { + t.Helper() + httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: version >= protocolVersionCaching})) + t.Cleanup(httpServer.Close) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + if err := AddMethods(client); err != nil { + t.Fatal(err) + } + session, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, &mcp.ClientSessionOptions{ProtocolVersion: version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return &Client{Session: session} +} + +func fixedHandlers(skill *Skill) *Handlers { + return &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, nil + }, + Get: func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + if p.URI != skill.URI { + return nil, nil + } + return &GetSkillResult{Skill: skill}, nil + }, + } +} + +func 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) + } + } + }) + } +} diff --git a/skills/types.go b/skills/types.go new file mode 100644 index 000000000..f14d6411a --- /dev/null +++ b/skills/types.go @@ -0,0 +1,288 @@ +// 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 register [AddMethods] before connecting, then use +// [Client] to discover entries. [VerifySkillMD] and [VerifyResource] check content +// retrieved on demand against an entry from the same server. +// +// [Skills extension]: https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx +package skills + +import ( + "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"` +} + +// ListSkillsParams contains parameters for skills/list. +type ListSkillsParams struct { + mcp.ParamsBase + Cursor string `json:"cursor,omitempty"` +} + +// 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"` + 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. +// [AddHandlers] sets ResultType for the request's protocol version. +type ReadDirectoryResult struct { + mcp.ResultBase + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Resources []*mcp.Resource `json:"resources"` +} + +func (r *ListSkillsResult) UnmarshalJSON(data []byte) error { + type wire ListSkillsResult + var decoded struct { + wire + decodedCache + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = ListSkillsResult(decoded.wire) + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) + 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..de8fe46ee --- /dev/null +++ b/skills/validation.go @@ -0,0 +1,412 @@ +// 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 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 +}