Skip to content

Commit 6cc49c5

Browse files
feat: add skill:// resource templates for Agent Skills discovery
Add MCP resource templates that expose Agent Skills (per agentskills.io spec) from GitHub repositories via the skill:// URI scheme. Resource templates: - skill://{owner}/{repo}/{skill_name}/SKILL.md — fetch skill content - skill://{owner}/{repo}/{skill_name}/_manifest — JSON manifest with repo:// URIs for each file, composing with existing repo:// resources Discovery uses the Git Trees API to find SKILL.md files under known paths (.github/skills/, skills/, .copilot/skills/). Completions support for owner, repo (reusing existing resolvers), and skill_name (discovers skills via tree search). Registered under a new non-default 'skills' toolset. Enable with --toolsets=skills or --toolsets=default,skills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c38802a commit 6cc49c5

File tree

5 files changed

+832
-0
lines changed

5 files changed

+832
-0
lines changed

pkg/github/resources.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,9 @@ func AllResources(t translations.TranslationHelperFunc) []inventory.ServerResour
1515
GetRepositoryResourceCommitContent(t),
1616
GetRepositoryResourceTagContent(t),
1717
GetRepositoryResourcePrContent(t),
18+
19+
// Skill resources
20+
GetSkillResourceContent(t),
21+
GetSkillResourceManifest(t),
1822
}
1923
}

pkg/github/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mc
200200
if strings.HasPrefix(req.Params.Ref.URI, "repo://") {
201201
return RepositoryResourceCompletionHandler(getClient)(ctx, req)
202202
}
203+
if strings.HasPrefix(req.Params.Ref.URI, "skill://") {
204+
return SkillResourceCompletionHandler(getClient)(ctx, req)
205+
}
203206
return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI)
204207
case "ref/prompt":
205208
return nil, nil

