skills

package module
v0.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 22, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

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

View Source
const ArchiveTarBz2 = ".tar.bz2"

ArchiveTarBz2 is the file suffix for bzip2-compressed tar archive entries. Read-only support — see ArchiveFormatTarBz2.

View Source
const ArchiveTarGz = ".tar.gz"

ArchiveTarGz is the file suffix for gzip-compressed tar archive entries.

View Source
const ArchiveZip = ".zip"

ArchiveZip is the file suffix for zip archive entries.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
const ExtensionID = "io.modelcontextprotocol/skills"

ExtensionID is the SEP-2133 extension identifier declared by servers in their initialize response under capabilities.extensions.

View Source
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.

View Source
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.

View Source
const IndexURI = "skill://index.json"

IndexURI is the full well-known URI for the discovery index.

View Source
const ManifestFilename = "SKILL.md"

ManifestFilename is the required filename at the root of every skill.

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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"]
  }
}
View Source
const Scheme = "skill"

Scheme is the URI scheme reserved for skill resources.

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func CatalogBlock(idx Index) string

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

func IsIndexURI(s string) bool

IsIndexURI reports whether s is the reserved skill://index.json URI. The check is exact-match per SEP-2640's reservation rule.

func PackSkill

func PackSkill(fsys fs.FS, skillDir string, format ArchiveFormat) ([]byte, error)

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

func ValidateSkillName(name string) error

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) Open

func (a *ArchiveFS) Open(name string) (fs.File, error)

Open implements fs.FS.

func (*ArchiveFS) ReadDir

func (a *ArchiveFS) ReadDir(name string) ([]fs.DirEntry, error)

ReadDir implements fs.ReadDirFS so fs.WalkDir descends into archive directories efficiently.

func (*ArchiveFS) ReadFile

func (a *ArchiveFS) ReadFile(name string) ([]byte, error)

ReadFile implements fs.ReadFileFS so callers do not have to manually open + io.ReadAll for the common "I want the bytes" path.

func (*ArchiveFS) Stat

func (a *ArchiveFS) Stat(name string) (fs.FileInfo, error)

Stat implements fs.StatFS. Reporting Stat directly (rather than having fs.Stat route through Open) keeps the Indexer's mtime check O(log n) per call instead of touching every file.

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

func NewClient(mcp *client.Client, opts ...Option) *Client

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

func (c *Client) BytesConsumed() int64

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

func (c *Client) ListSkills(ctx context.Context) (Index, error)

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

func (c *Client) ReadSkillManifest(ctx context.Context, uri string) (*SkillManifest, error)

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

func (c *Client) ReadSkillURI(ctx context.Context, uri string) ([]byte, error)

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

func (c *Client) SupportsDirectoryRead() bool

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

func (c *Client) SupportsSkills() bool

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

type FileDigest struct {
	Path   string `json:"path"`
	Digest string `json:"digest"`
}

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.

func (Frontmatter) Get

func (f Frontmatter) Get(key string) (any, bool)

Get returns the value of a frontmatter field by key. Name and Description are looked up directly; everything else falls through to Extra.

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.

func (Index) Validate

func (i Index) Validate() error

Validate checks every entry and the top-level shape. It is intended for servers preparing an index for publication; clients receiving an index SHOULD skip entries with unrecognized types rather than reject the document, so they MAY validate individually.

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

func (i *Indexer) Index() (Index, error)

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

func (i *Indexer) RegisterWith(srv *server.Server)

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

type Metadata struct {
	Name        string
	Description string
	Extra       map[string]any
	SourceURI   string
}

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

func WithMaxResourceBytes(n int64) Option

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

func WithServerByteBudget(n int64) Option

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

func (p *Provider) Close() error

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

func (p *Provider) NotifyChanged(paths ...string) error

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

func (p *Provider) Refresh() error

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

func (p *Provider) RegisterWith(srv *server.Server)

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

func (p *Provider) Shutdown(ctx context.Context) error

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

func (p *Provider) Version() uint64

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

type ReadResult struct {
	URI            string
	Bytes          []byte
	DigestVerified bool
}

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

func (t SkillType) HasManifestFields() bool

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.

func (SkillType) Valid

func (t SkillType) Valid() bool

Valid reports whether t is one of the SkillType values defined by SEP-2640. The previously valid "mcp-resource-template" type was dropped from the SEP on 2026-06-04; entries that carry it are now invalid.

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

type SourceFS interface {
	fs.FS
	io.Closer
}

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

func FetchArchive(ctx context.Context, url string, opts ...FetchOption) (SourceFS, error)

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

func OpenArchive(path string) (SourceFS, error)

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

func OpenArchivesDir(dir string) (SourceFS, error)

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

func ParseURI(s string) (URIParts, error)

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

func ResolveRelative(skillRoot URIParts, rel string) (URIParts, error)

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

func (p URIParts) ManifestURI() string

ManifestURI returns the URI of the skill's SKILL.md. Returns the empty string if SkillPath is not populated.

func (URIParts) SkillRootURI

func (p URIParts) SkillRootURI() string

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

func (p URIParts) SplitAt(n int) (URIParts, error)

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"]).

func (URIParts) String

func (p URIParts) String() string

String reconstructs the URI from the parsed segments. If FilePath is populated it joins SkillPath + FilePath; otherwise it falls back to AllSegments. The result is canonical, with each segment percent-encoded per RFC 3986.

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).

Directories

Path Synopsis
Package fsutil provides generic io/fs.FS adapters used by ext/skills — disk-backed zip access, layered fs.FS composition, etc.
Package fsutil provides generic io/fs.FS adapters used by ext/skills — disk-backed zip access, layered fs.FS composition, etc.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL