Skip to content
1 change: 0 additions & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker)

// Apply token scope filtering if scopes are known (for PAT filtering)
Expand Down
4 changes: 3 additions & 1 deletion pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ type MCPServerOption func(*mcp.ServerOptions)
func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependencies, inv *inventory.Inventory, middleware ...mcp.Middleware) (*mcp.Server, error) {
// Create the MCP server
serverOpts := &mcp.ServerOptions{
Instructions: inv.Instructions(),
Logger: cfg.Logger,
CompletionHandler: CompletionsHandler(deps.GetClient),
}
Expand Down Expand Up @@ -125,6 +124,9 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci
registerDynamicTools(ghServer, inv, deps, cfg.Translator)
}

// Register skill resources for MCP clients that support skills-based discovery.
RegisterSkillResources(ghServer)

return ghServer, nil
}

Expand Down
538 changes: 538 additions & 0 deletions pkg/github/skill_resources.go

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions pkg/github/skill_resources_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package github

import (
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAllSkillsCoverAllToolsets(t *testing.T) {
// Collect all tool names from AllTools
allToolNames := make(map[string]bool)
for _, tool := range AllTools(stubTranslator) {
allToolNames[tool.Tool.Name] = true
}

// Collect all tool names covered by skills
coveredTools := make(map[string]bool)
for _, skill := range allSkills() {
for _, toolName := range skill.allowedTools {
coveredTools[toolName] = true
}
}

// Every tool should be covered by at least one skill
for toolName := range allToolNames {
assert.True(t, coveredTools[toolName], "tool %q is not covered by any skill", toolName)
}
}

func TestBuildSkillContent(t *testing.T) {
skill := skillDefinition{
name: "test-skill",
description: "A test skill",
allowedTools: []string{"tool_a", "tool_b"},
body: "# Test\n\nUse these tools.\n",
}

content := buildSkillContent(skill)

assert.Contains(t, content, "---\n")
assert.Contains(t, content, "name: test-skill\n")
assert.Contains(t, content, "description: A test skill\n")
assert.Contains(t, content, " - tool_a\n")
assert.Contains(t, content, " - tool_b\n")
assert.Contains(t, content, "# Test\n")
}

func TestSkillResourceURIs(t *testing.T) {
skills := allSkills()
require.NotEmpty(t, skills)

uris := make(map[string]bool)
names := make(map[string]bool)

for _, skill := range skills {
uri := "skill://github/" + skill.name + "/SKILL.md"

assert.False(t, uris[uri], "duplicate skill URI: %s", uri)
uris[uri] = true

assert.False(t, names[skill.name], "duplicate skill name: %s", skill.name)
names[skill.name] = true

assert.NotEmpty(t, skill.description, "skill %s has empty description", skill.name)
assert.NotEmpty(t, skill.allowedTools, "skill %s has no allowed tools", skill.name)
assert.NotEmpty(t, skill.body, "skill %s has empty body", skill.name)
}
}

func TestRegisterSkillResources(t *testing.T) {
server := mcp.NewServer(&mcp.Implementation{
Name: "test-server",
Version: "0.0.1",
}, nil)

// Should not panic
RegisterSkillResources(server)

// Verify the expected number of skills were registered by counting definitions
skills := allSkills()
assert.Equal(t, 27, len(skills), "expected 27 workflow-oriented skills")
}
54 changes: 31 additions & 23 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,10 @@ var (
Icon: "check-circle",
}
ToolsetMetadataContext = inventory.ToolsetMetadata{
ID: "context",
Description: "Tools that provide context about the current user and GitHub context you are operating in",
Default: true,
Icon: "person",
InstructionsFunc: generateContextToolsetInstructions,
ID: "context",
Description: "Tools that provide context about the current user and GitHub context you are operating in",
Default: true,
Icon: "person",
}
ToolsetMetadataRepos = inventory.ToolsetMetadata{
ID: "repos",
Expand All @@ -44,21 +43,20 @@ var (
ToolsetMetadataGit = inventory.ToolsetMetadata{
ID: "git",
Description: "GitHub Git API related tools for low-level Git operations",
Default: true,
Icon: "git-branch",
}
ToolsetMetadataIssues = inventory.ToolsetMetadata{
ID: "issues",
Description: "GitHub Issues related tools",
Default: true,
Icon: "issue-opened",
InstructionsFunc: generateIssuesToolsetInstructions,
ID: "issues",
Description: "GitHub Issues related tools",
Default: true,
Icon: "issue-opened",
}
ToolsetMetadataPullRequests = inventory.ToolsetMetadata{
ID: "pull_requests",
Description: "GitHub Pull Request related tools",
Default: true,
Icon: "git-pull-request",
InstructionsFunc: generatePullRequestsToolsetInstructions,
ID: "pull_requests",
Description: "GitHub Pull Request related tools",
Default: true,
Icon: "git-pull-request",
}
ToolsetMetadataUsers = inventory.ToolsetMetadata{
ID: "users",
Expand All @@ -69,58 +67,67 @@ var (
ToolsetMetadataOrgs = inventory.ToolsetMetadata{
ID: "orgs",
Description: "GitHub Organization related tools",
Default: true,
Icon: "organization",
}
ToolsetMetadataActions = inventory.ToolsetMetadata{
ID: "actions",
Description: "GitHub Actions workflows and CI/CD operations",
Default: true,
Icon: "workflow",
}
ToolsetMetadataCodeSecurity = inventory.ToolsetMetadata{
ID: "code_security",
Description: "Code security related tools, such as GitHub Code Scanning",
Default: true,
Icon: "codescan",
}
ToolsetMetadataSecretProtection = inventory.ToolsetMetadata{
ID: "secret_protection",
Description: "Secret protection related tools, such as GitHub Secret Scanning",
Default: true,
Icon: "shield-lock",
}
ToolsetMetadataDependabot = inventory.ToolsetMetadata{
ID: "dependabot",
Description: "Dependabot tools",
Default: true,
Icon: "dependabot",
}
ToolsetMetadataNotifications = inventory.ToolsetMetadata{
ID: "notifications",
Description: "GitHub Notifications related tools",
Default: true,
Icon: "bell",
}
ToolsetMetadataDiscussions = inventory.ToolsetMetadata{
ID: "discussions",
Description: "GitHub Discussions related tools",
Icon: "comment-discussion",
InstructionsFunc: generateDiscussionsToolsetInstructions,
ID: "discussions",
Description: "GitHub Discussions related tools",
Default: true,
Icon: "comment-discussion",
}
ToolsetMetadataGists = inventory.ToolsetMetadata{
ID: "gists",
Description: "GitHub Gist related tools",
Default: true,
Icon: "logo-gist",
}
ToolsetMetadataSecurityAdvisories = inventory.ToolsetMetadata{
ID: "security_advisories",
Description: "Security advisories related tools",
Default: true,
Icon: "shield",
}
ToolsetMetadataProjects = inventory.ToolsetMetadata{
ID: "projects",
Description: "GitHub Projects related tools",
Icon: "project",
InstructionsFunc: generateProjectsToolsetInstructions,
ID: "projects",
Description: "GitHub Projects related tools",
Default: true,
Icon: "project",
}
ToolsetMetadataStargazers = inventory.ToolsetMetadata{
ID: "stargazers",
Description: "GitHub Stargazers related tools",
Default: true,
Icon: "star",
}
ToolsetMetadataDynamic = inventory.ToolsetMetadata{
Expand All @@ -131,6 +138,7 @@ var (
ToolsetLabels = inventory.ToolsetMetadata{
ID: "labels",
Description: "GitHub Labels related tools",
Default: true,
Icon: "tag",
}

Expand Down
63 changes: 31 additions & 32 deletions pkg/github/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ import (
)

func TestAddDefaultToolset(t *testing.T) {
allDefaultToolsets := []string{
"actions",
"code_security",
"context",
"copilot",
"dependabot",
"discussions",
"gists",
"git",
"issues",
"labels",
"notifications",
"orgs",
"projects",
"pull_requests",
"repos",
"secret_protection",
"security_advisories",
"stargazers",
"users",
}

tests := []struct {
name string
input []string
Expand All @@ -19,42 +41,19 @@ func TestAddDefaultToolset(t *testing.T) {
expected: []string{"actions", "gists"},
},
{
name: "default keyword present - expand and remove default",
input: []string{"default"},
expected: []string{
"context",
"copilot",
"repos",
"issues",
"pull_requests",
"users",
},
name: "default keyword present - expand and remove default",
input: []string{"default"},
expected: allDefaultToolsets,
},
{
name: "default with additional toolsets",
input: []string{"default", "actions", "gists"},
expected: []string{
"actions",
"gists",
"context",
"copilot",
"repos",
"issues",
"pull_requests",
"users",
},
name: "default with additional toolsets",
input: []string{"default", "actions", "gists"},
expected: allDefaultToolsets,
},
{
name: "default with overlapping toolsets - should not duplicate",
input: []string{"default", "context", "repos"},
expected: []string{
"context",
"copilot",
"repos",
"issues",
"pull_requests",
"users",
},
name: "default with overlapping toolsets - should not duplicate",
input: []string{"default", "context", "repos"},
expected: allDefaultToolsets,
},
{
name: "empty input",
Expand Down
Loading
Loading