Documentation
¶
Overview ¶
Package skills implements the Skills Extension binding for MCP (SEP-2640).
Experimental: SEP-2640 is a Draft Extensions Track SEP. Its surface, URI grammar, index shape, and capability identifier may change while the Skills Over MCP Working Group iterates. This package tracks the current draft and breaking changes are expected on pre-1.0 tags. Pin to a specific version if you need stability.
The extension defines a convention for serving Agent Skills over MCP using the existing Resources primitive. A skill is a directory containing a SKILL.md file at its root, addressed by the skill:// URI scheme. Files inside a skill are exposed as ordinary MCP resources; clients read them with resources/read and resolve relative references against the skill's root.
This package provides the value types (Index, IndexEntry, Frontmatter, Metadata), the skill:// URI parser, and the SKILL.md frontmatter parser. Higher-level affordances (provider, index generator, archives, client helpers) live in sibling files in this package.
No code execution, no disk staging. A skill is treated as data delivered over MCP resource primitives, never as code to run. This package neither imports os/exec nor stages skill content to a real filesystem: archive unpacking returns in-memory UnpackedEntry values, and nothing writes skill bytes to disk. This is the mcpkit reference posture for the SEP-2640 code-execution concern raised in the June 2026 core-maintainer review; it is enforced by TestNoCodeExecutionSurface, so a regression fails the build rather than silently widening the attack surface.
See: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640
Index ¶
- Constants
- Variables
- func CatalogBlock(idx Index) string
- func InstructionsBlock(loaded []LoadedSkill) string
- func IsIndexURI(s string) bool
- func PackSkill(fsys fs.FS, skillDir string, format ArchiveFormat) ([]byte, error)
- func ValidateSkillName(name string) error
- type ActivateOption
- type ActivationEvent
- type ArchiveFS
- type ArchiveFormat
- type ChangeAction
- type Client
- func (c *Client) Activate(ctx context.Context, uri string, opts ...ActivateOption) ActivationEvent
- func (c *Client) BytesConsumed() int64
- func (c *Client) ListSkills(ctx context.Context) (Index, error)
- func (c *Client) LoadAll(ctx context.Context) ([]LoadedSkill, error)
- func (c *Client) LoadIndex(ctx context.Context, idx Index) []LoadedSkill
- func (c *Client) ReadAndVerify(ctx context.Context, uri, expectedDigest string) (*ReadResult, error)
- func (c *Client) ReadDirectory(ctx context.Context, uri string, opts ...ReadDirectoryOption) (DirectoryReadResult, error)
- func (c *Client) ReadSkillFile(ctx context.Context, manifest *SkillManifest, relPath string) ([]byte, error)
- func (c *Client) ReadSkillFileVerified(ctx context.Context, entry IndexEntry, manifest *SkillManifest, relPath string) (*ReadResult, error)
- func (c *Client) ReadSkillManifest(ctx context.Context, uri string) (*SkillManifest, error)
- func (c *Client) ReadSkillURI(ctx context.Context, uri string) ([]byte, error)
- func (c *Client) SupportsDirectoryRead() bool
- func (c *Client) SupportsSkills() bool
- type DirectoryReadRequest
- type DirectoryReadResult
- type FetchOption
- type FileDigest
- type Frontmatter
- type GitHubOption
- type Index
- type IndexEntry
- type Indexer
- type IndexerOption
- type LoadedSkill
- type Metadata
- type Option
- type PathChange
- type PathChangeEntry
- type PathsChangedPayload
- type Provider
- func (p *Provider) Catalog() []IndexEntry
- func (p *Provider) Close() error
- func (p *Provider) NotifyChanged(paths ...string) error
- func (p *Provider) NotifyChangedEvents(events ...PathChange) error
- func (p *Provider) Refresh() error
- func (p *Provider) RegisterWith(srv *server.Server)
- func (p *Provider) Resources() []core.ResourceDef
- func (p *Provider) Shutdown(ctx context.Context) error
- func (p *Provider) Version() uint64
- type ProviderOption
- func WithArchiveMaxBytes(n int64) ProviderOption
- func WithArchiveMode(format ArchiveFormat) ProviderOption
- func WithCoalesceWindow(d time.Duration) ProviderOption
- func WithDirectory(path string) ProviderOption
- func WithFS(fsys fs.FS) ProviderOption
- func WithFSWatcher(opts ...WatcherOption) ProviderOption
- func WithIndexCacheTTL(d time.Duration) ProviderOption
- func WithMetaPrefix(prefix string) ProviderOption
- func WithMinBroadcastInterval(d time.Duration) ProviderOption
- func WithSupportingFileDigests(mode SupportingDigestMode) ProviderOption
- func WithURIPrefix(prefix string) ProviderOption
- func WithoutDirectoryRead() ProviderOption
- func WithoutIndex() ProviderOption
- type ReadDirectoryOption
- type ReadResult
- type SkillManifest
- type SkillType
- type SkillsExtension
- type SourceFS
- type SupportingDigestMode
- type URIParts
- type UnpackedEntry
- type WatcherOption
Constants ¶
const ArchiveTarBz2 = ".tar.bz2"
ArchiveTarBz2 is the file suffix for bzip2-compressed tar archive entries. Read-only support — see ArchiveFormatTarBz2.
const ArchiveTarGz = ".tar.gz"
ArchiveTarGz is the file suffix for gzip-compressed tar archive entries.
const ArchiveZip = ".zip"
ArchiveZip is the file suffix for zip archive entries.
const CapabilityDirectoryRead = "directoryRead"
CapabilityDirectoryRead is the SEP-2640 setting key inside the io.modelcontextprotocol/skills extension capability that gates resources/directory/read. Exported so client code can decode the same key without re-stringing the literal.
const DefaultArchiveMaxBytes int64 = 100 * 1024 * 1024
DefaultArchiveMaxBytes is the unpacked-size cap a Provider uses when no WithArchiveMaxBytes option is supplied. Per SEP-2640 the host MUST "enforce a limit on total unpacked size" to prevent decompression bombs. 100 MiB is a defensible default for skills which the SEP expects to be document-sized, not dataset-sized.
const DefaultMaxResourceBytes int64 = 10 * 1024 * 1024
DefaultMaxResourceBytes is the per-resource size cap the Client applies to skill:// reads when no WithMaxResourceBytes option is supplied. 10 MiB comfortably fits a SKILL.md and ordinary supporting files while rejecting payloads engineered to exhaust memory at fetch time (WG threat model T6, issue 867). It mirrors the archive extractor's own DefaultArchiveMaxBytes cap, applied to the individual-file paths the archive cap does not cover. Pass -1 to WithMaxResourceBytes to disable.
const ExperimentalNotice = "ext/skills tracks Draft SEP-2640; the surface may change on pre-1.0 tags."
ExperimentalNotice is a human-readable string examples and CLIs MAY emit to stderr on startup to signal that the package implements a Draft SEP.
const ExtensionID = "io.modelcontextprotocol/skills"
ExtensionID is the SEP-2133 extension identifier declared by servers in their initialize response under capabilities.extensions.
const IndexPath = "index.json"
IndexPath is the well-known URI at which a server SHOULD expose its discovery index. The full URI is skill://index.json.
const IndexSchemaURI = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
IndexSchemaURI is the JSON schema version URI the SEP currently pins to. Servers populate Index.Schema with this value; clients SHOULD compare against a known set before processing the rest of the document.
const IndexURI = "skill://index.json"
IndexURI is the full well-known URI for the discovery index.
const ManifestFilename = "SKILL.md"
ManifestFilename is the required filename at the root of every skill.
const MetaKeyFileDigests = MetaPrefix + "file-digests"
MetaKeyFileDigests is the reverse-domain _meta key under an IndexEntry that carries the entry's supporting-file integrity pins as a []FileDigest (issue 866). Namespaced so it cannot collide with a top-level field a future SEP revision may define for the same purpose.
const MetaKeyPathsChanged = MetaPrefix + "paths-changed"
MetaKeyPathsChanged is the reverse-domain key under the notifications/resources/list_changed params._meta map that carries a PathsChangedPayload. Subscribers that decode this payload get the deduplicated set of paths that changed plus the version counter at broadcast time; subscribers that ignore _meta still receive the standard list_changed signal and can re-read at their leisure.
const MetaKeyVersion = MetaPrefix + "version"
MetaKeyVersion is the reverse-domain key under skill://index.json's _meta map that carries Provider.Version() at index build time. Stateless polling clients read this field to detect changes without a persistent push channel (issue #795).
const MetaPrefix = "io.modelcontextprotocol.skills/"
MetaPrefix is the reverse-domain prefix recommended by the SEP for any SKILL.md frontmatter fields surfaced through a resource's _meta object.
const MethodResourcesDirectoryRead = "resources/directory/read"
MethodResourcesDirectoryRead is the JSON-RPC method name SEP-2640 added in commit 2e04c48d (2026-06-09) for scoped directory listing inside a resource subtree. Capability-gated via SkillsExtension.DirectoryRead.
const MimeTypeDirectory = "inode/directory"
MimeTypeDirectory is the value SEP-2640 reserves for resources that represent directories. Listed entries with this MIME type are intended to be navigated with another resources/directory/read call rather than fetched with resources/read.
const ReadResourceToolDescription = "Read an MCP resource from a connected server."
ReadResourceToolDescription is the description string SEP-2640 suggests for the tool, useful as the model-facing summary.
const ReadResourceToolName = "read_resource"
ReadResourceTool surfaces the SEP-2640 Implementation Guidelines host-exposed tool schema. Hosts that want to expose a generic resource-reading tool to their LLM can register this without copy-pasting the JSON Schema literal from the spec.
The shape matches SEP-2640 verbatim:
{
"name": "read_resource",
"description": "Read an MCP resource from a connected server.",
"inputSchema": {
"type": "object",
"properties": {
"server": { "type": "string", "description": "Name of the connected MCP server" },
"uri": { "type": "string", "description": "The resource URI" }
},
"required": ["server", "uri"]
}
}
const Scheme = "skill"
Scheme is the URI scheme reserved for skill resources.
const SpecVersion = "2026-04-23"
SpecVersion is the SEP-2640 draft version this implementation tracks. Updated when the SEP advances; the date matches the SEP's Created field when the spec is still in Draft.
Variables ¶
var ( // ErrArchivePathTraversal is returned when an entry path contains // ".." or otherwise resolves outside the archive root after // path.Clean. ErrArchivePathTraversal = errors.New("skills: archive entry escapes root") // ErrArchiveAbsolutePath is returned when an entry path is absolute // (starts with "/"). ErrArchiveAbsolutePath = errors.New("skills: archive entry has absolute path") // ErrArchiveSymlinkEscape is returned when a tar symlink or hardlink // resolves outside the archive root. ErrArchiveSymlinkEscape = errors.New("skills: archive symlink escapes root") // ErrArchiveTooLarge is returned when an archive's total unpacked // size exceeds the configured maximum. The unpack/read stream errors // as soon as the cap is crossed; no partial-file is returned. ErrArchiveTooLarge = errors.New("skills: archive exceeds max unpacked size") // ErrArchiveUnknownFormat is returned when neither the supplied // format nor the inferred magic bytes identify a supported format. ErrArchiveUnknownFormat = errors.New("skills: unknown archive format") // ErrArchiveUnsupportedForPack is returned by PackSkill when the // requested format is supported for reading but not for writing. // Currently fires for ArchiveFormatTarBz2 — bzip2 compression is slow // and rarely justified for skill-sized payloads; adopters that need // to produce tar.bz2 should do so externally (system tar, a CI step, // etc.) and pass the bytes to NewArchiveFS for read-side use. ErrArchiveUnsupportedForPack = errors.New("skills: archive format is read-only (not supported by PackSkill)") // ErrArchiveDownloadFailed is returned by FetchArchive when the // HTTP fetch fails — non-2xx status, transport error, etc. Wraps // the underlying cause. ErrArchiveDownloadFailed = errors.New("skills: archive download failed") // ErrArchiveExceedsMaxBytes is returned by FetchArchive when the // response body exceeds the configured WithHTTPMaxBytes cap (or the // default DefaultArchiveMaxBytes). The stream is cut off as soon as // the cap is crossed; no partial archive is returned. ErrArchiveExceedsMaxBytes = errors.New("skills: archive exceeds max bytes cap") // ErrArchivesDirCollision is returned by OpenArchivesDir when the // WithFlatLayer option is set and two archives in the directory // would map files to the same path. Layered mode (the default) // scopes each archive under its basename, so collisions are // impossible there. ErrArchivesDirCollision = errors.New("skills: archives-dir flat-layer collision") )
Archive safety errors.
var ( // ErrInvalidScheme is returned when a URI's scheme is not "skill". ErrInvalidScheme = errors.New("skills: scheme must be skill://") // ErrEmptySkillPath is returned when a skill:// URI has no path // segments at all (e.g., "skill://"). ErrEmptySkillPath = errors.New("skills: empty skill path") // ErrEmptySkillName is returned when the final segment of the skill // path is empty (e.g., "skill://foo//SKILL.md"). ErrEmptySkillName = errors.New("skills: empty skill name") // ErrInvalidSkillName is returned when the final segment of the skill // path violates the Agent Skills naming rules (lowercase letters, // digits, hyphens). ErrInvalidSkillName = errors.New("skills: invalid skill name") // ErrManifestNotInRoot is returned when a SKILL.md appears anywhere // other than the immediate root of a skill. SEP-2640 forbids nested // skills. ErrManifestNotInRoot = errors.New("skills: SKILL.md must be at skill root") // ErrEmptyPathSegment is returned when a URI contains an empty path // segment (consecutive slashes). ErrEmptyPathSegment = errors.New("skills: empty path segment") // ErrRelativeEscapesSkill is returned by ResolveRelative when the // resolved file path would escape the skill's root using "..". ErrRelativeEscapesSkill = errors.New("skills: relative reference escapes skill root") // ErrPathTraversal is returned by ParseURI when a URI contains a "." // or ".." segment. SEP-2640 skill paths use [a-z0-9-] segments only, // so dot-segments cannot appear legitimately — their presence // indicates either a malformed URI or an attempted traversal probe. // The strict rejection at parse time keeps the registry-miss path // (HTTP 200 + "unknown resource") from masking traversal-shaped // requests as ordinary typos in audit logs. ErrPathTraversal = errors.New("skills: URI contains traversal segment (. or ..)") // ErrNotManifestURI is returned when an operation requires a manifest // URI (ending in /SKILL.md) but received a different shape. ErrNotManifestURI = errors.New("skills: not a SKILL.md URI") )
URI parsing errors.
var ( // ErrDigestMismatch is returned by Client.ReadAndVerify when the // SHA-256 over the served bytes does not equal the expected digest // the caller supplied. Per SEP-2640's Integrity and Verification // section, hosts MUST NOT use unverified content; surfacing this as // a typed error makes the contract explicit at the call site. ErrDigestMismatch = errors.New("skills: digest mismatch — content MUST NOT be used") // ErrDirectoryReadNotSupported is returned by Client.ReadDirectory // when the connected server has not advertised the // io.modelcontextprotocol/skills.directoryRead capability. SEP-2640 // commit 2e04c48d's normative wording: clients MUST NOT call // resources/directory/read against a server that has not declared // directoryRead: true. Returning a typed error from the pre-call // guard keeps the contract explicit at the call site. ErrDirectoryReadNotSupported = errors.New("skills: server does not advertise the directoryRead capability") // ErrResourceTooLarge is returned by the Client read path when a // fetched resource exceeds the per-resource size cap configured via // WithMaxResourceBytes (default DefaultMaxResourceBytes). The size is // bounded before a blob is base64-decoded, so an oversized payload is // rejected without incurring the decode (or a subsequent hash) // allocation. This is the individual-file counterpart to the archive // extractor's ErrArchiveTooLarge (WG threat model T6, issue 867). ErrResourceTooLarge = errors.New("skills: resource exceeds max size") // ErrSupportingFileUnpinned is returned by Client.ReadSkillFileVerified // when the index entry carries no per-file digest for the requested // supporting file (issue 866). It is a secure-by-default signal: a host // that asked to verify a file against its pin, but found none, should // not silently fall back to an unverified read. Hosts that want a // best-effort read of an unpinned file call ReadSkillFile directly. ErrSupportingFileUnpinned = errors.New("skills: supporting file not pinned in index") // ErrServerByteBudgetExceeded is returned when a Client's cumulative // fetched-byte total would exceed the budget set via // WithServerByteBudget. The budget spans every ReadSkillURI-path read // (manifest, supporting file, ReadAndVerify) on that Client, so a walk // that fetches many individually-small files is still bounded past an // aggregate ceiling. Disabled by default; opt in per Client. ErrServerByteBudgetExceeded = errors.New("skills: server byte budget exceeded") )
Client-side errors.
var ( ErrIndexMissingSchema = errors.New("skills: index missing $schema") ErrUnknownSkillType = errors.New("skills: unknown skill type") ErrIndexEntryMissingDescription = errors.New("skills: index entry missing description") ErrIndexEntryMissingURL = errors.New("skills: index entry missing url") ErrIndexEntryMissingName = errors.New("skills: index entry missing name") ErrIndexEntryMissingDigest = errors.New("skills: index entry missing digest") )
Index validation errors.
var ( // ErrProviderMissingFS is returned by NewProvider when no fs.FS was // supplied via WithFS or WithDirectory. ErrProviderMissingFS = errors.New("skills: provider needs WithFS or WithDirectory") // ErrSkillNameMismatch is returned by NewProvider when a skill's // SKILL.md frontmatter name does not equal the parent directory base // name. SEP-2640 requires the two to match. ErrSkillNameMismatch = errors.New("skills: frontmatter name does not match directory") // ErrNestedSkill is returned by NewProvider when a SKILL.md is found // inside an existing skill's subtree. SEP-2640 forbids skill nesting. ErrNestedSkill = errors.New("skills: nested skill") // ErrFSWatcherMissingHostRoot is returned by NewProvider when // WithFSWatcher is supplied alongside WithFS (which does not // populate the hostRoot path). Watcher-based change detection // requires a real filesystem; use WithDirectory or arrange your // own Detector to call NotifyChangedEvents manually. ErrFSWatcherMissingHostRoot = errors.New("skills: WithFSWatcher requires WithDirectory (hostRoot not set)") // ErrFSWatcherSetupFailed is returned by NewProvider when the // underlying fsnotify.NewWatcher call or the initial directory // walk fails in a way that prevents any watching from happening. // Wraps the underlying error. ErrFSWatcherSetupFailed = errors.New("skills: fsnotify watcher setup failed") )
Provider configuration and walk errors.
var ( // ErrMissingFrontmatter is returned when a SKILL.md does not begin with // a "---" YAML delimiter. ErrMissingFrontmatter = errors.New("skills: missing YAML frontmatter") // ErrUnterminatedFrontmatter is returned when a SKILL.md opens with // "---" but never closes it. ErrUnterminatedFrontmatter = errors.New("skills: unterminated YAML frontmatter") // ErrNonMappingFrontmatter is returned when the frontmatter parses as // YAML but the top-level value is not a mapping (e.g., a list or // scalar). ErrNonMappingFrontmatter = errors.New("skills: frontmatter must be a YAML mapping") // ErrFrontmatterMissingName is returned when the frontmatter parses but // has no non-empty name field. ErrFrontmatterMissingName = errors.New("skills: frontmatter missing required field: name") // ErrFrontmatterMissingDescription is returned when the frontmatter // parses but has no non-empty description field. ErrFrontmatterMissingDescription = errors.New("skills: frontmatter missing required field: description") )
Frontmatter parsing errors.
var IndexResourceDef = core.ResourceDef{ URI: IndexURI, Name: "Skill discovery index", Description: "JSON catalog of skills served by this server per SEP-2640.", MimeType: "application/json", }
IndexResourceDef is the ResourceDef registered for skill://index.json. Servers reuse this for documentation surfaces (READMEs, OpenAPI-style catalogs) so the wire-level name stays in lock-step with the runtime resource.
var ReadResourceToolInputSchema = map[string]any{ "type": "object", "properties": map[string]any{ "server": map[string]any{ "type": "string", "description": "Name of the connected MCP server", }, "uri": map[string]any{ "type": "string", "description": "The resource URI", }, }, "required": []string{"server", "uri"}, }
ReadResourceToolInputSchema is the JSON Schema for the tool's arguments per the SEP-2640 sketch. Exposed as a map[string]any so hosts can register it directly through mcpkit's tool registration path without an intermediate JSON round-trip.
Functions ¶
func CatalogBlock ¶ added in v0.4.0
InstructionsBlock renders successfully loaded skills as a system-prompt section: a header, then each skill's name, description, and SKILL.md body. Failed skills are excluded (never inject unverified content); an empty or all-failed batch renders to the empty string so callers can append the result unconditionally. Ordering follows the input, which LoadAll already made deterministic. CatalogBlock renders a compact catalog of a server's skills — one line per skill-md entry (name + description), the two-tier alternative to InstructionsBlock's full-body injection (issue 910). It tells the model what skills exist for roughly a tenth of the tokens; the body is fetched on demand via a host load_skill tool. Archive entries are omitted (host-decided extraction, like InstructionsBlock). Returns "" when there are no skill-md entries.
func InstructionsBlock ¶ added in v0.4.0
func InstructionsBlock(loaded []LoadedSkill) string
InstructionsBlock renders the full SKILL.md body of every successfully loaded skill for eager injection into the system prompt.
func IsIndexURI ¶
IsIndexURI reports whether s is the reserved skill://index.json URI. The check is exact-match per SEP-2640's reservation rule.
func PackSkill ¶
PackSkill packs everything under skillDir in fsys into the chosen archive format. SKILL.md MUST be present at skillDir; the resulting archive carries it at the archive root and contains no entries outside the skill directory.
Entries are written in lexically sorted order so two packs of the same source produce byte-identical archives. This is load-bearing for the Indexer's digest stability across cache rebuilds.
Pack-side safety: PackSkill refuses to emit any entry whose normalized path contains ".." or starts with "/", and refuses to follow symlinks that point outside skillDir. These are SEP MUST rejections on the unpack side, so emitting such entries is a defect even when individual hosts might be lenient.
func ValidateSkillName ¶
ValidateSkillName checks that name satisfies the Agent Skills naming rules: 1+ characters, lowercase letters / digits / hyphens, no leading or trailing hyphen, no consecutive hyphens.
The SEP delegates the format to the Agent Skills specification but states that names cannot collide with the reserved well-known path "index.json" because "." is not permitted.
Types ¶
type ActivateOption ¶
type ActivateOption func(*activateConfig)
ActivateOption tunes a single Client.Activate call.
func WithReason ¶
func WithReason(reason string) ActivateOption
WithReason annotates the activation with a short human-readable reason (e.g., "agent_decided_pdf_processing", "user_requested", "test").
Surfaced as the mcp.skill.activation.reason span attribute (when a TracerProvider is installed) and as the Reason field on the returned ActivationEvent. Stays out of band for non-OTel hosts that rely solely on the WithActivationHook callback.
type ActivationEvent ¶
type ActivationEvent struct {
// URI is the skill:// URI the host activated. May be a manifest URI
// (skill://path/SKILL.md) or a sub-file URI within a skill — the
// host decides which level of granularity to report.
URI string
// Reason is an optional human-readable explanation for the
// activation, populated when the caller passed WithReason.
// Surfaced as the mcp.skill.activation.reason span attribute when a
// TracerProvider is installed.
Reason string
// Timestamp is when Activate was called. Hosts can use this to
// correlate activations against external event logs.
Timestamp time.Time
}
ActivationEvent is the payload Client.Activate returns and passes to the WithActivationHook callback. It captures the activation moment the agent loop signaled — the URI of the skill the host is about to put into the model context, an optional human-readable reason, and the activation timestamp.
Returned to the caller so non-OTel telemetry pipelines that prefer a pull-style emit point can stamp the event in whatever shape they want. The same struct is delivered to any installed activation hook, so a host with both span emission and a hook installed sees a consistent record across both sinks.
type ArchiveFS ¶
type ArchiveFS struct {
// contains filtered or unexported fields
}
ArchiveFS adapts archive bytes (.tar.gz or .zip) to an io/fs.FS view. Use it on the source side of a Provider when a server's skills live inside an archive on disk, or on the client side when consuming a type:"archive" resource and wanting to walk the contents as if they were files.
Construction unpacks the archive once into a slice of UnpackedEntry, enforcing the SEP-2640 safety rules on every entry. After construction, Open and ReadFile are O(log n) via a sorted index. The implementation does not stream from the original bytes; ArchiveFS is not appropriate for archives whose unpacked content does not fit comfortably in memory. Callers with that constraint should use UnpackBytes and stage to disk.
func NewArchiveFS ¶
func NewArchiveFS(data []byte, format ArchiveFormat, maxBytes int64) (*ArchiveFS, error)
NewArchiveFS unpacks archive bytes and returns a read-only fs.FS view. The format is detected from the supplied hint when non-Unknown; otherwise NewArchiveFS sniffs the magic bytes via DetectArchiveFormat. The maxBytes cap is the total unpacked size; 0 uses DefaultArchiveMaxBytes.
Safety rules from SEP-2640 are enforced at decode time. Any path-traversal, absolute-path, or symlink-escape entry causes NewArchiveFS to return the matching named error before any file is readable.
func (*ArchiveFS) ReadDir ¶
ReadDir implements fs.ReadDirFS so fs.WalkDir descends into archive directories efficiently.
type ArchiveFormat ¶
type ArchiveFormat int
ArchiveFormat selects the on-the-wire encoding for a packed skill. SEP-2640 fixes two formats: gzip-compressed tar (ArchiveTarGz, MIME application/gzip) and zip (ArchiveZip, MIME application/zip). Hosts MUST support both and SHOULD pick the format from a resource's mimeType, falling back to the URL suffix.
const ( // ArchiveFormatUnknown is the zero value. NewProvider rejects it // when WithArchiveMode is in effect. ArchiveFormatUnknown ArchiveFormat = iota // ArchiveFormatTarGz is the gzip-compressed tar format. ArchiveFormatTarGz // ArchiveFormatZip is the zip format. ArchiveFormatZip // ArchiveFormatTarBz2 is the bzip2-compressed tar format. Read-only: // ext/skills consumes .tar.bz2 archives via NewArchiveFS / OpenArchive // but PackSkill rejects it because bzip2 compression is slow and // rarely justified for skill-sized payloads. Adopters that need to // produce tar.bz2 should do so externally (e.g., system tar) and pass // the bytes to NewArchiveFS. ArchiveFormatTarBz2 )
func DetectArchiveFormat ¶
func DetectArchiveFormat(name string, peek []byte) ArchiveFormat
DetectArchiveFormat infers an ArchiveFormat from a URL or filename suffix (".tar.gz" or ".zip") and falls back to a magic-byte sniff on peek when the suffix is missing or ambiguous. Returns ArchiveFormatUnknown on no match.
Suffix matching is case-sensitive and trims a single ".gz"-like layer only when the file is also ".tar.gz" — bare ".gz" is not a SEP-2640 archive shape and gets ArchiveFormatUnknown.
func (ArchiveFormat) MimeType ¶
func (f ArchiveFormat) MimeType() string
MimeType returns the MIME type for the format. tar.bz2 uses application/x-bzip2 (the de facto convention; the SEP only formally names tar.gz and zip).
func (ArchiveFormat) String ¶
func (f ArchiveFormat) String() string
String renders the format for diagnostics. Not the wire form.
func (ArchiveFormat) Suffix ¶
func (f ArchiveFormat) Suffix() string
Suffix returns the URL/file-name suffix that identifies the format (".tar.gz", ".zip", or ".tar.bz2"). Returns the empty string for ArchiveFormatUnknown.
type ChangeAction ¶
type ChangeAction string
ChangeAction discriminates how a path entered the pending set in PathsChangedPayload. The zero value (empty string) is treated as ChangeActionModified on decode, so omitempty on the wire produces the most compact representation for the common case.
const ( // ChangeActionModified signals the path's content changed in place. // Subscribers should re-fetch and re-verify the digest. ChangeActionModified ChangeAction = "modified" // ChangeActionCreated signals the path is newly served. Subscribers // add the URI to their local cache; re-fetch on first access. ChangeActionCreated ChangeAction = "created" // ChangeActionDeleted signals the path is no longer served. // Subscribers prune the URI from their local cache without // re-fetching (a fetch would return a not-found error). ChangeActionDeleted ChangeAction = "deleted" )
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client wraps a *client.Client with SEP-2640 host-workflow helpers: capability detection, discovery index fetch, manifest and supporting file reads, digest verification, and SEP-414 P7 (#748) activation telemetry.
The wrapper holds the underlying client pointer plus optional telemetry plumbing (TracerProvider + activation hook). Methods are safe for concurrent use to the same degree *client.Client is.
Typical host workflow:
mcp := client.NewClient(serverURL, info)
mcp.Connect()
sc := skills.NewClient(mcp,
skills.WithTracerProvider(tp),
skills.WithActivationHook(myMetrics.RecordSkillActivation),
)
if !sc.SupportsSkills() { return }
idx, err := sc.ListSkills(ctx)
for _, entry := range idx.Skills {
result, err := sc.ReadAndVerify(ctx, entry.URL, entry.Digest)
// result.DigestVerified is true on match; ErrDigestMismatch otherwise
}
// At the point in the agent loop where the skill enters model context:
sc.Activate(ctx, "skill://pdf-processing/SKILL.md",
skills.WithReason("agent_decided"))
func NewClient ¶
NewClient builds a SEP-2640 host helper over the given mcpkit client. The underlying client must already be Connect()ed for any read method to succeed.
Options configure SEP-414 P7 (#748) telemetry plumbing:
- WithTracerProvider — span emission around read methods + Activate
- WithActivationHook — non-OTel telemetry callback fired from Activate
- WithMaxResourceBytes — per-resource fetch-size cap (issue 867)
- WithServerByteBudget — cumulative fetch-size budget (issue 867)
With no options, the helper behaves identically to the pre-P7 shape aside from the per-resource size cap, which defaults to DefaultMaxResourceBytes: zero telemetry overhead, no spans, no hook calls. NoopTracerProvider is the default — call sites do not nil-check.
func (*Client) Activate ¶
func (c *Client) Activate(ctx context.Context, uri string, opts ...ActivateOption) ActivationEvent
Activate signals SEP-414 P7 (#748) that the host just put the skill at uri into the model context. It is a PURE SDK-side telemetry emit point — no MCP wire traffic, no spec contract. The activation may follow a cached manifest read, a freshly-loaded skill, or any other path the agent loop chose; mcpkit's wire surface cannot otherwise observe the use because cached skills activate invisibly.
Side effects:
- When a TracerProvider is installed, emits an instant `skills.activate` span (Start+End back-to-back — activation is point-in-time, not a duration). Span attributes: mcp.skill.uri, mcp.skill.path (when uri is a manifest URI), mcp.skill.activation.reason (when WithReason is supplied).
- When an activation hook is installed (WithActivationHook), calls it synchronously with the same ActivationEvent that is returned.
The returned ActivationEvent is a structured pull-style emit duplicate of what the span / hook saw, suitable for non-OTel telemetry the caller wants to feed independently. Both telemetry sinks see the same Timestamp.
uri is not validated — Activate accepts any string the host wants to report (manifest URI, sub-file URI, even an opaque identifier the host minted for an in-process bundle). When uri parses as a SEP-2640 manifest URI, mcp.skill.path is emitted as well.
ctx parents the span. A canceled ctx does not block the activation — activation telemetry MUST NOT depend on context liveness because the agent loop reports activations after the originating handler ctx may have ended.
func (*Client) BytesConsumed ¶ added in v0.4.0
BytesConsumed reports the running total charged against the Client's WithServerByteBudget across every skill:// read so far. Returns 0 when no budget is configured or nothing has been read yet. Safe for concurrent use.
func (*Client) ListSkills ¶
ListSkills reads skill://index.json and returns the parsed Index.
SEP-2640 makes the index OPTIONAL: a server MAY decline to expose it. When the read returns a not-found error, ListSkills returns an empty Index (with the Schema field unset) and no error so callers can treat absent indexes the same as empty ones. Other read errors (transport, malformed JSON) propagate.
ctx parents the SEP-414 P7 (#748) `skills.list` span when a TracerProvider is installed. On success the span carries `mcp.skill.count`. ctx is not threaded to the underlying client (the legacy ReadResource API is ctx-free); it only governs span parentage.
func (*Client) LoadAll ¶ added in v0.4.0
func (c *Client) LoadAll(ctx context.Context) ([]LoadedSkill, error)
LoadAll fetches the discovery index and loads it via LoadIndex. The returned error is non-nil only when the index itself cannot be fetched; per-skill failures ride the results.
func (*Client) LoadIndex ¶ added in v0.4.0
func (c *Client) LoadIndex(ctx context.Context, idx Index) []LoadedSkill
LoadIndex reads every skill-md entry of idx with digest verification. Per-skill failures are isolated: one tampered or unreachable skill never poisons the batch, it just comes back with Err set so hosts can warn and continue. Archive entries are recorded as skipped (extraction is a host decision with its own security posture, not an implicit side effect of loading instructions).
Results are ordered by entry URL so instruction assembly is deterministic across runs regardless of index order. Callers that fetched (or filtered) an index themselves use this directly; LoadAll is the fetch-then-load convenience.
func (*Client) ReadAndVerify ¶
func (c *Client) ReadAndVerify(ctx context.Context, uri, expectedDigest string) (*ReadResult, error)
ReadAndVerify reads uri and checks the SHA-256 of the served bytes against expectedDigest (in SEP-2640's "sha256:{64-hex}" format).
On match, returns a ReadResult with DigestVerified=true and no error. On mismatch, returns ErrDigestMismatch wrapped with the expected and actual digests for diagnostics — per SEP-2640 the host MUST NOT use the bytes in that case.
expectedDigest of "" disables verification: ReadAndVerify behaves like ReadSkillURI and returns DigestVerified=false. This is a convenience for hosts that have a URI but no catalogued digest (server instructions, user-supplied URIs).
ctx parents the SEP-414 P7 (#748) `skills.read_and_verify` span when a TracerProvider is installed. The span carries mcp.skill.uri, mcp.skill.expected_digest, and (on a non-error return) mcp.skill.digest_verified.
func (*Client) ReadDirectory ¶
func (c *Client) ReadDirectory(ctx context.Context, uri string, opts ...ReadDirectoryOption) (DirectoryReadResult, error)
ReadDirectory issues a SEP-2640 resources/directory/read call for uri and returns the directory's direct children.
Per the SEP (commit 2e04c48d, 2026-06-09): files carry their ordinary resource metadata; subdirectories carry MimeTypeDirectory and a URI without trailing slash. Clients descend by calling ReadDirectory again on a child directory.
Pre-call guard: SupportsDirectoryRead must return true on the underlying connection, otherwise ReadDirectory returns ErrDirectoryReadNotSupported without issuing a network call. This matches the SEP's normative "Clients MUST NOT call" wording.
ctx parents the SEP-414 P7 (#748) `skills.read_directory` span when a TracerProvider is installed. On success the span carries mcp.skill.uri and mcp.skill.entries (count of returned ResourceDefs).
func (*Client) ReadSkillFile ¶
func (c *Client) ReadSkillFile(ctx context.Context, manifest *SkillManifest, relPath string) ([]byte, error)
ReadSkillFile resolves a relative reference against a manifest's skill root and reads the result via skill://. Used to follow links inside a SKILL.md body (e.g., "references/GUIDE.md") per SEP-2640's filesystem-style resolution.
Returns the same byte semantics as ReadSkillURI. The inner ReadSkillURI call emits the SEP-414 P7 (#748) `skills.read` span; ReadSkillFile itself adds no wrapping span (it's a thin URI resolver + delegated read).
func (*Client) ReadSkillFileVerified ¶ added in v0.4.0
func (c *Client) ReadSkillFileVerified(ctx context.Context, entry IndexEntry, manifest *SkillManifest, relPath string) (*ReadResult, error)
ReadSkillFileVerified resolves relPath against the manifest's skill root, reads it, and verifies the served bytes against the per-file digest pinned in the entry's supporting-file pins (issue 866). It is the integrity-checked counterpart to ReadSkillFile: use it for supporting files a re- verifying host must not trust unverified (scripts, templates, referenced docs), so a swapped file is rejected on read.
entry is the IndexEntry for the skill (from ListSkills + Index.Lookup), which carries the supporting-file pins mcpkit's Indexer writes under MetaKeyFileDigests (read via entry.FileDigest). The lookup key is the file's path relative to the skill root, derived from the resolved URI so that "./x", "a/../b", and percent-encoded forms all match the canonical pin.
Errors:
- ErrSupportingFileUnpinned when the entry carries no pin for the resolved path (secure default — no silent unverified fallback).
- ErrDigestMismatch when the served bytes do not match the pin; per SEP-2640 the host MUST NOT use the bytes.
On success returns a ReadResult with DigestVerified=true.
func (*Client) ReadSkillManifest ¶
ReadSkillManifest fetches a SKILL.md URI, parses the YAML frontmatter, and returns both the typed Frontmatter and the raw + post-frontmatter body bytes.
Validates that uri is a manifest URI (ends in /SKILL.md). For non- manifest URIs use ReadSkillURI.
ctx parents the SEP-414 P7 (#748) `skills.read_manifest` span when a TracerProvider is installed. On success the span carries mcp.skill.path and mcp.skill.name in addition to mcp.skill.uri.
func (*Client) ReadSkillURI ¶
ReadSkillURI reads any skill:// URI and returns the bytes the server served. Used when a host receives a skill URI from server instructions, the user, or another skill and wants to fetch the content without going through the index.
Returns text bytes when the resource is text-typed; base64-decoded blob bytes when the resource is binary-typed (e.g., archive entries served in archive mode).
ctx parents the SEP-414 P7 (#748) `skills.read` span when a TracerProvider is installed. ctx is not threaded to the underlying client (the legacy ReadResourceFull API is ctx-free); it only governs span parentage.
func (*Client) SupportsDirectoryRead ¶
SupportsDirectoryRead reports whether the connected server advertised the SEP-2640 directoryRead capability (added by SEP commit 2e04c48d on 2026-06-09) inside the io.modelcontextprotocol/skills extension. Per the SEP's normative wording, clients MUST NOT call resources/directory/read against a server that has not declared directoryRead: true. ReadDirectory's pre-call guard uses this check.
The signal is read from the cached initialize/discover response; this method does not issue a network call.
func (*Client) SupportsSkills ¶
SupportsSkills reports whether the connected server advertises the io.modelcontextprotocol/skills extension in its initialize (or server/discover) response. Hosts iterating connected servers can use this to skip ListSkills calls against servers that do not support the extension.
The signal is read from the cached initialize/discover response on the underlying client; this method does not issue a network call.
type DirectoryReadRequest ¶
type DirectoryReadRequest struct {
URI string `json:"uri"`
Cursor string `json:"cursor,omitempty"`
}
DirectoryReadRequest is the typed params shape for the SEP-2640 resources/directory/read method.
Cursor mirrors the resources/list pagination contract: empty on the first request, then the NextCursor returned by the prior response.
type DirectoryReadResult ¶
type DirectoryReadResult struct {
Resources []core.ResourceDef `json:"resources"`
NextCursor string `json:"nextCursor,omitempty"`
}
DirectoryReadResult is the typed result shape for the SEP-2640 resources/directory/read method.
Resources are the directory's direct children — files carry their ordinary resource metadata; subdirectories carry MimeTypeDirectory and a URI without trailing slash. The listing is not recursive: clients descend by calling the method again on a child directory.
NextCursor follows the resources/list contract: present and non-empty when more entries remain, omitted when the listing is complete.
type FetchOption ¶
type FetchOption func(*fetchConfig)
FetchOption tunes FetchArchive.
func WithHTTPClient ¶
func WithHTTPClient(c *http.Client) FetchOption
WithHTTPClient overrides the *http.Client used for the fetch. Default is http.DefaultClient. Adopters needing custom transports (proxy, mTLS, retries via middleware), timeouts, or redirect policies configure them on this client directly — there is no per-option wrapper, the client IS the configuration surface.
func WithHTTPMaxBytes ¶
func WithHTTPMaxBytes(n int64) FetchOption
WithHTTPMaxBytes caps the response body's accepted size. Default is DefaultArchiveMaxBytes (100 MiB). Pass -1 to disable the cap entirely. When the cap is exceeded the fetch errors with ErrArchiveExceedsMaxBytes and any partial bytes are discarded.
func WithRequestModifier ¶
func WithRequestModifier(fn func(*http.Request)) FetchOption
WithRequestModifier installs a callback that mutates the *http.Request immediately before the fetch fires. Use for per-call headers (User-Agent, Authorization, GitHub PAT, etc.) without having to build a custom RoundTripper for one-off concerns.
func WithStreamToDisk ¶
func WithStreamToDisk(dir string) FetchOption
WithStreamToDisk streams the response body to a temp file under dir, then opens it disk-backed. For ZIP archives this means the returned SourceFS reads per-file from disk throughout (low steady-state memory). For tar formats the bytes still load into memory after the stream completes (tar has no random access).
Pass empty string to use os.TempDir(). The tempfile is removed when the SourceFS's Close() is called.
Default: in-memory.
type FileDigest ¶ added in v0.4.0
FileDigest pins one supporting file within a skill-md skill to a SHA-256 digest (issue 866). Path is the file's path relative to the skill directory, forward-slash separated — the same relative reference Client.ReadSkillFile resolves against the manifest root (e.g. "references/GUIDE.md"). SKILL.md is pinned by IndexEntry.Digest and is never repeated here.
type Frontmatter ¶
type Frontmatter struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Extra map[string]any `yaml:"-"`
}
Frontmatter is the YAML block at the head of a SKILL.md file.
SEP-2640 requires only Name and Description; the Agent Skills specification (delegated to by the SEP) may require additional fields, and individual servers MAY surface arbitrary fields via the resource's _meta object. Extra captures anything the parser sees beyond Name and Description so callers can inspect or republish without losing data.
func ParseFrontmatter ¶
func ParseFrontmatter(src []byte) (Frontmatter, []byte, error)
ParseFrontmatter parses the YAML front-matter block at the head of a SKILL.md document and returns the parsed Frontmatter together with the remaining body bytes.
Behavior:
- A leading UTF-8 BOM is stripped before scanning.
- CRLF line endings are normalized to LF.
- The document MUST begin with a line containing exactly "---" (ErrMissingFrontmatter otherwise).
- The block MUST be terminated by another line containing exactly "---" (ErrUnterminatedFrontmatter otherwise).
- The YAML between the delimiters MUST decode to a mapping (ErrNonMappingFrontmatter otherwise).
- The mapping MUST contain non-empty name and description fields (ErrFrontmatterMissingName / ErrFrontmatterMissingDescription).
The returned body bytes are everything after the closing delimiter line (including the trailing newline that follows it, if any). The body is returned verbatim so callers can republish it without re-encoding.
func ParseFrontmatterReader ¶
func ParseFrontmatterReader(r io.Reader) (Frontmatter, []byte, error)
ParseFrontmatterReader streams from r and delegates to ParseFrontmatter. SKILL.md files are small in practice, so this buffers fully.
type GitHubOption ¶
type GitHubOption func(*githubConfig)
GitHubOption tunes FetchGitHubArchive.
func WithGitHubFetchOptions ¶
func WithGitHubFetchOptions(opts ...FetchOption) GitHubOption
WithGitHubFetchOptions forwards FetchOption values to the underlying FetchArchive call (HTTP client, size cap, stream-to-disk, request modifier). Pass an Authorization header here for private repos.
func WithGitHubSubdir ¶
func WithGitHubSubdir(subdir string) GitHubOption
WithGitHubSubdir re-roots the returned SourceFS into a subdirectory of the repo. Most adopter repos store skills under a subdir like "skills/" rather than at the repo root; this skips the noise.
type Index ¶
type Index struct {
Schema string `json:"$schema"`
Skills []IndexEntry `json:"skills"`
Meta map[string]any `json:"_meta,omitempty"`
}
Index is the document served at the well-known IndexURI.
Meta carries opt-in extension metadata under the `_meta` key per the MCP convention. Keys are reverse-domain-namespaced (io.modelcontextprotocol.skills/...) so they will not collide with any field the SEP may add in the future. mcpkit populates "io.modelcontextprotocol.skills/version" with Provider.Version() at index build time; stateless clients poll the index and observe this field bumping when content changes (issue #795).
func NewIndex ¶
func NewIndex(entries ...IndexEntry) Index
NewIndex returns an Index pre-populated with the schema URI defined by IndexSchemaURI.
func (Index) Lookup ¶
func (i Index) Lookup(uri string) (IndexEntry, bool)
Lookup returns the IndexEntry whose URL exactly matches uri. The second return value reports whether a match was found.
Client-side use: a host that receives a skill:// URI from server instructions, the user, or another skill can call Lookup against the index it fetched via ListSkills. A hit gives the host digest-verifiable metadata; a miss is the SEP-2640-sanctioned "skill exists but is not enumerated" case where the host falls back to a bare ReadSkillURI.
Comparison is exact-string. A trailing slash or differing percent encoding on the input is the caller's bug, not Lookup's concern.
type IndexEntry ¶
type IndexEntry struct {
Type SkillType `json:"type"`
Name string `json:"name,omitempty"`
Description string `json:"description"`
URL string `json:"url"`
Digest string `json:"digest,omitempty"`
// Meta carries opt-in, reverse-domain-namespaced extension metadata per
// the MCP _meta convention. mcpkit uses MetaKeyFileDigests to pin
// supporting-file integrity (issue 866). Placing the pins under _meta,
// rather than a top-level field, keeps them from colliding with any
// field a future SEP revision may add to the entry — the supporting-
// file digest shape is still spec-undecided (issues 780 / 839). When
// the SEP settles, mcpkit maps to whatever shape it defines. Read the
// pins with FileDigests / FileDigest.
Meta map[string]any `json:"_meta,omitempty"`
}
IndexEntry is a single skill entry in a server's skill://index.json.
Per SEP-2640, Name and Digest are required for both the skill-md and archive types. The JSON encoding keeps `omitempty` on both fields so future spec revisions that re-introduce a manifest-less entry type can be parsed without a struct-shape change.
func (IndexEntry) FileDigest ¶ added in v0.4.0
func (e IndexEntry) FileDigest(relPath string) (string, bool)
FileDigest returns the pinned SHA-256 for the supporting file at the given skill-directory-relative path, and whether a pin exists. The path is matched exactly against the canonical shape the Indexer writes (clean, forward-slash, relative to the skill root — the same shape ResolveRelative produces from a manifest URI + relative reference).
func (IndexEntry) FileDigests ¶ added in v0.4.0
func (e IndexEntry) FileDigests() []FileDigest
FileDigests returns the supporting-file pins carried under MetaKeyFileDigests in the entry's _meta, or nil when none are present (the server pinned SKILL.md only, or ran with WithSupportingFileDigests set to SupportingDigestsOff). It handles both a freshly-built entry (typed []FileDigest value) and a JSON-decoded one (generic []any), so it works on both the serving and consuming sides.
func (IndexEntry) Validate ¶
func (e IndexEntry) Validate() error
Validate checks the per-type field requirements from SEP-2640's index table. It does not check digest format (use ValidateDigest separately) or that URL is well-formed (use ParseURI).
type Indexer ¶
type Indexer struct {
// contains filtered or unexported fields
}
Indexer computes the SEP-2640 discovery index for a Provider's skills, digests each artifact with SHA-256, and exposes the result via resources/read of skill://index.json.
The zero value is not useful. Call NewIndexer.
Indexer is safe for concurrent use; Index() takes the cache lock internally. The cache invalidates on the first of two events:
- the configured cache TTL elapses, or
- any cataloged skill's SKILL.md mtime differs from its mtime at cache-build time.
When the underlying fs.FS reports a zero ModTime (notably embed.FS), mtime invalidation cannot run for that build and the cache reverts to TTL-only freshness. With TTL also unset (zero), every Index() call recomputes.
WithMtimeChecks(false) disables the per-skill mtime comparison for a backing where fs.Stat is expensive (an S3/HTTP-rooted fs.FS): the cache then invalidates on TTL and explicit Provider.NotifyChanged only. See that option for the tradeoff.
func NewIndexer ¶
func NewIndexer(provider *Provider, opts ...IndexerOption) *Indexer
NewIndexer constructs an Indexer that draws skills from provider. The provider must already be populated (NewProvider returned without error); subsequent changes to the provider's catalog are not picked up here. Live mutation is the concern of ext/skills issue 564 (hot-reload).
func (*Indexer) Index ¶
Index returns the discovery index per SEP-2640.
Each entry's Digest is a sha256:{64-hex} string over the raw bytes of the entry's canonical artifact. For skill-md entries that artifact is the SKILL.md file; archive entries (ext/skills issue 561) will hash the archive bytes instead.
The returned Index is the cached value when cache freshness rules allow; otherwise it is freshly computed. Callers should treat the returned Index as immutable. Subsequent calls may return the same underlying slices.
Errors propagate the underlying fs.FS read errors with the source path attached.
func (*Indexer) Invalidate ¶
func (i *Indexer) Invalidate()
Invalidate marks the cached index entry stale so the next Index() call rebuilds. Provider.NotifyChanged calls this when the version counter bumps; tests can use it to drive cache regeneration deterministically without waiting for TTL or mtime changes.
Safe to call before any cache has been built (no-op) and from any goroutine.
func (*Indexer) RegisterWith ¶
RegisterWith installs the index resource onto srv. The handler calls Index() at request time so the result reflects cache state plus any invalidation since the last call.
type IndexerOption ¶
type IndexerOption func(*indexerConfig)
IndexerOption configures an Indexer via NewIndexer.
func WithIndexerCacheTTL ¶
func WithIndexerCacheTTL(d time.Duration) IndexerOption
WithIndexerCacheTTL sets the duration the indexer caches a computed Index before recomputing. The default (zero) means every Index() call recomputes. Mtime-based invalidation runs in addition to TTL on fs.FS implementations that report a non-zero ModTime.
func WithMtimeChecks ¶ added in v0.4.0
func WithMtimeChecks(enabled bool) IndexerOption
WithMtimeChecks toggles the per-skill mtime-comparison branch of cache invalidation. Default true: on every cache hit the Indexer fs.Stat's each cataloged skill and recomputes when any mtime moved — cheap on local disk, correct for edit-in-place workflows.
Pass false for a backing where fs.Stat is expensive (an S3/HTTP-rooted fs.FS, etc.), where the per-skill stat round-trip dominates the cache-hit cost and defeats the point of caching. The cache then invalidates on TTL (WithIndexerCacheTTL) and explicit Provider.NotifyChanged only. With mtime checks off AND a zero TTL there is nothing to drive invalidation, so Index() recomputes every call (the same fallback as a zero-ModTime fs.FS) — pair WithMtimeChecks(false) with a non-zero TTL.
type LoadedSkill ¶ added in v0.4.0
type LoadedSkill struct {
Entry IndexEntry
Body []byte
Err error
}
LoadedSkill is one index entry's load outcome. Exactly one of Body or Err is meaningful: a nil Err means Body holds the verified SKILL.md bytes; a non-nil Err (digest mismatch, read failure, unsupported type) means the skill was skipped and the host should surface, not inject, it.
type Metadata ¶
Metadata is the host-side view of a skill, populated from its SKILL.md frontmatter plus the URI it was loaded from. It is what a SkillProvider surfaces to higher layers and what a client receives from helpers like ListSkills.
func MetadataFromFrontmatter ¶
func MetadataFromFrontmatter(fm Frontmatter, sourceURI string) Metadata
MetadataFromFrontmatter constructs a Metadata from a parsed Frontmatter and the URI of the SKILL.md it was loaded from.
type Option ¶
type Option func(*clientConfig)
Option configures a Client built via NewClient.
SEP-414 P7 (issue 748) introduces tracer + activation-hook plumbing on the SEP-2640 host helper. Options follow the same shape as the Provider options in this package: pass to NewClient, evaluated once at construction, immutable after.
func WithActivationHook ¶
func WithActivationHook(fn func(context.Context, ActivationEvent)) Option
WithActivationHook installs a callback invoked from Client.Activate in addition to the OTel span. The hook fires synchronously inside Activate before it returns.
Hosts that do not use OpenTelemetry can install the hook to feed their own telemetry pipeline (structured logging, internal counters, alternate tracing) without configuring a TracerProvider. The two telemetry sinks are independent — installing both fires both.
fn==nil is a no-op (equivalent to omitting the option).
func WithMaxResourceBytes ¶ added in v0.4.0
WithMaxResourceBytes sets the per-resource size cap for skill:// reads (ReadSkillURI and everything that flows through it: ReadSkillManifest, ReadSkillFile, ReadAndVerify). A read whose payload exceeds n is rejected with ErrResourceTooLarge before a blob is base64-decoded, so an oversized payload never incurs the decode or a subsequent hash allocation.
n <= 0 disables the cap (n == 0 is treated as "use the default" at construction; pass -1 to explicitly remove the cap). Default is DefaultMaxResourceBytes (10 MiB).
Note: the underlying resources/read call still buffers the full JSON-RPC response in memory before this cap is applied — bounding that requires a streaming read on the core client. This option bounds the decode + hash amplification and the retained bytes, which is the SDK surface ext/skills controls.
func WithServerByteBudget ¶ added in v0.4.0
WithServerByteBudget sets a cumulative cap on the total bytes a Client fetches across every skill:// read for its lifetime. Once the running total plus the next read would exceed n, that read is rejected with ErrServerByteBudgetExceeded. This bounds a directory walk that fetches many individually-small supporting files past an aggregate ceiling, which the per-resource cap alone does not catch.
n <= 0 disables the budget (the default). Reads are charged their decoded byte count; the running total is available via BytesConsumed.
func WithTracerProvider ¶
func WithTracerProvider(tp core.TracerProvider) Option
WithTracerProvider opts the Client into SEP-414 P7 (#748) span emission around its read-path methods + Activate.
When set to a non-Noop provider, each read method emits a span:
- ListSkills → skills.list
- ReadSkillURI → skills.read (attr: mcp.skill.uri)
- ReadSkillManifest → skills.read_manifest (attrs: mcp.skill.uri, mcp.skill.path, mcp.skill.name on success)
- ReadAndVerify → skills.read_and_verify (attrs: mcp.skill.uri, mcp.skill.expected_digest, mcp.skill.digest_verified on success)
- Activate → skills.activate (instant span — Start + End back-to-back; attrs: mcp.skill.uri, mcp.skill.path, mcp.skill.activation.reason when WithReason is set)
Spans inherit the W3C traceparent on the supplied ctx via the existing trace context propagation (#644 / #649 / #652), so the server-side resources/read dispatch span (now skill-attribute enriched per #748 Layer 1) lands as a child of the client read span automatically.
nil and core.NoopTracerProvider{} both short-circuit to zero overhead — the Client stores NoopTracerProvider as its default so call sites do not nil-check.
type PathChange ¶
type PathChange struct {
// Path is the fs.FS-relative path that changed. Required.
Path string
// Action is one of Created / Modified / Deleted. Empty value is
// treated as Modified.
Action ChangeAction
// Timestamp is when the Detector observed the change. Empty value
// is replaced with the Applier's call-time timestamp.
Timestamp time.Time
// Digest is the optional SHA-256 ("sha256:" + 64-hex) of the
// post-change content. Detectors that already have the digest
// supply it; the Applier does not compute it. Subscribers compare
// against their cached digest to avoid unnecessary re-fetches
// when the content matches what they already have.
Digest string
}
PathChange is the typed event a Detector passes to Provider.NotifyChangedEvents. Detectors that have richer signals than "this path is dirty" (fsnotify producing CREATE / WRITE / REMOVE; webhooks carrying mtime + content hash) populate the action + timestamp + digest fields; the Applier preserves them through coalesce + dedup into the broadcast payload. Detectors that only know "something changed" can use the simpler Provider.NotifyChanged(paths ...string) sugar; all entries default to ChangeActionModified with the call-time timestamp.
type PathChangeEntry ¶
type PathChangeEntry struct {
Action ChangeAction `json:"action,omitempty"`
Timestamp time.Time `json:"timestamp"`
Digest string `json:"digest,omitempty"`
}
PathChangeEntry is the per-path value in PathsChangedPayload.Paths. Wire-format counterpart of PathChange minus the Path itself (which is the map key).
type PathsChangedPayload ¶
type PathsChangedPayload struct {
Paths map[string]PathChangeEntry `json:"paths,omitempty"`
Version uint64 `json:"version"`
}
PathsChangedPayload is the structured _meta hint mcpkit attaches to notifications/resources/list_changed when ext/skills's Applier has path-level information about what changed (issue #795). Decode with DecodeListChangedNotification.
Paths is a map of fs.FS-relative path → PathChangeEntry, covering every path reported via Provider.NotifyChangedEvents (or NotifyChanged) in the coalesce window leading up to this broadcast. Repeated reports for the same path collapse to one entry under latest-wins semantics: the most recent action / timestamp / digest supersedes earlier ones. Empty when only opaque "something changed" signals were available (e.g., Refresh() called with no arguments) — subscribers should treat an empty Paths map as "re-read everything" and rely on Version alone.
Version is Provider.Version() at the moment this broadcast was constructed — the same counter the index will carry when a subscriber re-reads it within the same instant. Two contractual uses:
- Idempotency: a duplicate broadcast carrying the same Version can be silently dropped.
- ETag-like staleness check: if lastKnownVersion >= payload.Version the subscriber is already current and may skip the re-read.
Race note: between broadcast and re-read, another bump may land. The subscriber's re-read may legitimately return Version > payload.Version. Treat the re-read as the new ground truth; do not assume equality with the broadcast's Version.
func DecodeListChangedNotification ¶
func DecodeListChangedNotification(params any) (PathsChangedPayload, bool)
DecodeListChangedNotification extracts the optional ext/skills PathsChangedPayload from a notifications/resources/list_changed params object. Returns (payload, true) when the params carry the payload under _meta[MetaKeyPathsChanged]; returns (zero, false) for everything else — bare list_changed notifications, malformed payloads, or notifications from non-mcpkit servers.
The params argument accepts anything the standard client.WithNotificationCallback produces (map[string]any from generic unmarshal, json.RawMessage from custom transports). Internal JSON round-trip handles both shapes without forcing callers to reason about types.
Typical use from a client.WithNotificationCallback handler:
client.WithNotificationCallback(func(method string, params any) {
if method != "notifications/resources/list_changed" {
return
}
payload, ok := skills.DecodeListChangedNotification(params)
if !ok {
// Bare list_changed from a non-mcpkit server, or empty
// params — re-read everything.
return
}
for path, entry := range payload.Paths {
switch entry.Action {
case skills.ChangeActionDeleted:
// prune local cache entry for `path`
default:
// re-fetch and re-verify digest
}
}
})
Subscribers that compare entry.Digest against a cached digest can skip re-fetches entirely when the content already matches what they have; treat absent Digest as "fetch to find out."
type Provider ¶
type Provider struct {
// contains filtered or unexported fields
}
Provider walks an io/fs.FS, identifies SEP-2640 skills by SKILL.md presence, and exposes each skill's files as MCP resources under the skill:// URI convention.
A Provider is built once via NewProvider and is immutable thereafter. The walk that populates the resource map happens at construction time and surfaces any structural violation (name mismatch, nested skill, invalid skill name) as a typed error before any resource is registered with a server. The hot-reload path (ext/skills issue 564) is a separate affordance built on top of this one.
func NewProvider ¶
func NewProvider(opts ...ProviderOption) (*Provider, error)
NewProvider walks the configured fs.FS and returns a Provider with every skill's files mapped to MCP resources under the skill:// URI scheme. The walk is single-pass at construction time, then the resource map is fixed.
Errors at construction:
- ErrProviderMissingFS when neither WithFS nor WithDirectory was supplied.
- Frontmatter parse errors (ErrMissingFrontmatter, etc.) on a malformed SKILL.md.
- ErrSkillNameMismatch when frontmatter name does not equal the skill's parent directory base name.
- ErrNestedSkill when a SKILL.md is found inside another skill's subtree.
- ErrInvalidSkillName when the final skill-path segment violates the Agent Skills naming rules (the directory name and the matching frontmatter name must both satisfy them).
func (*Provider) Catalog ¶
func (p *Provider) Catalog() []IndexEntry
Catalog returns the index entries for every cataloged skill, suitable for marshalling into skill://index.json by ext/skills issue 560. The Digest field is left empty here. SHA-256 over the canonical artifact (the SKILL.md bytes, or the archive bytes for archive-mode skills) is computed at index generation time so its source of truth is colocated with the canonicalization rule.
func (*Provider) Close ¶
Close stops any pending broadcast timer, stops the fsnotify Detector goroutine (when WithFSWatcher is in effect) abruptly, and prevents future broadcasts. Idempotent. After Close, the version counter still bumps on NotifyChanged calls and the index cache still invalidates (polling clients continue to see fresh state), but the stateful-wire broadcast goroutine is dormant and any buffered fsnotify events are dropped.
Adopters wanting to drain in-flight events through one final flush before stopping should use Shutdown(ctx) instead — Close is process-exit semantics, Shutdown is the graceful path.
func (*Provider) NotifyChanged ¶
NotifyChanged is the simple Applier entry point: every path in the argument list is treated as ChangeActionModified with the call-time timestamp. Sugar over NotifyChangedEvents for Detectors that only know "this path is dirty"; Detectors with richer signals (fsnotify distinguishing CREATE / WRITE / REMOVE, webhooks carrying mtime) should call NotifyChangedEvents directly.
See NotifyChangedEvents for coalesce / throttle / dedup semantics and dual-wire delivery behavior.
func (*Provider) NotifyChangedEvents ¶
func (p *Provider) NotifyChangedEvents(events ...PathChange) error
NotifyChangedEvents is the typed Applier entry point. The Provider bumps its version counter, invalidates the Indexer cache so the next skill://index.json read rebuilds, accumulates the events into a deduplicated pending set, and — subject to the coalesce window and throttle interval — broadcasts a notifications/resources/list_changed event to subscribed sessions on the stateful wire.
Dedup semantics ¶
Pending paths are keyed by Path; multiple events for the same path within a coalesce window collapse to one entry under latest-wins rules (action, timestamp, and digest from the most recent event supersede earlier ones). Five NotifyChangedEvents calls naming the same path produce one entry in the broadcast payload.
Flow control ¶
WithCoalesceWindow(d): when set, NotifyChangedEvents schedules a trailing-edge flush at now+d (resetting the timer on each call). When d is zero, every call flushes immediately.
WithMinBroadcastInterval(d): when set, a flush arriving within d of the last broadcast defers to last+d so subscribers never see two broadcasts closer than d apart.
The version counter bump and index cache invalidation happen immediately on every call regardless of coalesce/throttle — only the broadcast is deferred. Polling stateless clients see the bumped _meta version on the next index read without waiting for the coalesce window.
Dual-wire ¶
Broadcast targets the stateful/streamable-HTTP wire. Stateless clients (SEP-2575) have no persistent push channel; they detect changes by polling skill://index.json and observing _meta.io.modelcontextprotocol.skills/version.
Lifecycle ¶
Safe to call before RegisterWith (the broadcast is a no-op until a server is bound) and from any goroutine. After Close(), version and index invalidation still fire but broadcasts are suppressed.
func (*Provider) Refresh ¶
Refresh signals "something in the underlying content changed but I don't know what." Bumps the version counter, invalidates the index cache, and (subject to coalesce + throttle) broadcasts an empty-paths notifications/resources/list_changed event. Subscribers seeing an empty Paths map should re-read everything; the Version bump remains the load-bearing identity.
Use from adopter-driven hot-reload hooks (webhook handler, build pipeline, manual sweep) when the caller doesn't have a precise changed-path list. Detectors that do have a path list should call NotifyChanged / NotifyChangedEvents instead so subscribers get the richer payload.
func (*Provider) RegisterWith ¶
RegisterWith installs each cataloged resource onto srv via the existing RegisterResource path. The handler streams the file from the underlying fs.FS at request time. Resources are registered in stable URI-sorted order.
RegisterWith also declares the io.modelcontextprotocol/skills extension on srv via srv.RegisterExtension so the capability appears in the initialize response. RegisterExtension is keyed by extension ID and idempotent, so an explicit srv.RegisterExtension or server.WithExtension call on the same SkillsExtension causes no double-emit.
Unless WithoutDirectoryRead was supplied to NewProvider, RegisterWith also installs the SEP-2640 resources/directory/read handler (added by SEP commit 2e04c48d) and emits {"directoryRead": true} inside the extension's capability Config. The Provider walks its underlying fs.FS at request time to enumerate the requested directory's direct children.
Unless WithoutIndex was supplied to NewProvider, RegisterWith also constructs an internal Indexer and registers skill://index.json on srv. Use WithIndexCacheTTL to tune the index's cache freshness or WithoutIndex to suppress registration entirely (when, for example, the caller wants to construct and register a custom Indexer).
func (*Provider) Resources ¶
func (p *Provider) Resources() []core.ResourceDef
Resources returns the cataloged resource definitions in stable URI-sorted order, suitable for inspection or for callers that need a list view without going through a server registration.
func (*Provider) Shutdown ¶
Shutdown is the graceful counterpart to Close: when WithFSWatcher is in effect, signals the Detector goroutine to drain any events already buffered in fsnotify's channel through one final NotifyChangedEvents call before exiting; runs a final flush so the accumulated PathChanges land in one last broadcast; then closes timers and the watcher.
Waits for the goroutine to exit or for ctx to cancel, whichever fires first. On ctx cancellation the watcher is still closed cleanly but ctx.Err() is returned so the caller knows the drain did not complete.
Idempotent. Calling Shutdown after Close (or vice versa) is a no-op returning nil. Process-restart-friendly: typical use is signal-handler → ctx, cancel := context.WithTimeout(ctx, 5*time.Second); defer cancel(); provider.Shutdown(ctx).
func (*Provider) Version ¶
Version returns the Provider's monotonic invalidation counter. The counter starts at zero and increments on every NotifyChanged / Refresh call. Consumers use Version() to drive cache freshness — the Indexer compares the counter at cache-build time against the current value and rebuilds on mismatch.
Forward-compatible with the per-artifact counters proposed in #798: when those land, the global counter remains as the ceiling that any per-artifact counter increments alongside.
type ProviderOption ¶
type ProviderOption func(*providerConfig)
ProviderOption configures a Provider via NewProvider.
func WithArchiveMaxBytes ¶
func WithArchiveMaxBytes(n int64) ProviderOption
WithArchiveMaxBytes caps the unpacked size of any archive the Provider produces or that an associated ArchiveFS reads. Pass 0 to use DefaultArchiveMaxBytes (100 MiB), pass -1 to disable the cap entirely (NOT recommended for untrusted archives).
func WithArchiveMode ¶
func WithArchiveMode(format ArchiveFormat) ProviderOption
WithArchiveMode publishes every skill as a single archive resource at skill://<path><suffix> instead of registering each file individually. Per SEP-2640, archive mode is a server-side packaging optimization that delivers a multi-file skill atomically in one round trip without changing the post-unpack virtual namespace hosts observe.
Index entries for archive-mode skills carry Type:archive, URL ending in the format suffix, and a Digest computed over the archive bytes.
Archive mode is per-Provider in this revision. Per-skill mode (mixing archive-served and file-served skills under one Provider) is deliberately out of scope; file a follow-up if a use case surfaces.
func WithCoalesceWindow ¶
func WithCoalesceWindow(d time.Duration) ProviderOption
WithCoalesceWindow groups NotifyChanged calls that land within d of each other into a single version bump + broadcast. Trailing-edge: the timer resets on each call, so a sustained burst defers the flush until the burst settles. Set to 0 (default) to disable coalescing — every NotifyChanged call flushes immediately (subject to throttle).
Within the window, paths are accumulated into a deduplicated set; five NotifyChanged calls naming the same path produce one entry, one version bump, and one broadcast. The set is reserved for the per-path dependency DAG that lands with #796 (sub-indexes) and #798 (pack cache); today it serves only as the dedup key.
Recommended: 100ms–500ms for fsnotify-style Detectors (editor saves typically fire 3–5 events in <50ms); 0 for explicit-call Detectors like admin endpoints where each call is intentional and unique.
func WithDirectory ¶
func WithDirectory(path string) ProviderOption
WithDirectory is sugar for WithFS(os.DirFS(path)). It exists for the common local-directory case so callers do not have to spell out the os.DirFS wrap.
Additionally captures path as providerConfig.hostRoot so WithFSWatcher can locate the underlying filesystem for fsnotify. Relative paths are fine — the watcher resolves to absolute at startup. Callers using WithFS instead must arrange their own change-detection (the watcher has no fs.FS-only path; non-os filesystems like embed.FS aren't watchable by construction).
func WithFS ¶
func WithFS(fsys fs.FS) ProviderOption
WithFS supplies the io/fs.FS that the Provider walks for skills. The FS is rooted at "." within itself. Use any fs.FS implementation: os.DirFS for a local directory, embed.FS for a binary-embedded tree, fstest.MapFS for tests, or a chained adapter that synthesizes content (e.g., generating SKILL.md for non-conforming directories).
func WithFSWatcher ¶
func WithFSWatcher(opts ...WatcherOption) ProviderOption
WithFSWatcher enables an fsnotify-driven Detector that watches the Provider's hostRoot (populated by WithDirectory) and feeds change events into Provider.NotifyChangedEvents.
Requires WithDirectory — fs.FS implementations like embed.FS and skills.ArchiveFS are read-only by construction and have no host path to watch; NewProvider returns ErrFSWatcherMissingHostRoot when WithFSWatcher is set against such a Provider.
Lifecycle:
- NewProvider creates the fsnotify.Watcher and walks the host directory tree to register watches. Errors on individual watcher.Add calls (permission denied on a subdir, etc.) are surfaced via the error handler set by WithFSWatcherErrorHandler and skipped — one unreadable subdir does not fail construction.
- RegisterWith starts the dispatch goroutine that reads watcher.Events / watcher.Errors and forwards into the Applier.
- Close stops the goroutine abruptly.
- Shutdown(ctx) drains buffered events through one final flush before stopping (graceful path).
Recursive watching is handled by re-walking on directory-create events; directory removals prune the watch set. Ignore patterns from WithFSWatcherIgnore are applied to both the initial walk and runtime additions.
The Detector emits no events for the initial catalog — the state at construction IS the baseline. Bursts of fsnotify events (editor saves typically fire 3-5 events in <50ms) collapse naturally through the Applier's WithCoalesceWindow.
func WithIndexCacheTTL ¶
func WithIndexCacheTTL(d time.Duration) ProviderOption
WithIndexCacheTTL forwards a cache TTL to the Indexer that Provider.RegisterWith builds for skill://index.json. Equivalent to constructing an Indexer explicitly with WithIndexerCacheTTL(d). Ignored when WithoutIndex is also supplied. See Indexer for the full cache semantics including the zero-mtime fallback.
func WithMetaPrefix ¶
func WithMetaPrefix(prefix string) ProviderOption
WithMetaPrefix overrides the reverse-domain prefix used to surface extra SKILL.md frontmatter fields through a resource's annotations. SEP-2640 recommends "io.modelcontextprotocol.skills/" and that is the default when this option is not supplied.
func WithMinBroadcastInterval ¶
func WithMinBroadcastInterval(d time.Duration) ProviderOption
WithMinBroadcastInterval enforces a minimum gap between consecutive broadcasts. A flush arriving within d of the last broadcast queues a single trailing broadcast at last+d. Set to 0 (default) to disable throttling.
Composes with WithCoalesceWindow: coalesce runs first (group events into one broadcast intent); throttle enforces the minimum-interval contract on the actual broadcast. The version counter and the index cache invalidation still fire on the coalesce boundary so polling stateless clients see changes promptly — only the stateful-wire notification rate is throttled.
func WithSupportingFileDigests ¶ added in v0.4.0
func WithSupportingFileDigests(mode SupportingDigestMode) ProviderOption
WithSupportingFileDigests selects the supporting-file integrity strategy (see SupportingDigestMode). Default is SupportingDigestsPerFile.
The pins live under a reverse-domain _meta key, so they never collide with a top-level field the SEP may later define; when the SEP settles on a shape mcpkit maps to it (issue 780). Until then this option lets an operator emit strict spec-only output (SupportingDigestsOff) or keep the per-file hardening (the default).
func WithURIPrefix ¶
func WithURIPrefix(prefix string) ProviderOption
WithURIPrefix sets the organizational prefix that segments every skill's URI under. Per SEP-2640, servers MAY organize skills hierarchically. With prefix "acme/billing" a skill named "refunds" becomes skill://acme/billing/refunds/SKILL.md instead of skill://refunds/SKILL.md.
The prefix is split on "/". An empty prefix means no prefix.
func WithoutDirectoryRead ¶
func WithoutDirectoryRead() ProviderOption
WithoutDirectoryRead suppresses registration of the SEP-2640 resources/directory/read method when the Provider's RegisterWith is called. The default is ON because a Provider can always enumerate directories from its underlying fs.FS at trivial cost.
Suppress when the caller wants the discovery index without the directory-navigation surface (e.g., to stay on the pre-2e04c48d SEP shape during a transition window, or to gate the capability behind a feature flag the application owns).
func WithoutIndex ¶
func WithoutIndex() ProviderOption
WithoutIndex suppresses the auto-registration of skill://index.json when the Provider's RegisterWith is called. Use this when the server wants to expose individual skill files but not the discovery index (e.g., a generated catalog the SEP says hosts MUST NOT treat absence as proof of "no skills"), or when the caller wants to construct and register an Indexer explicitly with non-default options.
type ReadDirectoryOption ¶
type ReadDirectoryOption func(*readDirectoryConfig)
ReadDirectoryOption tunes a single ReadDirectory call.
func WithDirectoryCursor ¶
func WithDirectoryCursor(cursor string) ReadDirectoryOption
WithDirectoryCursor sets the pagination cursor for the call. Pass the NextCursor returned by a prior ReadDirectory result to fetch the next page. Empty cursor (the default) requests the first page.
type ReadResult ¶
ReadResult is the typed return from ReadAndVerify. Bytes carries the served content; DigestVerified is true when the SHA-256 over Bytes equals the expected digest the caller supplied.
type SkillManifest ¶
type SkillManifest struct {
URI string
Frontmatter Frontmatter
Body []byte
Raw []byte
}
SkillManifest holds a parsed SKILL.md plus the raw bytes the server served. Raw is preserved so the caller can verify against a digest after parsing — the digest is computed over the raw artifact, not the post-parse representation.
type SkillType ¶
type SkillType string
SkillType is the discriminator for an entry in skill://index.json.
const ( // SkillTypeSkillMD points at an individual SKILL.md resource. Supporting // files are siblings under the same skill path. SkillTypeSkillMD SkillType = "skill-md" // SkillTypeArchive points at a packed skill directory served as a single // resource. The archive's URL suffix (.tar.gz or .zip) determines the // expected format. SkillTypeArchive SkillType = "archive" )
func (SkillType) HasManifestFields ¶
HasManifestFields reports whether entries of this type carry a Name and Digest. After the 2026-06-04 SEP HEAD removal of mcp-resource-template, both surviving types require both fields; the helper is retained for callers that still want to dispatch on the type symbolically.
type SkillsExtension ¶
type SkillsExtension struct {
// DirectoryRead reports the server's support for the SEP-2640
// resources/directory/read method (added by SEP commit 2e04c48d on
// 2026-06-09). When true, the extension's wire-level Config carries
// {"directoryRead": true}; when false the Config is omitted and clients
// MUST NOT call the method per the SEP's normative wording.
//
// Provider.RegisterWith sets this to true automatically because a
// Provider can always enumerate directories from its underlying fs.FS.
// Direct callers of RegisterExtension keep the default (false) and must
// opt in explicitly when they wire their own directory handler.
DirectoryRead bool
}
SkillsExtension declares support for the SEP-2640 Skills extension (io.modelcontextprotocol/skills).
Register it on a server one of two ways. Construction-time:
srv := server.NewServer(info,
server.WithExtension(skills.SkillsExtension{}),
)
Or post-construction, mirroring the ext/tasks pattern:
srv.RegisterExtension(skills.SkillsExtension{})
Provider.RegisterWith auto-declares the extension during its own registration pass, so callers that use a Provider do not need to call either form explicitly. RegisterExtension is idempotent (keyed by extension ID on the server's dispatcher), so combining auto-declaration with an explicit registration is safe.
func (SkillsExtension) Extension ¶
func (e SkillsExtension) Extension() core.Extension
Extension implements core.ExtensionProvider. It returns the SEP-2640 extension metadata. When DirectoryRead is set, the Config map carries the directoryRead capability flag; otherwise Config stays nil and the wire-level value is the empty JSON object {} (not [] — see SEP-2640 PR discussion).
type SourceFS ¶
SourceFS is the return type for every source adapter — a read-only fs.FS that is also an io.Closer. Most adapters need cleanup (file handles, tempfiles), so callers MUST defer Close. The interface is declared explicitly so adapters can satisfy it without leaking implementation types.
func FetchArchive ¶
FetchArchive issues an HTTP GET against url and returns the resulting archive as a SourceFS. Honors ctx for cancellation, the configured size cap, and the optional disk-streaming path.
Format detection: tries the URL suffix first, then sniffs the first 8 bytes of the response if the suffix is missing.
Errors:
- ctx.Err() on cancellation.
- ErrArchiveDownloadFailed when the transport fails or the response is non-2xx (status wrapped in the error message).
- ErrArchiveExceedsMaxBytes when the body exceeds the cap.
- ErrArchiveUnknownFormat when the bytes cannot be classified.
func FetchGitHubArchive ¶
func FetchGitHubArchive(ctx context.Context, owner, repo, ref string, opts ...GitHubOption) (SourceFS, error)
FetchGitHubArchive fetches a tarball of <owner>/<repo>@<ref> from GitHub's archive endpoint and returns a SourceFS rooted at the repository contents (the tarball's top-level <repo>-<safe-ref> directory is auto-detected and stripped). Optionally re-roots into a subdir via WithGitHubSubdir.
ref accepts branches (e.g. "main"), tags ("v1.2.3"), commit SHAs, and ref-form strings ("refs/heads/main"). Slashes in ref are URL-encoded; the tarball's mangled top-level dir is discovered by reading the unpacked archive's root entry rather than reverse- engineering GitHub's encoding rules.
Public repos work without auth. For private repos pass a PAT via
WithGitHubFetchOptions(WithRequestModifier(func(r *http.Request) {
r.Header.Set("Authorization", "Bearer " + pat)
})).
func OpenArchive ¶
OpenArchive opens a local archive file and returns a SourceFS view. Format is detected from the file suffix or magic bytes.
For ZIP archives the returned SourceFS is disk-backed via fsutil.ZipFS — only the central directory is mapped; per-file reads stream from disk on demand. For tar.gz / tar.bz2 the archive is loaded fully into memory via NewArchiveFS (tar is a streaming format with no random access). Callers SHOULD defer Close.
Auto-wrap by frontmatter name ¶
When the archive contains a SKILL.md at its root (as produced by PackSkill), OpenArchive parses that file's frontmatter and wraps the returned SourceFS under a top-level directory matching the frontmatter name. So a tar.gz built by PackSkill(fsys, "git-workflow", TarGz) — whose root holds SKILL.md + supporting files with frontmatter name="git-workflow" — presents as if its layout were git-workflow/SKILL.md + supporting files. This is required for SEP-2640 compliance when the archive is mounted under any prefix: Provider's rule is path.Base(skillDir) == frontmatter.Name, so the skill-name directory must exist in the served path.
Multi-skill archives — those without a root-level SKILL.md but containing one or more <skill-name>/SKILL.md subdirectories — are served as-is. Auto-wrap is a no-op in that case.
Errors:
- os.PathError on read failure.
- ErrArchiveUnknownFormat when the file's format cannot be identified from suffix or magic bytes.
- Frontmatter parse errors when the root SKILL.md is malformed.
func OpenArchivesDir ¶
OpenArchivesDir reads every archive file in dir (matching .tar.gz / .zip / .tar.bz2 suffixes) and returns a SourceFS that flat-merges their contents at the root. Each archive's auto-wrap (via OpenArchive) ensures single-skill archives surface as <frontmatter-name>/SKILL.md; multi-skill catalog archives contribute their already-named subdirectories directly.
Result:
- PackSkill-style archive "git-workflow.tar.gz" (root SKILL.md, frontmatter name="git-workflow") contributes "git-workflow/" at the merged root.
- Catalog archive "bundle.tar.gz" (containing "git-workflow/", "pdf-processing/" at its root) contributes both directly.
Two archives contributing the same root-level entry name error at construction (via fsutil.NewMountFS's root-collision check) — operators see ambiguity immediately rather than silently losing data.
Non-archive files in dir are skipped. Subdirectories are not recursed into; for nested layouts compose multiple OpenArchivesDir calls via fsutil.NewMountFS with explicit Mount Paths.
type SupportingDigestMode ¶ added in v0.4.0
type SupportingDigestMode int
SupportingDigestMode selects how a Provider pins the integrity of a skill's supporting files (every regular file under the skill directory except SKILL.md) in its index. The supporting-file digest shape is still undecided in the SEP (issues 780 / 839), so mcpkit makes the strategy selectable rather than committing to one on the wire.
const ( // SupportingDigestsPerFile pins each supporting file with its own // SHA-256, carried under MetaKeyFileDigests in the entry's _meta. A // host verifies a single file on read via Client.ReadSkillFileVerified // without fetching the whole skill. This is the default and closes WG // threat B1 (issue 866). SupportingDigestsPerFile SupportingDigestMode = iota // SupportingDigestsOff pins SKILL.md only (via IndexEntry.Digest), // matching today's spec text exactly. Supporting files carry no pin, so // ReadSkillFileVerified returns ErrSupportingFileUnpinned for them. Use // this for strict spec-only index output until the SEP clarifies. SupportingDigestsOff )
type URIParts ¶
type URIParts struct {
// Scheme is always "skill" for URIs that pass ParseURI's validation.
Scheme string
// Raw is the original URI string ParseURI was called with.
Raw string
// AllSegments is the canonical segment view: the authority component
// followed by the path component, decoded and split on "/". Empty
// segments cause a parse error rather than appearing in the slice.
AllSegments []string
// SkillPath is the skill directory path. Populated for manifest URIs;
// empty for non-manifest URIs parsed standalone.
SkillPath []string
// FilePath is the file path within the skill. Populated for manifest
// URIs (always ["SKILL.md"]) and after a successful SplitAt or
// ResolveRelative; empty otherwise.
FilePath []string
// SkillName is the final segment of SkillPath, equal to the skill's
// frontmatter name per SEP-2640. Empty when SkillPath is unset.
SkillName string
// IsManifest is true when FilePath equals exactly ["SKILL.md"].
IsManifest bool
}
URIParts is a parsed skill:// URI.
SEP-2640 splits a skill URI into a skill path (locating the skill directory within the server's namespace) and a file path (the file inside the skill). For manifest URIs ending in /SKILL.md the boundary is fixed by the SKILL.md suffix, and ParseURI sets SkillPath, FilePath, SkillName, and IsManifest from it.
For non-manifest URIs the boundary cannot be recovered from the URI alone. SEP-2640's claim that "the skill name is always recoverable from the URI alone, without reading frontmatter" is grounded in two manifest-URI examples where SKILL.md acts as the boundary. The spec also allows prefix segments to be any RFC 3986 path segment with no further constraint, so a prefix segment and an intermediate file-path segment share the same character class. In skill://acme/billing/refunds/templates/email.md the segments refunds, templates, billing, and acme all satisfy the Agent Skills name rules, and a URI-only scan cannot pick refunds over templates without external knowledge from the discovery index or a prior manifest read. SEP-2640's host workflow always supplies that knowledge, so the spec's claim holds operationally even though the URI string in isolation is ambiguous.
ParseURI therefore returns parsed segments in AllSegments and leaves SkillPath, FilePath, SkillName, and IsManifest unset for non-manifest URIs. Callers establish the boundary by calling SplitAt(n) when the skill path length is known from an index entry or a prior manifest read, or by calling ResolveRelative from a known skill root.
func ParseURI ¶
ParseURI parses a skill:// URI into a URIParts.
Rejects any "." or ".." path segment with ErrPathTraversal — SEP-2640 skill names use [a-z0-9-] only, so dot-segments cannot appear in a well-formed URI and their presence indicates a malformed or traversal probe. Production servers SHOULD validate inbound resources/read URIs against this parser before registry lookup so that traversal attempts produce a precise InvalidParams error instead of a generic "unknown resource" miss.
Validation rules:
- Scheme must be exactly "skill" (ErrInvalidScheme otherwise).
- At least one path segment is required (ErrEmptySkillPath).
- No segment may be empty, e.g. from consecutive slashes (ErrEmptyPathSegment).
- SKILL.md, when present, MUST be the final segment of the URI; it MUST NOT appear at any non-terminal position (ErrManifestNotInRoot).
- For manifest URIs the final skill-path segment (i.e. the segment just before SKILL.md) MUST be a valid Agent Skills name: lowercase letters, digits, and hyphens, neither leading nor trailing hyphen (ErrInvalidSkillName / ErrEmptySkillName).
ParseURI does not require a manifest URI. Non-manifest URIs validate scheme, segments, and the no-nested-SKILL.md rule, but SkillPath and FilePath are left empty (see URIParts).
func ResolveRelative ¶
ResolveRelative resolves a relative file reference against a known skill root URI, returning a fully-populated URIParts for the referenced file.
SEP-2640 specifies that relative references within a skill resolve like filesystem paths against the skill's root directory (the directory containing SKILL.md). Resolution delegates to RFC 3986 reference resolution via net/url, then checks the result stays inside the skill's scope.
Behavior:
- skillRoot must have SkillPath populated (typically a manifest URI produced by ParseURI). ResolveRelative returns ErrNotManifestURI if not.
- rel is a relative reference per RFC 3986. Dot-segment normalization (". ", "..") is delegated to the stdlib resolver.
- References that carry their own scheme or authority, an absolute path, or an empty path are rejected before resolution because RFC 3986 would let any of them short-circuit out of the skill scope.
- After resolution, the result MUST still start with skillRoot.SkillPath. Otherwise the reference escaped (e.g., excess ".." segments) and ErrRelativeEscapesSkill is returned.
- A resolved URI whose final segment is SKILL.md at a deeper position than the skill root is rejected with ErrManifestNotInRoot because SEP-2640 forbids skill nesting.
- The result reuses skillRoot.SkillPath. FilePath holds the resolved file path segments. IsManifest is set when the resolution lands on the skill's own SKILL.md (the idempotent case).
func (URIParts) ManifestURI ¶
ManifestURI returns the URI of the skill's SKILL.md. Returns the empty string if SkillPath is not populated.
func (URIParts) SkillRootURI ¶
SkillRootURI returns the URI of the skill's root directory (the URI obtained by stripping the trailing SKILL.md, with a trailing slash). Returns the empty string if SkillPath is not populated.
func (URIParts) SplitAt ¶
SplitAt returns a copy of p with the skill/file boundary set after n segments. The first n segments become SkillPath, the remainder become FilePath. This is the explicit-boundary form callers use when the skill path is known from an index entry or a prior manifest read.
SplitAt validates that:
- n is in range [1, len(AllSegments)],
- the segment at position n-1 is a valid skill name,
- no SKILL.md appears in FilePath at a non-root position (only allowed when FilePath is exactly ["SKILL.md"]).
type UnpackedEntry ¶
type UnpackedEntry struct {
// Path is the entry's archive-root-relative path.
Path string
// Mode is the entry's permission bits as stored in the archive.
Mode fs.FileMode
// Body is the entry's full decoded content.
Body []byte
}
UnpackedEntry is a single file extracted from an archive by UnpackBytes. Body is the full decoded payload; consumers that need streaming should iterate UnpackBytes's input directly.
func UnpackBytes ¶
func UnpackBytes(data []byte, format ArchiveFormat, maxBytes int64) ([]UnpackedEntry, error)
UnpackBytes decodes an archive into a list of files. Safety rules from SEP-2640 are enforced as each entry is read: any path-traversal, absolute-path, or symlink-escape entry causes the whole call to fail with the relevant named error. Directories are skipped (the unpacked tree is reconstructed from file paths alone). Symlinks and hardlinks are rejected unconditionally because their resolution rules are host-dependent and the safety MUST is "resolve outside the skill directory" which cannot be evaluated reliably without staging the archive to a real filesystem.
maxBytes is the total unpacked-size cap. Pass 0 to use DefaultArchiveMaxBytes. Pass -1 to disable the cap (NOT recommended for untrusted archives).
type WatcherOption ¶
type WatcherOption func(*providerConfig)
WatcherOption tunes the fsnotify-driven Detector that WithFSWatcher installs. Options are applied to providerConfig at NewProvider time.
func WithFSWatcherErrorHandler ¶
func WithFSWatcherErrorHandler(fn func(error)) WatcherOption
WithFSWatcherErrorHandler routes runtime fsnotify errors (buffer overflows on Linux, channel-level errors, per-subdir watcher.Add failures during the initial walk) to fn. fn is called from the watcher goroutine; implementations MUST NOT block (use a buffered channel + drain goroutine if heavy logging is required).
Default is nil: errors are silently dropped. Production deployments SHOULD wire this to their logger / metrics surface.
func WithFSWatcherIgnore ¶
func WithFSWatcherIgnore(patterns ...string) WatcherOption
WithFSWatcherIgnore appends path-fragment patterns to skip during the fsnotify Detector's directory walk. Any directory whose path (relative to hostRoot) contains one of the patterns as a path segment is not watched, and events for files inside it are suppressed.
Default ignore set (always applied): ".git", "node_modules", ".DS_Store". Custom patterns supplement the defaults; pass an empty slice to keep only the defaults.
Examples that match: ".git", "node_modules", "vendor". Examples that don't: "*.tmp" (no glob support today; file a follow-up if needed).