Skip to content

Commit 13e5fc3

Browse files
olaservoclaude
andcommitted
feat(skills): add list_repo_skills tool for autonomous-agent discovery
The per-repo MCP resource template added in the previous commit lets agents READ skills in any GitHub repo, but discovering which skills exist requires completion/complete — and that's a client-UI feature only, not accessible to the model. Headless agents have no way to enumerate skills in a repo they don't already know about. This adds a small read-only tool that wraps the existing discoverSkills() function and returns each discovered skill's name plus a ready-to-use skill:// URL the model can pass straight to resources/read. Returned shape: { "owner": "anthropics", "repo": "skills", "skills": [ { "name": "pdf", "url": "skill://anthropics/skills/pdf/SKILL.md" }, ... ], "totalCount": 18 } Returning the URL alongside the name is the killer feature — the model gets URIs ready for the per-file template handler without a second round-trip. Gated on the `skills` toolset; --toolsets=default does not expose it. Caveat: this is a workaround for a SEP-2640 discovery gap, not a SEP mechanism. The SEP's three documented discovery surfaces (skill://index.json, server `instructions` URI pointers, and mcp-resource-template entries surfaced via completion/complete) all assume either bounded enumeration or client-UI mediation. Once a host-side complete_resource_template tool pattern emerges, this server-side tool can be retired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 66b21fa commit 13e5fc3

4 files changed

Lines changed: 265 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "List Agent Skills in a repository"
5+
},
6+
"description": "List Agent Skills (SKILL.md files) defined in a GitHub repository. Returns each discovered skill's name plus a `skill://` URI you can pass directly to `resources/read` to fetch its SKILL.md. Recognizes the agentskills.io directory conventions: skills/*/SKILL.md, skills/{namespace}/*/SKILL.md, plugins/*/skills/*/SKILL.md, and root-level */SKILL.md. Use this when you need to discover what skills a repository exposes before reading any of them.",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "Repository owner (username or organization name).",
11+
"type": "string"
12+
},
13+
"repo": {
14+
"description": "Repository name.",
15+
"type": "string"
16+
}
17+
},
18+
"required": [
19+
"owner",
20+
"repo"
21+
],
22+
"type": "object"
23+
},
24+
"name": "list_repo_skills"
25+
}

