diff --git a/README.md b/README.md index baeb3e7f..bcefe4b4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. See the [client](docs/client.md#skills-extension) + and [server](docs/server.md#skills-extension) examples. The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. diff --git a/docs/README.md b/docs/README.md index e8b00a84..8cdbc026 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,11 @@ The SDK consists of several importable packages: [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. + These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Use the index below to learn how the SDK implements a particular aspect of the @@ -41,12 +46,14 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Skills extension](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Skills extension](server.md#skills-extension) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/docs/client.md b/docs/client.md index 23d57be1..5837fd99 100644 --- a/docs/client.md +++ b/docs/client.md @@ -544,5 +544,90 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so that optional capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values -are per-extension settings objects. +are per-extension settings objects. Extensions require explicit opt-in. +#### Skills extension + +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package provides typed calls for the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Call package-level functions with the connected session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: + +```go +ctx := context.Background() +client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1.0.0"}, nil) + +serverTransport, clientTransport := mcp.NewInMemoryTransports() +serverSession, err := server.Connect(ctx, serverTransport, nil) +if err != nil { + log.Fatal(err) +} +defer serverSession.Close() +session, err := client.Connect(ctx, clientTransport, nil) +if err != nil { + log.Fatal(err) +} +defer session.Close() + +for skill, err := range skills.All(ctx, session, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) +} +``` + +`List`, `Get`, and `All` validate structure without imposing count or size caps. +They support the required baseline of 512 files and 16 MiB per skill, as well as +larger manifests. Application download budgets for dynamic content remain the +host's responsibility. + +List calls reject invalid entries by default. To retain valid entries from a +malformed page, pass `skills.ListOptions{SkipInvalidSkills: true}` to `List` or +`All`. `List` reports skipped entries in `InvalidSkills`; `OnInvalidSkill` can +report them during iteration. Missing/null lists, invalid cache hints, and +invalid result types still fail. All entries with a duplicate URI are skipped, +so no duplicate silently replaces another. Page cursors are preserved. + +`ReadDirectory` and `DirectoryEntries` expose optional +directory browsing when the server advertises `directoryRead: true`. Calls fail +if the required server capabilities are absent. Iterators follow cursors without +modifying request parameters and stop after the first error. + +Listing does not fetch content. Read files on demand with `session.ReadResource` +and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. +A listed entry is complete; `Get` also retrieves a skill directly by URI even +when it was not listed. For example, when the user chooses to load a known skill: + +```go +result, err := skills.Get(ctx, session, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) +if err != nil { + log.Fatal(err) +} +resource, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: result.Skill.URI}) +if err != nil { + log.Fatal(err) +} +if len(resource.Contents) != 1 || resource.Contents[0] == nil || resource.Contents[0].URI != result.Skill.URI || resource.Contents[0].Blob != nil { + log.Fatal("expected one text resource for SKILL.md") +} +if err := skills.VerifySkillMD(result.Skill, []byte(resource.Contents[0].Text)); err != nil { + log.Fatal(err) +} +fmt.Println("verified", result.Skill.URI) +``` + +Keep skill entries scoped to their originating session: equal URIs from different +servers are different skills. Use a host-assigned server identity when persisting +entries or approvals. Directory results are live observations; they do not expand +the files authorized by a held manifest. + +`VerifyResource` checks manifest membership, byte length, and SHA-256 digest. +`VerifySkillMD` also compares every frontmatter field. JSON frontmatter numbers +are decoded as `json.Number` to preserve integer precision. For dynamic manifests, +`VerifySkillMD` still checks frontmatter and returns `skills.ErrDynamicResources` +only when it matches; malformed or mismatched frontmatter returns a different error. +Applications decide whether to accept content without integrity verification and +own skill approval and execution policy. A digest match alone does not make remote +instructions trustworthy. diff --git a/docs/server.md b/docs/server.md index 764c96bf..5eae92e3 100644 --- a/docs/server.md +++ b/docs/server.md @@ -220,7 +220,7 @@ The SDK ensures that a read succeeds only if the URI matches a registered resour or matches the URI pattern of a resource template. To list a server's resources and resource templates, use the -[`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) +[`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) and [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterators, or the lower-level `ListXXX` calls (see [pagination](#pagination)). @@ -1203,7 +1203,185 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ adds an `extensions` map to `ServerCapabilities` so that optional capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values are -per-extension settings objects. +per-extension settings objects. Extensions require explicit opt-in. + +#### Skills extension + +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package implements the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Use `skills.AddHandlers` for request-time +`skills/list` and `skills/get` handlers. An optional directory handler enables +`resources/directory/read` and advertises `directoryRead: true`. + +Before calling `AddHandlers`, register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. Registration fails if the effective resources capability is absent. +An entry's manifest includes every file, including `SKILL.md` and +nested skills. Use `skills.DynamicResources()` when stable digests cannot be +published, not simply because the catalog changes over time. + +This example serves a complete static manifest and its content. The +[client example](client.md#skills-extension) connects to this server and verifies +the resource bytes: + +```go +server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) +const uri = "skill://greeting/SKILL.md" +const content = "---\nname: greeting\ndescription: Greet the user.\n---\n# Greeting\nSay hello to the user.\n" +entry := &skills.Skill{ + URI: uri, + Frontmatter: skills.Frontmatter{ + "name": "greeting", "description": "Greet the user.", + }, + Resources: skills.StaticResources(&skills.Resource{ + URI: uri, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), Size: int64(len(content)), + }), +} + +server.AddResource(&mcp.Resource{ + URI: uri, Name: "greeting", Description: "Greet the user.", MIMEType: "text/markdown", +}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: uri, MIMEType: "text/markdown", Text: content, + }}}, nil +}) +err := skills.AddHandlers(server, &skills.Handlers{ + List: func(_ context.Context, _ *mcp.ServerSession, params *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + page, next, err := skills.PaginateSkills([]*skills.Skill{entry}, params.Cursor, 0) + return &skills.ListSkillsResult{Skills: page, NextCursor: next}, err + }, + Get: func(_ context.Context, _ *mcp.ServerSession, params *skills.GetSkillParams) (*skills.GetSkillResult, error) { + if params.URI != entry.URI { + return nil, nil + } + return &skills.GetSkillResult{Skill: entry}, nil + }, +}, nil) +if err != nil { + log.Fatal(err) +} +``` + +Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK +returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result +with an empty resource list. Explicit JSON-RPC errors retain their code and data; +other handler errors and invalid results become Internal Error (`-32603`). + +Handlers own pagination. `skills.PaginateSkills` and +`skills.PaginateDirectoryResources` sort by URI and return one page without +modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; +`mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill +entry contains its complete manifest, which is never split across pages. + +`skills.ServerOptions.Limits` is a `skills.Limits` value. By default it imposes no +manifest caps. Positive fields set exact caps, zero fields are unlimited, and +negative fields are invalid. Set `Limits: skills.BaselineLimits()` to opt into +the spec's interoperability baseline of 512 resources and 16 MiB per skill. +Servers should stay within this baseline for broad compatibility; serving larger +skills is allowed. Clients accept larger structurally valid manifests; these +server caps govern publication policy only. + +Structural validation always runs; put additional application policy in the +handlers themselves. Dynamic content budgets belong to the application; the SDK +does not accumulate sizes across resource reads. It copies options at registration and +prepares outgoing results without mutating handler-owned data. + +On protocol `2026-07-28` and later, list 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. + +#### Filesystem helper + +For skills stored on disk, `skills.AddDirectory` installs a filesystem-backed +provider. By default it discovers skills and files on every request, so +resources added after server startup are available without re-registering +them. This applies to `skills/list`, `skills/get`, `resources/list`, +`resources/read`, and `resources/directory/read`. Changes and removals are +visible on the next request too. + +```go +if err := skills.AddDirectory(server, "./skills", &skills.DirectoryOptions{ + PageSize: 100, +}); err != nil { + return err +} +``` + +`resources/list` includes the skill files alongside ordinary registered +resources. `SKILL.md` entries carry their frontmatter name and description and +the `text/markdown` MIME type. Directory reads return only direct children, +including subdirectories with MIME type `inode/directory`, and use the same +file metadata. Neither listing reads supporting file contents just to enumerate +them; `skills/list` and `skills/get` also hash files to build static manifests. + +The helper combines all pages of the underlying resource listing with its +current catalog, deduplicates by URI, and paginates the result using +`DirectoryOptions.PageSize`. Exact resource registrations take precedence over +the template, for both listing and reading. This uses existing receiving +middleware and resource-template routing; it does not change the core SDK APIs. +The merge costs a traversal of the underlying resource list on each request. + +Live discovery does not require `skills.DynamicResources()`: that marker means +the server cannot provide a complete manifest with stable digests, not that its +catalog changes over time. A filesystem provider returns a complete static +manifest for the current catalog; a later request can return a different one. + +Clients must re-list to see changes. The helper starts no watcher and sends no +filesystem-change notifications; SEP-2640 defines no `skills/list_changed` +notification. The merged `resources/list` response has a zero TTL and private +cache scope so a TTL configured for ordinary resources cannot conceal changes. +These cache fields are omitted on older protocol versions by the core SDK. + +Set `Cache` to `&skills.DirectoryCacheOptions{}` to load the catalog on the +first request and cache it indefinitely. Set `Preload: true` to load it while +constructing the provider, which also makes the constructor report initial scan +and validation errors. A positive `MaxAge` expires the cache after that +duration; the first request after expiry rebuilds it. + +Cached providers can also be invalidated by a clock or filesystem monitor through +`DirectoryCacheOptions.Invalidate`. Signals are coalesced and consumed when a +request arrives; use a buffered channel so producers do not block. Both +`MaxAge` and `Invalidate` are lazy: they do not start a background goroutine. +A failed rebuild retains the previous catalog but leaves it stale: requests +retry rebuilding until successful, even if the invalidation signal has already +been consumed. They return the scan error instead of silently serving stale +metadata. Resource bytes are always read on demand, including in cached modes. + +To rebuild before the next request, construct a provider directly and call +`Refresh` from the application's watcher goroutine. The caller owns goroutine +lifetime, cancellation, and error handling: + +```go +provider, err := skills.NewDirectoryProvider("./skills", &skills.DirectoryOptions{ + Cache: &skills.DirectoryCacheOptions{Preload: true}, +}) +if err != nil { + return err +} +if err := provider.AddTo(server); err != nil { + return err +} +go func() { + for range changed { + if err := provider.Refresh(ctx); err != nil { + logger.Error("refreshing skills", "error", err) + } + } +}() +``` + +Register one filesystem provider per server. Applications that need to combine +multiple filesystems can use an overlay `fs.FS` or aggregate them behind custom +`AddHandlers` handlers. + +Filesystem manifests use `skills.BaselineLimits()` by default. Override publication +caps through `DirectoryOptions.ServerOptions.Limits`; an explicit zero value +removes caps. Structural validation always runs. Catalog validators can add +application checks. Options are copied at construction. + ### Pagination diff --git a/go.mod b/go.mod index 3287a957..f2f70f10 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.15.0 golang.org/x/tools v0.42.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index c13454aa..b67dd1f4 100644 --- a/go.sum +++ b/go.sum @@ -20,3 +20,7 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/docs/README.src.md b/internal/docs/README.src.md index dc2407dc..01e72893 100644 --- a/internal/docs/README.src.md +++ b/internal/docs/README.src.md @@ -16,6 +16,11 @@ The SDK consists of several importable packages: [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. + These docs mirror the [official MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Use the index below to learn how the SDK implements a particular aspect of the @@ -40,12 +45,14 @@ protocol. 1. [Roots](client.md#roots) 1. [Sampling](client.md#sampling) 1. [Elicitation](client.md#elicitation) +1. [Skills extension](client.md#skills-extension) ## Server Features 1. [Prompts](server.md#prompts) 1. [Resources](server.md#resources) 1. [Tools](server.md#tools) +1. [Skills extension](server.md#skills-extension) 1. [Utilities](server.md#utilities) 1. [Completion](server.md#completion) 1. [Logging](server.md#logging) diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index c0f64b4b..ba7edb9d 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -233,5 +233,52 @@ client := mcp.NewClient(impl, &mcp.ClientOptions{ adds an `extensions` map to `ClientCapabilities` and `ServerCapabilities` so that optional capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values -are per-extension settings objects. - +are per-extension settings objects. Extensions require explicit opt-in. + +#### Skills extension + +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package provides typed calls for the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Call package-level functions with the connected session. This example connects to the server from the +[server example](server.md#skills-extension) over an in-memory transport: + +%include ../../skills/example_test.go skillsclient - + +`List`, `Get`, and `All` validate structure without imposing count or size caps. +They support the required baseline of 512 files and 16 MiB per skill, as well as +larger manifests. Application download budgets for dynamic content remain the +host's responsibility. + +List calls reject invalid entries by default. To retain valid entries from a +malformed page, pass `skills.ListOptions{SkipInvalidSkills: true}` to `List` or +`All`. `List` reports skipped entries in `InvalidSkills`; `OnInvalidSkill` can +report them during iteration. Missing/null lists, invalid cache hints, and +invalid result types still fail. All entries with a duplicate URI are skipped, +so no duplicate silently replaces another. Page cursors are preserved. + +`ReadDirectory` and `DirectoryEntries` expose optional +directory browsing when the server advertises `directoryRead: true`. Calls fail +if the required server capabilities are absent. Iterators follow cursors without +modifying request parameters and stop after the first error. + +Listing does not fetch content. Read files on demand with `session.ReadResource` +and check them with `skills.VerifyResource` or `skills.VerifySkillMD` before use. +A listed entry is complete; `Get` also retrieves a skill directly by URI even +when it was not listed. For example, when the user chooses to load a known skill: + +%include ../../skills/example_test.go skillsverify - + +Keep skill entries scoped to their originating session: equal URIs from different +servers are different skills. Use a host-assigned server identity when persisting +entries or approvals. Directory results are live observations; they do not expand +the files authorized by a held manifest. + +`VerifyResource` checks manifest membership, byte length, and SHA-256 digest. +`VerifySkillMD` also compares every frontmatter field. JSON frontmatter numbers +are decoded as `json.Number` to preserve integer precision. For dynamic manifests, +`VerifySkillMD` still checks frontmatter and returns `skills.ErrDynamicResources` +only when it matches; malformed or mismatched frontmatter returns a different error. +Applications decide whether to accept content without integrity verification and +own skill approval and execution policy. A digest match alone does not make remote +instructions trustworthy. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index 1b24c1d6..894537f9 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -66,7 +66,7 @@ The SDK ensures that a read succeeds only if the URI matches a registered resour or matches the URI pattern of a resource template. To list a server's resources and resource templates, use the -[`ClientSession.Resources`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resources) +[`ClientSession.Resource`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.Resource) and [`ClientSession.ResourceTemplates`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientSession.ResourceTemplates) iterators, or the lower-level `ListXXX` calls (see [pagination](#pagination)). @@ -517,7 +517,149 @@ server := mcp.NewServer(impl, &mcp.ServerOptions{ adds an `extensions` map to `ServerCapabilities` so that optional capabilities outside the core protocol can be declared on the wire. Keys are namespaced as `"{vendor-prefix}/{extension-name}"`; values are -per-extension settings objects. +per-extension settings objects. Extensions require explicit opt-in. + +#### Skills extension + +The [`skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) +package implements the +[Skills extension](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx). +Use `skills.AddHandlers` for request-time +`skills/list` and `skills/get` handlers. An optional directory handler enables +`resources/directory/read` and advertises `directoryRead: true`. + +Before calling `AddHandlers`, register the underlying content through `Server.AddResource` or +`Server.AddResourceTemplate`; these also advertise the required `resources` +capability. Registration fails if the effective resources capability is absent. +An entry's manifest includes every file, including `SKILL.md` and +nested skills. Use `skills.DynamicResources()` when stable digests cannot be +published, not simply because the catalog changes over time. + +This example serves a complete static manifest and its content. The +[client example](client.md#skills-extension) connects to this server and verifies +the resource bytes: + +%include ../../skills/example_test.go skillsserver - + +Return `(nil, nil)` from the get or directory handler for an unknown URI; the SDK +returns JSON-RPC Invalid Params (`-32602`). An empty directory has a non-nil result +with an empty resource list. Explicit JSON-RPC errors retain their code and data; +other handler errors and invalid results become Internal Error (`-32603`). + +Handlers own pagination. `skills.PaginateSkills` and +`skills.PaginateDirectoryResources` sort by URI and return one page without +modifying the input slice. A zero page size uses `mcp.DefaultPageSize`; +`mcp.ServerOptions.PageSize` does not configure custom Skills handlers. Each skill +entry contains its complete manifest, which is never split across pages. + +`skills.ServerOptions.Limits` is a `skills.Limits` value. By default it imposes no +manifest caps. Positive fields set exact caps, zero fields are unlimited, and +negative fields are invalid. Set `Limits: skills.BaselineLimits()` to opt into +the spec's interoperability baseline of 512 resources and 16 MiB per skill. +Servers should stay within this baseline for broad compatibility; serving larger +skills is allowed. Clients accept larger structurally valid manifests; these +server caps govern publication policy only. + +Structural validation always runs; put additional application policy in the +handlers themselves. Dynamic content budgets belong to the application; the SDK +does not accumulate sizes across resource reads. It copies options at registration and +prepares outgoing results without mutating handler-owned data. + +On protocol `2026-07-28` and later, list 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. + +#### Filesystem helper + +For skills stored on disk, `skills.AddDirectory` installs a filesystem-backed +provider. By default it discovers skills and files on every request, so +resources added after server startup are available without re-registering +them. This applies to `skills/list`, `skills/get`, `resources/list`, +`resources/read`, and `resources/directory/read`. Changes and removals are +visible on the next request too. + +```go +if err := skills.AddDirectory(server, "./skills", &skills.DirectoryOptions{ + PageSize: 100, +}); err != nil { + return err +} +``` + +`resources/list` includes the skill files alongside ordinary registered +resources. `SKILL.md` entries carry their frontmatter name and description and +the `text/markdown` MIME type. Directory reads return only direct children, +including subdirectories with MIME type `inode/directory`, and use the same +file metadata. Neither listing reads supporting file contents just to enumerate +them; `skills/list` and `skills/get` also hash files to build static manifests. + +The helper combines all pages of the underlying resource listing with its +current catalog, deduplicates by URI, and paginates the result using +`DirectoryOptions.PageSize`. Exact resource registrations take precedence over +the template, for both listing and reading. This uses existing receiving +middleware and resource-template routing; it does not change the core SDK APIs. +The merge costs a traversal of the underlying resource list on each request. + +Live discovery does not require `skills.DynamicResources()`: that marker means +the server cannot provide a complete manifest with stable digests, not that its +catalog changes over time. A filesystem provider returns a complete static +manifest for the current catalog; a later request can return a different one. + +Clients must re-list to see changes. The helper starts no watcher and sends no +filesystem-change notifications; SEP-2640 defines no `skills/list_changed` +notification. The merged `resources/list` response has a zero TTL and private +cache scope so a TTL configured for ordinary resources cannot conceal changes. +These cache fields are omitted on older protocol versions by the core SDK. + +Set `Cache` to `&skills.DirectoryCacheOptions{}` to load the catalog on the +first request and cache it indefinitely. Set `Preload: true` to load it while +constructing the provider, which also makes the constructor report initial scan +and validation errors. A positive `MaxAge` expires the cache after that +duration; the first request after expiry rebuilds it. + +Cached providers can also be invalidated by a clock or filesystem monitor through +`DirectoryCacheOptions.Invalidate`. Signals are coalesced and consumed when a +request arrives; use a buffered channel so producers do not block. Both +`MaxAge` and `Invalidate` are lazy: they do not start a background goroutine. +A failed rebuild retains the previous catalog but leaves it stale: requests +retry rebuilding until successful, even if the invalidation signal has already +been consumed. They return the scan error instead of silently serving stale +metadata. Resource bytes are always read on demand, including in cached modes. + +To rebuild before the next request, construct a provider directly and call +`Refresh` from the application's watcher goroutine. The caller owns goroutine +lifetime, cancellation, and error handling: + +```go +provider, err := skills.NewDirectoryProvider("./skills", &skills.DirectoryOptions{ + Cache: &skills.DirectoryCacheOptions{Preload: true}, +}) +if err != nil { + return err +} +if err := provider.AddTo(server); err != nil { + return err +} +go func() { + for range changed { + if err := provider.Refresh(ctx); err != nil { + logger.Error("refreshing skills", "error", err) + } + } +}() +``` + +Register one filesystem provider per server. Applications that need to combine +multiple filesystems can use an overlay `fs.FS` or aggregate them behind custom +`AddHandlers` handlers. + +Filesystem manifests use `skills.BaselineLimits()` by default. Override publication +caps through `DirectoryOptions.ServerOptions.Limits`; an explicit zero value +removes caps. Structural validation always runs. Catalog validators can add +application checks. Options are copied at construction. + ### Pagination diff --git a/internal/readme/README.src.md b/internal/readme/README.src.md index ce1f6fc3..e0921d4a 100644 --- a/internal/readme/README.src.md +++ b/internal/readme/README.src.md @@ -25,6 +25,11 @@ The SDK consists of several importable packages: - The [`github.com/modelcontextprotocol/go-sdk/oauthex`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/oauthex) package provides extensions to the OAuth protocol, such as ProtectedResourceMetadata. +- The + [`github.com/modelcontextprotocol/go-sdk/skills`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/skills) + package provides opt-in Skills extension support for discovery, directory + browsing, and content verification. See the [client](docs/client.md#skills-extension) + and [server](docs/server.md#skills-extension) examples. The SDK endeavors to implement the full MCP spec. The [`docs/`](/docs/) directory contains feature documentation, mapping the MCP spec to the packages above. diff --git a/mcp/client.go b/mcp/client.go index 44be0ef6..cc358f2b 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -1212,7 +1212,7 @@ var clientMethodInfosLegacyParams = func() map[string]methodInfo { func (cs *ClientSession) sendingMethodInfos() map[string]methodInfo { cs.client.mu.Lock() defer cs.client.mu.Unlock() - return cs.client.sendMethods + return maps.Clone(cs.client.sendMethods) } func (cs *ClientSession) receivingMethodInfos() map[string]methodInfo { @@ -1745,21 +1745,33 @@ func AddSendingCustomMethod[P paramsPtr[PT], R Result, PT any]( // CallCustomMethod sends a custom (non-standard) JSON-RPC method to the // server and decodes the response into R. // -// The method must have been registered on the session's client via -// [AddSendingCustomMethod]. +// The method is registered automatically on first use. An existing registration +// is preserved and must decode into R. Standard MCP methods are rejected. func CallCustomMethod[P paramsPtr[PT], R Result, PT any]( ctx context.Context, cs *ClientSession, method string, params P, ) (R, error) { + var zero R + if cs == nil { + return zero, fmt.Errorf("mcp: CallCustomMethod: nil session") + } + if _, ok := serverMethodInfos[method]; ok { + return zero, fmt.Errorf("mcp: CallCustomMethod: %q shadows a standard MCP method", method) + } c := cs.client c.mu.Lock() - _, ok := c.sendMethods[method] - c.mu.Unlock() + mi, ok := c.sendMethods[method] if !ok { - var zero R - return zero, fmt.Errorf("mcp: CallCustomMethod: %q is not registered; call AddSendingCustomMethod first", method) + mi = methodInfo{newResult: func() Result { + return reflect.New(reflect.TypeFor[R]().Elem()).Interface().(R) + }} + c.sendMethods[method] = mi + } + c.mu.Unlock() + if _, ok := mi.newResult().(R); !ok { + return zero, fmt.Errorf("mcp: CallCustomMethod: %q is registered with a different result type", method) } if cs.usesNewProtocol() { params = injectRequestMeta(cs, params) diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 6fb2d2c7..f87624f3 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -3892,9 +3892,6 @@ func TestCustomMethods(t *testing.T) { t.Cleanup(func() { _ = ss.Close() }) c := NewClient(testImpl, nil) - if err := AddSendingCustomMethod[*searchParams, *searchResult](c, "acme/search"); err != nil { - t.Fatal(err) - } cs, err := c.Connect(ctx, ct, nil) if err != nil { @@ -3927,7 +3924,7 @@ func TestCustomMethods(t *testing.T) { t.Errorf("CallCustomMethod: unexpected total: %d", result2.Total) } - // CallCustomMethod must reject methods that were never registered. + // Unknown methods still return the server's Method Not Found error. if _, err := CallCustomMethod[*searchParams, *searchResult]( ctx, cs, "acme/unregistered", &searchParams{Query: "x"}); err == nil { t.Error("CallCustomMethod: expected error for unregistered method, got nil") @@ -4273,3 +4270,58 @@ func TestResourceSubscriptions_GenGuard(t *testing.T) { t.Fatal("clearing an absent URI reported a deletion") } } + +func TestCallCustomMethodAutomaticRegistration(t *testing.T) { + type params struct{ ParamsBase } + type result struct { + ResultBase + Value string `json:"value"` + } + type otherResult struct{ ResultBase } + server := NewServer(testImpl, nil) + if err := AddReceivingCustomMethod(server, "example/auto", func(context.Context, *ServerSession, *params) (*result, error) { return &result{Value: "ok"}, nil }); err != nil { + t.Fatal(err) + } + client := NewClient(testImpl, nil) + st, ct := NewInMemoryTransports() + ss, err := server.Connect(t.Context(), st, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + cs, err := client.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + r, err := CallCustomMethod[*params, *result](t.Context(), cs, "example/auto", nil) + if err != nil { + t.Error(err) + } else if r.Value != "ok" { + t.Errorf("result = %+v", r) + } + }) + } + wg.Wait() + for _, method := range []string{"tools/call", "resources/read"} { + if _, err := CallCustomMethod[*params, *result](t.Context(), cs, method, nil); err == nil { + t.Errorf("accepted standard method %s", method) + } + } + if _, err := CallCustomMethod[*params, *otherResult](t.Context(), cs, "example/auto", nil); err == nil { + t.Fatal("accepted incompatible result type") + } + // The failed call must preserve the original registration. + if _, err := CallCustomMethod[*params, *result](t.Context(), cs, "example/auto", nil); err != nil { + t.Fatal(err) + } + if err := AddSendingCustomMethod[*params, *result](client, "example/auto"); err != nil { + t.Fatal(err) + } + if _, err := CallCustomMethod[*params, *result](t.Context(), cs, "example/auto", nil); err != nil { + t.Fatal(err) + } +} diff --git a/mcp/server.go b/mcp/server.go index 16aad6a9..937c167a 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -200,6 +200,23 @@ type ServerOptions struct { SupportedProtocolVersions []string } +// AddExtension adds an extension capability to the server. +// +// Extensions should normally be added before the server accepts connections, +// so that clients observe them during capability negotiation. If settings is +// nil, an empty object is advertised. The settings map is copied shallowly; +// nested maps, slices, and pointers must not be modified after the call. +func (s *Server) AddExtension(name string, settings map[string]any) { + s.mu.Lock() + defer s.mu.Unlock() + if s.opts.Capabilities == nil { + s.opts.Capabilities = defaultCapabilities() + } else { + s.opts.Capabilities = s.opts.Capabilities.clone() + } + s.opts.Capabilities.AddExtension(name, maps.Clone(settings)) +} + // NewServer creates a new MCP server. The resulting server has no features: // add features using the various Server.AddXXX methods, and the [AddTool] function. // @@ -657,6 +674,19 @@ func (s *Server) RemoveResourceTemplates(uriTemplates ...string) { s.changeAndNotify(notificationResourceListChanged, func() bool { return s.resourceTemplates.remove(uriTemplates...) }) } +// defaultCapabilities returns the capabilities of a server whose options do not +// set any: only logging. +func defaultCapabilities() *ServerCapabilities { + return &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } +} + +// Capabilities returns a snapshot of the server's effective capabilities, +// including capabilities inferred from registered handlers and resources. +// Changes to the returned value do not affect the server. +func (s *Server) Capabilities() *ServerCapabilities { return s.capabilities() } + func (s *Server) capabilities() *ServerCapabilities { s.mu.Lock() defer s.mu.Unlock() @@ -667,10 +697,7 @@ func (s *Server) capabilities() *ServerCapabilities { // Deep copy the user-provided capabilities to avoid mutation. caps = s.opts.Capabilities.clone() } else { - // SDK defaults: only logging capability. - caps = &ServerCapabilities{ - Logging: &LoggingCapabilities{}, - } + caps = defaultCapabilities() } // Augment with tools capability if tools exist or legacy HasTools is set. diff --git a/mcp/server_test.go b/mcp/server_test.go index 64e7de0d..c5c13006 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -478,6 +478,31 @@ func TestServerCapabilities(t *testing.T) { } } +func TestServerAddExtension(t *testing.T) { + capabilities := &ServerCapabilities{Tools: &ToolCapabilities{}} + server := NewServer(testImpl, &ServerOptions{Capabilities: capabilities}) + settings := map[string]any{"enabled": true} + server.AddExtension("io.example/test", settings) + settings["enabled"] = false + + got := server.capabilities().Extensions["io.example/test"] + want := map[string]any{"enabled": true} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("extension settings mismatch (-want +got):\n%s", diff) + } + if capabilities.Extensions != nil { + t.Fatal("AddExtension mutated the caller's capabilities") + } +} + +func TestServerAddExtensionPreservesDefaultCapabilities(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddExtension("io.example/test", nil) + if server.capabilities().Logging == nil { + t.Fatal("AddExtension removed the default logging capability") + } +} + func TestServerAddResourceTemplate(t *testing.T) { tests := []struct { name string @@ -2692,3 +2717,19 @@ func TestServerUnknownProtocolVersion_NewProtocol(t *testing.T) { }) } } + +func TestServerCapabilitiesSnapshot(t *testing.T) { + server := NewServer(testImpl, nil) + server.AddResource(&Resource{URI: "example://resource", Name: "resource"}, func(context.Context, *ReadResourceRequest) (*ReadResourceResult, error) { return nil, nil }) + server.AddExtension("example/extension", map[string]any{"enabled": true}) + caps := server.Capabilities() + if caps.Resources == nil { + t.Fatal("resource capability was not inferred") + } + caps.Resources.ListChanged = false + delete(caps.Extensions, "example/extension") + fresh := server.Capabilities() + if !fresh.Resources.ListChanged || fresh.Extensions["example/extension"] == nil { + t.Fatal("snapshot modified the server") + } +} diff --git a/skills/client.go b/skills/client.go new file mode 100644 index 00000000..562f7bad --- /dev/null +++ b/skills/client.go @@ -0,0 +1,211 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + "iter" + "maps" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ListOptions controls how list calls handle malformed entries. +type ListOptions struct { + // SkipInvalidSkills omits invalid entries instead of rejecting the page. + // Invalid response envelopes always fail. Skipped entries are reported in + // ListSkillsResult.InvalidSkills and, if set, to OnInvalidSkill. + SkipInvalidSkills bool + // OnInvalidSkill receives each skipped entry, including during All. + OnInvalidSkill func(InvalidSkill) +} + +// List calls skills/list and validates the response without imposing manifest size caps. +// If params is nil, List requests the first page. +func List(ctx context.Context, session *mcp.ClientSession, params *ListSkillsParams, options ...ListOptions) (*ListSkillsResult, error) { + if _, err := requireExtension(session); err != nil { + return nil, err + } + if len(options) > 1 { + return nil, fmt.Errorf("skills: at most one ListOptions is allowed") + } + var opts ListOptions + if len(options) == 1 { + opts = options[0] + } + if params == nil { + params = &ListSkillsParams{} + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ListSkillsParams, *ListSkillsResult](ctx, session, MethodList, &request) + if err != nil { + return nil, err + } + if err := validateEnvelope(session, result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + if err := validateClientList(result, opts); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid skills/list result: %w", err) + } + return result, nil +} + +// Get calls skills/get and validates the response without imposing manifest size caps. +// The URI in params must identify a SKILL.md, whether or not it was listed. +func Get(ctx context.Context, session *mcp.ClientSession, params *GetSkillParams) (*GetSkillResult, error) { + if _, err := requireExtension(session); err != nil { + return nil, err + } + limits := Limits{} + if params == nil || params.URI == "" { + return nil, fmt.Errorf("skills: get requires a URI") + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*GetSkillParams, *GetSkillResult](ctx, session, MethodGet, &request) + if err != nil { + return nil, err + } + if err := validateGetResult(params.URI, result, limits); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid skill: %w", err) + } + if err := validateEnvelope(session, result.ResultType, result.Cacheable, result.cachePresent); err != nil { + return nil, err + } + return result, nil +} + +// ReadDirectory calls resources/directory/read and validates the response. +// The server must advertise directoryRead, and params must specify a directory URI. +func ReadDirectory(ctx context.Context, session *mcp.ClientSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if err := requireDirectoryRead(session); err != nil { + return nil, err + } + if params == nil || params.URI == "" { + return nil, fmt.Errorf("skills: directory read requires a URI") + } + request := *params + request.Meta = maps.Clone(params.Meta) + result, err := mcp.CallCustomMethod[*ReadDirectoryParams, *ReadDirectoryResult](ctx, session, MethodReadDirectory, &request) + if err != nil { + return nil, err + } + if err := ValidateDirectoryResult(params.URI, result); err != nil { + return nil, fmt.Errorf("skills: server returned an invalid directory result: %w", err) + } + if err := validateResultType(session, 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 [List]. +// The session, options, and parameters are captured when All is called. +// The iterator stops after yielding its first error. +func All(ctx context.Context, session *mcp.ClientSession, params *ListSkillsParams, options ...ListOptions) iter.Seq2[*Skill, error] { + options = append([]ListOptions(nil), options...) + var initial ListSkillsParams + if params != nil { + initial = *params + initial.Meta = maps.Clone(params.Meta) + } + return func(yield func(*Skill, error) bool) { + request := initial + allPages(initial.Cursor, func(cursor string) ([]*Skill, string, error) { + request.Cursor = cursor + result, err := List(ctx, session, &request, options...) + if err != nil { + return nil, "", err + } + return result.Skills, result.NextCursor, nil + })(yield) + } +} + +// DirectoryEntries returns an iterator over a directory read, starting at params.Cursor. +// Each page is validated as in [ReadDirectory]. +// The iterator stops after yielding its first error. +func DirectoryEntries(ctx context.Context, session *mcp.ClientSession, params *ReadDirectoryParams) iter.Seq2[*mcp.Resource, error] { + var initial ReadDirectoryParams + if params != nil { + initial = *params + initial.Meta = maps.Clone(params.Meta) + } + return func(yield func(*mcp.Resource, error) bool) { + request := initial + allPages(initial.Cursor, func(cursor string) ([]*mcp.Resource, string, error) { + request.Cursor = cursor + result, err := ReadDirectory(ctx, session, &request) + if err != nil { + return nil, "", err + } + return result.Resources, result.NextCursor, nil + })(yield) + } +} + +// requireExtension reports the settings the server advertised for the Skills +// extension, or an error explaining which capability is missing. +func requireExtension(session *mcp.ClientSession) (map[string]any, error) { + if session == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") + } + init := session.InitializeResult() + if init == nil || init.Capabilities == nil { + return nil, fmt.Errorf("skills: session has no server capabilities") + } + settings, ok := init.Capabilities.Extensions[ExtensionID] + if !ok { + return nil, fmt.Errorf("skills: server does not advertise %s", ExtensionID) + } + m, ok := settings.(map[string]any) + if !ok { + return nil, fmt.Errorf("skills: server advertised invalid extension settings") + } + if init.Capabilities.Resources == nil { + return nil, fmt.Errorf("skills: server does not advertise resources") + } + return m, nil +} + +func requireDirectoryRead(session *mcp.ClientSession) error { + settings, err := requireExtension(session) + if err != nil { + return err + } + if enabled, _ := settings[capabilityDirectoryRead].(bool); !enabled { + return fmt.Errorf("skills: server does not advertise directoryRead") + } + return nil +} + +// usesCaching reports whether the negotiated protocol version requires a result +// type, and with it the cache hints on skills/list and skills/get. +func usesCaching(session *mcp.ClientSession) bool { + return session.InitializeResult().ProtocolVersion >= protocolVersionCaching +} + +func validateResultType(session *mcp.ClientSession, resultType string) error { + if usesCaching(session) && resultType != resultTypeComplete { + return fmt.Errorf("skills: expected complete result, got %q", resultType) + } + return nil +} + +func validateEnvelope(session *mcp.ClientSession, resultType string, cache mcp.Cacheable, cachePresent bool) error { + if err := validateResultType(session, resultType); err != nil { + return err + } + if !usesCaching(session) { + return nil + } + if !cachePresent { + return fmt.Errorf("skills: missing ttlMs or cacheScope") + } + return validateCache(cache) +} diff --git a/skills/directory.go b/skills/directory.go new file mode 100644 index 00000000..18132cd8 --- /dev/null +++ b/skills/directory.go @@ -0,0 +1,636 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "crypto/sha256" + "fmt" + "io/fs" + "mime" + "net/url" + "os" + "path" + "path/filepath" + "slices" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DirectoryCacheOptions configures catalog caching. A zero value builds the +// catalog on the first request and caches it indefinitely. +type DirectoryCacheOptions struct { + // Preload builds the catalog during provider construction, so scan and + // validation errors are returned by the constructor. + Preload bool + // MaxAge makes the catalog expire after this duration. The next request + // rebuilds an expired catalog. A zero value disables expiry. + MaxAge time.Duration + // Invalidate marks the catalog stale. Signals are coalesced and consumed + // when a request arrives, so callers should use a buffered channel. + Invalidate <-chan struct{} +} + +// DirectoryOptions configures a filesystem-backed provider. +type DirectoryOptions struct { + URIPathPrefix string + PageSize int + // Cache enables catalog caching. A nil value rebuilds the catalog for every + // request. + Cache *DirectoryCacheOptions + ServerOptions *ServerOptions + CatalogValidators []func(context.Context, []*Skill) error +} + +// DirectoryProvider discovers and serves skills from a filesystem. +type DirectoryProvider struct { + fsys fs.FS + osPath string + rootName string + prefix []string + pageSize int + serverOptions *ServerOptions + catalogValidators []func(context.Context, []*Skill) error + cacheEnabled bool + preload bool + maxAge time.Duration + invalidate <-chan struct{} + cacheMu sync.Mutex + cached *directoryCatalog + refreshedAt time.Time + stale bool +} + +type catalogMode int + +const ( + catalogPaths catalogMode = iota + catalogMetadata + catalogManifests +) + +type directoryCatalog struct { + skills []*Skill + bySkill map[string]*Skill + files map[string]catalogFile + dirs map[string][]*mcp.Resource + seen map[[2]string]bool + resources []*mcp.Resource +} + +type catalogFile struct { + path string + mimeType string + resource *mcp.Resource +} + +type catalogEntry struct { + path string + info fs.FileInfo +} + +type catalogDigest struct { + digest string + size int64 +} + +type rootedFS struct{ root *os.Root } + +func (f rootedFS) Open(name string) (fs.File, error) { return f.root.Open(name) } + +// NewDirectoryProvider constructs a live provider rooted at dir. +func NewDirectoryProvider(dir string, options *DirectoryOptions) (*DirectoryProvider, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, err + } + info, err := os.Stat(abs) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("skills: %q is not a directory", dir) + } + provider, err := newDirectoryProvider(nil, options) + if err != nil { + return nil, err + } + provider.osPath = abs + provider.rootName = filepath.Base(abs) + return provider.initialize() +} + +// NewFSProvider constructs a filesystem-backed provider using fsys. +func NewFSProvider(fsys fs.FS, options *DirectoryOptions) (*DirectoryProvider, error) { + if fsys == nil { + return nil, fmt.Errorf("skills: nil filesystem") + } + provider, err := newDirectoryProvider(fsys, options) + if err != nil { + return nil, err + } + return provider.initialize() +} + +func newDirectoryProvider(fsys fs.FS, options *DirectoryOptions) (*DirectoryProvider, error) { + p := &DirectoryProvider{fsys: fsys, pageSize: mcp.DefaultPageSize, serverOptions: &ServerOptions{Limits: BaselineLimits()}} + if options != nil { + if options.PageSize < 0 { + return nil, fmt.Errorf("skills: invalid page size %d", options.PageSize) + } + if options.PageSize > 0 { + p.pageSize = options.PageSize + } + if options.Cache != nil { + if options.Cache.MaxAge < 0 { + return nil, fmt.Errorf("skills: invalid cache max age %s", options.Cache.MaxAge) + } + p.cacheEnabled = true + p.preload = options.Cache.Preload + p.maxAge = options.Cache.MaxAge + p.invalidate = options.Cache.Invalidate + } + if options.ServerOptions != nil { + copy := *options.ServerOptions + p.serverOptions = © + } + if err := p.serverOptions.Limits.validate(); err != nil { + return nil, err + } + p.catalogValidators = slices.Clone(options.CatalogValidators) + if options.URIPathPrefix != "" { + p.prefix = strings.Split(strings.Trim(options.URIPathPrefix, "/"), "/") + for _, segment := range p.prefix { + if segment == "" || segment == "." || segment == ".." { + return nil, fmt.Errorf("skills: invalid URI path prefix %q", options.URIPathPrefix) + } + } + } + } + return p, nil +} + +func (p *DirectoryProvider) initialize() (*DirectoryProvider, error) { + if p.preload { + if _, err := p.catalog(context.Background(), catalogManifests); err != nil { + return nil, err + } + } + return p, nil +} + +// AddDirectory discovers and serves skills rooted at dir. +func AddDirectory(server *mcp.Server, dir string, options *DirectoryOptions) error { + provider, err := NewDirectoryProvider(dir, options) + if err != nil { + return err + } + return provider.AddTo(server) +} + +// AddFS discovers and serves skills from fsys. +func AddFS(server *mcp.Server, fsys fs.FS, options *DirectoryOptions) error { + provider, err := NewFSProvider(fsys, options) + if err != nil { + return err + } + return provider.AddTo(server) +} + +// AddTo registers the provider's extension handlers, resource template, and live +// resource listing. Register one filesystem provider per server. +func (p *DirectoryProvider) AddTo(server *mcp.Server) error { + if server == nil { + return fmt.Errorf("skills: nil server") + } + if err := p.serverOptions.Limits.validate(); err != nil { + return err + } + server.AddResourceTemplate(&mcp.ResourceTemplate{ + Name: "skills", Description: "Resources served by the MCP Skills extension.", + URITemplate: "skill://{authority}/{+path}", + }, p.ReadResource) + if err := AddHandlers(server, &Handlers{ + List: p.ListSkills, Get: p.GetSkill, ReadDirectory: p.ReadDirectory, + }, p.serverOptions); err != nil { + return err + } + server.AddReceivingMiddleware(p.listResourcesMiddleware) + return nil +} + +// Refresh rebuilds a cached catalog immediately. +func (p *DirectoryProvider) Refresh(ctx context.Context) error { + if !p.cacheEnabled { + return fmt.Errorf("skills: explicit refresh requires a cached catalog mode") + } + p.cacheMu.Lock() + defer p.cacheMu.Unlock() + return p.refreshLocked(ctx) +} + +// ListSkills returns a current, paginated view of the skills in the filesystem. +func (p *DirectoryProvider) ListSkills(ctx context.Context, _ *mcp.ServerSession, params *ListSkillsParams) (*ListSkillsResult, error) { + catalog, err := p.catalog(ctx, catalogManifests) + if err != nil { + return nil, err + } + cursor := "" + if params != nil { + cursor = params.Cursor + } + page, next, err := paginate(catalog.skills, cursor, p.pageSize, func(skill *Skill) string { return skill.URI }) + if err != nil { + return nil, invalidParams(err.Error()) + } + return &ListSkillsResult{Skills: page, NextCursor: next}, nil +} + +// GetSkill returns the current entry for one skill URI. +func (p *DirectoryProvider) GetSkill(ctx context.Context, _ *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + catalog, err := p.catalog(ctx, catalogManifests) + if err != nil { + return nil, err + } + skill, ok := catalog.bySkill[params.URI] + if !ok { + return nil, invalidParams(fmt.Sprintf("unknown skill %q", params.URI)) + } + return &GetSkillResult{Skill: skill}, nil +} + +// ReadResource resolves and reads a current skill resource. +func (p *DirectoryProvider) ReadResource(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + if req == nil || req.Params == nil { + return nil, invalidParams("missing required uri") + } + catalog, err := p.catalog(ctx, catalogPaths) + if err != nil { + return nil, err + } + file, ok := catalog.files[req.Params.URI] + if !ok { + return nil, mcp.ResourceNotFoundError(req.Params.URI) + } + data, err := p.readFile(file.path) + if err != nil { + if os.IsNotExist(err) { + return nil, mcp.ResourceNotFoundError(req.Params.URI) + } + return nil, err + } + contents := &mcp.ResourceContents{URI: req.Params.URI, MIMEType: file.mimeType} + if textualMIME(file.mimeType) && utf8.Valid(data) { + contents.Text = string(data) + } else { + contents.Blob = data + } + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{contents}}, nil +} + +// ReadDirectory returns a current, paginated view of a directory's direct children. +func (p *DirectoryProvider) ReadDirectory(ctx context.Context, _ *mcp.ServerSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + catalog, err := p.catalog(ctx, catalogMetadata) + if err != nil { + return nil, err + } + children, ok := catalog.dirs[params.URI] + if !ok { + return nil, invalidParams(fmt.Sprintf("unknown directory %q", params.URI)) + } + page, next, err := paginate(children, params.Cursor, p.pageSize, func(resource *mcp.Resource) string { return resource.URI }) + if err != nil { + return nil, invalidParams(err.Error()) + } + return &ReadDirectoryResult{Resources: page, NextCursor: next}, nil +} + +func (p *DirectoryProvider) catalog(ctx context.Context, mode catalogMode) (*directoryCatalog, error) { + if !p.cacheEnabled { + return p.scanCatalog(ctx, mode) + } + p.cacheMu.Lock() + defer p.cacheMu.Unlock() + invalidated := p.invalidated() + p.stale = p.stale || invalidated + if p.cached != nil && !p.stale && (p.maxAge == 0 || time.Since(p.refreshedAt) < p.maxAge) { + return p.cached, nil + } + if err := p.refreshLocked(ctx); err != nil { + return nil, err + } + return p.cached, nil +} + +func (p *DirectoryProvider) refreshLocked(ctx context.Context) error { + p.stale = true + catalog, err := p.scanCatalog(ctx, catalogManifests) + if err != nil { + return err + } + p.cached = catalog + p.refreshedAt = time.Now() + p.stale = false + return nil +} + +func (p *DirectoryProvider) invalidated() bool { + requested := false + for p.invalidate != nil { + select { + case _, ok := <-p.invalidate: + if !ok { + p.invalidate = nil + return requested + } + requested = true + default: + return requested + } + } + return requested +} + +func (p *DirectoryProvider) scanCatalog(ctx context.Context, mode catalogMode) (*directoryCatalog, error) { + manifests := mode == catalogManifests + fsys, closeFS, err := p.openFS() + if err != nil { + return nil, err + } + defer closeFS() + + var entries []catalogEntry + var skillDirs []string + err = fs.WalkDir(fsys, ".", func(name string, dirEntry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if dirEntry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("skills: symlink %q is not supported", name) + } + info, err := dirEntry.Info() + if err != nil { + return err + } + if !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("skills: non-regular file %q is not supported", name) + } + entries = append(entries, catalogEntry{path: name, info: info}) + if !info.IsDir() && path.Base(name) == "SKILL.md" { + skillDirs = append(skillDirs, path.Dir(name)) + } + return nil + }) + if err != nil { + return nil, err + } + slices.Sort(skillDirs) + catalog := &directoryCatalog{ + bySkill: make(map[string]*Skill), + files: make(map[string]catalogFile), + dirs: make(map[string][]*mcp.Resource), + seen: make(map[[2]string]bool), + } + digests := make(map[string]catalogDigest) + frontmatters := make(map[string]Frontmatter) + for _, skillDir := range skillDirs { + if err := ctx.Err(); err != nil { + return nil, err + } + var frontmatter Frontmatter + if mode >= catalogMetadata || skillDir == "." && p.rootName == "" { + data, err := fs.ReadFile(fsys, path.Join(skillDir, "SKILL.md")) + if err != nil { + return nil, err + } + frontmatter, err = parseFrontmatter(data) + if err != nil { + return nil, fmt.Errorf("skills: parsing %s/SKILL.md: %w", skillDir, err) + } + frontmatters[path.Join(skillDir, "SKILL.md")] = frontmatter + } + segments := slices.Clone(p.prefix) + if skillDir == "." { + physicalName := p.rootName + if physicalName == "" { + physicalName, _ = frontmatter["name"].(string) + } + segments = append(segments, physicalName) + } else { + segments = append(segments, strings.Split(skillDir, "/")...) + } + rootURI, err := skillURI(segments) + if err != nil { + return nil, err + } + var resources []*Resource + limits := p.serverOptions.Limits + var totalSize int64 + for _, item := range entries { + if err := ctx.Err(); err != nil { + return nil, err + } + if item.info.IsDir() || !withinDir(skillDir, item.path) { + continue + } + if manifests { + if limits.MaxResourcesPerSkill > 0 && len(resources) == limits.MaxResourcesPerSkill { + return nil, fmt.Errorf("skill %q exceeds the resource limit of %d", rootURI, limits.MaxResourcesPerSkill) + } + if limits.MaxTotalSize > 0 && item.info.Size() > 0 && totalSize > limits.MaxTotalSize-item.info.Size() { + return nil, fmt.Errorf("skill %q exceeds the total size limit of %d", rootURI, limits.MaxTotalSize) + } + } + rel := item.path + if skillDir != "." { + rel = strings.TrimPrefix(item.path, skillDir+"/") + } + resourceURI := rootURI + "/" + escapePath(rel) + mimeType := resourceMIME(item.path, rel) + file := catalogFile{path: item.path, mimeType: mimeType} + if mode >= catalogMetadata { + file.resource = &mcp.Resource{ + URI: resourceURI, Name: path.Base(item.path), MIMEType: mimeType, Size: item.info.Size(), + } + } + catalog.files[resourceURI] = file + if !manifests { + continue + } + totalSize += item.info.Size() + digest, ok := digests[item.path] + if !ok { + data, err := fs.ReadFile(fsys, item.path) + if err != nil { + return nil, err + } + sum := sha256.Sum256(data) + digest = catalogDigest{digest: fmt.Sprintf("sha256:%x", sum), size: int64(len(data))} + digests[item.path] = digest + } + resources = append(resources, &Resource{ + URI: resourceURI, + Digest: digest.digest, + Size: digest.size, + }) + } + p.addDirectories(catalog, skillDir, rootURI, entries) + if manifests { + slices.SortFunc(resources, func(a, b *Resource) int { return strings.Compare(a.URI, b.URI) }) + skill := &Skill{URI: rootURI + "/SKILL.md", Frontmatter: frontmatter, Resources: StaticResources(resources...)} + if err := ValidateSkillWithLimits(skill, p.serverOptions.Limits); err != nil { + return nil, err + } + if _, exists := catalog.bySkill[skill.URI]; exists { + return nil, fmt.Errorf("skills: duplicate skill URI %q", skill.URI) + } + catalog.skills = append(catalog.skills, skill) + catalog.bySkill[skill.URI] = skill + } + } + for _, file := range catalog.files { + if file.resource == nil { + continue + } + if frontmatter, ok := frontmatters[file.path]; ok { + file.resource.Name, _ = frontmatter["name"].(string) + file.resource.Description, _ = frontmatter["description"].(string) + } + catalog.resources = append(catalog.resources, file.resource) + } + slices.SortFunc(catalog.resources, func(a, b *mcp.Resource) int { return strings.Compare(a.URI, b.URI) }) + slices.SortFunc(catalog.skills, func(a, b *Skill) int { return strings.Compare(a.URI, b.URI) }) + for uri := range catalog.dirs { + for i, resource := range catalog.dirs[uri] { + if file, ok := catalog.files[resource.URI]; ok && file.resource != nil { + catalog.dirs[uri][i] = file.resource + } + } + slices.SortFunc(catalog.dirs[uri], func(a, b *mcp.Resource) int { return strings.Compare(a.URI, b.URI) }) + } + if manifests { + for _, validate := range p.catalogValidators { + if validate != nil { + if err := validate(ctx, catalog.skills); err != nil { + return nil, err + } + } + } + } + return catalog, nil +} + +func (p *DirectoryProvider) addDirectories(catalog *directoryCatalog, skillDir, rootURI string, entries []catalogEntry) { + // Kept as a separate pass so empty directories are represented too. + for _, item := range entries { + if item.path == skillDir || !withinDir(skillDir, item.path) { + continue + } + rel := item.path + if skillDir != "." { + rel = strings.TrimPrefix(item.path, skillDir+"/") + } + parts := strings.Split(rel, "/") + parentURI := rootURI + for i, part := range parts { + childURI := parentURI + "/" + url.PathEscape(part) + isDirectory := i < len(parts)-1 || item.info.IsDir() + key := [2]string{parentURI, childURI} + if !catalog.seen[key] { + mimeType := "" + var size int64 + if isDirectory { + mimeType = "inode/directory" + } else { + mimeType = mime.TypeByExtension(path.Ext(part)) + size = item.info.Size() + if part == "SKILL.md" { + mimeType = "text/markdown" + } + } + catalog.dirs[parentURI] = append(catalog.dirs[parentURI], &mcp.Resource{ + URI: childURI, Name: part, MIMEType: mimeType, Size: size, + }) + catalog.seen[key] = true + } + if isDirectory { + if _, ok := catalog.dirs[childURI]; !ok { + catalog.dirs[childURI] = []*mcp.Resource{} + } + parentURI = childURI + } + } + } + if _, ok := catalog.dirs[rootURI]; !ok { + catalog.dirs[rootURI] = []*mcp.Resource{} + } +} + +func (p *DirectoryProvider) openFS() (fs.FS, func(), error) { + if p.osPath == "" { + return p.fsys, func() {}, nil + } + root, err := os.OpenRoot(p.osPath) + if err != nil { + return nil, nil, err + } + return rootedFS{root}, func() { _ = root.Close() }, nil +} + +func (p *DirectoryProvider) readFile(name string) ([]byte, error) { + fsys, closeFS, err := p.openFS() + if err != nil { + return nil, err + } + defer closeFS() + return fs.ReadFile(fsys, name) +} + +func skillURI(segments []string) (string, error) { + if len(segments) == 0 || segments[0] == "" { + return "", fmt.Errorf("skills: cannot construct a skill URI without a path") + } + u := &url.URL{Scheme: "skill", Host: segments[0]} + if len(segments) > 1 { + u.Path = "/" + strings.Join(segments[1:], "/") + } + return strings.TrimSuffix(u.String(), "/"), nil +} + +func escapePath(name string) string { + parts := strings.Split(name, "/") + for i, part := range parts { + parts[i] = url.PathEscape(part) + } + return strings.Join(parts, "/") +} + +func withinDir(dir, name string) bool { + return dir == "." || name == dir || strings.HasPrefix(name, dir+"/") +} + +func textualMIME(mimeType string) bool { + return strings.HasPrefix(mimeType, "text/") || strings.HasPrefix(mimeType, "application/json") || strings.HasPrefix(mimeType, "application/xml") || strings.HasPrefix(mimeType, "application/yaml") +} + +func resourceMIME(filename, relative string) string { + if relative == "SKILL.md" { + return "text/markdown" + } + return mime.TypeByExtension(path.Ext(filename)) +} diff --git a/skills/directory_example_test.go b/skills/directory_example_test.go new file mode 100644 index 00000000..56125cd2 --- /dev/null +++ b/skills/directory_example_test.go @@ -0,0 +1,28 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills_test + +import ( + "log" + "testing/fstest" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +func ExampleAddFS() { + server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) + skillFS := fstest.MapFS{ + "demo/SKILL.md": { + Data: []byte("---\nname: demo\ndescription: Demonstrates an in-memory skill.\n---\n# Demo\n"), + }, + "demo/references/guide.md": {Data: []byte("# Guide\n")}, + } + if err := skills.AddFS(server, skillFS, &skills.DirectoryOptions{ + Cache: &skills.DirectoryCacheOptions{Preload: true}, + }); err != nil { + log.Fatal(err) + } +} diff --git a/skills/directory_test.go b/skills/directory_test.go new file mode 100644 index 00000000..fee81195 --- /dev/null +++ b/skills/directory_test.go @@ -0,0 +1,376 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "testing" + "testing/fstest" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestDirectoryProviderLiveAndPaginated(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", map[string]string{ + "references/ONE.md": "one", + "references/TWO.md": "two", + }) + writeSkill(t, dir, "beta", "Beta skill.", nil) + + server := mcp.NewServer(&mcp.Implementation{Name: "skills-test", Version: "v1"}, nil) + if err := AddDirectory(server, dir, &DirectoryOptions{PageSize: 1}); err != nil { + t.Fatal(err) + } + client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1"}, nil) + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + clientSession, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = clientSession.Close() }) + + params := &ListSkillsParams{} + var uris []string + for skill, err := range All(ctx, clientSession, params) { + if err != nil { + t.Fatal(err) + } + uris = append(uris, skill.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://beta/SKILL.md"}; !slices.Equal(uris, want) { + t.Fatalf("All() = %v, want %v", uris, want) + } + if params.Cursor != "" { + t.Fatalf("All mutated caller cursor to %q", params.Cursor) + } + + writeSkill(t, dir, "gamma", "Gamma skill.", nil) + result, err := Get(ctx, clientSession, &GetSkillParams{URI: "skill://gamma/SKILL.md"}) + if err != nil { + t.Fatal(err) + } + if result.Skill.Frontmatter["name"] != "gamma" { + t.Fatalf("Get() returned %v", result.Skill.Frontmatter) + } + read, err := clientSession.ReadResource(ctx, &mcp.ReadResourceParams{URI: "skill://gamma/SKILL.md"}) + if err != nil { + t.Fatal(err) + } + if len(read.Contents) != 1 || read.Contents[0].Text == "" { + t.Fatalf("ReadResource() = %+v", read) + } + var liveURIs []string + for skill, err := range All(ctx, clientSession, nil) { + if err != nil { + t.Fatal(err) + } + liveURIs = append(liveURIs, skill.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://beta/SKILL.md", "skill://gamma/SKILL.md"}; !slices.Equal(liveURIs, want) { + t.Fatalf("live All() = %v, want %v", liveURIs, want) + } + + directoryParams := &ReadDirectoryParams{URI: "skill://alpha"} + var children []string + for resource, err := range DirectoryEntries(ctx, clientSession, directoryParams) { + if err != nil { + t.Fatal(err) + } + children = append(children, resource.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://alpha/references"}; !slices.Equal(children, want) { + t.Fatalf("DirectoryEntries() = %v, want %v", children, want) + } + if directoryParams.Cursor != "" { + t.Fatalf("DirectoryEntries mutated caller cursor to %q", directoryParams.Cursor) + } + + if _, err := List(ctx, clientSession, &ListSkillsParams{Cursor: "%%%"}); err == nil { + t.Fatal("List accepted an invalid cursor") + } + if _, err := ReadDirectory(ctx, clientSession, &ReadDirectoryParams{URI: "skill://alpha/SKILL.md"}); err == nil { + t.Fatal("ReadDirectory accepted a file URI") + } + if err := os.RemoveAll(filepath.Join(dir, "beta")); err != nil { + t.Fatal(err) + } + liveURIs = nil + for skill, err := range All(ctx, clientSession, nil) { + if err != nil { + t.Fatal(err) + } + liveURIs = append(liveURIs, skill.URI) + } + if want := []string{"skill://alpha/SKILL.md", "skill://gamma/SKILL.md"}; !slices.Equal(liveURIs, want) { + t.Fatalf("All() after removal = %v, want %v", liveURIs, want) + } +} + +func TestCatalogValidatorSeesAllSkills(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + writeSkill(t, dir, "beta", "Beta skill.", nil) + provider, err := NewDirectoryProvider(dir, &DirectoryOptions{ + PageSize: 1, + CatalogValidators: []func(context.Context, []*Skill) error{ + func(_ context.Context, skills []*Skill) error { + if len(skills) > 1 { + return fmt.Errorf("too many skills") + } + return nil + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}); err == nil { + t.Fatal("catalog validator did not see skills beyond the first page") + } +} + +func TestFSProviderRootSkill(t *testing.T) { + provider, err := NewFSProvider(fstest.MapFS{ + "SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n")}, + "guide.md": {Data: []byte("guide")}, + }, nil) + if err != nil { + t.Fatal(err) + } + result, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + if len(result.Skills) != 1 || result.Skills[0].URI != "skill://demo/SKILL.md" { + t.Fatalf("ListSkills() = %+v", result.Skills) + } + directory, err := provider.ReadDirectory(context.Background(), nil, &ReadDirectoryParams{URI: "skill://demo"}) + if err != nil { + t.Fatal(err) + } + if got := len(directory.Resources); got != 2 { + t.Fatalf("ReadDirectory() returned %d resources, want 2", got) + } +} + +func TestDirectoryProviderCatalogModes(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + + invalidate := make(chan struct{}, 1) + startup, err := NewDirectoryProvider(dir, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{Preload: true, Invalidate: invalidate}, + }) + if err != nil { + t.Fatal(err) + } + lazy, err := NewDirectoryProvider(dir, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{MaxAge: time.Hour}, + }) + if err != nil { + t.Fatal(err) + } + writeSkill(t, dir, "beta", "Beta skill.", nil) + + list := func(provider *DirectoryProvider) *ListSkillsResult { + t.Helper() + result, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + return result + } + if got := len(list(startup).Skills); got != 1 { + t.Fatalf("startup snapshot contains %d skills, want 1", got) + } + if got := len(list(lazy).Skills); got != 2 { + t.Fatalf("lazy catalog contains %d skills, want 2", got) + } + writeSkill(t, dir, "gamma", "Gamma skill.", nil) + invalidate <- struct{}{} + if got := len(list(startup).Skills); got != 3 { + t.Fatalf("invalidated startup catalog contains %d skills, want 3", got) + } + lazy.cacheMu.Lock() + lazy.refreshedAt = time.Time{} + lazy.cacheMu.Unlock() + if got := len(list(lazy).Skills); got != 3 { + t.Fatalf("refreshed lazy catalog contains %d skills, want 3", got) + } +} + +func TestDirectoryProviderExplicitRefresh(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + provider, err := NewDirectoryProvider(dir, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{Preload: true}, + }) + if err != nil { + t.Fatal(err) + } + writeSkill(t, dir, "beta", "Beta skill.", nil) + if err := provider.Refresh(context.Background()); err != nil { + t.Fatal(err) + } + result, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + if got := len(result.Skills); got != 2 { + t.Fatalf("refreshed catalog contains %d skills, want 2", got) + } +} + +func TestDirectoryProviderRejectsNegativeCacheMaxAge(t *testing.T) { + fsys := fstest.MapFS{ + "SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n")}, + } + options := &DirectoryOptions{Cache: &DirectoryCacheOptions{MaxAge: -time.Second}} + if _, err := NewFSProvider(fsys, options); err == nil { + t.Fatalf("NewFSProvider accepted options %+v", options) + } +} + +func TestDirectoryProviderRejectsSymlinks(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", nil) + if err := os.Symlink(filepath.Join(dir, "alpha", "SKILL.md"), filepath.Join(dir, "alpha", "linked.md")); err != nil { + t.Skipf("creating symlink: %v", err) + } + provider, err := NewDirectoryProvider(dir, nil) + if err != nil { + t.Fatal(err) + } + if _, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}); err == nil { + t.Fatal("provider accepted a symlink") + } +} + +func TestDirectoryProviderNestedSkillsAndEmptyDirectories(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "parent", "Parent skill.", map[string]string{ + "child/SKILL.md": "---\nname: child\ndescription: Child skill.\n---\n# Child\n", + "child/info.txt": "child info", + }) + if err := os.Mkdir(filepath.Join(dir, "parent", "empty"), 0o755); err != nil { + t.Fatal(err) + } + provider, err := NewDirectoryProvider(dir, nil) + if err != nil { + t.Fatal(err) + } + list, err := provider.ListSkills(context.Background(), nil, &ListSkillsParams{}) + if err != nil { + t.Fatal(err) + } + if len(list.Skills) != 2 { + t.Fatalf("got %d skills, want 2", len(list.Skills)) + } + parent := list.Skills[0] + if parent.URI != "skill://parent/SKILL.md" { + t.Fatalf("first skill is %q", parent.URI) + } + resources, static := parent.Resources.List() + if !static || len(resources) != 3 { + t.Fatalf("parent resources = %v, static %v", resources, static) + } + empty, err := provider.ReadDirectory(context.Background(), nil, &ReadDirectoryParams{URI: "skill://parent/empty"}) + if err != nil { + t.Fatal(err) + } + if empty.Resources == nil || len(empty.Resources) != 0 { + t.Fatalf("empty directory result = %+v", empty.Resources) + } +} + +func TestDirectoryProviderHashesNestedFilesOnce(t *testing.T) { + fsys := &countingFS{ + FS: fstest.MapFS{ + "parent/SKILL.md": {Data: []byte("---\nname: parent\ndescription: Parent skill.\n---\n")}, + "parent/child/SKILL.md": {Data: []byte("---\nname: child\ndescription: Child skill.\n---\n")}, + "parent/child/info.txt": {Data: []byte("child info")}, + }, + opens: make(map[string]int), + } + if _, err := NewFSProvider(fsys, &DirectoryOptions{ + Cache: &DirectoryCacheOptions{Preload: true}, + }); err != nil { + t.Fatal(err) + } + if got := fsys.opens["parent/child/info.txt"]; got != 1 { + t.Fatalf("nested file was opened %d times, want 1", got) + } +} + +func writeSkill(t *testing.T, root, name, description string, files map[string]string) { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("---\nname: %s\ndescription: %s\n---\n# %s\n", name, description, name) + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + for filename, content := range files { + full := filepath.Join(dir, filepath.FromSlash(filename)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +type countingFS struct { + fs.FS + opens map[string]int +} + +func (f *countingFS) Open(name string) (fs.File, error) { + f.opens[name]++ + return f.FS.Open(name) +} + +func TestDirectoryOptionsLimitOwnership(t *testing.T) { + files := fstest.MapFS{"demo/SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo\n---\n")}} + options := &DirectoryOptions{ServerOptions: &ServerOptions{Limits: BaselineLimits()}} + provider, err := NewFSProvider(files, options) + if err != nil { + t.Fatal(err) + } + options.ServerOptions.Limits = Limits{MaxTotalSize: -1} + server := mcp.NewServer(&mcp.Implementation{Name: "fs", Version: "1"}, nil) + if err := provider.AddTo(server); err != nil { + t.Fatal(err) + } + if server.Capabilities().Resources == nil { + t.Fatal("provider did not advertise resources before skills registration") + } + result, err := List(t.Context(), connectSkills(t, server, protocolVersionCaching), nil) + if err != nil || len(result.Skills) != 1 { + t.Fatalf("List = %+v, %v", result, err) + } + if _, err := NewFSProvider(files, options); err == nil { + t.Fatal("accepted negative publication limits") + } + if err := provider.AddTo(nil); err == nil { + t.Fatal("accepted nil server") + } +} diff --git a/skills/example_test.go b/skills/example_test.go new file mode 100644 index 00000000..52f13d8f --- /dev/null +++ b/skills/example_test.go @@ -0,0 +1,101 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills_test + +import ( + "context" + "crypto/sha256" + "fmt" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/skills" +) + +func ExampleAddHandlers() { + // !+skillsserver + server := mcp.NewServer(&mcp.Implementation{Name: "skills", Version: "v1.0.0"}, nil) + const uri = "skill://greeting/SKILL.md" + const content = "---\nname: greeting\ndescription: Greet the user.\n---\n# Greeting\nSay hello to the user.\n" + entry := &skills.Skill{ + URI: uri, + Frontmatter: skills.Frontmatter{ + "name": "greeting", "description": "Greet the user.", + }, + Resources: skills.StaticResources(&skills.Resource{ + URI: uri, Digest: fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(content))), Size: int64(len(content)), + }), + } + + server.AddResource(&mcp.Resource{ + URI: uri, Name: "greeting", Description: "Greet the user.", MIMEType: "text/markdown", + }, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{ + URI: uri, MIMEType: "text/markdown", Text: content, + }}}, nil + }) + err := skills.AddHandlers(server, &skills.Handlers{ + List: func(_ context.Context, _ *mcp.ServerSession, params *skills.ListSkillsParams) (*skills.ListSkillsResult, error) { + page, next, err := skills.PaginateSkills([]*skills.Skill{entry}, params.Cursor, 0) + return &skills.ListSkillsResult{Skills: page, NextCursor: next}, err + }, + Get: func(_ context.Context, _ *mcp.ServerSession, params *skills.GetSkillParams) (*skills.GetSkillResult, error) { + if params.URI != entry.URI { + return nil, nil + } + return &skills.GetSkillResult{Skill: entry}, nil + }, + }, nil) + if err != nil { + log.Fatal(err) + } + // !-skillsserver + + // !+skillsclient + ctx := context.Background() + client := mcp.NewClient(&mcp.Implementation{Name: "skills-client", Version: "v1.0.0"}, nil) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + log.Fatal(err) + } + defer serverSession.Close() + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + log.Fatal(err) + } + defer session.Close() + + for skill, err := range skills.All(ctx, session, nil) { + if err != nil { + log.Fatal(err) + } + fmt.Println(skill.URI, skill.Frontmatter["description"]) + } + // !-skillsclient + + // !+skillsverify + result, err := skills.Get(ctx, session, &skills.GetSkillParams{URI: "skill://greeting/SKILL.md"}) + if err != nil { + log.Fatal(err) + } + resource, err := session.ReadResource(ctx, &mcp.ReadResourceParams{URI: result.Skill.URI}) + if err != nil { + log.Fatal(err) + } + if len(resource.Contents) != 1 || resource.Contents[0] == nil || resource.Contents[0].URI != result.Skill.URI || resource.Contents[0].Blob != nil { + log.Fatal("expected one text resource for SKILL.md") + } + if err := skills.VerifySkillMD(result.Skill, []byte(resource.Contents[0].Text)); err != nil { + log.Fatal(err) + } + fmt.Println("verified", result.Skill.URI) + // !-skillsverify + + // Output: + // skill://greeting/SKILL.md Greet the user. + // verified skill://greeting/SKILL.md +} diff --git a/skills/limits_test.go b/skills/limits_test.go new file mode 100644 index 00000000..ec850c19 --- /dev/null +++ b/skills/limits_test.go @@ -0,0 +1,169 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "fmt" + "math" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// oversized returns a skill that exceeds exactly one baseline dimension. +func oversized(kind string) *Skill { + return skillWith(func(s *Skill) { + entries, _ := s.Resources.List() + if kind == "count" { + for i := range BaselineLimits().MaxResourcesPerSkill { + entries = append(entries, &Resource{URI: fmt.Sprintf("skill://demo/%d.txt", i), Digest: testDigest, Size: 1}) + } + } else { + entries[0].Size = BaselineLimits().MaxTotalSize + 1 + } + s.Resources = StaticResources(entries...) + }) +} + +// checkLimitCalls exercises every client entry point that validates a manifest. +func checkLimitCalls(t *testing.T, client *mcp.ClientSession, skill *Skill, wantOK bool) { + t.Helper() + _, listErr := List(t.Context(), client, nil) + _, getErr := Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + var allErr error + count := 0 + for _, err := range All(t.Context(), client, nil) { + if err != nil { + allErr = err + break + } + count++ + } + for method, err := range map[string]error{"List": listErr, "Get": getErr, "All": allErr} { + if (err == nil) != wantOK { + t.Errorf("%s: error = %v, want success = %v", method, err, wantOK) + } + } + if wantOK && count != 1 { + t.Errorf("All yielded %d skills, want 1", count) + } +} + +// TestValidateSkillWithLimits covers the limit matrix by calling validation +// directly. Limits are SDK policy rather than protocol, so the conformance suite +// cannot observe them; TestServerLimits checks that requests reach this code. +func TestValidateSkillWithLimits(t *testing.T) { + baseline := BaselineLimits() + for _, kind := range []string{"count", "bytes"} { + skill := oversized(kind) + for _, test := range []struct { + name string + limits Limits + wantOK bool + }{ + {"zero value imposes no caps", Limits{}, true}, + {"baseline", baseline, false}, + {"count only", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill}, kind == "bytes"}, + {"bytes only", Limits{MaxTotalSize: baseline.MaxTotalSize}, kind == "count"}, + {"raised count", Limits{MaxResourcesPerSkill: baseline.MaxResourcesPerSkill + 1}, true}, + {"raised bytes", Limits{MaxTotalSize: baseline.MaxTotalSize + 1}, true}, + {"negative count", Limits{MaxResourcesPerSkill: -1}, false}, + {"negative bytes", Limits{MaxTotalSize: -1}, false}, + } { + t.Run(kind+"/"+test.name, func(t *testing.T) { + if err := ValidateSkillWithLimits(skill, test.limits); (err == nil) != test.wantOK { + t.Fatalf("ValidateSkillWithLimits() = %v, want success = %v", err, test.wantOK) + } + }) + } + if err := ValidateSkill(skill); err != nil { + t.Errorf("%s: ValidateSkill imposed a manifest cap: %v", kind, err) + } + } + + for _, test := range []struct { + name string + skill *Skill + limits Limits + wantOK bool + }{ + // Structural validation runs whatever the limits are. + {"unlimited still validates structure", skillWith(func(s *Skill) { s.Frontmatter["name"] = "BAD" }), Limits{}, false}, + // A dynamic manifest has no countable resources, so caps do not apply. + {"dynamic is exempt", skillWith(func(s *Skill) { s.Resources = DynamicResources() }), Limits{MaxResourcesPerSkill: 1, MaxTotalSize: 1}, true}, + // Sizes are compared against the remaining budget so the sum cannot overflow. + {"total size cannot overflow", skillWith(func(s *Skill) { + s.Resources = StaticResources( + &Resource{URI: "skill://demo/SKILL.md", Digest: testDigest, Size: math.MaxInt64}, + &Resource{URI: "skill://demo/helper.txt", Digest: testDigest, Size: 1}) + }), Limits{MaxTotalSize: math.MaxInt64}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if err := ValidateSkillWithLimits(test.skill, test.limits); (err == nil) != test.wantOK { + t.Fatalf("ValidateSkillWithLimits() = %v, want success = %v", err, test.wantOK) + } + }) + } +} + +func TestServerLimits(t *testing.T) { + for _, kind := range []string{"count", "bytes"} { + for _, limited := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/limited=%v", kind, limited), func(t *testing.T) { + server := testServer() + var options *ServerOptions + if limited { + options = &ServerOptions{Limits: BaselineLimits()} + } + skill := oversized(kind) + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + checkLimitCalls(t, connectSkills(t, server, protocolVersionCaching), skill, !limited) + }) + } + } + for _, limits := range []Limits{{MaxResourcesPerSkill: -1}, {MaxTotalSize: -1}} { + if err := AddHandlers(testServer(), fixedHandlers(testSkill()), &ServerOptions{Limits: limits}); err == nil { + t.Fatal("accepted negative server limits") + } + } +} + +func TestServerLimitOwnership(t *testing.T) { + server := testServer() + options := &ServerOptions{Limits: BaselineLimits()} + skill := testSkill() + if err := AddHandlers(server, fixedHandlers(skill), options); err != nil { + t.Fatal(err) + } + options.Limits = Limits{MaxTotalSize: -1} + checkLimitCalls(t, connectSkills(t, server, protocolVersionCaching), skill, true) +} + +func TestClientSupportsBaselineAndLargerSkills(t *testing.T) { + for _, version := range []string{"2025-11-25", protocolVersionCaching} { + for _, kind := range []string{"count", "bytes"} { + for _, above := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/above=%v", version, kind, above), func(t *testing.T) { + skill := oversized(kind) + if !above { + entries, _ := skill.Resources.List() + if kind == "count" { + skill.Resources = StaticResources(entries[:512]...) + } else { + entries[0].Size = 16 << 20 + } + } + server := testServer() + if err := AddHandlers(server, fixedHandlers(skill), nil); err != nil { + t.Fatal(err) + } + checkLimitCalls(t, connectSkills(t, server, version), skill, true) + }) + } + } + } +} diff --git a/skills/pagination.go b/skills/pagination.go new file mode 100644 index 00000000..ed596cc7 --- /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 00000000..8053f484 --- /dev/null +++ b/skills/protocol_test.go @@ -0,0 +1,491 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// wantRPCCode reports whether err is a JSON-RPC error with the given code. +func wantRPCCode(t *testing.T, context string, err error, code int64) *jsonrpc.Error { + t.Helper() + var rpc *jsonrpc.Error + if !errors.As(err, &rpc) { + t.Errorf("%s: error = %v, want a JSON-RPC error with code %d", context, err, code) + return nil + } + if rpc.Code != code { + t.Errorf("%s: error code = %d (%v), want %d", context, rpc.Code, err, code) + } + return rpc +} + +// TestSpecErrorScenarios covers wire behavior that the SEP-2640 server +// conformance scenarios (sep-2640-skills-*) also check. Once those scenarios +// run in CI against ./conformance/skills-server, this test can be deleted. +func TestSpecErrorScenarios(t *testing.T) { + server := testServer() + skill := testSkill() + handlers := fixedHandlers(skill) + handlers.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + page, next, err := PaginateSkills([]*Skill{skill}, p.Cursor, 0) + return &ListSkillsResult{Skills: page, NextCursor: next}, err + } + handlers.ReadDirectory = func(_ context.Context, _ *mcp.ServerSession, p *ReadDirectoryParams) (*ReadDirectoryResult, error) { + switch p.URI { + case "skill://demo": + page, next, err := PaginateDirectoryResources([]*mcp.Resource{{URI: skill.URI, Name: "demo"}}, p.Cursor, 0) + return &ReadDirectoryResult{Resources: page, NextCursor: next}, err + case "skill://empty": + return &ReadDirectoryResult{}, nil + default: + return nil, nil // unknown directory + } + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + + t.Run("get", func(t *testing.T) { + for _, test := range []struct { + name, uri string + }{ + {"unknown skill", "skill://unknown/SKILL.md"}, + {"malformed uri", "malformed"}, + {"not a SKILL.md", "skill://demo/other.md"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := Get(t.Context(), client, &GetSkillParams{URI: test.uri}) + wantRPCCode(t, test.uri, err, jsonrpc.CodeInvalidParams) + }) + } + }) + + t.Run("directory", func(t *testing.T) { + if _, err := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://missing"}); err != nil { + wantRPCCode(t, "unknown directory", err, jsonrpc.CodeInvalidParams) + } else { + t.Error("unknown directory accepted") + } + // An empty directory is a success with an empty, non-null array. + empty, err := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://empty"}) + if err != nil || empty.Resources == nil || len(empty.Resources) != 0 { + t.Errorf("empty directory = %+v, %v", empty, err) + } + }) + + t.Run("invalid cursor", func(t *testing.T) { + _, listErr := List(t.Context(), client, &ListSkillsParams{Cursor: "%"}) + _, directoryErr := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://demo", Cursor: "%"}) + for method, err := range map[string]error{MethodList: listErr, MethodReadDirectory: directoryErr} { + wantRPCCode(t, method, err, jsonrpc.CodeInvalidParams) + } + }) +} + +// TestHandlerErrorMapping covers how AddHandlers translates a Go handler's +// return values into JSON-RPC errors. This mapping is SDK behavior and is not +// visible to the conformance suite. +func TestHandlerErrorMapping(t *testing.T) { + coded := &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "invalid request", Data: json.RawMessage(`{"reason":"test"}`)} + for _, test := range []struct { + name string + err error + invalid bool // the handler also returns a structurally invalid result + code int64 + wantData bool + }{ + {name: "plain error becomes internal", err: errors.New("backend unavailable"), code: jsonrpc.CodeInternalError}, + {name: "invalid result becomes internal", invalid: true, code: jsonrpc.CodeInternalError}, + {name: "coded error is preserved", err: coded, code: jsonrpc.CodeInvalidParams, wantData: true}, + {name: "wrapped coded error is preserved", err: fmt.Errorf("handler: %w", coded), code: jsonrpc.CodeInvalidParams, wantData: true}, + } { + t.Run(test.name, func(t *testing.T) { + server := testServer() + skill := testSkill() + if test.invalid || test.err == nil { + skill.Frontmatter["description"] = false // fails validateSkill + } + handlers := &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, test.err + }, + Get: func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) { + return &GetSkillResult{Skill: skill}, test.err + }, + ReadDirectory: func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) { + return &ReadDirectoryResult{Resources: []*mcp.Resource{{URI: skill.URI}}}, test.err + }, + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + _, listErr := List(t.Context(), client, nil) + _, getErr := Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + _, directoryErr := ReadDirectory(t.Context(), client, &ReadDirectoryParams{URI: "skill://demo"}) + for method, err := range map[string]error{MethodList: listErr, MethodGet: getErr, MethodReadDirectory: directoryErr} { + rpc := wantRPCCode(t, method, err, test.code) + if rpc != nil && test.wantData && string(rpc.Data) != string(coded.Data) { + t.Errorf("%s: error data = %s, want %s", method, rpc.Data, coded.Data) + } + } + }) + } + + t.Run("nil list result", func(t *testing.T) { + server := testServer() + handlers := fixedHandlers(testSkill()) + handlers.List = func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return nil, nil + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + _, err := List(t.Context(), connectSkills(t, server, protocolVersionCaching), nil) + wantRPCCode(t, MethodList, err, jsonrpc.CodeInternalError) + }) +} + +// TestClientRequiresCapabilities checks the guards that run before a request is +// sent. A server that advertises neither the extension nor directoryRead must be +// rejected locally rather than called. +func TestClientRequiresCapabilities(t *testing.T) { + ctx := t.Context() + if _, err := List(ctx, nil, nil); err == nil { + t.Error("client without a session accepted List") + } + // A server with no Skills handlers does not advertise the extension. + if _, err := List(ctx, connectSkills(t, testServer(), protocolVersionCaching), nil); err == nil { + t.Error("client called a server that does not advertise the extension") + } + // Handlers without ReadDirectory advertise the extension but not directoryRead. + server := testServer() + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + if _, err := ReadDirectory(ctx, client, &ReadDirectoryParams{URI: "skill://demo"}); err == nil { + t.Error("client called resources/directory/read without the capability") + } + for _, params := range []*GetSkillParams{nil, {}} { + if _, err := Get(ctx, client, params); err == nil { + t.Errorf("Get accepted %+v", params) + } + } +} + +// TestResponsesAndParamsAreNotMutated checks the ownership contract: handler +// results and caller parameters are copied, never modified in place, even under +// concurrent use across two protocol versions. +func TestResponsesAndParamsAreNotMutated(t *testing.T) { + server := testServer() + skill := testSkill() + list := &ListSkillsResult{Skills: []*Skill{skill}, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}} + get := &GetSkillResult{Skill: skill, ResultBase: mcp.ResultBase{Meta: mcp.Meta{"owner": "app"}}, Cacheable: mcp.Cacheable{TTLMs: 123, CacheScope: "private"}} + dir := &ReadDirectoryResult{} + handlers := &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return list, nil + }, + Get: func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) { return get, nil }, + ReadDirectory: func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) { + return dir, nil + }, + } + before := map[string][]byte{} + for name, result := range map[string]any{"list": list, "get": get, "dir": dir} { + before[name], _ = json.Marshal(result) + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + // The legacy version omits the cache hints; the modern one requires them. + clients := map[string]*mcp.ClientSession{"2025-11-25": connectSkills(t, server, "2025-11-25"), protocolVersionCaching: connectSkills(t, server, protocolVersionCaching)} + var wg sync.WaitGroup + for version, client := range clients { + modern := version == protocolVersionCaching + wg.Go(func() { + for range 4 { + params := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"owner": "caller"}}} + listResult, err := List(t.Context(), client, params) + if err != nil { + t.Error(err) + return + } + if !reflect.DeepEqual(params.Meta, mcp.Meta{"owner": "caller"}) { + t.Errorf("%s: request metadata mutated", version) + } + // Re-encoding a received legacy result need not preserve wire + // omission; the decoder tracks actual presence separately. + if listResult.cachePresent != modern { + t.Errorf("%s: list cachePresent = %v", version, listResult.cachePresent) + } + getResult, err := Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + if err != nil { + t.Error(err) + return + } + if getResult.cachePresent != modern { + t.Errorf("%s: get cachePresent = %v", version, getResult.cachePresent) + } + if modern && (getResult.TTLMs != 123 || getResult.CacheScope != "private") { + t.Errorf("%s: cache hints lost", version) + } + if _, err := ReadDirectory(t.Context(), client, &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 = Get(t.Context(), client, &GetSkillParams{URI: skill.URI}) + } else { + _, err = List(t.Context(), client, nil) + } + if err == nil { + t.Fatalf("accepted malformed %s response", test.method) + } + }) + } +} + +// TestIteratorOwnership checks that All captures the session and parameters when +// it is called, and that the returned sequence is reusable. +func TestIteratorOwnership(t *testing.T) { + server := testServer() + one := testSkill() + two := skillWith(func(s *Skill) { + s.URI, s.Frontmatter, s.Resources = "skill://other/SKILL.md", Frontmatter{"name": "other", "description": "Other"}, DynamicResources() + }) + handlers := fixedHandlers(one) + handlers.List = func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*ListSkillsResult, error) { + page, next, err := PaginateSkills([]*Skill{two, one}, p.Cursor, 1) + return &ListSkillsResult{Skills: page, NextCursor: next}, err + } + if err := AddHandlers(server, handlers, nil); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + params := &ListSkillsParams{ParamsBase: mcp.ParamsBase{Meta: mcp.Meta{"key": "value"}}} + seq := All(t.Context(), client, params) + for range 2 { + count := 0 + for _, err := range seq { + if err != nil { + t.Fatal(err) + } + count++ + } + if count != 2 { + t.Fatalf("iterator yielded %d skills across pages, want 2", count) + } + } + if params.Cursor != "" || !reflect.DeepEqual(params.Meta, mcp.Meta{"key": "value"}) { + t.Fatal("iterator mutated its parameters") + } +} + +func TestListSkipsInvalidSkills(t *testing.T) { + valid, _ := json.Marshal(testSkill()) + for _, version := range []string{"2025-11-25", protocolVersionCaching} { + for _, bad := range []string{ + `null`, `42`, `{"uri":"skill://broken/SKILL.md","frontmatter":{},"resources":"dynamic"}`, + `{"uri":"skill://broken/SKILL.md","frontmatter":{"name":"broken","description":"Broken"},"resources":[{"uri":"skill://broken/SKILL.md","digest":"` + testDigest + `"}]}`, + `{"uri":"skill://broken/SKILL.md","frontmatter":{"name":"broken","description":"Broken"},"resources":"invalid"}`, + } { + t.Run(version+"/"+bad, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*rawResult, error) { + envelope := `,"resultType":"complete","ttlMs":0,"cacheScope":"public"` + if version < protocolVersionCaching { + envelope = "" + } + if p.Cursor == "next" { + return &rawResult{data: json.RawMessage(fmt.Sprintf(`{"skills":[%s]%s}`, valid, envelope))}, nil + } + return &rawResult{data: json.RawMessage(fmt.Sprintf(`{"skills":[%s,%s],"nextCursor":"next"%s}`, bad, valid, envelope))}, nil + }); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, version) + if result, err := List(t.Context(), client, nil); err == nil || result != nil { + t.Fatalf("strict List = %+v, %v", result, err) + } + var rejected []InvalidSkill + options := ListOptions{SkipInvalidSkills: true, OnInvalidSkill: func(s InvalidSkill) { rejected = append(rejected, s) }} + result, err := List(t.Context(), client, nil, options) + if err != nil { + t.Fatal(err) + } + if len(result.Skills) != 1 || result.Skills[0].URI != testSkill().URI || result.NextCursor != "next" || len(result.InvalidSkills) != 1 || result.InvalidSkills[0].Index != 0 || result.InvalidSkills[0].Err == nil || len(rejected) != 1 { + t.Fatalf("filtered result = %+v, diagnostics = %+v", result, rejected) + } + rejected = nil + count := 0 + for skill, err := range All(t.Context(), client, nil, options) { + if err != nil { + t.Fatal(err) + } + if skill.URI != testSkill().URI { + t.Fatal(skill.URI) + } + count++ + } + if count != 2 || len(rejected) != 1 { + t.Fatalf("All returned %d valid, %d invalid", count, len(rejected)) + } + }) + } + } +} + +func TestSkipRejectsInvalidEnvelopesAndDuplicateIdentities(t *testing.T) { + valid, _ := json.Marshal(testSkill()) + for _, body := range []string{ + `{"skills":null,"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, + `{"skills":[],"resultType":"complete","cacheScope":"public"}`, + `{"skills":[],"resultType":"input_required","ttlMs":0,"cacheScope":"public"}`, + `{"skills":{},"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, + fmt.Sprintf(`{"skills":[%s,%s],"nextCursor":"next","resultType":"complete","ttlMs":0,"cacheScope":"public"}`, valid, valid), + } { + t.Run(body, func(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*rawResult, error) { + return &rawResult{data: json.RawMessage(body)}, nil + }); err != nil { + t.Fatal(err) + } + result, err := List(t.Context(), connectSkills(t, server, protocolVersionCaching), nil, ListOptions{SkipInvalidSkills: true}) + if strings.Contains(body, `"nextCursor"`) { + if err != nil || len(result.Skills) != 0 || len(result.InvalidSkills) != 2 || result.NextCursor != "next" { + t.Fatalf("duplicate identities = %+v, %v", result, err) + } + } else if err == nil { + t.Fatalf("accepted invalid envelope: %+v", result) + } + }) + } +} + +func TestAddHandlersRequiresResources(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err == nil { + t.Fatal("registered without resources") + } + if _, ok := server.Capabilities().Extensions[ExtensionID]; ok { + t.Fatal("failed registration advertised extension") + } + server.AddResourceTemplate(&mcp.ResourceTemplate{Name: "skills", URITemplate: "skill://{authority}/{+path}"}, func(context.Context, *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { return nil, nil }) + if err := AddHandlers(server, fixedHandlers(testSkill()), nil); err != nil { + t.Fatal(err) + } + checkLimitCalls(t, connectSkills(t, server, protocolVersionCaching), testSkill(), true) +} + +func TestAllContinuesAfterEntirelyInvalidPage(t *testing.T) { + server := testServer() + server.AddExtension(ExtensionID, nil) + valid, _ := json.Marshal(testSkill()) + var cursors []string + if err := mcp.AddReceivingCustomMethod(server, MethodList, func(_ context.Context, _ *mcp.ServerSession, p *ListSkillsParams) (*rawResult, error) { + cursors = append(cursors, p.Cursor) + if p.Cursor == "" { + return &rawResult{data: json.RawMessage(`{"skills":[null],"nextCursor":"next","resultType":"complete","ttlMs":0,"cacheScope":"public"}`)}, nil + } + return &rawResult{data: json.RawMessage(fmt.Sprintf(`{"skills":[%s],"resultType":"complete","ttlMs":0,"cacheScope":"public"}`, valid))}, nil + }); err != nil { + t.Fatal(err) + } + client := connectSkills(t, server, protocolVersionCaching) + var rejected []InvalidSkill + options := []ListOptions{{SkipInvalidSkills: true, OnInvalidSkill: func(s InvalidSkill) { rejected = append(rejected, s) }}} + params := &ListSkillsParams{} + seq := All(t.Context(), client, params, options...) + options[0].SkipInvalidSkills = false + count := 0 + for skill, err := range seq { + if err != nil { + t.Fatal(err) + } + if skill.URI != testSkill().URI { + t.Fatal(skill.URI) + } + count++ + } + if count != 1 || len(rejected) != 1 || !reflect.DeepEqual(cursors, []string{"", "next"}) || params.Cursor != "" { + t.Fatalf("count=%d rejected=%+v cursors=%v params=%+v", count, rejected, cursors, params) + } +} diff --git a/skills/resources.go b/skills/resources.go new file mode 100644 index 00000000..8a9c7603 --- /dev/null +++ b/skills/resources.go @@ -0,0 +1,107 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "fmt" + "maps" + "slices" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ListResources returns a current, paginated listing of skill files. It reads +// frontmatter for resource metadata without hashing supporting files. Directory +// entries are available separately through ReadDirectory. +func (p *DirectoryProvider) ListResources(ctx context.Context, req *mcp.ListResourcesRequest) (*mcp.ListResourcesResult, error) { + catalog, err := p.catalog(ctx, catalogMetadata) + if err != nil { + return nil, err + } + cursor := "" + if req != nil && req.Params != nil { + cursor = req.Params.Cursor + } + page, next, err := paginate(catalog.resources, cursor, p.pageSize, func(r *mcp.Resource) string { return r.URI }) + if err != nil { + return nil, invalidParams(err.Error()) + } + return &mcp.ListResourcesResult{ + Resources: page, NextCursor: next, + Cacheable: mcp.Cacheable{CacheScope: "private"}, + }, nil +} + +func (p *DirectoryProvider) listResourcesMiddleware(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method != "resources/list" { + return next(ctx, method, req) + } + byURI := make(map[string]*mcp.Resource) + + // Merge the underlying listing before paginating: its opaque cursors and + // page boundaries need not match this provider's live URI-ordered catalog. + request := *req.(*mcp.ListResourcesRequest) + params := mcp.ListResourcesParams{} + if request.Params != nil { + params = *request.Params + params.Meta = maps.Clone(params.Meta) + } + cursor := params.Cursor + params.Cursor = "" + request.Params = ¶ms + seen := make(map[string]bool) + var result mcp.ListResourcesResult + for { + if err := ctx.Err(); err != nil { + return nil, err + } + response, err := next(ctx, method, &request) + if err != nil { + return nil, err + } + listed, ok := response.(*mcp.ListResourcesResult) + if !ok || listed == nil { + return nil, fmt.Errorf("skills: resources/list returned an invalid result") + } + if params.Cursor == "" { + result = *listed + } + for _, resource := range listed.Resources { + if resource == nil { + return nil, fmt.Errorf("skills: resources/list returned a nil resource") + } + // Exact registrations also take precedence over templates on reads. + byURI[resource.URI] = resource + } + if listed.NextCursor == "" { + break + } + if seen[listed.NextCursor] { + return nil, fmt.Errorf("skills: resources/list repeated a cursor") + } + seen[listed.NextCursor] = true + params.Cursor = listed.NextCursor + } + catalog, err := p.catalog(ctx, catalogMetadata) + if err != nil { + return nil, err + } + for _, resource := range catalog.resources { + if _, exists := byURI[resource.URI]; !exists { + byURI[resource.URI] = resource + } + } + result.Resources, result.NextCursor, err = PaginateDirectoryResources(slices.Collect(maps.Values(byURI)), cursor, p.pageSize) + if err != nil { + return nil, invalidParams(err.Error()) + } + // Filesystem changes are discovered on request, without notifications. + // Do not inherit a TTL intended only for the registered resources. + result.Cacheable = mcp.Cacheable{CacheScope: "private"} + return &result, nil + } +} diff --git a/skills/resources_test.go b/skills/resources_test.go new file mode 100644 index 00000000..8873c0cc --- /dev/null +++ b/skills/resources_test.go @@ -0,0 +1,237 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + "testing/fstest" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func connectResourceClient(t *testing.T, server *mcp.Server) *mcp.ClientSession { + t.Helper() + client := mcp.NewClient(&mcp.Implementation{Name: "resources-test", Version: "v1"}, nil) + ct, st := mcp.NewInMemoryTransports() + ss, err := server.Connect(t.Context(), st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + cs, err := client.Connect(t.Context(), ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +func TestResourceListingLiveAndMerged(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "alpha", "Alpha skill.", map[string]string{"reference notes.md": "notes"}) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v1"}, &mcp.ServerOptions{PageSize: 1}) + readStatic := func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: req.Params.URI, Text: "registered"}}}, nil + } + server.AddResource(&mcp.Resource{URI: "config://one", Name: "one"}, readStatic) + server.AddResource(&mcp.Resource{URI: "config://two", Name: "two"}, readStatic) + if err := AddDirectory(server, dir, &DirectoryOptions{PageSize: 1}); err != nil { + t.Fatal(err) + } + cs := connectResourceClient(t, server) + list := func() []*mcp.Resource { + t.Helper() + params := &mcp.ListResourcesParams{} + var resources []*mcp.Resource + for range 20 { + page, err := cs.ListResources(t.Context(), params) + if err != nil { + t.Fatal(err) + } + if len(page.Resources) > 1 { + t.Fatalf("page has %d resources, want at most 1", len(page.Resources)) + } + resources = append(resources, page.Resources...) + if page.NextCursor == "" { + return resources + } + params.Cursor = page.NextCursor + } + t.Fatal("resources/list did not terminate") + return nil + } + check := func(want []string, description string) { + t.Helper() + var uris []string + for _, resource := range list() { + uris = append(uris, resource.URI) + if resource.URI == "skill://alpha/SKILL.md" { + if resource.Name != "alpha" || resource.Description != description || resource.MIMEType != "text/markdown" { + t.Fatalf("incorrect SKILL.md metadata: %+v", resource) + } + } + } + if !slices.Equal(uris, want) { + t.Fatalf("resources/list = %v, want %v", uris, want) + } + } + check([]string{"config://one", "config://two", "skill://alpha/SKILL.md", "skill://alpha/reference%20notes.md"}, "Alpha skill.") + writeSkill(t, dir, "beta", "Beta skill.", nil) + writeSkill(t, dir, "alpha", "Updated description.", nil) + if err := os.Remove(filepath.Join(dir, "alpha", "reference notes.md")); err != nil { + t.Fatal(err) + } + server.RemoveResources("config://one") + check([]string{"config://two", "skill://alpha/SKILL.md", "skill://beta/SKILL.md"}, "Updated description.") + + // Exact registrations win for both listing and reading, even when added later. + server.AddResource(&mcp.Resource{URI: "skill://beta/SKILL.md", Name: "override"}, readStatic) + resources := list() + if len(resources) != 3 || resources[2].Name != "override" { + t.Fatalf("duplicate URI not resolved to exact registration: %+v", resources) + } + read, err := cs.ReadResource(t.Context(), &mcp.ReadResourceParams{URI: "skill://beta/SKILL.md"}) + if err != nil || read.Contents[0].Text != "registered" { + t.Fatalf("ReadResource = %v, %v", read, err) + } + if _, err := cs.ListResources(t.Context(), &mcp.ListResourcesParams{Cursor: "%%%"}); err == nil { + t.Fatal("accepted invalid cursor") + } +} + +func TestResourceAndDirectoryMetadataWithoutHashing(t *testing.T) { + files := fstest.MapFS{ + "parent/SKILL.md": {Data: []byte("---\nname: parent\ndescription: Parent.\n---\n")}, + "parent/child/SKILL.md": {Data: []byte("---\nname: child\ndescription: Child.\n---\n")}, + "parent/child/info.bin": {Data: []byte{0xff, 0, 1}}, + "unpublished.txt": {Data: []byte("outside skills")}, + } + fsys := &countingFS{FS: files, opens: make(map[string]int)} + p, err := NewFSProvider(fsys, &DirectoryOptions{PageSize: 1}) + if err != nil { + t.Fatal(err) + } + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "v1"}, nil) + if err := p.AddTo(server); err != nil { + t.Fatal(err) + } + cs := connectResourceClient(t, server) + for _, description := range []string{"Child.", "Updated child."} { + files["parent/child/SKILL.md"].Data = fmt.Appendf(nil, "---\nname: child\ndescription: %s\n---\n", description) + var uris []string + for resource, err := range cs.Resources(t.Context(), nil) { + if err != nil { + t.Fatal(err) + } + uris = append(uris, resource.URI) + } + want := []string{"skill://parent/SKILL.md", "skill://parent/child/SKILL.md", "skill://parent/child/info.bin"} + if !slices.Equal(uris, want) { + t.Fatalf("listed %v, want %v", uris, want) + } + for resource, err := range DirectoryEntries(t.Context(), cs, &ReadDirectoryParams{URI: "skill://parent/child"}) { + if err != nil { + t.Fatal(err) + } + if resource.URI == "skill://parent/child/SKILL.md" && (resource.Name != "child" || resource.Description != description) { + t.Fatalf("directory metadata = %+v", resource) + } + } + } + if got := fsys.opens["parent/child/info.bin"]; got != 0 { + t.Fatalf("listing read supporting bytes %d times", got) + } + delete(files, "parent/child/info.bin") + files["parent/child/new.txt"] = &fstest.MapFile{Data: []byte("new")} + var children []string + for resource, err := range DirectoryEntries(t.Context(), cs, &ReadDirectoryParams{URI: "skill://parent/child"}) { + if err != nil { + t.Fatal(err) + } + children = append(children, resource.URI) + } + if want := []string{"skill://parent/child/SKILL.md", "skill://parent/child/new.txt"}; !slices.Equal(children, want) { + t.Fatalf("live directory = %v, want %v", children, want) + } +} + +func TestCatalogRefreshFailureRetries(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(fmt.Sprintf("explicit=%v", explicit), func(t *testing.T) { + files := fstest.MapFS{"demo/SKILL.md": {Data: []byte("---\nname: demo\ndescription: Demo.\n---\n")}} + invalidate := make(chan struct{}, 1) + p, err := NewFSProvider(files, &DirectoryOptions{Cache: &DirectoryCacheOptions{Preload: true, Invalidate: invalidate}}) + if err != nil { + t.Fatal(err) + } + previous := p.cached + files["new/SKILL.md"] = &fstest.MapFile{Data: []byte("incomplete write")} + if explicit { + err = p.Refresh(t.Context()) + } else { + invalidate <- struct{}{} + _, err = p.ListResources(t.Context(), nil) + } + if err == nil || p.cached != previous { + t.Fatalf("failed refresh = %v; previous cache retained = %v", err, p.cached == previous) + } + if _, err := p.ListResources(t.Context(), nil); err == nil { + t.Fatal("silently reused stale cache after a failed refresh") + } + files["new/SKILL.md"].Data = []byte("---\nname: new\ndescription: New.\n---\n") + result, err := p.ListResources(t.Context(), nil) + if err != nil || len(result.Resources) != 2 { + t.Fatalf("retry without another invalidation = %v, %v", result, err) + } + }) + } +} + +func TestResourceListingMiddlewareComposition(t *testing.T) { + p, err := NewFSProvider(fstest.MapFS{}, nil) + if err != nil { + t.Fatal(err) + } + params := &mcp.ListResourcesParams{Meta: mcp.Meta{"caller": "original"}} + req := &mcp.ListResourcesRequest{Params: params, Extra: &mcp.RequestExtra{}} + upstream := &mcp.ListResourcesResult{Meta: mcp.Meta{"source": "registered"}, Cacheable: mcp.Cacheable{TTLMs: 60000, CacheScope: "public"}} + handler := p.listResourcesMiddleware(func(_ context.Context, _ string, got mcp.Request) (mcp.Result, error) { + if got.GetExtra() != req.Extra || got.GetSession() != req.Session { + t.Fatal("lost request context") + } + got.GetParams().GetMeta()["caller"] = "modified" + return upstream, nil + }) + result, err := handler(t.Context(), "resources/list", req) + if err != nil { + t.Fatal(err) + } + listed := result.(*mcp.ListResourcesResult) + if listed.TTLMs != 0 || listed.CacheScope != "private" || listed.Meta["source"] != "registered" { + t.Fatalf("merged cache policy and metadata = %+v", listed) + } + if upstream.TTLMs != 60000 || params.Meta["caller"] != "original" || params.Cursor != "" { + t.Fatal("mutated upstream result or caller params") + } + upstreamErr := errors.New("access denied") + handler = p.listResourcesMiddleware(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, upstreamErr + }) + if _, err := handler(t.Context(), "resources/list", req); !errors.Is(err, upstreamErr) { + t.Fatalf("lost upstream error: %v", err) + } + handler = p.listResourcesMiddleware(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.ListResourcesResult{NextCursor: "loop"}, nil + }) + if _, err := handler(t.Context(), "resources/list", req); err == nil { + t.Fatal("accepted a repeated upstream cursor") + } +} diff --git a/skills/server.go b/skills/server.go new file mode 100644 index 00000000..1d32e50a --- /dev/null +++ b/skills/server.go @@ -0,0 +1,204 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "context" + "errors" + "fmt" + "maps" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ListSkillsHandler handles skills/list requests. +type ListSkillsHandler func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) + +// GetSkillHandler handles skills/get. Return (nil, nil) for an unknown skill; +// [AddHandlers] translates it to JSON-RPC Invalid Params. Explicit JSON-RPC errors +// are preserved; other errors become Internal Error. +type GetSkillHandler func(context.Context, *mcp.ServerSession, *GetSkillParams) (*GetSkillResult, error) + +// ReadDirectoryHandler handles resources/directory/read. Return (nil, nil) if +// the URI does not exist or is not a directory. An empty directory has a non-nil result. +type ReadDirectoryHandler func(context.Context, *mcp.ServerSession, *ReadDirectoryParams) (*ReadDirectoryResult, error) + +// ServerOptions configures the per-skill limits. Protocol validation always +// runs; applications can perform additional checks in their handlers. +type ServerOptions struct { + // Limits optionally bounds static manifests; zero fields impose no caps. + // Use [BaselineLimits] to opt into the spec's interoperability baseline. + // AddHandlers copies the value during registration. + Limits Limits +} + +// Handlers contains the Skills extension handlers. +// List and Get are required; ReadDirectory is optional. +type Handlers struct { + List ListSkillsHandler + Get GetSkillHandler + ReadDirectory ReadDirectoryHandler +} + +// AddHandlers registers the Skills extension. Register skill content separately +// with [mcp.Server.AddResource] or [mcp.Server.AddResourceTemplate] before calling +// AddHandlers. These registrations also advertise +// the required resources capability. Configure the server before connecting. +// +// If options is nil, no manifest caps apply. Handlers own pagination; use +// [PaginateSkills] or [PaginateDirectoryResources] to paginate in-memory slices. +// AddHandlers supplies resultType and default cache hints for the request's +// protocol version. See [ListSkillsResult] and [GetSkillResult]. +// +// Options and handler functions are copied. Results are validated without +// modifying handler-owned values; handlers must synchronize their own state. +func AddHandlers(server *mcp.Server, handlers *Handlers, options *ServerOptions) error { + if server == nil { + return fmt.Errorf("skills: nil server") + } + if handlers == nil || handlers.List == nil || handlers.Get == nil { + return fmt.Errorf("skills: list and get handlers are required") + } + if server.Capabilities().Resources == nil { + return fmt.Errorf("skills: register resources or advertise the resources capability before AddHandlers") + } + h := *handlers + var limits Limits + if options != nil { + limits = options.Limits + } + if err := limits.validate(); err != nil { + return err + } + if err := mcp.AddReceivingCustomMethod(server, MethodList, + func(ctx context.Context, session *mcp.ServerSession, params *ListSkillsParams) (*ListSkillsResult, error) { + if params == nil { + params = &ListSkillsParams{} + } + result, err := h.List(ctx, session, params) + if err != nil { + return nil, internalError(err) + } + if result == nil { + return nil, internalError(fmt.Errorf("skills/list handler returned a nil result")) + } + out := *result + out.Meta = maps.Clone(result.Meta) + if out.Skills == nil { + out.Skills = []*Skill{} + } + if err := validateListResult(&out, limits); err != nil { + return nil, internalError(fmt.Errorf("skills/list handler returned an invalid result: %w", err)) + } + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { + return nil, internalError(err) + } + return &out, nil + }); err != nil { + return err + } + if err := mcp.AddReceivingCustomMethod(server, MethodGet, + func(ctx context.Context, session *mcp.ServerSession, params *GetSkillParams) (*GetSkillResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + if _, err := skillNameFromURI(params.URI); err != nil { + return nil, invalidParams(err.Error()) + } + result, err := h.Get(ctx, session, params) + if err != nil { + return nil, internalError(err) + } + if result == nil || result.Skill == nil { + return nil, invalidParams("unknown skill: " + params.URI) + } + if err := validateGetResult(params.URI, result, limits); err != nil { + return nil, internalError(fmt.Errorf("skills/get handler returned an invalid result: %w", err)) + } + out := *result + out.Meta = maps.Clone(result.Meta) + if err := stampEnvelope(params.Meta, &out.ResultType, &out.omitCache, &out.Cacheable); err != nil { + return nil, internalError(err) + } + return &out, nil + }); err != nil { + return err + } + settings := map[string]any{} + if h.ReadDirectory != nil { + if err := mcp.AddReceivingCustomMethod(server, MethodReadDirectory, + func(ctx context.Context, session *mcp.ServerSession, params *ReadDirectoryParams) (*ReadDirectoryResult, error) { + if params == nil { + return nil, invalidParams("missing required uri") + } + if _, err := parseDirectoryURI(params.URI); err != nil { + return nil, invalidParams(err.Error()) + } + result, err := h.ReadDirectory(ctx, session, params) + if err != nil { + return nil, internalError(err) + } + if result == nil { + return nil, invalidParams("unknown directory: " + params.URI) + } + out := *result + out.Meta = maps.Clone(result.Meta) + if out.Resources == nil { + out.Resources = []*mcp.Resource{} + } + if err := ValidateDirectoryResult(params.URI, &out); err != nil { + return nil, internalError(fmt.Errorf("resources/directory/read handler returned an invalid result: %w", err)) + } + 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 00000000..263846f6 --- /dev/null +++ b/skills/skills_test.go @@ -0,0 +1,263 @@ +// 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) *mcp.ClientSession { + t.Helper() + httpServer := httptest.NewServer(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{Stateless: version >= protocolVersionCaching})) + t.Cleanup(httpServer.Close) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, &mcp.ClientSessionOptions{ProtocolVersion: version}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func fixedHandlers(skill *Skill) *Handlers { + return &Handlers{ + List: func(context.Context, *mcp.ServerSession, *ListSkillsParams) (*ListSkillsResult, error) { + return &ListSkillsResult{Skills: []*Skill{skill}}, nil + }, + Get: func(_ context.Context, _ *mcp.ServerSession, p *GetSkillParams) (*GetSkillResult, error) { + if p.URI != skill.URI { + return nil, nil + } + return &GetSkillResult{Skill: skill}, nil + }, + } +} + +func TestResourcesJSON(t *testing.T) { + for _, test := range []struct { + name string + resources Resources + want string // "" means Marshal must fail + }{ + {name: "static", resources: StaticResources(&Resource{URI: "skill://a/SKILL.md", Digest: testDigest, Size: 1}), + want: `[{"uri":"skill://a/SKILL.md","digest":"` + testDigest + `","size":1}]`}, + {name: "empty static", resources: StaticResources(), want: `[]`}, + {name: "dynamic", resources: DynamicResources(), want: `"dynamic"`}, + {name: "unset", resources: Resources{}}, + } { + t.Run("encode/"+test.name, func(t *testing.T) { + data, err := json.Marshal(test.resources) + if (err == nil) != (test.want != "") { + t.Fatalf("Marshal() = %s, %v, want %q", data, err, test.want) + } + if err == nil && string(data) != test.want { + t.Fatalf("Marshal() = %s, want %s", data, test.want) + } + }) + } + + for _, test := range []struct { + data string + wantErr, wantDyn bool + }{ + {data: `"dynamic"`, wantDyn: true}, + {data: `"dynamic"`, wantDyn: true}, + {data: ` "dynamic" `, wantDyn: true}, + {data: `[]`}, + {data: `"other"`, wantErr: true}, + {data: `null`, wantErr: true}, + {data: `42`, wantErr: true}, + {data: `{}`, wantErr: true}, + } { + t.Run("decode/"+test.data, func(t *testing.T) { + var resources Resources + err := json.Unmarshal([]byte(test.data), &resources) + if (err != nil) != test.wantErr { + t.Fatalf("Unmarshal() error = %v, want error = %v", err, test.wantErr) + } + if err == nil && resources.IsDynamic() != test.wantDyn { + t.Fatalf("IsDynamic() = %v, want %v", resources.IsDynamic(), test.wantDyn) + } + }) + } +} + +func TestPaginate(t *testing.T) { + cursor := func(uri string) string { return base64.RawURLEncoding.EncodeToString([]byte(uri)) } + a, b, c := &Skill{URI: "skill://a/SKILL.md"}, &Skill{URI: "skill://b/SKILL.md"}, &Skill{URI: "skill://c/SKILL.md"} + unsorted := []*Skill{c, a, b} + + for _, test := range []struct { + name string + input []*Skill + cursor string + pageSize int + want []string + wantNext string + wantErr bool + }{ + {name: "sorts by uri", input: unsorted, pageSize: 2, want: []string{a.URI, b.URI}, wantNext: cursor(b.URI)}, + {name: "resumes after cursor", input: unsorted, cursor: cursor(b.URI), pageSize: 2, want: []string{c.URI}}, + {name: "cursor past the end", input: unsorted, cursor: cursor("skill://z/SKILL.md"), pageSize: 2}, + {name: "default page size", input: unsorted, want: []string{a.URI, b.URI, c.URI}}, + {name: "empty input", pageSize: 2}, + {name: "negative page size", input: unsorted, pageSize: -1, wantErr: true}, + {name: "invalid cursor", input: unsorted, cursor: "%", pageSize: 1, wantErr: true}, + {name: "nil entry", input: []*Skill{nil}, pageSize: 1, wantErr: true}, + {name: "duplicate uri", input: []*Skill{a, a}, pageSize: 1, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + page, next, err := PaginateSkills(test.input, test.cursor, test.pageSize) + if (err != nil) != test.wantErr { + t.Fatalf("PaginateSkills() error = %v, want error = %v", err, test.wantErr) + } + if err != nil { + return + } + var got []string + for _, skill := range page { + got = append(got, skill.URI) + } + if !slices.Equal(got, test.want) || next != test.wantNext || page == nil { + t.Fatalf("PaginateSkills() = %v, %q, want %v, %q", got, next, test.want, test.wantNext) + } + }) + } + if unsorted[0] != c { + t.Error("PaginateSkills modified its input") + } + + // PaginateDirectoryResources shares paginate; check only its key function. + page, next, err := PaginateDirectoryResources([]*mcp.Resource{{URI: "skill://demo/b"}, {URI: "skill://demo/a"}}, "", 1) + if err != nil || len(page) != 1 || page[0].URI != "skill://demo/a" || next != cursor("skill://demo/a") { + t.Fatalf("PaginateDirectoryResources() = %v, %q, %v", page, next, err) + } + if _, _, err := PaginateDirectoryResources([]*mcp.Resource{nil}, "", 1); err == nil { + t.Error("PaginateDirectoryResources accepted a nil resource") + } +} + +func TestAllPages(t *testing.T) { + t.Run("reusable", func(t *testing.T) { + calls := 0 + seq := allPages("", func(cursor string) ([]string, string, error) { + calls++ + if cursor == "" { + return []string{"a"}, "next", nil + } + return []string{"b"}, "", nil + }) + for range 2 { + var got []string + for value, err := range seq { + if err != nil { + t.Fatal(err) + } + got = append(got, value) + } + if !slices.Equal(got, []string{"a", "b"}) { + t.Fatalf("iteration yielded %v", got) + } + } + if calls != 4 { + t.Fatalf("re-iterating made %d fetches, want 4", calls) + } + }) + + // Each of these must stop the walk after a bounded number of fetches: a + // server repeating a cursor would otherwise loop forever, and a consumer + // breaking out must not trigger another fetch. + for _, test := range []struct { + name, initial, next string + fetchErr bool + stopEarly bool + wantCalls int + }{ + {name: "repeated cursor", next: "repeat", wantCalls: 2}, + {name: "cursor equals the initial cursor", initial: "start", next: "start", wantCalls: 1}, + {name: "fetch error", fetchErr: true, wantCalls: 1}, + {name: "consumer stops early", next: "next", stopEarly: true, wantCalls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + calls, failed := 0, false + for _, err := range allPages(test.initial, func(string) ([]int, string, error) { + calls++ + if test.fetchErr { + return nil, "", fmt.Errorf("boom") + } + return []int{1}, test.next, nil + }) { + if err != nil { + failed = true + } + if err != nil || test.stopEarly { + break + } + } + if failed == test.stopEarly { + t.Errorf("yielded an error = %v, want %v", failed, !test.stopEarly) + } + if calls != test.wantCalls { + t.Errorf("made %d fetches, want %d", calls, test.wantCalls) + } + }) + } +} + +func TestResultCacheFieldsOmittedForLegacyProtocol(t *testing.T) { + for name, result := range map[string]json.Marshaler{ + "list": &ListSkillsResult{Skills: []*Skill{}, omitCache: true}, + "get": &GetSkillResult{Skill: testSkill(), omitCache: true}, + } { + t.Run(name, func(t *testing.T) { + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + t.Fatal(err) + } + for _, key := range []string{"ttlMs", "cacheScope", "resultType"} { + if _, ok := fields[key]; ok { + t.Errorf("legacy result contains %s: %s", key, data) + } + } + }) + } +} diff --git a/skills/types.go b/skills/types.go new file mode 100644 index 00000000..2ebaabfe --- /dev/null +++ b/skills/types.go @@ -0,0 +1,317 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +// Package skills implements the MCP [Skills extension]. +// +// Servers opt in with [AddHandlers] and serve skill files through the ordinary +// MCP resource APIs. Clients use [List], [Get], and [All] on a connected session +// to discover entries. [VerifySkillMD] and [VerifyResource] check content +// retrieved on demand against an entry from the same server. +// +// [Skills extension]: https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx +package skills + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + capabilityDirectoryRead = "directoryRead" + // protocolVersionCaching is the first protocol version whose results carry + // a result type and cache hints. + protocolVersionCaching = "2026-07-28" + resultTypeComplete = "complete" + cacheScopePublic = "public" + cacheScopePrivate = "private" +) + +const ( + // ExtensionID is the capability identifier for the Skills extension. + ExtensionID = "io.modelcontextprotocol/skills" + // MethodList is the skills/list method name. + MethodList = "skills/list" + // MethodGet is the skills/get method name. + MethodGet = "skills/get" + // MethodReadDirectory is the resources/directory/read method name. + MethodReadDirectory = "resources/directory/read" +) + +// Frontmatter is all of a SKILL.md's YAML frontmatter represented as JSON-compatible values. +// JSON numbers are decoded as [json.Number] to preserve their precision. +type Frontmatter map[string]any + +func (f *Frontmatter) UnmarshalJSON(data []byte) error { + var fields map[string]any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&fields); err != nil { + return err + } + *f = fields + return nil +} + +// Resource identifies and fingerprints one file in a skill. +type Resource struct { + URI string `json:"uri"` + // Digest is the SHA-256 hash of the raw file bytes, as "sha256:" followed + // by 64 lowercase hexadecimal digits. + Digest string `json:"digest"` + // Size is the length of the raw file content in bytes. + Size int64 `json:"size"` +} + +func (r *Resource) UnmarshalJSON(data []byte) error { + type wire Resource + var decoded struct { + wire + Size *int64 `json:"size"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + if decoded.Size == nil { + return fmt.Errorf("skills: resource size is missing or null") + } + *r = Resource(decoded.wire) + r.Size = *decoded.Size + return nil +} + +// Resources is either a complete static resource manifest or the dynamic marker. +// Its zero value is invalid; use [StaticResources] or [DynamicResources]. +type Resources struct { + dynamic bool + entries []*Resource +} + +// StaticResources constructs a complete static resource manifest. +// It must include SKILL.md and every supporting file, including nested skills. +// The resource slice and its entries are retained, not copied. +func StaticResources(resources ...*Resource) Resources { + if resources == nil { + resources = []*Resource{} + } + return Resources{entries: resources} +} + +// DynamicResources constructs the marker used when stable digests cannot be published. +func DynamicResources() Resources { return Resources{dynamic: true} } + +// IsDynamic reports whether r contains the dynamic marker. +func (r Resources) IsDynamic() bool { return r.dynamic } + +// List returns the static manifest and true, or nil and false for dynamic or unset resources. +// The returned slice and entries are shared with r. +func (r Resources) List() ([]*Resource, bool) { + if r.entries == nil || r.dynamic { + return nil, false + } + return r.entries, true +} + +func (r Resources) MarshalJSON() ([]byte, error) { + if r.entries == nil && !r.dynamic { + return nil, fmt.Errorf("skills: resources is not set") + } + if r.dynamic { + return []byte(`"dynamic"`), nil + } + return json.Marshal(r.entries) +} + +func (r *Resources) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) > 0 && data[0] == '"' { + var marker string + if err := json.Unmarshal(data, &marker); err != nil { + return err + } + if marker != "dynamic" { + return fmt.Errorf("skills: unknown resources marker %q", marker) + } + *r = DynamicResources() + return nil + } + var entries []*Resource + if err := json.Unmarshal(data, &entries); err != nil { + return fmt.Errorf("skills: resources must be an array or %q: %w", "dynamic", err) + } + if entries == nil { + return fmt.Errorf("skills: resources must not be null") + } + *r = StaticResources(entries...) + return nil +} + +// Skill is an entry returned by skills/list or skills/get. +type Skill struct { + URI string `json:"uri"` + Frontmatter Frontmatter `json:"frontmatter"` + Resources Resources `json:"resources"` +} + +// ListSkillsParams contains parameters for skills/list. +type ListSkillsParams struct { + mcp.ParamsBase + Cursor string `json:"cursor,omitempty"` +} + +// InvalidSkill describes an entry omitted from a list response. +type InvalidSkill struct { + Index int + URI string + Err error +} + +// ListSkillsResult is one page of skills/list. Each Skill is a complete entry; +// its manifest is never split across pages. +// [AddHandlers] sets ResultType and defaults CacheScope to "public"; a zero TTLMs +// marks the response immediately stale. These fields are omitted before protocol +// version 2026-07-28. +type ListSkillsResult struct { + mcp.ResultBase + mcp.Cacheable + ResultType string `json:"resultType,omitempty"` + NextCursor string `json:"nextCursor,omitempty"` + Skills []*Skill `json:"skills"` + // InvalidSkills reports entries skipped by List with SkipInvalidSkills enabled. + InvalidSkills []InvalidSkill `json:"-"` + entryErrors map[int]InvalidSkill + omitCache bool + cachePresent bool +} + +// omittedCache shadows the [mcp.Cacheable] hints with nil pointers, dropping +// them from the wire form of a result marshaled before protocol version +// 2026-07-28. +type omittedCache struct { + TTLMs *int `json:"ttlMs,omitempty"` + CacheScope *string `json:"cacheScope,omitempty"` +} + +// decodedCache captures the cache hints as they appeared on the wire, so that a +// result can distinguish an absent hint from a zero-valued one. +type decodedCache struct { + TTLMs *int `json:"ttlMs"` + CacheScope *string `json:"cacheScope"` +} + +// apply copies the hints that were present into cache, and reports whether the +// response carried both of them. +func (d decodedCache) apply(cache *mcp.Cacheable) bool { + if d.TTLMs != nil { + cache.TTLMs = *d.TTLMs + } + if d.CacheScope != nil { + cache.CacheScope = *d.CacheScope + } + return d.TTLMs != nil && d.CacheScope != nil +} + +func (r *ListSkillsResult) MarshalJSON() ([]byte, error) { + type wire ListSkillsResult + if !r.omitCache { + return json.Marshal((*wire)(r)) + } + return json.Marshal(struct { + *wire + omittedCache + }{wire: (*wire)(r)}) +} + +// GetSkillParams contains parameters for skills/get. +type GetSkillParams struct { + mcp.ParamsBase + URI string `json:"uri"` +} + +// GetSkillResult is the result of skills/get. +// Its result type and cache hints are handled as in [ListSkillsResult]. +type GetSkillResult struct { + mcp.ResultBase + mcp.Cacheable + ResultType string `json:"resultType,omitempty"` + Skill *Skill `json:"skill"` + omitCache bool + cachePresent bool +} + +func (r *GetSkillResult) MarshalJSON() ([]byte, error) { + type wire GetSkillResult + if !r.omitCache { + return json.Marshal((*wire)(r)) + } + return json.Marshal(struct { + *wire + omittedCache + }{wire: (*wire)(r)}) +} + +// ReadDirectoryParams contains parameters for resources/directory/read. +type ReadDirectoryParams struct { + mcp.ParamsBase + URI string `json:"uri"` + Cursor string `json:"cursor,omitempty"` +} + +// ReadDirectoryResult is the result of resources/directory/read. +// [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 + Skills []json.RawMessage `json:"skills"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = ListSkillsResult(decoded.wire) + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) + if decoded.Skills != nil { + r.Skills = make([]*Skill, len(decoded.Skills)) + } + for i, raw := range decoded.Skills { + var skill *Skill + if err := json.Unmarshal(raw, &skill); err != nil { + var identity struct { + URI string `json:"uri"` + } + _ = json.Unmarshal(raw, &identity) + if r.entryErrors == nil { + r.entryErrors = make(map[int]InvalidSkill) + } + r.entryErrors[i] = InvalidSkill{Index: i, URI: identity.URI, Err: err} + } else { + r.Skills[i] = skill + } + } + return nil +} + +func (r *GetSkillResult) UnmarshalJSON(data []byte) error { + type wire GetSkillResult + var decoded struct { + wire + decodedCache + } + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = GetSkillResult(decoded.wire) + r.cachePresent = decoded.decodedCache.apply(&r.Cacheable) + return nil +} diff --git a/skills/validation.go b/skills/validation.go new file mode 100644 index 00000000..6a8ca057 --- /dev/null +++ b/skills/validation.go @@ -0,0 +1,456 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package skills + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "gopkg.in/yaml.v3" +) + +func parseFrontmatter(data []byte) (Frontmatter, error) { + normalized := bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + if !bytes.HasPrefix(normalized, []byte("---\n")) { + return nil, fmt.Errorf("SKILL.md must begin with YAML frontmatter") + } + end := bytes.Index(normalized[4:], []byte("\n---\n")) + if end < 0 && bytes.HasSuffix(normalized, []byte("\n---")) { + end = len(normalized) - 8 + } + if end < 0 { + return nil, fmt.Errorf("SKILL.md frontmatter has no closing delimiter") + } + var fields map[string]any + if err := yaml.Unmarshal(normalized[4:4+end], &fields); err != nil { + return nil, err + } + if fields == nil { + return nil, fmt.Errorf("SKILL.md frontmatter is empty") + } + frontmatter := Frontmatter(fields) + for key, value := range frontmatter { + normalized, err := normalizeYAML(value) + if err != nil { + return nil, fmt.Errorf("frontmatter field %q: %w", key, err) + } + frontmatter[key] = normalized + } + return frontmatter, nil +} + +func normalizeYAML(value any) (any, error) { + switch value := value.(type) { + case map[string]any: + for key, item := range value { + normalized, err := normalizeYAML(item) + if err != nil { + return nil, err + } + value[key] = normalized + } + return value, nil + case map[any]any: + result := make(map[string]any, len(value)) + for key, item := range value { + name, ok := key.(string) + if !ok { + return nil, fmt.Errorf("mapping key must be a string") + } + normalized, err := normalizeYAML(item) + if err != nil { + return nil, err + } + result[name] = normalized + } + return result, nil + case []any: + for i, item := range value { + normalized, err := normalizeYAML(item) + if err != nil { + return nil, err + } + value[i] = normalized + } + return value, nil + default: + return value, nil + } +} + +// Limits bounds a static skill manifest. Positive fields are exact caps; zero +// fields are unlimited, and negative fields are invalid. The zero value imposes +// no manifest caps. Limits never disable structural validation. +// +// Limits are optional application policy. [BaselineLimits] provides the spec's +// interoperability baseline. Applications manage budgets for dynamic content. +type Limits struct { + // MaxResourcesPerSkill limits the number of files, including SKILL.md. + MaxResourcesPerSkill int + // MaxTotalSize limits the sum of the files' raw byte lengths. + MaxTotalSize int64 +} + +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// BaselineLimits returns the Skills spec's interoperability baseline: 512 files +// and 16 MiB per skill. Hosts must support at least this much and may support +// more; servers should stay within it for broad compatibility. The SDK does not +// impose these caps by default. Use explicit numeric limits to pin application +// policy independently of future spec revisions. +func BaselineLimits() Limits { + return Limits{ + MaxResourcesPerSkill: 512, + MaxTotalSize: 16 * 1024 * 1024, + } +} + +// ValidateSkill checks a skill's structure without imposing manifest caps. +func ValidateSkill(skill *Skill) error { + return validateSkill(skill, Limits{}) +} + +// ValidateSkillWithLimits validates a skill using exactly the supplied limits. +// Zero fields impose no cap on that dimension; structural validation always runs. +func ValidateSkillWithLimits(skill *Skill, limits Limits) error { + if err := limits.validate(); err != nil { + return err + } + return validateSkill(skill, limits) +} + +func (l Limits) validate() error { + if l.MaxResourcesPerSkill < 0 || l.MaxTotalSize < 0 { + return fmt.Errorf("skills: limits must not be negative") + } + return nil +} + +func validateSkill(skill *Skill, limits Limits) error { + if skill == nil { + return fmt.Errorf("skill is nil") + } + name, skillURL, err := parseSkillURI(skill.URI) + if err != nil { + return err + } + if skill.Frontmatter == nil { + return fmt.Errorf("skill %q has no frontmatter", skill.URI) + } + if _, err := json.Marshal(skill.Frontmatter); err != nil { + return fmt.Errorf("skill %q frontmatter is not JSON-compatible: %w", skill.URI, err) + } + frontmatterName, ok := skill.Frontmatter["name"].(string) + if !ok { + return fmt.Errorf("skill %q frontmatter name must be a string", skill.URI) + } + if frontmatterName != name { + return fmt.Errorf("skill %q frontmatter name %q does not match URI name %q", skill.URI, frontmatterName, name) + } + description, ok := skill.Frontmatter["description"].(string) + if length := utf8.RuneCountInString(description); !ok || length < 1 || length > 1024 { + return fmt.Errorf("skill %q frontmatter description must contain 1 to 1024 characters", skill.URI) + } + if compatibility, ok := skill.Frontmatter["compatibility"]; ok { + s, ok := compatibility.(string) + if length := utf8.RuneCountInString(s); !ok || length < 1 || length > 500 { + return fmt.Errorf("skill %q frontmatter compatibility must contain 1 to 500 characters", skill.URI) + } + } + if license, ok := skill.Frontmatter["license"]; ok { + if _, ok := license.(string); !ok { + return fmt.Errorf("skill %q frontmatter license must be a string", skill.URI) + } + } + if metadata, ok := skill.Frontmatter["metadata"]; ok { + var m map[string]any + switch metadata := metadata.(type) { + case map[string]any: + m = metadata + case map[string]string: + m = make(map[string]any, len(metadata)) + for key, value := range metadata { + m[key] = value + } + default: + return fmt.Errorf("skill %q frontmatter metadata must be an object", skill.URI) + } + for key, value := range m { + if _, ok := value.(string); !ok { + return fmt.Errorf("skill %q frontmatter metadata value %q must be a string", skill.URI, key) + } + } + } + if allowedTools, ok := skill.Frontmatter["allowed-tools"]; ok { + if _, ok := allowedTools.(string); !ok { + return fmt.Errorf("skill %q frontmatter allowed-tools must be a string", skill.URI) + } + } + + if skill.Resources.IsDynamic() { + return nil + } + resources, static := skill.Resources.List() + if !static { + return fmt.Errorf("skill %q resources is not set", skill.URI) + } + if limits.MaxResourcesPerSkill > 0 && len(resources) > limits.MaxResourcesPerSkill { + return fmt.Errorf("skill %q has %d resources, exceeding the limit of %d", skill.URI, len(resources), limits.MaxResourcesPerSkill) + } + seen := make(map[string]bool, len(resources)) + var total int64 + for i, resource := range resources { + if resource == nil { + return fmt.Errorf("skill %q resource %d is nil", skill.URI, i) + } + if err := validateResourceURI(skillURL, resource.URI); err != nil { + return fmt.Errorf("skill %q resource %q: %w", skill.URI, resource.URI, err) + } + if seen[resource.URI] { + return fmt.Errorf("skill %q lists resource %q more than once", skill.URI, resource.URI) + } + seen[resource.URI] = true + if !digestRE.MatchString(resource.Digest) { + return fmt.Errorf("skill %q resource %q has invalid SHA-256 digest", skill.URI, resource.URI) + } + if resource.Size < 0 { + return fmt.Errorf("skill %q resource %q has a negative size", skill.URI, resource.URI) + } + if limits.MaxTotalSize > 0 { + if resource.Size > limits.MaxTotalSize-total { + return fmt.Errorf("skill %q resource sizes exceed the limit of %d bytes", skill.URI, limits.MaxTotalSize) + } + total += resource.Size + } + } + if !seen[skill.URI] { + return fmt.Errorf("skill %q resources does not include its SKILL.md", skill.URI) + } + return nil +} + +// ValidateDirectoryResult validates that result contains direct children of uri. +func ValidateDirectoryResult(uri string, result *ReadDirectoryResult) error { + if result == nil { + return fmt.Errorf("directory result is nil") + } + parent, err := parseDirectoryURI(uri) + if err != nil { + return err + } + if result.Resources == nil { + return fmt.Errorf("directory %q returned a null resources array", uri) + } + seenURIs := make(map[string]bool, len(result.Resources)) + for i, resource := range result.Resources { + if resource == nil { + return fmt.Errorf("directory %q resource %d is nil", uri, i) + } + child, err := parseURI(resource.URI) + if err != nil { + return fmt.Errorf("directory %q child has invalid URI %q", uri, resource.URI) + } + if child.Scheme != parent.Scheme || child.Host != parent.Host || child.User.String() != parent.User.String() { + return fmt.Errorf("resource %q is not a child of directory %q", resource.URI, uri) + } + rel := strings.TrimPrefix(child.Path, parent.Path+"/") + if rel == child.Path || rel == "" || strings.Contains(rel, "/") { + return fmt.Errorf("resource %q is not a direct child of directory %q", resource.URI, uri) + } + if resource.Name == "" { + return fmt.Errorf("directory %q child has no name", uri) + } + if seenURIs[resource.URI] { + return fmt.Errorf("directory %q contains a duplicate child %q", uri, resource.URI) + } + seenURIs[resource.URI] = true + } + return nil +} + +func validateName(name string) error { + invalid := fmt.Errorf("name %q must contain 1 to 64 lowercase Unicode letters, numbers, or non-consecutive hyphens, with no leading or trailing hyphen", name) + if length := utf8.RuneCountInString(name); length < 1 || length > 64 { + return invalid + } + if name != strings.ToLower(name) { + return invalid + } + if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") || strings.Contains(name, "--") { + return invalid + } + // Invalid UTF-8 decodes to U+FFFD, which is neither a letter nor a number. + for _, r := range name { + if r != '-' && !unicode.IsLetter(r) && !unicode.IsNumber(r) { + return invalid + } + } + return nil +} + +func skillNameFromURI(rawURI string) (string, error) { + name, _, err := parseSkillURI(rawURI) + return name, err +} + +// parseSkillURI validates a SKILL.md URI, returning the skill name and the +// parsed URI so that callers checking many resources parse the root only once. +func parseSkillURI(rawURI string) (string, *url.URL, error) { + u, err := parseURI(rawURI) + if err != nil { + return "", nil, err + } + if !strings.HasSuffix(u.Path, "/SKILL.md") { + return "", nil, fmt.Errorf("skill URI %q must end in /SKILL.md", rawURI) + } + dir := strings.TrimPrefix(strings.TrimSuffix(u.Path, "/SKILL.md"), "/") + if dir == "" { + dir = u.Hostname() + } else { + parts := strings.Split(dir, "/") + dir = parts[len(parts)-1] + } + if dir == "" { + return "", nil, fmt.Errorf("skill URI %q has no skill name", rawURI) + } + if err := validateName(dir); err != nil { + return "", nil, err + } + return dir, u, nil +} + +// validateResourceURI checks that resourceURI names a file under the skill root +// described by the already-parsed skillURL. +func validateResourceURI(skillURL *url.URL, resourceURI string) error { + resourceURL, err := parseURI(resourceURI) + if err != nil { + return err + } + if skillURL.Scheme != resourceURL.Scheme || skillURL.Host != resourceURL.Host || skillURL.User.String() != resourceURL.User.String() { + return fmt.Errorf("URI is outside the skill root") + } + rootPath := strings.TrimSuffix(skillURL.Path, "/SKILL.md") + if !strings.HasPrefix(resourceURL.Path, rootPath+"/") || strings.HasSuffix(resourceURL.Path, "/") { + return fmt.Errorf("URI is outside the skill root or is not a file") + } + return nil +} + +func parseDirectoryURI(rawURI string) (*url.URL, error) { + u, err := parseURI(rawURI) + if err != nil { + return nil, err + } + if strings.HasSuffix(u.Path, "/") { + return nil, fmt.Errorf("directory URI %q must not have a trailing slash", rawURI) + } + return u, nil +} + +func parseURI(rawURI string) (*url.URL, error) { + u, err := url.Parse(rawURI) + // A non-empty fragment always leaves a "#" in the raw URI, so the raw check + // covers both a parsed fragment and an empty one. + if err != nil || u.Scheme == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || strings.Contains(rawURI, "#") { + return nil, fmt.Errorf("invalid resource URI %q", rawURI) + } + if u.Scheme == "skill" && (u.Host == "" || u.User != nil || u.Port() != "") { + return nil, fmt.Errorf("invalid skill authority in %q", rawURI) + } + for segment := range strings.SplitSeq(u.Path, "/") { + if segment == "." || segment == ".." { + return nil, fmt.Errorf("URI %q contains a traversal segment", rawURI) + } + } + return u, nil +} + +func validateListResult(result *ListSkillsResult, limits Limits) error { + if result == nil || result.Skills == nil { + return fmt.Errorf("skills is missing or null") + } + seen := make(map[string]bool, len(result.Skills)) + for _, skill := range result.Skills { + if err := validateSkill(skill, limits); err != nil { + return err + } + if seen[skill.URI] { + return fmt.Errorf("skill URI %q occurs more than once", skill.URI) + } + seen[skill.URI] = true + } + return nil +} + +func validateClientList(result *ListSkillsResult, options ListOptions) error { + if result == nil || result.Skills == nil { + return fmt.Errorf("skills is missing or null") + } + counts := make(map[string]int) + for _, skill := range result.Skills { + if skill != nil { + counts[skill.URI]++ + } + } + for _, invalid := range result.entryErrors { + if invalid.URI != "" { + counts[invalid.URI]++ + } + } + valid := make([]*Skill, 0, len(result.Skills)) + for i, skill := range result.Skills { + invalid, decodeFailed := result.entryErrors[i] + if !decodeFailed { + invalid.Index = i + if skill != nil { + invalid.URI = skill.URI + } + invalid.Err = ValidateSkill(skill) + if invalid.Err == nil && counts[skill.URI] > 1 { + invalid.Err = fmt.Errorf("skill URI %q occurs more than once", skill.URI) + } + } + if invalid.Err != nil { + if !options.SkipInvalidSkills { + return fmt.Errorf("entry %d (%q): %w", i, invalid.URI, invalid.Err) + } + result.InvalidSkills = append(result.InvalidSkills, invalid) + if options.OnInvalidSkill != nil { + options.OnInvalidSkill(invalid) + } + continue + } + valid = append(valid, skill) + } + result.Skills = valid + return nil +} + +func validateGetResult(uri string, result *GetSkillResult, limits Limits) error { + if result == nil || result.Skill == nil { + return fmt.Errorf("skill is missing or null") + } + if result.Skill.URI != uri { + return fmt.Errorf("returned URI %q for %q", result.Skill.URI, uri) + } + return validateSkill(result.Skill, limits) +} + +func validateCache(cache mcp.Cacheable) error { + if cache.TTLMs < 0 { + return fmt.Errorf("skills: ttlMs must not be negative") + } + if cache.CacheScope != cacheScopePublic && cache.CacheScope != cacheScopePrivate { + return fmt.Errorf("skills: invalid cacheScope %q", cache.CacheScope) + } + return nil +} diff --git a/skills/validation_test.go b/skills/validation_test.go new file mode 100644 index 00000000..3e6c2cb8 --- /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 00000000..49638fb9 --- /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 +}