Skip to content

Commit 18dcf1c

Browse files
feat(skills): publish skill://index.json and advertise skills extension
Adds SEP-2640 skill discovery so skill-aware MCP clients can enumerate the server's skills (and the tools each governs) from a single resource read, without first fetching every SKILL.md. - Register a `skill://index.json` resource whose handler emits the Agent Skills discovery index built from the in-memory skill set. Each entry carries `type: skill-md`, `name`, `description`, `url` (the skill's SKILL.md), and the skill's `allowedTools`, so clients can gate/defer those tools before loading the skill. - Advertise the `io.modelcontextprotocol/skills` capability extension in the server's initialize result. Only the extension is set on ServerOptions, so the SDK still infers tools/resources/prompts capabilities from registered handlers. - Tests: index builder shape + an end-to-end in-memory initialize asserting the extension is advertised (without clobbering tools/resources) and index.json serves the discovery shape. Verified against the runtime SEP-2640 consumer: all 27 skills are discovered with their allowed-tools lists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cee2d67 commit 18dcf1c

3 files changed

Lines changed: 207 additions & 0 deletions

File tree

pkg/github/server.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci
100100
}
101101
}
102102

103+
// Advertise the skills extension (SEP-2640) so skill-aware clients use skill:// discovery
104+
// (skill://index.json + per-skill SKILL.md). Adding only the extension leaves Tools/Resources/
105+
// Prompts nil so the SDK still infers them from the registered handlers (see Server.capabilities).
106+
if serverOpts.Capabilities == nil {
107+
serverOpts.Capabilities = &mcp.ServerCapabilities{}
108+
}
109+
serverOpts.Capabilities.AddExtension(skillsExtensionID, nil)
110+
103111
ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts)
104112

105113
// Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured,

pkg/github/skill_resources.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,70 @@ package github
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"strings"
78

89
"github.com/modelcontextprotocol/go-sdk/mcp"
910
)
1011