pkg/github/skills_tool.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/github/github-mcp-server/pkg/inventory"
9+
"github.com/github/github-mcp-server/pkg/scopes"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/github/github-mcp-server/pkg/utils"
12+
"github.com/google/jsonschema-go/jsonschema"
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
)
15+
16+
// ListRepoSkills exposes the per-repo Agent Skills discovery (`discoverSkills`)
17+
// as an MCP tool the model can call directly. Bridges the autonomous-agent
18+
// gap left by `completion/complete`, which is a client-UI feature only.
19+
//
20+
// The output URLs are constructed via SkillFileURI so they're guaranteed to
21+
// match the per-file resource template registered in GetSkillResourceFile —
22+
// the model can hand each URL straight to `resources/read`.
23+
func ListRepoSkills(t translations.TranslationHelperFunc) inventory.ServerTool {
24+
return NewTool(
25+
ToolsetMetadataSkills,
26+
mcp.Tool{
27+
Name: "list_repo_skills",
28+
Description: t("TOOL_LIST_REPO_SKILLS_DESCRIPTION",
29+
"List Agent Skills (SKILL.md files) defined in a GitHub repository. "+
30+
"Returns each discovered skill's name plus a `skill://` URI you can pass "+
31+
"directly to `resources/read` to fetch its SKILL.md. Recognizes the "+
32+
"agentskills.io directory conventions: skills/*/SKILL.md, "+
33+
"skills/{namespace}/*/SKILL.md, plugins/*/skills/*/SKILL.md, and "+
34+
"root-level */SKILL.md. Use this when you need to discover what skills "+
35+
"a repository exposes before reading any of them."),
36+
Annotations: &mcp.ToolAnnotations{
37+
Title: t("TOOL_LIST_REPO_SKILLS_TITLE", "List Agent Skills in a repository"),
38+
ReadOnlyHint: true,
39+
},
40+
InputSchema: &jsonschema.Schema{
41+
Type: "object",
42+
Properties: map[string]*jsonschema.Schema{
43+
"owner": {
44+
Type: "string",
45+
Description: "Repository owner (username or organization name).",
46+
},
47+
"repo": {
48+
Type: "string",
49+
Description: "Repository name.",
50+
},
51+
},
52+
Required: []string{"owner", "repo"},
53+
},
54+
},
55+
[]scopes.Scope{scopes.Repo},
56+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
57+
owner, err := RequiredParam[string](args, "owner")
58+
if err != nil {
59+
return utils.NewToolResultError(err.Error()), nil, nil
60+
}
61+
repo, err := RequiredParam[string](args, "repo")
62+
if err != nil {
63+
return utils.NewToolResultError(err.Error()), nil, nil
64+
}
65+
66+
client, err := deps.GetClient(ctx)
67+
if err != nil {
68+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
69+
}
70+
71+
names, err := discoverSkills(ctx, client, owner, repo)
72+
if err != nil {
73+
return utils.NewToolResultError(err.Error()), nil, nil
74+
}
75+
76+
type skillEntry struct {
77+
Name string `json:"name"`
78+
URL string `json:"url"`
79+
}
80+
entries := make([]skillEntry, 0, len(names))
81+
for _, name := range names {
82+
entries = append(entries, skillEntry{
83+
Name: name,
84+
URL: SkillFileURI(owner, repo, name, "SKILL.md"),
85+
})
86+
}
87+
88+
response := map[string]any{
89+
"owner": owner,
90+
"repo": repo,
91+
"skills": entries,
92+
"totalCount": len(entries),
93+
}
94+
out, err := json.Marshal(response)
95+
if err != nil {
96+
return nil, nil, fmt.Errorf("failed to marshal skill list: %w", err)
97+
}
98+
return utils.NewToolResultText(string(out)), nil, nil
99+
},
100+
)
101+
}

