|
| 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