12+
// skillsExtensionID is the MCP capabilities extension key (SEP-2640) that signals a server
13+
// publishes a skill discovery index. Clients that recognise it read skillIndexURI to enumerate
14+
// skills and the tools each one governs without first fetching every SKILL.md.
15+
const skillsExtensionID = "io.modelcontextprotocol/skills"
16+
17+
// skillIndexURI is the well-known resource enumerating the server's skills (SEP-2640).
18+
const skillIndexURI = "skill://index.json"
19+
20+
// skillIndexSchema is the Agent Skills discovery-index schema URI advertised in index.json.
21+
const skillIndexSchema = "https://schemas.agentskills.io/discovery/0.2.0/index.json"
22+
23+
// skillIndexEntry is one entry in the skill discovery index. `allowedTools` is an additive
24+
// (passthrough) hint, so SEP-2640-aware clients can gate/defer those tools before a skill is
25+
// loaded WITHOUT first fetching SKILL.md frontmatter.
26+
type skillIndexEntry struct {
27+
Type string `json:"type"`
28+
Name string `json:"name"`
29+
Description string `json:"description"`
30+
URL string `json:"url"`
31+
AllowedTools []string `json:"allowedTools"`
32+
}
33+
34+
// skillIndexDocument is the full `skill://index.json` payload.
35+
type skillIndexDocument struct {
36+
Schema string `json:"$schema"`
37+
Skills []skillIndexEntry `json:"skills"`
38+
}
39+
40+
// buildSkillIndex constructs the SEP-2640 discovery index from the in-memory skill set. Each entry
41+
// points at the skill's individually readable SKILL.md resource and carries its allowed-tools list.
42+
func buildSkillIndex() skillIndexDocument {
43+
skills := allSkills()
44+
doc := skillIndexDocument{
45+
Schema: skillIndexSchema,
46+
Skills: make([]skillIndexEntry, 0, len(skills)),
47+
}
48+
for _, skill := range skills {
49+
doc.Skills = append(doc.Skills, skillIndexEntry{
50+
Type: "skill-md",
51+
Name: skill.name,
52+
Description: skill.description,
53+
URL: fmt.Sprintf("skill://github/%s/SKILL.md", skill.name),
54+
AllowedTools: skill.allowedTools,
55+
})
56+
}
57+
return doc
58+
}
59+
60+
// buildSkillIndexJSON serialises the discovery index returned by skill://index.json.
61+
func buildSkillIndexJSON() (string, error) {
62+
body, err := json.MarshalIndent(buildSkillIndex(), "", " ")
63+
if err != nil {
64+
return "", fmt.Errorf("marshal skill index: %w", err)
65+
}
66+
return string(body), nil
67+
}
68+
1169
// skillDefinition holds the metadata and content for a single skill resource.
1270
type skillDefinition struct {
1371
// name is the skill identifier used in frontmatter and URI
@@ -509,6 +567,33 @@ func buildSkillContent(skill skillDefinition) string {
509567
// Each skill is a static resource with a skill:// URI that can be discovered
510568
// by MCP clients supporting the skills pattern.
511569
func RegisterSkillResources(s *mcp.Server) {
570+
// Publish the discovery index first so SEP-2640 clients can enumerate skills (and the tools
571+
// each governs) from a single resource read, without fetching every SKILL.md.
572+
s.AddResource(
573+
&mcp.Resource{
574+
URI: skillIndexURI,
575+
Name: "skill-index",
576+
Title: "Skill index",
577+
Description: "SEP-2640 skill discovery index enumerating available skills and their allowed tools.",
578+
MIMEType: "application/json",
579+
},
580+
func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
581+
body, err := buildSkillIndexJSON()
582+
if err != nil {
583+
return nil, err
584+
}
585+
return &mcp.ReadResourceResult{
586+
Contents: []*mcp.ResourceContents{
587+
{
588+
URI: skillIndexURI,
589+
MIMEType: "application/json",
590+
Text: body,
591+
},
592+
},
593+
}, nil
594+
},
595+
)
596+
512597
for _, skill := range allSkills() {
513598
content := buildSkillContent(skill)
514599
uri := fmt.Sprintf("skill://github/%s/SKILL.md", skill.name)

pkg/github/skill_resources_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package github
22

33
import (
4+
"context"
5+
"encoding/json"
6+
"strings"
47
"testing"
58

9+
"github.com/github/github-mcp-server/pkg/translations"
610
"github.com/modelcontextprotocol/go-sdk/mcp"
711
"github.com/stretchr/testify/assert"
812
"github.com/stretchr/testify/require"
@@ -82,3 +86,113 @@ func TestRegisterSkillResources(t *testing.T) {
8286
skills := allSkills()
8387
assert.Equal(t, 27, len(skills), "expected 27 workflow-oriented skills")
8488
}
89+
90+
func TestBuildSkillIndex(t *testing.T) {
91+
doc := buildSkillIndex()
92+
93+
assert.Equal(t, skillIndexSchema, doc.Schema)
94+
assert.True(t, strings.HasPrefix(doc.Schema, "https://schemas.agentskills.io/discovery/"),
95+
"schema must use the Agent Skills discovery prefix the runtime gates on")
96+
97+
skills := allSkills()
98+
require.Len(t, doc.Skills, len(skills), "index must contain one entry per skill")
99+
100+
byName := make(map[string]skillIndexEntry, len(doc.Skills))
101+
for _, entry := range doc.Skills {
102+
assert.Equal(t, "skill-md", entry.Type, "entry %q must be type skill-md", entry.Name)
103+
assert.Equal(t, "skill://github/"+entry.Name+"/SKILL.md", entry.URL,
104+
"entry %q url must point at its registered SKILL.md resource", entry.Name)
105+
assert.NotEmpty(t, entry.Description, "entry %q must carry a description", entry.Name)
106+
assert.NotEmpty(t, entry.AllowedTools, "entry %q must advertise its allowed tools", entry.Name)
107+
byName[entry.Name] = entry
108+
}
109+
110+
// Every skill's allow-list must round-trip into the index so clients can defer those tools
111+
// without first fetching SKILL.md.
112+
for _, skill := range skills {
113+
entry, ok := byName[skill.name]
114+
require.True(t, ok, "skill %q missing from index", skill.name)
115+
assert.Equal(t, skill.allowedTools, entry.AllowedTools)
116+
}
117+
}
118+
119+
func TestBuildSkillIndexJSON_IsValid(t *testing.T) {
120+
body, err := buildSkillIndexJSON()
121+
require.NoError(t, err)
122+
123+
var parsed skillIndexDocument
124+
require.NoError(t, json.Unmarshal([]byte(body), &parsed), "index.json must be valid JSON")
125+
assert.Equal(t, skillIndexSchema, parsed.Schema)
126+
assert.Equal(t, len(allSkills()), len(parsed.Skills))
127+
}
128+
129+
// TestSkillsExtensionAndIndexAdvertised verifies the SEP-2640 wire contract end-to-end: the server
130+
// advertises the skills extension in its initialize capabilities and serves skill://index.json with
131+
// the discovery shape skill-aware clients consume.
132+
func TestSkillsExtensionAndIndexAdvertised(t *testing.T) {
133+
t.Parallel()
134+
135+
cfg := MCPServerConfig{
136+
Version: "test",
137+
Token: "test-token",
138+
EnabledToolsets: []string{"context"},
139+
Translator: translations.NullTranslationHelper,
140+
ContentWindowSize: 5000,
141+
}
142+
deps := stubDeps{obsv: stubExporters()}
143+
inv, err := NewInventory(cfg.Translator).
144+
WithDeprecatedAliases(DeprecatedToolAliases).
145+
WithToolsets(cfg.EnabledToolsets).
146+
Build()
147+
require.NoError(t, err)
148+
149+
srv, err := NewMCPServer(context.Background(), &cfg, deps, inv)
150+
require.NoError(t, err)
151+
152+
st, ct := mcp.NewInMemoryTransports()
153+
client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
154+
155+
type connResult struct {
156+
session *mcp.ClientSession
157+
err error
158+
}
159+
connCh := make(chan connResult, 1)
160+
go func() {
161+
cs, cerr := client.Connect(context.Background(), ct, nil)
162+
connCh <- connResult{session: cs, err: cerr}
163+
}()
164+
165+
ss, err := srv.Connect(context.Background(), st, nil)
166+
require.NoError(t, err)
167+
t.Cleanup(func() { _ = ss.Close() })
168+
169+
got := <-connCh
170+
require.NoError(t, got.err)
171+
require.NotNil(t, got.session)
172+
t.Cleanup(func() { _ = got.session.Close() })
173+
174+
// 1. The skills extension must be advertised so SEP-2640 clients opt into skill:// discovery.
175+
init := got.session.InitializeResult()
176+
require.NotNil(t, init)
177+
require.NotNil(t, init.Capabilities)
178+
require.Contains(t, init.Capabilities.Extensions, skillsExtensionID,
179+
"server must advertise the skills extension")
180+
// Tools/resources must still be inferred — advertising the extension must not clobber them.
181+
assert.NotNil(t, init.Capabilities.Tools, "tools capability must still be advertised")
182+
assert.NotNil(t, init.Capabilities.Resources, "resources capability must still be advertised")
183+
184+
// 2. skill://index.json must serve the discovery index.
185+
res, err := got.session.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: skillIndexURI})
186+
require.NoError(t, err)
187+
require.Len(t, res.Contents, 1)
188+
assert.Equal(t, "application/json", res.Contents[0].MIMEType)
189+
190+
var index skillIndexDocument
191+
require.NoError(t, json.Unmarshal([]byte(res.Contents[0].Text), &index))
192+
assert.True(t, strings.HasPrefix(index.Schema, "https://schemas.agentskills.io/discovery/"))
193+
require.Len(t, index.Skills, len(allSkills()))
194+
for _, entry := range index.Skills {
195+
assert.Equal(t, "skill-md", entry.Type)
196+
assert.NotEmpty(t, entry.AllowedTools)
197+
}
198+
}

0 commit comments

Comments
 (0)