pkg/github/skills_tool_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/internal/toolsnaps"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
gogithub "github.com/google/go-github/v82/github"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func Test_ListRepoSkills(t *testing.T) {
17+
t.Parallel()
18+
19+
serverTool := ListRepoSkills(translations.NullTranslationHelper)
20+
tool := serverTool.Tool
21+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
22+
23+
assert.Equal(t, "list_repo_skills", tool.Name)
24+
assert.NotEmpty(t, tool.Description)
25+
assert.True(t, tool.Annotations.ReadOnlyHint, "list_repo_skills must be read-only")
26+
27+
treeMock := func(entries ...*gogithub.TreeEntry) http.HandlerFunc {
28+
return func(w http.ResponseWriter, _ *http.Request) {
29+
data, _ := json.Marshal(&gogithub.Tree{Entries: entries})
30+
w.Header().Set("Content-Type", "application/json")
31+
_, _ = w.Write(data)
32+
}
33+
}
34+
35+
tests := []struct {
36+
name string
37+
args map[string]any
38+
handlers map[string]http.HandlerFunc
39+
expectToolError bool
40+
expectErrText string
41+
expectSkills []string // names; URLs are checked structurally
42+
}{
43+
{
44+
name: "missing owner",
45+
args: map[string]any{"repo": "hello-world"},
46+
handlers: map[string]http.HandlerFunc{
47+
GetReposGitTreesByOwnerByRepoByTree: treeMock(),
48+
},
49+
expectToolError: true,
50+
expectErrText: "owner",
51+
},
52+
{
53+
name: "missing repo",
54+
args: map[string]any{"owner": "octocat"},
55+
handlers: map[string]http.HandlerFunc{
56+
GetReposGitTreesByOwnerByRepoByTree: treeMock(),
57+
},
58+
expectToolError: true,
59+
expectErrText: "repo",
60+
},
61+
{
62+
name: "empty repo returns no skills",
63+
args: map[string]any{"owner": "octocat", "repo": "hello-world"},
64+
handlers: map[string]http.HandlerFunc{
65+
GetReposGitTreesByOwnerByRepoByTree: treeMock(
66+
&gogithub.TreeEntry{Path: gogithub.Ptr("README.md"), Type: gogithub.Ptr("blob")},
67+
),
68+
},
69+
expectSkills: []string{},
70+
},
71+
{
72+
name: "discovers across all four conventions",
73+
args: map[string]any{"owner": "octocat", "repo": "hello-world"},
74+
handlers: map[string]http.HandlerFunc{
75+
GetReposGitTreesByOwnerByRepoByTree: treeMock(
76+
&gogithub.TreeEntry{Path: gogithub.Ptr("skills/code-review/SKILL.md"), Type: gogithub.Ptr("blob")},
77+
&gogithub.TreeEntry{Path: gogithub.Ptr("skills/acme/data-tool/SKILL.md"), Type: gogithub.Ptr("blob")},
78+
&gogithub.TreeEntry{Path: gogithub.Ptr("plugins/my-plugin/skills/lint/SKILL.md"), Type: gogithub.Ptr("blob")},
79+
&gogithub.TreeEntry{Path: gogithub.Ptr("root-level-skill/SKILL.md"), Type: gogithub.Ptr("blob")},
80+
),
81+
},
82+
expectSkills: []string{"code-review", "data-tool", "lint", "root-level-skill"},
83+
},
84+
}
85+
86+
for _, tc := range tests {
87+
t.Run(tc.name, func(t *testing.T) {
88+
client := gogithub.NewClient(MockHTTPClientWithHandlers(tc.handlers))
89+
deps := BaseDeps{Client: client}
90+
handler := serverTool.Handler(deps)
91+
92+
request := createMCPRequest(tc.args)
93+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
94+
require.NoError(t, err)
95+
require.NotNil(t, result)
96+
97+
if tc.expectToolError {
98+
assert.True(t, result.IsError, "expected tool error result")
99+
if tc.expectErrText != "" {
100+
textContent := getErrorResult(t, result)
101+
assert.Contains(t, textContent.Text, tc.expectErrText)
102+
}
103+
return
104+
}
105+
106+
assert.False(t, result.IsError, "unexpected tool error: %+v", result)
107+
108+
textContent := getTextResult(t, result)
109+
var payload struct {
110+
Owner string `json:"owner"`
111+
Repo string `json:"repo"`
112+
Skills []struct {
113+
Name string `json:"name"`
114+
URL string `json:"url"`
115+
} `json:"skills"`
116+
TotalCount int `json:"totalCount"`
117+
}
118+
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &payload))
119+
120+
assert.Equal(t, tc.args["owner"], payload.Owner)
121+
assert.Equal(t, tc.args["repo"], payload.Repo)
122+
assert.Equal(t, len(tc.expectSkills), payload.TotalCount)
123+
require.Len(t, payload.Skills, len(tc.expectSkills))
124+
125+
gotNames := make([]string, 0, len(payload.Skills))
126+
for _, s := range payload.Skills {
127+
gotNames = append(gotNames, s.Name)
128+
// Each URL must match the canonical SkillFileURI shape so the
129+
// model can pass it straight to resources/read.
130+
expectedURL := SkillFileURI(payload.Owner, payload.Repo, s.Name, "SKILL.md")
131+
assert.Equal(t, expectedURL, s.URL, "URL must match SkillFileURI(owner, repo, name, SKILL.md)")
132+
}
133+
assert.ElementsMatch(t, tc.expectSkills, gotNames)
134+
})
135+
}
136+
}

pkg/github/tools.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
304304
GranularReprioritizeSubIssue(t),
305305
GranularSetIssueFields(t),
306306

307+
// Skill tools (per-repo Agent Skills discovery — see also pkg/github/skills_resource.go)
308+
ListRepoSkills(t),
309+
307310
// Granular pull request tools (feature-flagged, replace consolidated update_pull_request/pull_request_review_write)
308311
GranularUpdatePullRequestTitle(t),
309312
GranularUpdatePullRequestBody(t),

0 commit comments

Comments
 (0)