|
| 1 | +package skills |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + |
| 7 | + "github.com/modelcontextprotocol/go-sdk/mcp" |
| 8 | +) |
| 9 | + |
| 10 | +// Well-known identifiers from the skills-over-MCP SEP (SEP-2133) and the |
| 11 | +// Agent Skills discovery index schema (agentskills.io). |
| 12 | +const ( |
| 13 | + // IndexURI is the well-known URI for the per-server discovery index. |
| 14 | + IndexURI = "skill://index.json" |
| 15 | + // ExtensionKey is the MCP capability extension identifier that a server |
| 16 | + // MUST declare when it publishes skill:// resources. |
| 17 | + ExtensionKey = "io.modelcontextprotocol/skills" |
| 18 | + // IndexSchema is the $schema value servers MUST emit in their index. |
| 19 | + IndexSchema = "https://schemas.agentskills.io/discovery/0.2.0/schema.json" |
| 20 | +) |
| 21 | + |
| 22 | +// Bundled describes a single server-bundled Agent Skill — a SKILL.md the |
| 23 | +// server ships in its binary and serves at a stable skill:// URI. |
| 24 | +type Bundled struct { |
| 25 | + // Name is the skill name. Must match the SKILL.md frontmatter `name` |
| 26 | + // and the final segment of the skill-path in the URI. |
| 27 | + Name string |
| 28 | + // Description is the text shown to the agent in the discovery index. |
| 29 | + // Should describe both what the skill does and when to use it. |
| 30 | + Description string |
| 31 | + // Content is the SKILL.md body (typically a //go:embed string). |
| 32 | + Content string |
| 33 | + // Icons, if non-empty, are attached to the SKILL.md MCP resource so |
| 34 | + // hosts that render icons in their resource list can show one. |
| 35 | + Icons []mcp.Icon |
| 36 | + // Enabled, if set, is called to determine whether this skill should |
| 37 | + // be published on the current server instance. Leave nil for "always |
| 38 | + // publish". Useful for gating on a toolset, feature flag, or request |
| 39 | + // context in per-request server builds. |
| 40 | + Enabled func() bool |
| 41 | +} |
| 42 | + |
| 43 | +// URI returns the skill's canonical SKILL.md URI: skill://<name>/SKILL.md. |
| 44 | +func (b Bundled) URI() string { return "skill://" + b.Name + "/SKILL.md" } |
| 45 | + |
| 46 | +func (b Bundled) enabled() bool { return b.Enabled == nil || b.Enabled() } |
| 47 | + |
| 48 | +// Registry is the set of bundled skills a server publishes. Build one at |
| 49 | +// server-construction time with New().Add(...).Add(...); then call |
| 50 | +// DeclareCapability before mcp.NewServer and Install after. |
| 51 | +type Registry struct { |
| 52 | + entries []Bundled |
| 53 | +} |
| 54 | + |
| 55 | +// New returns an empty registry. |
| 56 | +func New() *Registry { return &Registry{} } |
| 57 | + |
| 58 | +// Add appends a bundled skill and returns the registry for chaining. |
| 59 | +func (r *Registry) Add(b Bundled) *Registry { |
| 60 | + r.entries = append(r.entries, b) |
| 61 | + return r |
| 62 | +} |
| 63 | + |
| 64 | +// Enabled returns the subset of entries currently enabled. |
| 65 | +func (r *Registry) Enabled() []Bundled { |
| 66 | + var out []Bundled |
| 67 | + for _, e := range r.entries { |
| 68 | + if e.enabled() { |
| 69 | + out = append(out, e) |
| 70 | + } |
| 71 | + } |
| 72 | + return out |
| 73 | +} |
| 74 | + |
| 75 | +// DeclareCapability adds the skills-over-MCP extension to the provided |
| 76 | +// ServerOptions.Capabilities if any entry is currently enabled. Must be |
| 77 | +// called BEFORE mcp.NewServer since capabilities are captured at |
| 78 | +// construction. |
| 79 | +func (r *Registry) DeclareCapability(opts *mcp.ServerOptions) { |
| 80 | + if opts == nil || len(r.Enabled()) == 0 { |
| 81 | + return |
| 82 | + } |
| 83 | + if opts.Capabilities == nil { |
| 84 | + opts.Capabilities = &mcp.ServerCapabilities{} |
| 85 | + } |
| 86 | + opts.Capabilities.AddExtension(ExtensionKey, nil) |
| 87 | +} |
| 88 | + |
| 89 | +// Install registers each enabled skill's SKILL.md as an MCP resource and |
| 90 | +// publishes the skill://index.json discovery document. |
| 91 | +func (r *Registry) Install(s *mcp.Server) { |
| 92 | + enabled := r.Enabled() |
| 93 | + if len(enabled) == 0 { |
| 94 | + return |
| 95 | + } |
| 96 | + |
| 97 | + for _, e := range enabled { |
| 98 | + e := e |
| 99 | + s.AddResource( |
| 100 | + &mcp.Resource{ |
| 101 | + URI: e.URI(), |
| 102 | + Name: e.Name + "_skill", |
| 103 | + Description: e.Description, |
| 104 | + MIMEType: "text/markdown", |
| 105 | + Icons: e.Icons, |
| 106 | + }, |
| 107 | + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { |
| 108 | + return &mcp.ReadResourceResult{ |
| 109 | + Contents: []*mcp.ResourceContents{{ |
| 110 | + URI: e.URI(), |
| 111 | + MIMEType: "text/markdown", |
| 112 | + Text: e.Content, |
| 113 | + }}, |
| 114 | + }, nil |
| 115 | + }, |
| 116 | + ) |
| 117 | + } |
| 118 | + |
| 119 | + indexJSON := buildIndex(enabled) |
| 120 | + s.AddResource( |
| 121 | + &mcp.Resource{ |
| 122 | + URI: IndexURI, |
| 123 | + Name: "skills_index", |
| 124 | + Description: "Agent Skill discovery index for this server.", |
| 125 | + MIMEType: "application/json", |
| 126 | + }, |
| 127 | + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { |
| 128 | + return &mcp.ReadResourceResult{ |
| 129 | + Contents: []*mcp.ResourceContents{{ |
| 130 | + URI: IndexURI, |
| 131 | + MIMEType: "application/json", |
| 132 | + Text: indexJSON, |
| 133 | + }}, |
| 134 | + }, nil |
| 135 | + }, |
| 136 | + ) |
| 137 | +} |
| 138 | + |
| 139 | +// IndexEntry matches the agentskills.io discovery schema, with MCP-specific |
| 140 | +// fields: `url` holds the MCP resource URI; `digest` is omitted because |
| 141 | +// integrity is handled by the authenticated MCP connection. |
| 142 | +type IndexEntry struct { |
| 143 | + Name string `json:"name"` |
| 144 | + Type string `json:"type"` |
| 145 | + Description string `json:"description"` |
| 146 | + URL string `json:"url"` |
| 147 | +} |
| 148 | + |
| 149 | +// IndexDoc is the top-level shape of skill://index.json. |
| 150 | +type IndexDoc struct { |
| 151 | + Schema string `json:"$schema"` |
| 152 | + Skills []IndexEntry `json:"skills"` |
| 153 | +} |
| 154 | + |
| 155 | +func buildIndex(entries []Bundled) string { |
| 156 | + doc := IndexDoc{Schema: IndexSchema, Skills: make([]IndexEntry, len(entries))} |
| 157 | + for i, e := range entries { |
| 158 | + doc.Skills[i] = IndexEntry{Name: e.Name, Type: "skill-md", Description: e.Description, URL: e.URI()} |
| 159 | + } |
| 160 | + b, err := json.Marshal(doc) |
| 161 | + if err != nil { |
| 162 | + panic("skills: failed to marshal index: " + err.Error()) |
| 163 | + } |
| 164 | + return string(b) |
| 165 | +} |
0 commit comments