From 1d8019b08ffea85bb32d81c449a509451e121374 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 12:32:04 +0000 Subject: [PATCH 1/9] add instructions to inventory --- pkg/inventory/registry.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index 885617b43..5da3bbc4a 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -58,6 +58,8 @@ type Inventory struct { filters []ToolFilter // unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets unrecognizedToolsets []string + // server instructions hold high-lelvel instructions for agents to use the server effectively + instructions string } // UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't @@ -292,3 +294,7 @@ func (r *Inventory) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata { } return result } + +func (r *Inventory) Instructions() string { + return r.instructions +} From 61be0e1a89918ab6cdf1cfc3867709e0b64d94b1 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 12:32:22 +0000 Subject: [PATCH 2/9] move instructions.go and tests from github package to inventory package --- pkg/{github => inventory}/instructions.go | 20 +++++----- .../instructions_test.go | 38 +++++++++---------- 2 files changed, 29 insertions(+), 29 deletions(-) rename pkg/{github => inventory}/instructions.go (93%) rename pkg/{github => inventory}/instructions_test.go (80%) diff --git a/pkg/github/instructions.go b/pkg/inventory/instructions.go similarity index 93% rename from pkg/github/instructions.go rename to pkg/inventory/instructions.go index 3a5fb54bb..9c551e545 100644 --- a/pkg/github/instructions.go +++ b/pkg/inventory/instructions.go @@ -1,4 +1,4 @@ -package github +package inventory import ( "os" @@ -6,8 +6,8 @@ import ( "strings" ) -// GenerateInstructions creates server instructions based on enabled toolsets -func GenerateInstructions(enabledToolsets []string) string { +// generateInstructions creates server instructions based on enabled toolsets +func generateInstructions(enabledToolsets []ToolsetID) string { // For testing - add a flag to disable instructions if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { return "" // Baseline mode @@ -16,7 +16,7 @@ func GenerateInstructions(enabledToolsets []string) string { var instructions []string // Core instruction - always included if context toolset enabled - if slices.Contains(enabledToolsets, "context") { + if slices.Contains(enabledToolsets, ToolsetID("context")) { instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.") } @@ -48,27 +48,27 @@ Tool usage guidance: } // getToolsetInstructions returns specific instructions for individual toolsets -func getToolsetInstructions(toolset string, enabledToolsets []string) string { +func getToolsetInstructions(toolset ToolsetID, enabledToolsets []ToolsetID) string { switch toolset { - case "pull_requests": + case ToolsetID("pull_requests"): pullRequestInstructions := `## Pull Requests PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` - if slices.Contains(enabledToolsets, "repos") { + if slices.Contains(enabledToolsets, ToolsetID("repos")) { pullRequestInstructions += ` Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.` } return pullRequestInstructions - case "issues": + case ToolsetID("issues"): return `## Issues Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` - case "discussions": + case ToolsetID("discussions"): return `## Discussions Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` - case "projects": + case ToolsetID("projects"): return `## Projects Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. diff --git a/pkg/github/instructions_test.go b/pkg/inventory/instructions_test.go similarity index 80% rename from pkg/github/instructions_test.go rename to pkg/inventory/instructions_test.go index b8ad2ba8c..abbad3b67 100644 --- a/pkg/github/instructions_test.go +++ b/pkg/inventory/instructions_test.go @@ -1,4 +1,4 @@ -package github +package inventory import ( "os" @@ -9,49 +9,49 @@ import ( func TestGenerateInstructions(t *testing.T) { tests := []struct { name string - enabledToolsets []string + enabledToolsets []ToolsetID expectedEmpty bool }{ { name: "empty toolsets", - enabledToolsets: []string{}, + enabledToolsets: []ToolsetID{}, expectedEmpty: false, }, { name: "only context toolset", - enabledToolsets: []string{"context"}, + enabledToolsets: []ToolsetID{"context"}, expectedEmpty: false, }, { name: "pull requests toolset", - enabledToolsets: []string{"pull_requests"}, + enabledToolsets: []ToolsetID{"pull_requests"}, expectedEmpty: false, }, { name: "issues toolset", - enabledToolsets: []string{"issues"}, + enabledToolsets: []ToolsetID{"issues"}, expectedEmpty: false, }, { name: "discussions toolset", - enabledToolsets: []string{"discussions"}, + enabledToolsets: []ToolsetID{"discussions"}, expectedEmpty: false, }, { name: "multiple toolsets (context + pull_requests)", - enabledToolsets: []string{"context", "pull_requests"}, + enabledToolsets: []ToolsetID{"context", "pull_requests"}, expectedEmpty: false, }, { name: "multiple toolsets (issues + pull_requests)", - enabledToolsets: []string{"issues", "pull_requests"}, + enabledToolsets: []ToolsetID{"issues", "pull_requests"}, expectedEmpty: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := GenerateInstructions(tt.enabledToolsets) + result := generateInstructions(tt.enabledToolsets) if tt.expectedEmpty { if result != "" { @@ -70,25 +70,25 @@ func TestGenerateInstructionsWithDisableFlag(t *testing.T) { tests := []struct { name string disableEnvValue string - enabledToolsets []string + enabledToolsets []ToolsetID expectedEmpty bool }{ { name: "DISABLE_INSTRUCTIONS=true returns empty", disableEnvValue: "true", - enabledToolsets: []string{"context", "issues", "pull_requests"}, + enabledToolsets: []ToolsetID{"context", "issues", "pull_requests"}, expectedEmpty: true, }, { name: "DISABLE_INSTRUCTIONS=false returns normal instructions", disableEnvValue: "false", - enabledToolsets: []string{"context"}, + enabledToolsets: []ToolsetID{"context"}, expectedEmpty: false, }, { name: "DISABLE_INSTRUCTIONS unset returns normal instructions", disableEnvValue: "", - enabledToolsets: []string{"issues"}, + enabledToolsets: []ToolsetID{"issues"}, expectedEmpty: false, }, } @@ -112,7 +112,7 @@ func TestGenerateInstructionsWithDisableFlag(t *testing.T) { os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue) } - result := GenerateInstructions(tt.enabledToolsets) + result := generateInstructions(tt.enabledToolsets) if tt.expectedEmpty { if result != "" { @@ -131,20 +131,20 @@ func TestGetToolsetInstructions(t *testing.T) { tests := []struct { toolset string expectedEmpty bool - enabledToolsets []string + enabledToolsets []ToolsetID expectedToContain string notExpectedToContain string }{ { toolset: "pull_requests", expectedEmpty: false, - enabledToolsets: []string{"pull_requests", "repos"}, + enabledToolsets: []ToolsetID{"pull_requests", "repos"}, expectedToContain: "pull_request_template.md", }, { toolset: "pull_requests", expectedEmpty: false, - enabledToolsets: []string{"pull_requests"}, + enabledToolsets: []ToolsetID{"pull_requests"}, notExpectedToContain: "pull_request_template.md", }, { @@ -163,7 +163,7 @@ func TestGetToolsetInstructions(t *testing.T) { for _, tt := range tests { t.Run(tt.toolset, func(t *testing.T) { - result := getToolsetInstructions(tt.toolset, tt.enabledToolsets) + result := getToolsetInstructions(ToolsetID(tt.toolset), tt.enabledToolsets) if tt.expectedEmpty { if result != "" { t.Errorf("Expected empty result for toolset '%s', but got: %s", tt.toolset, result) From 540a0e74e088216303b547a1f13f005c073ed1ad Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 12:32:53 +0000 Subject: [PATCH 3/9] add WithServerInstructions to builder --- pkg/inventory/builder.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 0400c2a24..477d1bd1e 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -33,12 +33,13 @@ type Builder struct { deprecatedAliases map[string]string // Configuration options (processed at Build time) - readOnly bool - toolsetIDs []string // raw input, processed at Build() - toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults) - additionalTools []string // raw input, processed at Build() - featureChecker FeatureFlagChecker - filters []ToolFilter // filters to apply to all tools + readOnly bool + toolsetIDs []string // raw input, processed at Build() + toolsetIDsIsNil bool // tracks if nil was passed (nil = defaults) + additionalTools []string // raw input, processed at Build() + featureChecker FeatureFlagChecker + filters []ToolFilter // filters to apply to all tools + generateInstructions bool } // NewBuilder creates a new Builder. @@ -83,6 +84,11 @@ func (b *Builder) WithReadOnly(readOnly bool) *Builder { return b } +func (b *Builder) WithServerInstructions() *Builder { + b.generateInstructions = true + return b +} + // WithToolsets specifies which toolsets should be enabled. // Special keywords: // - "all": enables all toolsets @@ -161,6 +167,10 @@ func (b *Builder) Build() *Inventory { } } + if b.generateInstructions { + r.instructions = generateInstructions(r.EnabledToolsetIDs()) + } + return r } From 30bc840294a13ba9d39918a2e4aeaf7a302c4870 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 12:33:24 +0000 Subject: [PATCH 4/9] add instructions step to inventory creation both in stdio and http server --- internal/ghmcp/server.go | 3 ++- pkg/github/server.go | 11 +---------- pkg/http/handler.go | 1 + 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 89b290db2..317936533 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -124,7 +124,8 @@ func NewStdioMCPServer(cfg github.MCPServerConfig) (*mcp.Server, error) { WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(cfg.EnabledToolsets). - WithTools(github.CleanTools(cfg.EnabledTools)) + WithTools(github.CleanTools(cfg.EnabledTools)). + WithServerInstructions() // WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) // Apply token scope filtering if scopes are known (for PAT filtering) diff --git a/pkg/github/server.go b/pkg/github/server.go index cc8d815ef..047647206 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -71,18 +71,9 @@ type MCPServerConfig struct { type MCPServerOption func(*mcp.ServerOptions) func NewMCPServer(cfg *MCPServerConfig, deps ToolDependencies, inventory *inventory.Inventory) (*mcp.Server, error) { - enabledToolsets := resolveEnabledToolsets(cfg) - // For instruction generation, we need actual toolset names (not nil). - // nil means "use defaults" in inventory, so expand it for instructions. - instructionToolsets := enabledToolsets - if instructionToolsets == nil { - instructionToolsets = GetDefaultToolsetIDs() - } - - // Create the MCP server serverOpts := &mcp.ServerOptions{ - Instructions: GenerateInstructions(instructionToolsets), + Instructions: inventory.Instructions(), Logger: cfg.Logger, CompletionHandler: CompletionsHandler(deps.GetClient), } diff --git a/pkg/http/handler.go b/pkg/http/handler.go index f2fcb531f..7181fc8ee 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -125,6 +125,7 @@ func DefaultInventoryFactory(cfg *HTTPServerConfig, t translations.TranslationHe } b = InventoryFiltersForRequestHeaders(r, b) + b = b.WithServerInstructions() return b.Build() } } From e6dd894b8c74d15552b2fc7a171b43ea87807466 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 12:53:33 +0000 Subject: [PATCH 5/9] fix typo --- pkg/inventory/registry.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index 5da3bbc4a..e4113b452 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -58,7 +58,7 @@ type Inventory struct { filters []ToolFilter // unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets unrecognizedToolsets []string - // server instructions hold high-lelvel instructions for agents to use the server effectively + // server instructions hold high-level instructions for agents to use the server effectively instructions string } From e743cf113e97aa838d846d1322d59fd636f06924 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 14:53:57 +0000 Subject: [PATCH 6/9] add instructions generation to toolset metadata --- pkg/github/tools.go | 41 ++++++----- pkg/github/toolset_instructions.go | 106 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 pkg/github/toolset_instructions.go diff --git a/pkg/github/tools.go b/pkg/github/tools.go index b15c4fc9a..a4db2e0be 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -28,10 +28,11 @@ 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", + ID: "context", + Description: "Tools that provide context about the current user and GitHub context you are operating in", + Default: true, + Icon: "person", + InstructionsFunc: generateContextToolsetInstructions, } ToolsetMetadataRepos = inventory.ToolsetMetadata{ ID: "repos", @@ -45,16 +46,18 @@ var ( Icon: "git-branch", } ToolsetMetadataIssues = inventory.ToolsetMetadata{ - ID: "issues", - Description: "GitHub Issues related tools", - Default: true, - Icon: "issue-opened", + ID: "issues", + Description: "GitHub Issues related tools", + Default: true, + Icon: "issue-opened", + InstructionsFunc: generateIssuesToolsetInstructions, } ToolsetMetadataPullRequests = inventory.ToolsetMetadata{ - ID: "pull_requests", - Description: "GitHub Pull Request related tools", - Default: true, - Icon: "git-pull-request", + ID: "pull_requests", + Description: "GitHub Pull Request related tools", + Default: true, + Icon: "git-pull-request", + InstructionsFunc: generatePullRequestsToolsetInstructions, } ToolsetMetadataUsers = inventory.ToolsetMetadata{ ID: "users", @@ -93,9 +96,10 @@ var ( Icon: "bell", } ToolsetMetadataDiscussions = inventory.ToolsetMetadata{ - ID: "discussions", - Description: "GitHub Discussions related tools", - Icon: "comment-discussion", + ID: "discussions", + Description: "GitHub Discussions related tools", + Icon: "comment-discussion", + InstructionsFunc: generateDiscussionsToolsetInstructions, } ToolsetMetadataGists = inventory.ToolsetMetadata{ ID: "gists", @@ -108,9 +112,10 @@ var ( Icon: "shield", } ToolsetMetadataProjects = inventory.ToolsetMetadata{ - ID: "projects", - Description: "GitHub Projects related tools", - Icon: "project", + ID: "projects", + Description: "GitHub Projects related tools", + Icon: "project", + InstructionsFunc: generateProjectsToolsetInstructions, } ToolsetMetadataStargazers = inventory.ToolsetMetadata{ ID: "stargazers", diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go new file mode 100644 index 000000000..bf2388a3d --- /dev/null +++ b/pkg/github/toolset_instructions.go @@ -0,0 +1,106 @@ +package github + +import "github.com/github/github-mcp-server/pkg/inventory" + +// Toolset instruction functions - these generate context-aware instructions for each toolset. +// They are called during inventory build to generate server instructions. + +func generateContextToolsetInstructions(_ *inventory.Inventory) string { + return "Always call 'get_me' first to understand current user permissions and context." +} + +func generateIssuesToolsetInstructions(_ *inventory.Inventory) string { + return `## Issues + +Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` +} + +func generatePullRequestsToolsetInstructions(inv *inventory.Inventory) string { + instructions := `## Pull Requests + +PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` + + if inv.HasToolset("repos") { + instructions += ` + +Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.` + } + return instructions +} + +func generateDiscussionsToolsetInstructions(_ *inventory.Inventory) string { + return `## Discussions + +Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` +} + +func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { + return `## Projects + +Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. + +Field usage: + - Call list_project_fields first to understand available fields and get IDs/types before filtering. + - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. + - Iteration synonyms (sprint/cycle) only if that field exists; map to the actual name (e.g. sprint:@current). + - Only include filters for fields that exist and are relevant. + +Pagination (mandatory): + - Loop while pageInfo.hasNextPage=true using after=pageInfo.nextCursor. + - Keep query, fields, per_page IDENTICAL on every page. + - Use before=pageInfo.prevCursor only when explicitly navigating to a previous page. + +Counting rules: + - Count items array length after full pagination. + - Never count field objects, content, or nested arrays as separate items. + +Summary vs list: + - Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights. + - Listing verbs (list/show/get/fetch/display/enumerate) → enumerate + total. + +Self-check before returning: + - Paginated fully + - Correct IDs used + - Field names valid + - Summary only if requested. + +Return COMPLETE data or state what's missing (e.g. pages skipped). + +list_project_items query rules: +Query string - For advanced filtering of project items using GitHub's project filtering syntax: + +MUST reflect user intent; strongly prefer explicit content type if narrowed: + - "open issues" → state:open is:issue + - "merged PRs" → state:merged is:pr + - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) + - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") + - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") + - "all open issues I'm working on" → is:issue state:open assignee:@me + +Query Construction Heuristics: + a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) + b. Map temporal phrases: "this week" → updated:>@today-7d + c. Map negations: "excluding wontfix" → -label:wontfix + d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) + e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) + f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) + +Syntax Essentials (items): + AND: space-separated. (label:bug priority:high). + OR: comma inside one qualifier (label:bug,critical). + NOT: leading '-' (-label:wontfix). + Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). + Quote multi-word values. (status:"In Review" team-name:"Backend Team"). + Ranges: points:1..3, updated:<@today-30d. + Wildcards: title:*crash*, label:bug*. + Assigned to User: assignee:@me | assignee:username | no:assignee + +Common Qualifier Glossary (items): + is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | + priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | + updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label + +Never: + - Infer field IDs; fetch via list_project_fields. + - Drop 'fields' param on subsequent pages if field values are needed.` +} From 0c16e33286621e26b5f316211408d39e2ab6de40 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 14:54:19 +0000 Subject: [PATCH 7/9] refactor to use toolset instructions from new toolset metadata --- pkg/inventory/instructions.go | 120 ++------------------- pkg/inventory/instructions_test.go | 163 ++++++++++++++++------------- 2 files changed, 100 insertions(+), 183 deletions(-) diff --git a/pkg/inventory/instructions.go b/pkg/inventory/instructions.go index 9c551e545..e4524eb43 100644 --- a/pkg/inventory/instructions.go +++ b/pkg/inventory/instructions.go @@ -2,12 +2,11 @@ package inventory import ( "os" - "slices" "strings" ) // generateInstructions creates server instructions based on enabled toolsets -func generateInstructions(enabledToolsets []ToolsetID) string { +func generateInstructions(inv *Inventory) string { // For testing - add a flag to disable instructions if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { return "" // Baseline mode @@ -15,18 +14,6 @@ func generateInstructions(enabledToolsets []ToolsetID) string { var instructions []string - // Core instruction - always included if context toolset enabled - if slices.Contains(enabledToolsets, ToolsetID("context")) { - instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.") - } - - // Individual toolset instructions - for _, toolset := range enabledToolsets { - if inst := getToolsetInstructions(toolset, enabledToolsets); inst != "" { - instructions = append(instructions, inst) - } - } - // Base instruction with context management baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform. @@ -41,103 +28,16 @@ Context management: Tool usage guidance: 1. For 'search_*' tools: Use separate 'sort' and 'order' parameters if available for sorting results - do not include 'sort:' syntax in query strings. Query strings should contain only search criteria (e.g., 'org:google language:python'), not sorting instructions.` - allInstructions := []string{baseInstruction} - allInstructions = append(allInstructions, instructions...) - - return strings.Join(allInstructions, " ") -} - -// getToolsetInstructions returns specific instructions for individual toolsets -func getToolsetInstructions(toolset ToolsetID, enabledToolsets []ToolsetID) string { - switch toolset { - case ToolsetID("pull_requests"): - pullRequestInstructions := `## Pull Requests - -PR review workflow: Always use 'pull_request_review_write' with method 'create' to create a pending review, then 'add_comment_to_pending_review' to add comments, and finally 'pull_request_review_write' with method 'submit_pending' to submit the review for complex reviews with line-specific comments.` - if slices.Contains(enabledToolsets, ToolsetID("repos")) { - pullRequestInstructions += ` + instructions = append(instructions, baseInstruction) -Before creating a pull request, search for pull request templates in the repository. Template files are called pull_request_template.md or they're located in '.github/PULL_REQUEST_TEMPLATE' directory. Use the template content to structure the PR description and then call create_pull_request tool.` + // Collect instructions from each enabled toolset + for _, toolset := range inv.AvailableToolsets() { + if toolset.InstructionsFunc != nil { + if toolsetInstructions := toolset.InstructionsFunc(inv); toolsetInstructions != "" { + instructions = append(instructions, toolsetInstructions) + } } - return pullRequestInstructions - case ToolsetID("issues"): - return `## Issues - -Check 'list_issue_types' first for organizations to use proper issue types. Use 'search_issues' before creating new issues to avoid duplicates. Always set 'state_reason' when closing issues.` - case ToolsetID("discussions"): - return `## Discussions - -Use 'list_discussion_categories' to understand available categories before creating discussions. Filter by category for better organization.` - case ToolsetID("projects"): - return `## Projects - -Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. - -Field usage: - - Call list_project_fields first to understand available fields and get IDs/types before filtering. - - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. - - Iteration synonyms (sprint/cycle) only if that field exists; map to the actual name (e.g. sprint:@current). - - Only include filters for fields that exist and are relevant. - -Pagination (mandatory): - - Loop while pageInfo.hasNextPage=true using after=pageInfo.nextCursor. - - Keep query, fields, per_page IDENTICAL on every page. - - Use before=pageInfo.prevCursor only when explicitly navigating to a previous page. - -Counting rules: - - Count items array length after full pagination. - - Never count field objects, content, or nested arrays as separate items. - -Summary vs list: - - Summaries ONLY if user uses verbs: analyze | summarize | summary | report | overview | insights. - - Listing verbs (list/show/get/fetch/display/enumerate) → enumerate + total. - -Self-check before returning: - - Paginated fully - - Correct IDs used - - Field names valid - - Summary only if requested. - -Return COMPLETE data or state what's missing (e.g. pages skipped). - -list_project_items query rules: -Query string - For advanced filtering of project items using GitHub's project filtering syntax: - -MUST reflect user intent; strongly prefer explicit content type if narrowed: - - "open issues" → state:open is:issue - - "merged PRs" → state:merged is:pr - - "items updated this week" → updated:>@today-7d (omit type only if mixed desired) - - "list all P1 priority items" → priority:p1 (omit state if user wants all, omit type if user specifies "items") - - "list all open P2 issues" → is:issue state:open priority:p2 (include state if user wants open or closed, include type if user specifies "issues" or "PRs") - - "all open issues I'm working on" → is:issue state:open assignee:@me - -Query Construction Heuristics: - a. Extract type nouns: issues → is:issue | PRs, Pulls, or Pull Requests → is:pr | tasks/tickets → is:issue (ask if ambiguity) - b. Map temporal phrases: "this week" → updated:>@today-7d - c. Map negations: "excluding wontfix" → -label:wontfix - d. Map priority adjectives: "high/sev1/p1" → priority:high OR priority:p1 (choose based on field presence) - e. When filtering by label, always use wildcard matching to account for cross-repository differences or emojis: (e.g. "bug 🐛" → label:*bug*) - f. When filtering by milestone, always use wildcard matching to account for cross-repository differences: (e.g. "v1.0" → milestone:*v1.0*) - -Syntax Essentials (items): - AND: space-separated. (label:bug priority:high). - OR: comma inside one qualifier (label:bug,critical). - NOT: leading '-' (-label:wontfix). - Hyphenate multi-word field names. (team-name:"Backend Team", story-points:>5). - Quote multi-word values. (status:"In Review" team-name:"Backend Team"). - Ranges: points:1..3, updated:<@today-30d. - Wildcards: title:*crash*, label:bug*. - Assigned to User: assignee:@me | assignee:username | no:assignee - -Common Qualifier Glossary (items): - is:issue | is:pr | state:open|closed|merged | assignee:@me|username | label:NAME | status:VALUE | - priority:p1|high | sprint-name:@current | team-name:"Backend Team" | parent-issue:"org/repo#123" | - updated:>@today-7d | title:*text* | -label:wontfix | label:bug,critical | no:assignee | has:label - -Never: - - Infer field IDs; fetch via list_project_fields. - - Drop 'fields' param on subsequent pages if field values are needed.` - default: - return "" } + + return strings.Join(instructions, " ") } diff --git a/pkg/inventory/instructions_test.go b/pkg/inventory/instructions_test.go index abbad3b67..e12840b2a 100644 --- a/pkg/inventory/instructions_test.go +++ b/pkg/inventory/instructions_test.go @@ -6,52 +6,61 @@ import ( "testing" ) +// createTestInventory creates an inventory with the specified toolsets for testing. +func createTestInventory(toolsets []ToolsetMetadata) *Inventory { + // Create tools for each toolset so they show up in AvailableToolsets() + var tools []ServerTool + for _, ts := range toolsets { + tools = append(tools, ServerTool{ + Toolset: ts, + }) + } + + return NewBuilder(). + SetTools(tools). + Build() +} + func TestGenerateInstructions(t *testing.T) { tests := []struct { - name string - enabledToolsets []ToolsetID - expectedEmpty bool + name string + toolsets []ToolsetMetadata + expectedEmpty bool }{ { - name: "empty toolsets", - enabledToolsets: []ToolsetID{}, - expectedEmpty: false, + name: "empty toolsets", + toolsets: []ToolsetMetadata{}, + expectedEmpty: false, // base instructions are always included }, { - name: "only context toolset", - enabledToolsets: []ToolsetID{"context"}, - expectedEmpty: false, - }, - { - name: "pull requests toolset", - enabledToolsets: []ToolsetID{"pull_requests"}, - expectedEmpty: false, - }, - { - name: "issues toolset", - enabledToolsets: []ToolsetID{"issues"}, - expectedEmpty: false, - }, - { - name: "discussions toolset", - enabledToolsets: []ToolsetID{"discussions"}, - expectedEmpty: false, - }, - { - name: "multiple toolsets (context + pull_requests)", - enabledToolsets: []ToolsetID{"context", "pull_requests"}, - expectedEmpty: false, + name: "toolset with instructions", + toolsets: []ToolsetMetadata{ + { + ID: "test", + Description: "Test toolset", + InstructionsFunc: func(inv *Inventory) string { + return "Test instructions" + }, + }, + }, + expectedEmpty: false, }, { - name: "multiple toolsets (issues + pull_requests)", - enabledToolsets: []ToolsetID{"issues", "pull_requests"}, - expectedEmpty: false, + name: "toolset without instructions", + toolsets: []ToolsetMetadata{ + { + ID: "test", + Description: "Test toolset", + }, + }, + expectedEmpty: false, // base instructions still included }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := generateInstructions(tt.enabledToolsets) + inv := createTestInventory(tt.toolsets) + result := generateInstructions(inv) if tt.expectedEmpty { if result != "" { @@ -70,25 +79,21 @@ func TestGenerateInstructionsWithDisableFlag(t *testing.T) { tests := []struct { name string disableEnvValue string - enabledToolsets []ToolsetID expectedEmpty bool }{ { name: "DISABLE_INSTRUCTIONS=true returns empty", disableEnvValue: "true", - enabledToolsets: []ToolsetID{"context", "issues", "pull_requests"}, expectedEmpty: true, }, { name: "DISABLE_INSTRUCTIONS=false returns normal instructions", disableEnvValue: "false", - enabledToolsets: []ToolsetID{"context"}, expectedEmpty: false, }, { name: "DISABLE_INSTRUCTIONS unset returns normal instructions", disableEnvValue: "", - enabledToolsets: []ToolsetID{"issues"}, expectedEmpty: false, }, } @@ -112,7 +117,10 @@ func TestGenerateInstructionsWithDisableFlag(t *testing.T) { os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue) } - result := generateInstructions(tt.enabledToolsets) + inv := createTestInventory([]ToolsetMetadata{ + {ID: "test", Description: "Test"}, + }) + result := generateInstructions(inv) if tt.expectedEmpty { if result != "" { @@ -127,59 +135,68 @@ func TestGenerateInstructionsWithDisableFlag(t *testing.T) { } } -func TestGetToolsetInstructions(t *testing.T) { +func TestToolsetInstructionsFunc(t *testing.T) { tests := []struct { - toolset string - expectedEmpty bool - enabledToolsets []ToolsetID + name string + toolsets []ToolsetMetadata expectedToContain string notExpectedToContain string }{ { - toolset: "pull_requests", - expectedEmpty: false, - enabledToolsets: []ToolsetID{"pull_requests", "repos"}, - expectedToContain: "pull_request_template.md", - }, - { - toolset: "pull_requests", - expectedEmpty: false, - enabledToolsets: []ToolsetID{"pull_requests"}, - notExpectedToContain: "pull_request_template.md", - }, - { - toolset: "issues", - expectedEmpty: false, + name: "toolset with context-aware instructions includes extra text when dependency present", + toolsets: []ToolsetMetadata{ + {ID: "repos", Description: "Repos"}, + { + ID: "pull_requests", + Description: "PRs", + InstructionsFunc: func(inv *Inventory) string { + instructions := "PR base instructions" + if inv.HasToolset("repos") { + instructions += " PR template instructions" + } + return instructions + }, + }, + }, + expectedToContain: "PR template instructions", }, { - toolset: "discussions", - expectedEmpty: false, + name: "toolset with context-aware instructions excludes extra text when dependency missing", + toolsets: []ToolsetMetadata{ + { + ID: "pull_requests", + Description: "PRs", + InstructionsFunc: func(inv *Inventory) string { + instructions := "PR base instructions" + if inv.HasToolset("repos") { + instructions += " PR template instructions" + } + return instructions + }, + }, + }, + notExpectedToContain: "PR template instructions", }, { - toolset: "nonexistent", - expectedEmpty: true, + name: "toolset without InstructionsFunc returns no toolset-specific instructions", + toolsets: []ToolsetMetadata{ + {ID: "test", Description: "Test without instructions"}, + }, + notExpectedToContain: "## Test", }, } for _, tt := range tests { - t.Run(tt.toolset, func(t *testing.T) { - result := getToolsetInstructions(ToolsetID(tt.toolset), tt.enabledToolsets) - if tt.expectedEmpty { - if result != "" { - t.Errorf("Expected empty result for toolset '%s', but got: %s", tt.toolset, result) - } - } else { - if result == "" { - t.Errorf("Expected non-empty result for toolset '%s', but got empty", tt.toolset) - } - } + t.Run(tt.name, func(t *testing.T) { + inv := createTestInventory(tt.toolsets) + result := generateInstructions(inv) if tt.expectedToContain != "" && !strings.Contains(result, tt.expectedToContain) { - t.Errorf("Expected result to contain '%s' for toolset '%s', but it did not. Result: %s", tt.expectedToContain, tt.toolset, result) + t.Errorf("Expected result to contain '%s', but it did not. Result: %s", tt.expectedToContain, result) } if tt.notExpectedToContain != "" && strings.Contains(result, tt.notExpectedToContain) { - t.Errorf("Did not expect result to contain '%s' for toolset '%s', but it did. Result: %s", tt.notExpectedToContain, tt.toolset, result) + t.Errorf("Did not expect result to contain '%s', but it did. Result: %s", tt.notExpectedToContain, result) } }) } From 98267fef7bd07fb9efb5d93ef924270212926faa Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 14:54:48 +0000 Subject: [PATCH 8/9] add instructionsFunc to toolset metadata --- pkg/inventory/server_tool.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 095bedf2b..752a4c2bd 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -31,6 +31,9 @@ type ToolsetMetadata struct { // Use the base name without size suffix, e.g., "repo" not "repo-16". // See https://primer.style/foundations/icons for available icons. Icon string + // InstructionsFunc optionally returns instructions for this toolset. + // It receives the inventory so it can check what other toolsets are enabled. + InstructionsFunc func(inv *Inventory) string } // Icons returns MCP Icon objects for this toolset, or nil if no icon is set. From 78b3d7a4cbae0c23c5f85c25e68454d3dfb1bbb5 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 22 Jan 2026 14:55:05 +0000 Subject: [PATCH 9/9] pass inventory only, as now toolsets are handled in instructions generation process --- pkg/inventory/builder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 477d1bd1e..ed5e33e42 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -168,7 +168,7 @@ func (b *Builder) Build() *Inventory { } if b.generateInstructions { - r.instructions = generateInstructions(r.EnabledToolsetIDs()) + r.instructions = generateInstructions(r) } return r