diff --git a/pkg/github/resources.go b/pkg/github/resources.go index 2db7cac55..2b0f95042 100644 --- a/pkg/github/resources.go +++ b/pkg/github/resources.go @@ -15,5 +15,9 @@ func AllResources(t translations.TranslationHelperFunc) []inventory.ServerResour GetRepositoryResourceCommitContent(t), GetRepositoryResourceTagContent(t), GetRepositoryResourcePrContent(t), + + // Skill resources + GetSkillResourceContent(t), + GetSkillResourceManifest(t), } } diff --git a/pkg/github/server.go b/pkg/github/server.go index 06c12575d..87091a861 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -200,6 +200,9 @@ func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mc if strings.HasPrefix(req.Params.Ref.URI, "repo://") { return RepositoryResourceCompletionHandler(getClient)(ctx, req) } + if strings.HasPrefix(req.Params.Ref.URI, "skill://") { + return SkillResourceCompletionHandler(getClient)(ctx, req) + } return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI) case "ref/prompt": return nil, nil diff --git a/pkg/github/skills_resource.go b/pkg/github/skills_resource.go new file mode 100644 index 000000000..76fa7ffd8 --- /dev/null +++ b/pkg/github/skills_resource.go @@ -0,0 +1,395 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v82/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/yosida95/uritemplate/v3" +) + +var ( + skillResourceContentURITemplate = uritemplate.MustNew("skill://{owner}/{repo}/{skill_name}/SKILL.md") + skillResourceManifestURITemplate = uritemplate.MustNew("skill://{owner}/{repo}/{skill_name}/_manifest") +) + +// GetSkillResourceContent defines the resource template for reading a skill's SKILL.md. +func GetSkillResourceContent(t translations.TranslationHelperFunc) inventory.ServerResourceTemplate { + return inventory.NewServerResourceTemplate( + ToolsetMetadataSkills, + mcp.ResourceTemplate{ + Name: "skill_content", + URITemplate: skillResourceContentURITemplate.Raw(), + Description: t("RESOURCE_SKILL_CONTENT_DESCRIPTION", "Agent Skill instructions (SKILL.md) from a GitHub repository"), + Icons: octicons.Icons("light-bulb"), + }, + skillResourceContentHandlerFunc(skillResourceContentURITemplate), + ) +} + +// GetSkillResourceManifest defines the resource template for a skill's file manifest. +func GetSkillResourceManifest(t translations.TranslationHelperFunc) inventory.ServerResourceTemplate { + return inventory.NewServerResourceTemplate( + ToolsetMetadataSkills, + mcp.ResourceTemplate{ + Name: "skill_manifest", + URITemplate: skillResourceManifestURITemplate.Raw(), + Description: t("RESOURCE_SKILL_MANIFEST_DESCRIPTION", "File manifest for an Agent Skill in a GitHub repository"), + Icons: octicons.Icons("light-bulb"), + }, + skillResourceManifestHandlerFunc(skillResourceManifestURITemplate), + ) +} + +func skillResourceContentHandlerFunc(tmpl *uritemplate.Template) inventory.ResourceHandlerFunc { + return func(_ any) mcp.ResourceHandler { + return skillContentHandler(tmpl) + } +} + +func skillResourceManifestHandlerFunc(tmpl *uritemplate.Template) inventory.ResourceHandlerFunc { + return func(_ any) mcp.ResourceHandler { + return skillManifestHandler(tmpl) + } +} + +// skillContentHandler returns a handler that fetches a skill's SKILL.md content. +func skillContentHandler(tmpl *uritemplate.Template) mcp.ResourceHandler { + return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + deps := MustDepsFromContext(ctx) + owner, repo, skillName, err := parseSkillURI(tmpl, request.Params.URI) + if err != nil { + return nil, err + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + skill, err := findSkill(ctx, client, owner, repo, skillName) + if err != nil { + return nil, err + } + + skillMDPath := path.Join(skill.Dir, "SKILL.md") + fileContent, _, _, err := client.Repositories.GetContents(ctx, owner, repo, skillMDPath, nil) + if err != nil { + return nil, fmt.Errorf("failed to get SKILL.md: %w", err) + } + + content, err := fileContent.GetContent() + if err != nil { + return nil, fmt.Errorf("failed to decode SKILL.md content: %w", err) + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: request.Params.URI, + MIMEType: "text/markdown", + Text: content, + }, + }, + }, nil + } +} + +// SkillManifestEntry represents a single file in a skill's manifest. +type SkillManifestEntry struct { + Path string `json:"path"` + URI string `json:"uri"` + Size int `json:"size"` +} + +// SkillManifest represents the file listing for a skill directory. +type SkillManifest struct { + Skill string `json:"skill"` + Files []SkillManifestEntry `json:"files"` +} + +// skillManifestHandler returns a handler that lists files in a skill directory. +func skillManifestHandler(tmpl *uritemplate.Template) mcp.ResourceHandler { + return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + deps := MustDepsFromContext(ctx) + owner, repo, skillName, err := parseSkillURI(tmpl, request.Params.URI) + if err != nil { + return nil, err + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + skill, err := findSkill(ctx, client, owner, repo, skillName) + if err != nil { + return nil, err + } + + // Use recursive tree from repo root and filter to the skill directory + tree, _, err := client.Git.GetTree(ctx, owner, repo, "HEAD", true) + if err != nil { + return nil, fmt.Errorf("failed to get repository tree: %w", err) + } + + prefix := skill.Dir + "/" + manifest := SkillManifest{ + Skill: skillName, + Files: make([]SkillManifestEntry, 0), + } + for _, entry := range tree.Entries { + if entry.GetType() != "blob" { + continue + } + entryPath := entry.GetPath() + if !strings.HasPrefix(entryPath, prefix) { + continue + } + relativePath := strings.TrimPrefix(entryPath, prefix) + pathParts := strings.Split(entryPath, "/") + repoURI, err := expandRepoResourceURI(owner, repo, "", "", pathParts) + if err != nil { + continue + } + manifest.Files = append(manifest.Files, SkillManifestEntry{ + Path: relativePath, + URI: repoURI, + Size: entry.GetSize(), + }) + } + + data, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("failed to marshal manifest: %w", err) + } + + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: request.Params.URI, + MIMEType: "application/json", + Text: string(data), + }, + }, + }, nil + } +} + +// parseSkillURI extracts owner, repo, and skill_name from a skill:// URI. +func parseSkillURI(tmpl *uritemplate.Template, uri string) (owner, repo, skillName string, err error) { + values := tmpl.Match(uri) + if values == nil { + return "", "", "", fmt.Errorf("failed to match skill URI: %s", uri) + } + + owner = values.Get("owner").String() + repo = values.Get("repo").String() + skillName = values.Get("skill_name").String() + + if owner == "" { + return "", "", "", errors.New("owner is required") + } + if repo == "" { + return "", "", "", errors.New("repo is required") + } + if skillName == "" { + return "", "", "", errors.New("skill_name is required") + } + + return owner, repo, skillName, nil +} + +// discoveredSkill holds a matched skill's name and directory path. +type discoveredSkill struct { + Name string + Dir string +} + +// matchSkillConventions checks if a blob path matches any known skill directory convention. +// Aligned with the agentskills.io spec and common community conventions: +// - skills/*/SKILL.md (agentskills.io spec) +// - skills/{namespace}/*/SKILL.md (namespaced skills) +// - plugins/*/skills/*/SKILL.md (plugin marketplace convention) +// - */SKILL.md (root-level skill directories) +func matchSkillConventions(entryPath string) *discoveredSkill { + if path.Base(entryPath) != "SKILL.md" { + return nil + } + + dir := path.Dir(entryPath) + parentDir := path.Dir(dir) + skillName := path.Base(dir) + + if skillName == "." || skillName == "" { + return nil + } + + // Convention 1: skills/*/SKILL.md + if parentDir == "skills" { + return &discoveredSkill{Name: skillName, Dir: dir} + } + + // Convention 2: skills/{namespace}/*/SKILL.md + grandparentDir := path.Dir(parentDir) + if grandparentDir == "skills" { + return &discoveredSkill{Name: skillName, Dir: dir} + } + + // Convention 3: plugins/*/skills/*/SKILL.md + if path.Base(parentDir) == "skills" && path.Dir(grandparentDir) == "plugins" { + return &discoveredSkill{Name: skillName, Dir: dir} + } + + // Convention 4: */SKILL.md (root-level skill directories) + // Exclude convention prefixes and hidden directories. + if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") { + return &discoveredSkill{Name: skillName, Dir: dir} + } + + return nil +} + +// findSkill locates a named skill within a repository by scanning the tree. +func findSkill(ctx context.Context, client *gogithub.Client, owner, repo, skillName string) (*discoveredSkill, error) { + tree, _, err := client.Git.GetTree(ctx, owner, repo, "HEAD", true) + if err != nil { + return nil, fmt.Errorf("failed to get repository tree: %w", err) + } + + for _, entry := range tree.Entries { + if entry.GetType() != "blob" { + continue + } + skill := matchSkillConventions(entry.GetPath()) + if skill != nil && skill.Name == skillName { + return skill, nil + } + } + + return nil, fmt.Errorf("skill %q not found in repository %s/%s", skillName, owner, repo) +} + +// discoverSkills finds all skill directories in a repository by scanning the tree +// for SKILL.md files matching known directory conventions. +func discoverSkills(ctx context.Context, client *gogithub.Client, owner, repo string) ([]string, error) { + tree, _, err := client.Git.GetTree(ctx, owner, repo, "HEAD", true) + if err != nil { + return nil, fmt.Errorf("failed to get repository tree: %w", err) + } + + seen := make(map[string]bool) + var skills []string + + for _, entry := range tree.Entries { + if entry.GetType() != "blob" { + continue + } + skill := matchSkillConventions(entry.GetPath()) + if skill == nil { + continue + } + if !seen[skill.Name] { + seen[skill.Name] = true + skills = append(skills, skill.Name) + } + } + + return skills, nil +} + +// SkillResourceCompletionHandler handles completions for skill:// resource URIs. +func SkillResourceCompletionHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { + return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { + argName := req.Params.Argument.Name + argValue := req.Params.Argument.Value + var resolved map[string]string + if req.Params.Context != nil && req.Params.Context.Arguments != nil { + resolved = req.Params.Context.Arguments + } else { + resolved = map[string]string{} + } + + // Reuse existing owner/repo resolvers + switch argName { + case "owner": + client, err := getClient(ctx) + if err != nil { + return nil, err + } + values, err := completeOwner(ctx, client, resolved, argValue) + if err != nil { + return nil, err + } + return completionResult(values), nil + + case "repo": + client, err := getClient(ctx) + if err != nil { + return nil, err + } + values, err := completeRepo(ctx, client, resolved, argValue) + if err != nil { + return nil, err + } + return completionResult(values), nil + + case "skill_name": + return completeSkillName(ctx, getClient, resolved, argValue) + + default: + return nil, fmt.Errorf("no resolver for skill argument: %s", argName) + } + } +} + +func completeSkillName(ctx context.Context, getClient GetClientFn, resolved map[string]string, argValue string) (*mcp.CompleteResult, error) { + owner := resolved["owner"] + repo := resolved["repo"] + if owner == "" || repo == "" { + return completionResult(nil), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, err + } + + skills, err := discoverSkills(ctx, client, owner, repo) + if err != nil { + return completionResult(nil), nil //nolint:nilerr // graceful degradation + } + + if argValue != "" { + var filtered []string + for _, s := range skills { + if strings.HasPrefix(s, argValue) { + filtered = append(filtered, s) + } + } + skills = filtered + } + + return completionResult(skills), nil +} + +func completionResult(values []string) *mcp.CompleteResult { + if len(values) > 100 { + values = values[:100] + } + return &mcp.CompleteResult{ + Completion: mcp.CompletionResultDetails{ + Values: values, + Total: len(values), + HasMore: false, + }, + } +} diff --git a/pkg/github/skills_resource_test.go b/pkg/github/skills_resource_test.go new file mode 100644 index 000000000..fdb185060 --- /dev/null +++ b/pkg/github/skills_resource_test.go @@ -0,0 +1,571 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + gogithub "github.com/google/go-github/v82/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yosida95/uritemplate/v3" +) + +func Test_GetSkillResourceContent(t *testing.T) { + t.Run("definition", func(t *testing.T) { + res := GetSkillResourceContent(translations.NullTranslationHelper) + assert.Equal(t, "skill_content", res.Template.Name) + assert.Contains(t, res.Template.URITemplate, "skill://") + assert.Contains(t, res.Template.URITemplate, "{skill_name}") + assert.NotEmpty(t, res.Template.Description) + assert.True(t, res.HasHandler()) + }) +} + +func Test_GetSkillResourceManifest(t *testing.T) { + t.Run("definition", func(t *testing.T) { + res := GetSkillResourceManifest(translations.NullTranslationHelper) + assert.Equal(t, "skill_manifest", res.Template.Name) + assert.Contains(t, res.Template.URITemplate, "_manifest") + assert.NotEmpty(t, res.Template.Description) + assert.True(t, res.HasHandler()) + }) +} + +func Test_skillContentHandler(t *testing.T) { + skillMDContent := "---\nname: my-skill\ndescription: A test skill\n---\n\n# My Skill\n\nInstructions here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillMDContent)) + + // Wildcard pattern to match deep paths under /repos/{owner}/{repo}/contents/ + const getContentsWildcard = "GET /repos/{owner}/{repo}/contents/{path:.*}" + + tests := []struct { + name string + uri string + handlers map[string]http.HandlerFunc + expectError string + expectText string + }{ + { + name: "missing owner", + uri: "skill:///repo/my-skill/SKILL.md", + handlers: map[string]http.HandlerFunc{}, + expectError: "owner is required", + }, + { + name: "missing repo", + uri: "skill://owner//my-skill/SKILL.md", + handlers: map[string]http.HandlerFunc{}, + expectError: "repo is required", + }, + { + name: "missing skill_name", + uri: "skill://owner/repo//SKILL.md", + handlers: map[string]http.HandlerFunc{}, + expectError: "skill_name is required", + }, + { + name: "successful fetch", + uri: "skill://owner/repo/my-skill/SKILL.md", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/my-skill/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + getContentsWildcard: func(w http.ResponseWriter, _ *http.Request) { + resp := &gogithub.RepositoryContent{ + Type: gogithub.Ptr("file"), + Name: gogithub.Ptr("SKILL.md"), + Content: gogithub.Ptr(encodedContent), + Encoding: gogithub.Ptr("base64"), + } + data, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expectText: skillMDContent, + }, + { + name: "skill not found", + uri: "skill://owner/repo/nonexistent/SKILL.md", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("README.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expectError: `skill "nonexistent" not found`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gogithub.NewClient(MockHTTPClientWithHandlers(tc.handlers)) + deps := BaseDeps{Client: client} + ctx := ContextWithDeps(t.Context(), deps) + + handler := skillContentHandler(skillResourceContentURITemplate) + result, err := handler(ctx, &mcp.ReadResourceRequest{ + Params: &mcp.ReadResourceParams{URI: tc.uri}, + }) + + if tc.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectError) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Contents, 1) + assert.Equal(t, "text/markdown", result.Contents[0].MIMEType) + assert.Equal(t, tc.expectText, result.Contents[0].Text) + }) + } +} + +func Test_skillManifestHandler(t *testing.T) { + tests := []struct { + name string + uri string + handlers map[string]http.HandlerFunc + expectError string + expectSkill string + expectFiles int + }{ + { + name: "successful manifest", + uri: "skill://owner/repo/my-skill/_manifest", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/my-skill/SKILL.md"), Type: gogithub.Ptr("blob"), Size: gogithub.Ptr(256)}, + {Path: gogithub.Ptr("skills/my-skill/references/REFERENCE.md"), Type: gogithub.Ptr("blob"), Size: gogithub.Ptr(1024)}, + {Path: gogithub.Ptr("skills/my-skill/references"), Type: gogithub.Ptr("tree"), Size: gogithub.Ptr(0)}, + {Path: gogithub.Ptr("README.md"), Type: gogithub.Ptr("blob"), Size: gogithub.Ptr(100)}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expectSkill: "my-skill", + expectFiles: 2, // only blobs under the skill dir + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gogithub.NewClient(MockHTTPClientWithHandlers(tc.handlers)) + deps := BaseDeps{Client: client} + ctx := ContextWithDeps(t.Context(), deps) + + handler := skillManifestHandler(skillResourceManifestURITemplate) + result, err := handler(ctx, &mcp.ReadResourceRequest{ + Params: &mcp.ReadResourceParams{URI: tc.uri}, + }) + + if tc.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectError) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Contents, 1) + assert.Equal(t, "application/json", result.Contents[0].MIMEType) + + var manifest SkillManifest + err = json.Unmarshal([]byte(result.Contents[0].Text), &manifest) + require.NoError(t, err) + assert.Equal(t, tc.expectSkill, manifest.Skill) + assert.Len(t, manifest.Files, tc.expectFiles) + + // Verify each file has a repo:// URI + for _, f := range manifest.Files { + assert.True(t, strings.HasPrefix(f.URI, "repo://"), "expected repo:// URI, got %s", f.URI) + } + }) + } +} + +func Test_discoverSkills(t *testing.T) { + tests := []struct { + name string + handlers map[string]http.HandlerFunc + expectError string + expect []string + }{ + { + name: "finds skills under standard conventions", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/code-review/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("skills/pdf-processing/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("skills/pdf-processing/references/REF.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: []string{"code-review", "pdf-processing"}, + }, + { + name: "finds namespaced skills", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/acme/data-analysis/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("skills/acme/code-review/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: []string{"data-analysis", "code-review"}, + }, + { + name: "finds plugin convention skills", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("plugins/my-plugin/skills/lint-check/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: []string{"lint-check"}, + }, + { + name: "finds root-level skills", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("my-skill/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: []string{"my-skill"}, + }, + { + name: "excludes hidden and convention-prefix root dirs", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr(".github/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("skills/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("plugins/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("legit-skill/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: []string{"legit-skill"}, + }, + { + name: "no skills found", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("README.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: nil, + }, + { + name: "deduplicates skills across conventions", + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/my-skill/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("my-skill/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expect: []string{"my-skill"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gogithub.NewClient(MockHTTPClientWithHandlers(tc.handlers)) + + skills, err := discoverSkills(t.Context(), client, "owner", "repo") + + if tc.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectError) + return + } + + require.NoError(t, err) + assert.ElementsMatch(t, tc.expect, skills) + }) + } +} + +func Test_matchSkillConventions(t *testing.T) { + tests := []struct { + path string + expectNil bool + name string + dir string + }{ + // Convention 1: skills/*/SKILL.md + {path: "skills/code-review/SKILL.md", name: "code-review", dir: "skills/code-review"}, + // Convention 2: skills/{namespace}/*/SKILL.md + {path: "skills/acme/data-tool/SKILL.md", name: "data-tool", dir: "skills/acme/data-tool"}, + // Convention 3: plugins/*/skills/*/SKILL.md + {path: "plugins/my-plugin/skills/lint/SKILL.md", name: "lint", dir: "plugins/my-plugin/skills/lint"}, + // Convention 4: */SKILL.md (root-level) + {path: "my-skill/SKILL.md", name: "my-skill", dir: "my-skill"}, + // Excluded: hidden dirs + {path: ".github/SKILL.md", expectNil: true}, + // Excluded: convention prefixes as root skills + {path: "skills/SKILL.md", expectNil: true}, + {path: "plugins/SKILL.md", expectNil: true}, + // Excluded: not SKILL.md + {path: "skills/code-review/README.md", expectNil: true}, + // Excluded: bare SKILL.md at repo root + {path: "SKILL.md", expectNil: true}, + // Excluded: too deeply nested without matching convention + {path: "a/b/c/d/SKILL.md", expectNil: true}, + } + + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + result := matchSkillConventions(tc.path) + if tc.expectNil { + assert.Nil(t, result) + return + } + require.NotNil(t, result) + assert.Equal(t, tc.name, result.Name) + assert.Equal(t, tc.dir, result.Dir) + }) + } +} + +func Test_parseSkillURI(t *testing.T) { + tests := []struct { + name string + uri string + tmpl string + expectOwner string + expectRepo string + expectSkill string + expectError string + }{ + { + name: "valid content URI", + uri: "skill://octocat/hello-world/my-skill/SKILL.md", + tmpl: "skill://{owner}/{repo}/{skill_name}/SKILL.md", + expectOwner: "octocat", + expectRepo: "hello-world", + expectSkill: "my-skill", + }, + { + name: "valid manifest URI", + uri: "skill://octocat/hello-world/my-skill/_manifest", + tmpl: "skill://{owner}/{repo}/{skill_name}/_manifest", + expectOwner: "octocat", + expectRepo: "hello-world", + expectSkill: "my-skill", + }, + { + name: "missing owner", + uri: "skill:///hello-world/my-skill/SKILL.md", + tmpl: "skill://{owner}/{repo}/{skill_name}/SKILL.md", + expectError: "owner is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmpl := uritemplate.MustNew(tc.tmpl) + owner, repo, skill, err := parseSkillURI(tmpl, tc.uri) + + if tc.expectError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectError) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectOwner, owner) + assert.Equal(t, tc.expectRepo, repo) + assert.Equal(t, tc.expectSkill, skill) + }) + } +} + +func Test_SkillResourceCompletionHandler(t *testing.T) { + tests := []struct { + name string + request *mcp.CompleteRequest + handlers map[string]http.HandlerFunc + expected int // number of completion values + wantErr bool + }{ + { + name: "completes skill_name", + request: &mcp.CompleteRequest{ + Params: &mcp.CompleteParams{ + Ref: &mcp.CompleteReference{ + Type: "ref/resource", + URI: "skill://owner/repo/{skill_name}/SKILL.md", + }, + Argument: mcp.CompleteParamsArgument{ + Name: "skill_name", + Value: "", + }, + Context: &mcp.CompleteContext{ + Arguments: map[string]string{ + "owner": "owner", + "repo": "repo", + }, + }, + }, + }, + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/skill-a/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("skills/skill-b/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expected: 2, + }, + { + name: "filters skill_name by prefix", + request: &mcp.CompleteRequest{ + Params: &mcp.CompleteParams{ + Ref: &mcp.CompleteReference{ + Type: "ref/resource", + URI: "skill://owner/repo/{skill_name}/SKILL.md", + }, + Argument: mcp.CompleteParamsArgument{ + Name: "skill_name", + Value: "skill-a", + }, + Context: &mcp.CompleteContext{ + Arguments: map[string]string{ + "owner": "owner", + "repo": "repo", + }, + }, + }, + }, + handlers: map[string]http.HandlerFunc{ + GetReposGitTreesByOwnerByRepoByTree: func(w http.ResponseWriter, _ *http.Request) { + tree := &gogithub.Tree{ + Entries: []*gogithub.TreeEntry{ + {Path: gogithub.Ptr("skills/skill-a/SKILL.md"), Type: gogithub.Ptr("blob")}, + {Path: gogithub.Ptr("skills/skill-b/SKILL.md"), Type: gogithub.Ptr("blob")}, + }, + } + data, _ := json.Marshal(tree) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + }, + }, + expected: 1, + }, + { + name: "unknown argument returns error", + request: &mcp.CompleteRequest{ + Params: &mcp.CompleteParams{ + Ref: &mcp.CompleteReference{ + Type: "ref/resource", + URI: "skill://owner/repo/{skill_name}/SKILL.md", + }, + Argument: mcp.CompleteParamsArgument{ + Name: "unknown_arg", + Value: "", + }, + }, + }, + handlers: map[string]http.HandlerFunc{}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gogithub.NewClient(MockHTTPClientWithHandlers(tc.handlers)) + getClient := func(_ context.Context) (*gogithub.Client, error) { + return client, nil + } + + handler := SkillResourceCompletionHandler(getClient) + result, err := handler(t.Context(), tc.request) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Completion.Values, tc.expected) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0164b48e5..78cd229e8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -133,6 +133,11 @@ var ( Description: "GitHub Labels related tools", Icon: "tag", } + ToolsetMetadataSkills = inventory.ToolsetMetadata{ + ID: "skills", + Description: "Agent Skills discovery via skill:// resources (experimental, see agentskills.io)", + Icon: "light-bulb", + } // Remote-only toolsets - these are only available in the remote MCP server // but are documented here for consistency and to enable automated documentation.