pkg/github/skills_resource.go

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"path"
9+
"strings"
10+
11+
"github.com/github/github-mcp-server/pkg/inventory"
12+
"github.com/github/github-mcp-server/pkg/octicons"
13+
"github.com/github/github-mcp-server/pkg/translations"
14+
gogithub "github.com/google/go-github/v82/github"
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
"github.com/yosida95/uritemplate/v3"
17+
)
18+
19+
// skillSearchPaths are the directory prefixes where SKILL.md files are expected
20+
// per the Agent Skills specification (agentskills.io).
21+
var skillSearchPaths = []string{".github/skills/", "skills/", ".copilot/skills/"}
22+
23+
var (
24+
skillResourceContentURITemplate = uritemplate.MustNew("skill://{owner}/{repo}/{skill_name}/SKILL.md")
25+
skillResourceManifestURITemplate = uritemplate.MustNew("skill://{owner}/{repo}/{skill_name}/_manifest")
26+
)
27+
28+
// GetSkillResourceContent defines the resource template for reading a skill's SKILL.md.
29+
func GetSkillResourceContent(t translations.TranslationHelperFunc) inventory.ServerResourceTemplate {
30+
return inventory.NewServerResourceTemplate(
31+
ToolsetMetadataSkills,
32+
mcp.ResourceTemplate{
33+
Name: "skill_content",
34+
URITemplate: skillResourceContentURITemplate.Raw(),
35+
Description: t("RESOURCE_SKILL_CONTENT_DESCRIPTION", "Agent Skill instructions (SKILL.md) from a GitHub repository"),
36+
Icons: octicons.Icons("light-bulb"),
37+
},
38+
skillResourceContentHandlerFunc(skillResourceContentURITemplate),
39+
)
40+
}
41+
42+
// GetSkillResourceManifest defines the resource template for a skill's file manifest.
43+
func GetSkillResourceManifest(t translations.TranslationHelperFunc) inventory.ServerResourceTemplate {
44+
return inventory.NewServerResourceTemplate(
45+
ToolsetMetadataSkills,
46+
mcp.ResourceTemplate{
47+
Name: "skill_manifest",
48+
URITemplate: skillResourceManifestURITemplate.Raw(),
49+
Description: t("RESOURCE_SKILL_MANIFEST_DESCRIPTION", "File manifest for an Agent Skill in a GitHub repository"),
50+
Icons: octicons.Icons("light-bulb"),
51+
},
52+
skillResourceManifestHandlerFunc(skillResourceManifestURITemplate),
53+
)
54+
}
55+
56+
func skillResourceContentHandlerFunc(tmpl *uritemplate.Template) inventory.ResourceHandlerFunc {
57+
return func(_ any) mcp.ResourceHandler {
58+
return skillContentHandler(tmpl)
59+
}
60+
}
61+
62+
func skillResourceManifestHandlerFunc(tmpl *uritemplate.Template) inventory.ResourceHandlerFunc {
63+
return func(_ any) mcp.ResourceHandler {
64+
return skillManifestHandler(tmpl)
65+
}
66+
}
67+
68+
// skillContentHandler returns a handler that fetches a skill's SKILL.md content.
69+
func skillContentHandler(tmpl *uritemplate.Template) mcp.ResourceHandler {
70+
return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
71+
deps := MustDepsFromContext(ctx)
72+
owner, repo, skillName, err := parseSkillURI(tmpl, request.Params.URI)
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
client, err := deps.GetClient(ctx)
78+
if err != nil {
79+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
80+
}
81+
82+
skillDir, err := findSkillDir(ctx, client, owner, repo, skillName)
83+
if err != nil {
84+
return nil, err
85+
}
86+
87+
skillMDPath := path.Join(skillDir, "SKILL.md")
88+
fileContent, _, _, err := client.Repositories.GetContents(ctx, owner, repo, skillMDPath, nil)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to get SKILL.md: %w", err)
91+
}
92+
93+
content, err := fileContent.GetContent()
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to decode SKILL.md content: %w", err)
96+
}
97+
98+
return &mcp.ReadResourceResult{
99+
Contents: []*mcp.ResourceContents{
100+
{
101+
URI: request.Params.URI,
102+
MIMEType: "text/markdown",
103+
Text: content,
104+
},
105+
},
106+
}, nil
107+
}
108+
}
109+
110+
// SkillManifestEntry represents a single file in a skill's manifest.
111+
type SkillManifestEntry struct {
112+
Path string `json:"path"`
113+
URI string `json:"uri"`
114+
Size int `json:"size"`
115+
}
116+
117+
// SkillManifest represents the file listing for a skill directory.
118+
type SkillManifest struct {
119+
Skill string `json:"skill"`
120+
Files []SkillManifestEntry `json:"files"`
121+
}
122+
123+
// skillManifestHandler returns a handler that lists files in a skill directory.
124+
func skillManifestHandler(tmpl *uritemplate.Template) mcp.ResourceHandler {
125+
return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
126+
deps := MustDepsFromContext(ctx)
127+
owner, repo, skillName, err := parseSkillURI(tmpl, request.Params.URI)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
client, err := deps.GetClient(ctx)
133+
if err != nil {
134+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
135+
}
136+
137+
skillDir, err := findSkillDir(ctx, client, owner, repo, skillName)
138+
if err != nil {
139+
return nil, err
140+
}
141+
142+
// Use recursive tree from repo root and filter to the skill directory
143+
tree, _, err := client.Git.GetTree(ctx, owner, repo, "HEAD", true)
144+
if err != nil {
145+
return nil, fmt.Errorf("failed to get repository tree: %w", err)
146+
}
147+
148+
prefix := skillDir + "/"
149+
manifest := SkillManifest{
150+
Skill: skillName,
151+
Files: make([]SkillManifestEntry, 0),
152+
}
153+
for _, entry := range tree.Entries {
154+
if entry.GetType() != "blob" {
155+
continue
156+
}
157+
entryPath := entry.GetPath()
158+
if !strings.HasPrefix(entryPath, prefix) {
159+
continue
160+
}
161+
relativePath := strings.TrimPrefix(entryPath, prefix)
162+
pathParts := strings.Split(entryPath, "/")
163+
repoURI, err := expandRepoResourceURI(owner, repo, "", "", pathParts)
164+
if err != nil {
165+
continue
166+
}
167+
manifest.Files = append(manifest.Files, SkillManifestEntry{
168+
Path: relativePath,
169+
URI: repoURI,
170+
Size: entry.GetSize(),
171+
})
172+
}
173+
174+
data, err := json.Marshal(manifest)
175+
if err != nil {
176+
return nil, fmt.Errorf("failed to marshal manifest: %w", err)
177+
}
178+
179+
return &mcp.ReadResourceResult{
180+
Contents: []*mcp.ResourceContents{
181+
{
182+
URI: request.Params.URI,
183+
MIMEType: "application/json",
184+
Text: string(data),
185+
},
186+
},
187+
}, nil
188+
}
189+
}
190+
191+
// parseSkillURI extracts owner, repo, and skill_name from a skill:// URI.
192+
func parseSkillURI(tmpl *uritemplate.Template, uri string) (owner, repo, skillName string, err error) {
193+
values := tmpl.Match(uri)
194+
if values == nil {
195+
return "", "", "", fmt.Errorf("failed to match skill URI: %s", uri)
196+
}
197+
198+
owner = values.Get("owner").String()
199+
repo = values.Get("repo").String()
200+
skillName = values.Get("skill_name").String()
201+
202+
if owner == "" {
203+
return "", "", "", errors.New("owner is required")
204+
}
205+
if repo == "" {
206+
return "", "", "", errors.New("repo is required")
207+
}
208+
if skillName == "" {
209+
return "", "", "", errors.New("skill_name is required")
210+
}
211+
212+
return owner, repo, skillName, nil
213+
}
214+
215+
// findSkillDir locates the directory for a named skill within a repository.
216+
// It searches the known skill directory prefixes for a directory matching the skill name
217+
// that contains a SKILL.md file.
218+
func findSkillDir(ctx context.Context, client *gogithub.Client, owner, repo, skillName string) (string, error) {
219+
for _, prefix := range skillSearchPaths {
220+
candidatePath := prefix + skillName + "/SKILL.md"
221+
_, _, resp, err := client.Repositories.GetContents(ctx, owner, repo, candidatePath, nil)
222+
if err == nil {
223+
return prefix + skillName, nil
224+
}
225+
if resp != nil && resp.StatusCode == 404 {
226+
continue
227+
}
228+
if err != nil {
229+
return "", fmt.Errorf("failed checking for skill at %s: %w", candidatePath, err)
230+
}
231+
}
232+
return "", fmt.Errorf("skill %q not found in repository %s/%s", skillName, owner, repo)
233+
}
234+
235+
// discoverSkills finds all skill directories in a repository by searching the tree
236+
// for directories containing SKILL.md files under the known skill search paths.
237+
func discoverSkills(ctx context.Context, client *gogithub.Client, owner, repo string) ([]string, error) {
238+
tree, _, err := client.Git.GetTree(ctx, owner, repo, "HEAD", true)
239+
if err != nil {
240+
return nil, fmt.Errorf("failed to get repository tree: %w", err)
241+
}
242+
243+
seen := make(map[string]bool)
244+
var skills []string
245+
246+
for _, entry := range tree.Entries {
247+
if entry.GetType() != "blob" {
248+
continue
249+
}
250+
entryPath := entry.GetPath()
251+
if !strings.HasSuffix(entryPath, "/SKILL.md") {
252+
continue
253+
}
254+
255+
for _, prefix := range skillSearchPaths {
256+
if !strings.HasPrefix(entryPath, prefix) {
257+
continue
258+
}
259+
// Extract skill name: prefix + skillName + "/SKILL.md"
260+
rest := strings.TrimPrefix(entryPath, prefix)
261+
parts := strings.SplitN(rest, "/", 2)
262+
if len(parts) == 2 && parts[1] == "SKILL.md" {
263+
name := parts[0]
264+
if !seen[name] {
265+
seen[name] = true
266+
skills = append(skills, name)
267+
}
268+
}
269+
}
270+
}
271+
272+
return skills, nil
273+
}
274+
275+
// SkillResourceCompletionHandler handles completions for skill:// resource URIs.
276+
func SkillResourceCompletionHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
277+
return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
278+
argName := req.Params.Argument.Name
279+
argValue := req.Params.Argument.Value
280+
var resolved map[string]string
281+
if req.Params.Context != nil && req.Params.Context.Arguments != nil {
282+
resolved = req.Params.Context.Arguments
283+
} else {
284+
resolved = map[string]string{}
285+
}
286+
287+
// Reuse existing owner/repo resolvers
288+
switch argName {
289+
case "owner":
290+
client, err := getClient(ctx)
291+
if err != nil {
292+
return nil, err
293+
}
294+
values, err := completeOwner(ctx, client, resolved, argValue)
295+
if err != nil {
296+
return nil, err
297+
}
298+
return completionResult(values), nil
299+
300+
case "repo":
301+
client, err := getClient(ctx)
302+
if err != nil {
303+
return nil, err
304+
}
305+
values, err := completeRepo(ctx, client, resolved, argValue)
306+
if err != nil {
307+
return nil, err
308+
}
309+
return completionResult(values), nil
310+
311+
case "skill_name":
312+
return completeSkillName(ctx, getClient, resolved, argValue)
313+
314+
default:
315+
return nil, fmt.Errorf("no resolver for skill argument: %s", argName)
316+
}
317+
}
318+
}
319+
320+
func completeSkillName(ctx context.Context, getClient GetClientFn, resolved map[string]string, argValue string) (*mcp.CompleteResult, error) {
321+
owner := resolved["owner"]
322+
repo := resolved["repo"]
323+
if owner == "" || repo == "" {
324+
return completionResult(nil), nil
325+
}
326+
327+
client, err := getClient(ctx)
328+
if err != nil {
329+
return nil, err
330+
}
331+
332+
skills, err := discoverSkills(ctx, client, owner, repo)
333+
if err != nil {
334+
return completionResult(nil), nil //nolint:nilerr // graceful degradation
335+
}
336+
337+
if argValue != "" {
338+
var filtered []string
339+
for _, s := range skills {
340+
if strings.HasPrefix(s, argValue) {
341+
filtered = append(filtered, s)
342+
}
343+
}
344+
skills = filtered
345+
}
346+
347+
return completionResult(skills), nil
348+
}
349+
350+
func completionResult(values []string) *mcp.CompleteResult {
351+
if len(values) > 100 {
352+
values = values[:100]
353+
}
354+
return &mcp.CompleteResult{
355+
Completion: mcp.CompletionResultDetails{
356+
Values: values,
357+
Total: len(values),
358+
HasMore: false,
359+
},
360+
}
361+
}

0 commit comments

Comments
 (0)