chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+443
View File
@@ -0,0 +1,443 @@
package inventory
import (
"context"
"errors"
"fmt"
"maps"
"slices"
"strings"
)
var (
// ErrUnknownTools is returned when tools specified via WithTools() are not recognized.
ErrUnknownTools = errors.New("unknown tools specified in WithTools")
)
// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata.
// This is defined here to avoid importing pkg/github (which imports pkg/inventory).
// The value must match github.MCPAppsFeatureFlag.
const mcpAppsFeatureFlag = "remote_mcp_ui_apps"
// ToolFilter is a function that determines if a tool should be included.
// Returns true if the tool should be included, false to exclude it.
type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error)
// Builder builds a Registry with the specified configuration.
// Use NewBuilder to create a builder, chain configuration methods,
// then call Build() to create the final inventory.
//
// Example:
//
// reg := NewBuilder().
// SetTools(tools).
// SetResources(resources).
// SetPrompts(prompts).
// WithDeprecatedAliases(aliases).
// WithReadOnly(true).
// WithToolsets([]string{"repos", "issues"}).
// WithFeatureChecker(checker).
// WithFilter(myFilter).
// Build()
type Builder struct {
tools []ServerTool
resourceTemplates []ServerResourceTemplate
prompts []ServerPrompt
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
generateInstructions bool
}
// NewBuilder creates a new Builder.
func NewBuilder() *Builder {
return &Builder{
deprecatedAliases: make(map[string]string),
toolsetIDsIsNil: true, // default to nil (use defaults)
}
}
// SetTools sets the tools for the inventory. Returns self for chaining.
func (b *Builder) SetTools(tools []ServerTool) *Builder {
b.tools = tools
return b
}
// SetResources sets the resource templates for the inventory. Returns self for chaining.
func (b *Builder) SetResources(resources []ServerResourceTemplate) *Builder {
b.resourceTemplates = resources
return b
}
// SetPrompts sets the prompts for the inventory. Returns self for chaining.
func (b *Builder) SetPrompts(prompts []ServerPrompt) *Builder {
b.prompts = prompts
return b
}
// WithDeprecatedAliases adds deprecated tool name aliases that map to canonical names.
// Returns self for chaining.
func (b *Builder) WithDeprecatedAliases(aliases map[string]string) *Builder {
maps.Copy(b.deprecatedAliases, aliases)
return b
}
// WithReadOnly sets whether only read-only tools should be available.
// When true, write tools are filtered out. Returns self for chaining.
func (b *Builder) WithReadOnly(readOnly bool) *Builder {
b.readOnly = readOnly
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
// - "default": expands to toolsets marked with Default: true in their metadata
//
// Input strings are trimmed of whitespace and duplicates are removed.
// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets.
// Returns self for chaining.
func (b *Builder) WithToolsets(toolsetIDs []string) *Builder {
b.toolsetIDs = toolsetIDs
b.toolsetIDsIsNil = toolsetIDs == nil
return b
}
// WithTools specifies additional tools that bypass toolset filtering.
// These tools are additive - they will be included even if their toolset is not enabled.
// Read-only filtering still applies to these tools.
// Input is cleaned (trimmed, deduplicated) during Build().
// Deprecated tool aliases are automatically resolved to their canonical names during Build().
// Returns self for chaining.
func (b *Builder) WithTools(toolNames []string) *Builder {
b.additionalTools = toolNames
return b
}
// WithFeatureChecker sets the feature flag checker function.
// The checker receives a context (for actor extraction) and feature flag name,
// and returns (enabled, error). Errors are logged and treated as "not enabled".
//
// When the checker is non-nil, Build() installs a feature-flag ToolFilter
// at the head of the filter pipeline so that tools annotated with
// FeatureFlagEnable / FeatureFlagDisable are gated accordingly. Resources
// and prompts use the same checker via an explicit guard at their iteration
// site.
//
// When the checker is nil, no feature-flag filter is installed; tools,
// resources, and prompts pass through feature-flag gating unchanged. The
// per-request inventory in HTTP mode must always install a checker so that
// MCP registration (which can only serve a given tool name once) sees a
// deduplicated set of dual-name variants.
//
// Returns self for chaining.
func (b *Builder) WithFeatureChecker(checker FeatureFlagChecker) *Builder {
b.featureChecker = checker
return b
}
// WithFilter adds a filter function that will be applied to all tools.
// Multiple filters can be added and are evaluated in order.
// If any filter returns false or an error, the tool is excluded.
// Returns self for chaining.
func (b *Builder) WithFilter(filter ToolFilter) *Builder {
b.filters = append(b.filters, filter)
return b
}
// WithExcludeTools specifies tools that should be disabled regardless of other settings.
// These tools will be excluded even if their toolset is enabled or they are in the
// additional tools list. This takes precedence over all other tool enablement settings.
// Input is cleaned (trimmed, deduplicated) before applying.
// Returns self for chaining.
func (b *Builder) WithExcludeTools(toolNames []string) *Builder {
cleaned := cleanTools(toolNames)
if len(cleaned) > 0 {
b.filters = append(b.filters, CreateExcludeToolsFilter(cleaned))
}
return b
}
// CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name.
// Any tool whose name appears in the excluded list will be filtered out.
// The input slice should already be cleaned (trimmed, deduplicated).
func CreateExcludeToolsFilter(excluded []string) ToolFilter {
set := make(map[string]struct{}, len(excluded))
for _, name := range excluded {
set[name] = struct{}{}
}
return func(_ context.Context, tool *ServerTool) (bool, error) {
_, blocked := set[tool.Tool.Name]
return !blocked, nil
}
}
// cleanTools trims whitespace and removes duplicates from tool names.
// Empty strings after trimming are excluded.
func cleanTools(tools []string) []string {
seen := make(map[string]bool)
var cleaned []string
for _, name := range tools {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if !seen[trimmed] {
seen[trimmed] = true
cleaned = append(cleaned, trimmed)
}
}
return cleaned
}
// Build creates the final Inventory with all configuration applied.
// This processes toolset filtering, tool name resolution, and sets up
// the inventory for use. The returned Inventory is ready for use with
// AvailableTools(), RegisterAll(), etc.
//
// Build returns an error if any tools specified via WithTools() are not recognized
// (i.e., they don't exist in the tool set and are not deprecated aliases).
// This ensures invalid tool configurations fail fast at build time.
func (b *Builder) Build() (*Inventory, error) {
tools := b.tools
// Install the feature-flag filter at the head of the pipeline so that
// flag-gated tools are excluded before any user-supplied WithFilter sees
// them. Doing this in Build() (rather than inside WithFeatureChecker)
// keeps the install idempotent — repeated WithFeatureChecker calls
// replace the checker without stacking duplicate filters.
filters := b.filters
if b.featureChecker != nil {
filters = append([]ToolFilter{createFeatureFlagFilter(b.featureChecker)}, filters...)
}
r := &Inventory{
tools: tools,
resourceTemplates: b.resourceTemplates,
prompts: b.prompts,
deprecatedAliases: b.deprecatedAliases,
readOnly: b.readOnly,
featureChecker: b.featureChecker,
filters: filters,
}
// Process toolsets and pre-compute metadata in a single pass
r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets()
// Build set of valid tool names for validation
validToolNames := make(map[string]bool, len(tools))
for i := range tools {
validToolNames[tools[i].Tool.Name] = true
}
// Process additional tools (clean, resolve aliases, and track unrecognized)
if len(b.additionalTools) > 0 {
cleanedTools := cleanTools(b.additionalTools)
r.additionalTools = make(map[string]bool, len(cleanedTools))
var unrecognizedTools []string
for _, name := range cleanedTools {
// Always include the original name - this handles the case where
// the tool exists but is controlled by a feature flag that's OFF.
r.additionalTools[name] = true
// Also include the canonical name if this is a deprecated alias.
// This handles the case where the feature flag is ON and only
// the new consolidated tool is available.
if canonical, isAlias := b.deprecatedAliases[name]; isAlias {
r.additionalTools[canonical] = true
} else if !validToolNames[name] {
// Not a valid tool and not a deprecated alias - track as unrecognized
unrecognizedTools = append(unrecognizedTools, name)
}
}
// Error out if there are unrecognized tools
if len(unrecognizedTools) > 0 {
return nil, fmt.Errorf("%w: %s", ErrUnknownTools, strings.Join(unrecognizedTools, ", "))
}
}
if b.generateInstructions {
r.instructions = generateInstructions(r)
}
return r, nil
}
// processToolsets processes the toolsetIDs configuration and returns:
// - enabledToolsets map (nil means all enabled)
// - unrecognizedToolsets list for warnings
// - allToolsetIDs sorted list of all toolset IDs
// - toolsetIDSet map for O(1) HasToolset lookup
// - defaultToolsetIDs sorted list of default toolset IDs
// - toolsetDescriptions map of toolset ID to description
func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, map[ToolsetID]bool, []ToolsetID, map[ToolsetID]string) {
// Single pass: collect all toolset metadata together
validIDs := make(map[ToolsetID]bool)
defaultIDs := make(map[ToolsetID]bool)
descriptions := make(map[ToolsetID]string)
for i := range b.tools {
t := &b.tools[i]
validIDs[t.Toolset.ID] = true
if t.Toolset.Default {
defaultIDs[t.Toolset.ID] = true
}
if t.Toolset.Description != "" {
descriptions[t.Toolset.ID] = t.Toolset.Description
}
}
for i := range b.resourceTemplates {
r := &b.resourceTemplates[i]
validIDs[r.Toolset.ID] = true
if r.Toolset.Default {
defaultIDs[r.Toolset.ID] = true
}
if r.Toolset.Description != "" {
descriptions[r.Toolset.ID] = r.Toolset.Description
}
}
for i := range b.prompts {
p := &b.prompts[i]
validIDs[p.Toolset.ID] = true
if p.Toolset.Default {
defaultIDs[p.Toolset.ID] = true
}
if p.Toolset.Description != "" {
descriptions[p.Toolset.ID] = p.Toolset.Description
}
}
// Build sorted slices from the collected maps
allToolsetIDs := make([]ToolsetID, 0, len(validIDs))
for id := range validIDs {
allToolsetIDs = append(allToolsetIDs, id)
}
slices.Sort(allToolsetIDs)
defaultToolsetIDList := make([]ToolsetID, 0, len(defaultIDs))
for id := range defaultIDs {
defaultToolsetIDList = append(defaultToolsetIDList, id)
}
slices.Sort(defaultToolsetIDList)
toolsetIDs := b.toolsetIDs
// Check for "all" keyword - enables all toolsets
for _, id := range toolsetIDs {
if strings.TrimSpace(id) == "all" {
return nil, nil, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions // nil means all enabled
}
}
// nil means use defaults, empty slice means no toolsets
if b.toolsetIDsIsNil {
toolsetIDs = []string{"default"}
}
// Expand "default" keyword, trim whitespace, collect other IDs, and track unrecognized
seen := make(map[ToolsetID]bool)
expanded := make([]ToolsetID, 0, len(toolsetIDs))
var unrecognized []string
for _, id := range toolsetIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if trimmed == "default" {
for _, defaultID := range defaultToolsetIDList {
if !seen[defaultID] {
seen[defaultID] = true
expanded = append(expanded, defaultID)
}
}
} else {
tsID := ToolsetID(trimmed)
if !seen[tsID] {
seen[tsID] = true
expanded = append(expanded, tsID)
// Track if this toolset doesn't exist
if !validIDs[tsID] {
unrecognized = append(unrecognized, trimmed)
}
}
}
}
if len(expanded) == 0 {
return make(map[ToolsetID]bool), unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions
}
enabledToolsets := make(map[ToolsetID]bool, len(expanded))
for _, id := range expanded {
enabledToolsets[id] = true
}
return enabledToolsets, unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions
}
// mcpAppsMetaKeys lists the Meta keys controlled by the remote_mcp_ui_apps feature flag.
var mcpAppsMetaKeys = []string{
"ui", // MCP Apps UI metadata
}
// stripMCPAppsMetadata removes MCP Apps UI metadata from tools when the
// remote_mcp_ui_apps feature flag is not enabled.
func stripMCPAppsMetadata(tools []ServerTool) []ServerTool {
result := make([]ServerTool, 0, len(tools))
for _, tool := range tools {
if stripped := stripMetaKeys(tool, mcpAppsMetaKeys); stripped != nil {
result = append(result, *stripped)
} else {
result = append(result, tool)
}
}
return result
}
// stripMetaKeys removes the specified Meta keys from a single tool.
// Returns a modified copy if changes were made, nil otherwise.
func stripMetaKeys(tool ServerTool, keys []string) *ServerTool {
if tool.Tool.Meta == nil || len(keys) == 0 {
return nil
}
// Check if any of the specified keys exist
hasKeys := false
for _, key := range keys {
if _, ok := tool.Tool.Meta[key]; ok {
hasKeys = true
break
}
}
if !hasKeys {
return nil
}
// Make a shallow copy and remove specified keys
toolCopy := tool
newMeta := make(map[string]any, len(tool.Tool.Meta))
for k, v := range tool.Tool.Meta {
if !slices.Contains(keys, k) {
newMeta[k] = v
}
}
if len(newMeta) == 0 {
toolCopy.Tool.Meta = nil
} else {
toolCopy.Tool.Meta = newMeta
}
return &toolCopy
}
+41
View File
@@ -0,0 +1,41 @@
package inventory
import "fmt"
// ToolsetDoesNotExistError is returned when a toolset is not found.
type ToolsetDoesNotExistError struct {
Name string
}
func (e *ToolsetDoesNotExistError) Error() string {
return fmt.Sprintf("toolset %s does not exist", e.Name)
}
func (e *ToolsetDoesNotExistError) Is(target error) bool {
if target == nil {
return false
}
if _, ok := target.(*ToolsetDoesNotExistError); ok {
return true
}
return false
}
// NewToolsetDoesNotExistError creates a new ToolsetDoesNotExistError.
func NewToolsetDoesNotExistError(name string) *ToolsetDoesNotExistError {
return &ToolsetDoesNotExistError{Name: name}
}
// ToolDoesNotExistError is returned when a tool is not found.
type ToolDoesNotExistError struct {
Name string
}
func (e *ToolDoesNotExistError) Error() string {
return fmt.Sprintf("tool %s does not exist", e.Name)
}
// NewToolDoesNotExistError creates a new ToolDoesNotExistError.
func NewToolDoesNotExistError(name string) *ToolDoesNotExistError {
return &ToolDoesNotExistError{Name: name}
}
+274
View File
@@ -0,0 +1,274 @@
package inventory
import (
"context"
"fmt"
"os"
"slices"
"sort"
)
// FeatureFlagChecker is a function that checks if a feature flag is enabled.
// The context can be used to extract actor/user information for flag evaluation.
// Returns (enabled, error). If error occurs, the caller should log and treat as false.
type FeatureFlagChecker func(ctx context.Context, flagName string) (bool, error)
// isToolsetEnabled checks if a toolset is enabled based on current filters.
func (r *Inventory) isToolsetEnabled(toolsetID ToolsetID) bool {
// Check enabled toolsets filter
if r.enabledToolsets != nil {
return r.enabledToolsets[toolsetID]
}
return true
}
// checkFeatureFlag checks a feature flag using the feature checker.
// Returns false if checker is nil or returns an error (errors are logged).
func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool {
if r.featureChecker == nil || flagName == "" {
return false
}
enabled, err := r.featureChecker(ctx, flagName)
if err != nil {
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err)
return false
}
return enabled
}
// featureFlagAllowed reports whether an item with the given enable/disable
// flag pair is permitted under the supplied checker. The checker must be
// non-nil — callers that don't want feature filtering should not call this at
// all (this is also the contract for createFeatureFlagFilter, which is only
// installed when WithFeatureChecker received a non-nil checker).
//
// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled.
// - If FeatureFlagDisable is non-empty, the item is excluded if any listed flag is enabled.
func featureFlagAllowed(ctx context.Context, checker FeatureFlagChecker, enableFlag string, disableFlags []string) bool {
// Error semantics match the previous checkFeatureFlag helper: a checker
// error is logged and treated as "flag not enabled". So an enable-flag
// check on error excludes the tool, but a disable-flag check on error
// keeps it (the disable condition wasn't met).
check := func(flag string) bool {
enabled, err := checker(ctx, flag)
if err != nil {
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flag, err)
return false
}
return enabled
}
if enableFlag != "" && !check(enableFlag) {
return false
}
return !slices.ContainsFunc(disableFlags, check)
}
// createFeatureFlagFilter returns a ToolFilter that gates tools on their
// FeatureFlagEnable / FeatureFlagDisable annotations using the given checker.
// Builder.Build() installs this filter exactly once when WithFeatureChecker
// has been called with a non-nil checker, so "no feature filtering" is
// expressed structurally — by the absence of the filter — rather than by a
// runtime nil check inside the filter itself.
func createFeatureFlagFilter(checker FeatureFlagChecker) ToolFilter {
return func(ctx context.Context, tool *ServerTool) (bool, error) {
return featureFlagAllowed(ctx, checker, tool.FeatureFlagEnable, tool.FeatureFlagDisable), nil
}
}
// isToolEnabled checks if a specific tool is enabled based on current filters.
// Filter evaluation order:
// 1. Tool.Enabled (tool self-filtering)
// 2. Read-only filter
// 3. Builder filters (via WithFilter; the feature-flag filter, when
// installed via WithFeatureChecker, runs as part of this step)
// 4. Toolset/additional tools
func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool {
// 1. Check tool's own Enabled function first
if tool.Enabled != nil {
enabled, err := tool.Enabled(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Tool.Enabled check error for %q: %v\n", tool.Tool.Name, err)
return false
}
if !enabled {
return false
}
}
// 2. Check read-only filter (applies to all tools)
if r.readOnly && !tool.IsReadOnly() {
return false
}
// 3. Apply builder filters (includes the feature-flag filter when set)
for _, filter := range r.filters {
allowed, err := filter(ctx, tool)
if err != nil {
fmt.Fprintf(os.Stderr, "Builder filter error for tool %q: %v\n", tool.Tool.Name, err)
return false
}
if !allowed {
return false
}
}
// 4. Check if tool is in additionalTools (bypasses toolset filter)
if r.additionalTools != nil && r.additionalTools[tool.Tool.Name] {
return true
}
// 4. Check toolset filter
if !r.isToolsetEnabled(tool.Toolset.ID) {
return false
}
return true
}
// sortByToolsetThenName sorts items deterministically by their toolset ID,
// breaking ties by name. The two extractor closures keep this generic helper
// independent of the concrete inventory item shape (tools, resource templates,
// prompts).
func sortByToolsetThenName[T any](items []T, toolsetID func(T) ToolsetID, name func(T) string) {
sort.Slice(items, func(i, j int) bool {
idI, idJ := toolsetID(items[i]), toolsetID(items[j])
if idI != idJ {
return idI < idJ
}
return name(items[i]) < name(items[j])
})
}
func sortTools(tools []ServerTool) {
sortByToolsetThenName(tools,
func(t ServerTool) ToolsetID { return t.Toolset.ID },
func(t ServerTool) string { return t.Tool.Name },
)
}
// AvailableTools returns the tools that pass all current filters,
// sorted deterministically by toolset ID, then tool name.
// The context is used for feature flag evaluation.
func (r *Inventory) AvailableTools(ctx context.Context) []ServerTool {
var result []ServerTool
for i := range r.tools {
tool := &r.tools[i]
if r.isToolEnabled(ctx, tool) {
result = append(result, *tool)
}
}
// Sort deterministically: by toolset ID, then by tool name
sortTools(result)
return result
}
func sortResourceTemplates(resourceTemplates []ServerResourceTemplate) {
sortByToolsetThenName(resourceTemplates,
func(r ServerResourceTemplate) ToolsetID { return r.Toolset.ID },
func(r ServerResourceTemplate) string { return r.Template.Name },
)
}
// AvailableResourceTemplates returns resource templates that pass all current filters,
// sorted deterministically by toolset ID, then template name.
// The context is used for feature flag evaluation.
func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerResourceTemplate {
var result []ServerResourceTemplate
for i := range r.resourceTemplates {
res := &r.resourceTemplates[i]
// Resources have no filter pipeline, so feature gating runs inline.
// The featureChecker != nil guard mirrors the structural "no checker
// = no filtering" contract used for tools (where the absence of a
// pipeline step expresses the same thing).
if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, res.FeatureFlagEnable, res.FeatureFlagDisable) {
continue
}
if r.isToolsetEnabled(res.Toolset.ID) {
result = append(result, *res)
}
}
// Sort deterministically: by toolset ID, then by template name
sortResourceTemplates(result)
return result
}
func sortPrompts(prompts []ServerPrompt) {
sortByToolsetThenName(prompts,
func(p ServerPrompt) ToolsetID { return p.Toolset.ID },
func(p ServerPrompt) string { return p.Prompt.Name },
)
}
// AvailablePrompts returns prompts that pass all current filters,
// sorted deterministically by toolset ID, then prompt name.
// The context is used for feature flag evaluation.
func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt {
var result []ServerPrompt
for i := range r.prompts {
prompt := &r.prompts[i]
// Prompts have no filter pipeline; see AvailableResourceTemplates for
// the rationale behind the explicit nil guard.
if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) {
continue
}
if r.isToolsetEnabled(prompt.Toolset.ID) {
result = append(result, *prompt)
}
}
// Sort deterministically: by toolset ID, then by prompt name
sortPrompts(result)
return result
}
// filterToolsByName returns tools matching the given name, checking deprecated aliases.
// Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest).
// Returns ALL tools matching the name to support feature-flagged tool variants
// (e.g., GetJobLogs and ActionsGetJobLogs both use name "get_job_logs" but are
// controlled by different feature flags).
func (r *Inventory) filterToolsByName(name string) []ServerTool {
var result []ServerTool
// Check for exact matches - multiple tools may share the same name with different feature flags
for i := range r.tools {
if r.tools[i].Tool.Name == name {
result = append(result, r.tools[i])
}
}
if len(result) > 0 {
return result
}
// Check if name is a deprecated alias
if canonical, isAlias := r.deprecatedAliases[name]; isAlias {
for i := range r.tools {
if r.tools[i].Tool.Name == canonical {
result = append(result, r.tools[i])
}
}
}
return result
}
// filterPromptsByName returns prompts matching the given name.
// Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest).
func (r *Inventory) filterPromptsByName(name string) []ServerPrompt {
for i := range r.prompts {
if r.prompts[i].Prompt.Name == name {
return []ServerPrompt{r.prompts[i]}
}
}
return []ServerPrompt{}
}
// FilteredTools returns tools filtered by the Enabled function and builder filters.
// This provides an explicit API for accessing filtered tools, currently implemented
// as an alias for AvailableTools.
//
// The error return is currently always nil but is included for future extensibility.
// Library consumers (e.g., remote server implementations) may need to surface
// recoverable filter errors rather than silently logging them. Having the error
// return in the API now avoids breaking changes later.
//
// The context is used for Enabled function evaluation and builder filter checks.
func (r *Inventory) FilteredTools(ctx context.Context) ([]ServerTool, error) {
return r.AvailableTools(ctx), nil
}
+43
View File
@@ -0,0 +1,43 @@
package inventory
import (
"os"
"strings"
)
// generateInstructions creates server instructions based on enabled toolsets
func generateInstructions(inv *Inventory) string {
// For testing - add a flag to disable instructions
if os.Getenv("DISABLE_INSTRUCTIONS") == "true" {
return "" // Baseline mode
}
var instructions []string
// Base instruction with context management
baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform.
Tool selection guidance:
1. Use 'list_*' tools for broad, simple retrieval and pagination of all items of a type (e.g., all issues, all PRs, all branches) with basic filtering.
2. Use 'search_*' tools for targeted queries with specific criteria, keywords, or complex filters (e.g., issues with certain text, PRs by author, code containing functions).
Context management:
1. Use pagination whenever possible with batches of 5-10 items.
2. Use minimal_output parameter set to true if the full information is not needed to accomplish a task.
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.`
instructions = append(instructions, baseInstruction)
// Collect instructions from each enabled toolset
for _, toolset := range inv.EnabledToolsets() {
if toolset.InstructionsFunc != nil {
if toolsetInstructions := toolset.InstructionsFunc(inv); toolsetInstructions != "" {
instructions = append(instructions, toolsetInstructions)
}
}
}
return strings.Join(instructions, " ")
}
+265
View File
@@ -0,0 +1,265 @@
package inventory
import (
"os"
"strings"
"testing"
)
// createTestInventory creates an inventory with the specified toolsets for testing.
// All toolsets are enabled by default using WithToolsets([]string{"all"}).
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,
})
}
inv, _ := NewBuilder().
SetTools(tools).
WithToolsets([]string{"all"}).
Build()
return inv
}
func TestGenerateInstructions(t *testing.T) {
tests := []struct {
name string
toolsets []ToolsetMetadata
expectedEmpty bool
}{
{
name: "empty toolsets",
toolsets: []ToolsetMetadata{},
expectedEmpty: false, // base instructions are always included
},
{
name: "toolset with instructions",
toolsets: []ToolsetMetadata{
{
ID: "test",
Description: "Test toolset",
InstructionsFunc: func(_ *Inventory) string {
return "Test instructions"
},
},
},
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) {
inv := createTestInventory(tt.toolsets)
result := generateInstructions(inv)
if tt.expectedEmpty {
if result != "" {
t.Errorf("Expected empty instructions but got: %s", result)
}
} else {
if result == "" {
t.Errorf("Expected non-empty instructions but got empty result")
}
}
})
}
}
func TestGenerateInstructionsWithDisableFlag(t *testing.T) {
tests := []struct {
name string
disableEnvValue string
expectedEmpty bool
}{
{
name: "DISABLE_INSTRUCTIONS=true returns empty",
disableEnvValue: "true",
expectedEmpty: true,
},
{
name: "DISABLE_INSTRUCTIONS=false returns normal instructions",
disableEnvValue: "false",
expectedEmpty: false,
},
{
name: "DISABLE_INSTRUCTIONS unset returns normal instructions",
disableEnvValue: "",
expectedEmpty: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save original env value
originalValue := os.Getenv("DISABLE_INSTRUCTIONS")
defer func() {
if originalValue == "" {
os.Unsetenv("DISABLE_INSTRUCTIONS")
} else {
os.Setenv("DISABLE_INSTRUCTIONS", originalValue)
}
}()
// Set test env value
if tt.disableEnvValue == "" {
os.Unsetenv("DISABLE_INSTRUCTIONS")
} else {
os.Setenv("DISABLE_INSTRUCTIONS", tt.disableEnvValue)
}
inv := createTestInventory([]ToolsetMetadata{
{ID: "test", Description: "Test"},
})
result := generateInstructions(inv)
if tt.expectedEmpty {
if result != "" {
t.Errorf("Expected empty instructions but got: %s", result)
}
} else {
if result == "" {
t.Errorf("Expected non-empty instructions but got empty result")
}
}
})
}
}
func TestToolsetInstructionsFunc(t *testing.T) {
tests := []struct {
name string
toolsets []ToolsetMetadata
expectedToContain string
notExpectedToContain string
}{
{
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",
},
{
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",
},
{
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.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', 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', but it did. Result: %s", tt.notExpectedToContain, result)
}
})
}
}
// TestGenerateInstructionsOnlyEnabledToolsets verifies that generateInstructions
// only includes instructions from enabled toolsets, not all available toolsets.
// This is a regression test for https://github.com/github/github-mcp-server/issues/1897
func TestGenerateInstructionsOnlyEnabledToolsets(t *testing.T) {
// Create tools for multiple toolsets
reposToolset := ToolsetMetadata{
ID: "repos",
Description: "Repository tools",
InstructionsFunc: func(_ *Inventory) string {
return "REPOS_INSTRUCTIONS"
},
}
issuesToolset := ToolsetMetadata{
ID: "issues",
Description: "Issue tools",
InstructionsFunc: func(_ *Inventory) string {
return "ISSUES_INSTRUCTIONS"
},
}
prsToolset := ToolsetMetadata{
ID: "pull_requests",
Description: "PR tools",
InstructionsFunc: func(_ *Inventory) string {
return "PRS_INSTRUCTIONS"
},
}
tools := []ServerTool{
{Toolset: reposToolset},
{Toolset: issuesToolset},
{Toolset: prsToolset},
}
// Build inventory with only "repos" toolset enabled
inv, err := NewBuilder().
SetTools(tools).
WithToolsets([]string{"repos"}).
Build()
if err != nil {
t.Fatalf("Failed to build inventory: %v", err)
}
result := generateInstructions(inv)
// Should contain instructions from enabled toolset
if !strings.Contains(result, "REPOS_INSTRUCTIONS") {
t.Errorf("Expected instructions to contain 'REPOS_INSTRUCTIONS' for enabled toolset, but it did not. Result: %s", result)
}
// Should NOT contain instructions from non-enabled toolsets
if strings.Contains(result, "ISSUES_INSTRUCTIONS") {
t.Errorf("Did not expect instructions to contain 'ISSUES_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result)
}
if strings.Contains(result, "PRS_INSTRUCTIONS") {
t.Errorf("Did not expect instructions to contain 'PRS_INSTRUCTIONS' for disabled toolset, but it did. Result: %s", result)
}
}
+26
View File
@@ -0,0 +1,26 @@
package inventory
import "github.com/modelcontextprotocol/go-sdk/mcp"
// ServerPrompt pairs a prompt with its toolset metadata.
type ServerPrompt struct {
Prompt mcp.Prompt
Handler mcp.PromptHandler
// Toolset identifies which toolset this prompt belongs to
Toolset ToolsetMetadata
// FeatureFlagEnable specifies a feature flag that must be enabled for this prompt
// to be available. If set and the flag is not enabled, the prompt is omitted.
FeatureFlagEnable string
// FeatureFlagDisable specifies feature flags that, when any is enabled, cause this
// prompt to be omitted. Used to disable prompts when a feature flag is on.
FeatureFlagDisable []string
}
// NewServerPrompt creates a new ServerPrompt with toolset metadata.
func NewServerPrompt(toolset ToolsetMetadata, prompt mcp.Prompt, handler mcp.PromptHandler) ServerPrompt {
return ServerPrompt{
Prompt: prompt,
Handler: handler,
Toolset: toolset,
}
}
+371
View File
@@ -0,0 +1,371 @@
package inventory
import (
"context"
"fmt"
"os"
"slices"
"sort"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Inventory holds a collection of tools, resources, and prompts with filtering applied.
// Create a Inventory using Builder:
//
// reg := NewBuilder().
// SetTools(tools).
// WithReadOnly(true).
// WithToolsets([]string{"repos"}).
// Build()
//
// The Inventory is configured at build time and provides:
// - Filtered access to tools/resources/prompts via Available* methods
// - Deterministic ordering for documentation generation
// - Lazy dependency injection during registration via RegisterAll()
type Inventory struct {
// tools holds all tools in this group (ordered for iteration)
tools []ServerTool
// resourceTemplates holds all resource templates in this group (ordered for iteration)
resourceTemplates []ServerResourceTemplate
// prompts holds all prompts in this group (ordered for iteration)
prompts []ServerPrompt
// deprecatedAliases maps old tool names to new canonical names
deprecatedAliases map[string]string
// Pre-computed toolset metadata (set during Build)
toolsetIDs []ToolsetID // sorted list of all toolset IDs
toolsetIDSet map[ToolsetID]bool // set for O(1) HasToolset lookup
defaultToolsetIDs []ToolsetID // sorted list of default toolset IDs
toolsetDescriptions map[ToolsetID]string // toolset ID -> description
// Filters - these control what's returned by Available* methods
// readOnly when true filters out write tools
readOnly bool
// enabledToolsets when non-nil, only include tools/resources/prompts from these toolsets
// when nil, all toolsets are enabled
enabledToolsets map[ToolsetID]bool
// additionalTools are specific tools that bypass toolset filtering (but still respect read-only)
// These are additive - a tool is included if it matches toolset filters OR is in this set
additionalTools map[string]bool
// featureChecker when non-nil, checks if a feature flag is enabled.
// Takes context and flag name, returns (enabled, error). If error, log and treat as false.
// If checker is nil, all flag checks return false.
featureChecker FeatureFlagChecker
// filters are functions that will be applied to all tools during filtering.
// If any filter returns false or an error, the tool is excluded.
filters []ToolFilter
// unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets
unrecognizedToolsets []string
// server instructions hold high-level instructions for agents to use the server effectively
instructions string
}
// UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't
// match any registered toolsets. This is useful for warning users about typos.
func (r *Inventory) UnrecognizedToolsets() []string {
return r.unrecognizedToolsets
}
// MCP method constants for use with ForMCPRequest.
const (
MCPMethodInitialize = "initialize"
MCPMethodDiscover = "server/discover"
MCPMethodToolsList = "tools/list"
MCPMethodToolsCall = "tools/call"
MCPMethodResourcesList = "resources/list"
MCPMethodResourcesRead = "resources/read"
MCPMethodResourcesTemplatesList = "resources/templates/list"
MCPMethodPromptsList = "prompts/list"
MCPMethodPromptsGet = "prompts/get"
)
// ForMCPRequest returns a Registry optimized for a specific MCP request.
// This is designed for servers that create a new instance per request (like the remote server),
// allowing them to only register the items needed for that specific request rather than all ~90 tools.
//
// Parameters:
// - method: The MCP method being called (use MCP* constants)
// - itemName: Name of specific item for call/get methods (tool name, resource URI, or prompt name)
//
// Returns a new Registry containing only the items relevant to the request:
// - MCPMethodInitialize / MCPMethodDiscover: Empty items (capabilities from ServerOptions; instructions preserved)
// - MCPMethodToolsList: All available tools (no resources/prompts)
// - MCPMethodToolsCall: Only the named tool
// - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts)
// - MCPMethodResourcesRead: All resources (SDK handles URI template matching)
// - MCPMethodPromptsList: All available prompts (no tools/resources)
// - MCPMethodPromptsGet: Only the named prompt
// - Unknown methods: Empty (no items registered)
//
// All existing filters (read-only, toolsets, etc.) still apply to the returned items.
func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory {
// Create a shallow copy with shared filter settings
// Note: lazy-init maps (toolsByName, etc.) are NOT copied - the new Registry
// will initialize its own maps on first use if needed
result := &Inventory{
tools: r.tools,
resourceTemplates: r.resourceTemplates,
prompts: r.prompts,
deprecatedAliases: r.deprecatedAliases,
readOnly: r.readOnly,
enabledToolsets: r.enabledToolsets, // shared, not modified
additionalTools: r.additionalTools, // shared, not modified
featureChecker: r.featureChecker,
filters: r.filters, // shared, not modified
unrecognizedToolsets: r.unrecognizedToolsets,
instructions: r.instructions, // server identity; preserved for all methods
}
// Helper to clear all item types
clearAll := func() {
result.tools = []ServerTool{}
result.resourceTemplates = []ServerResourceTemplate{}
result.prompts = []ServerPrompt{}
}
switch method {
case MCPMethodInitialize, MCPMethodDiscover:
// Both handshakes register no items; capabilities come from ServerOptions
// and instructions are preserved via the copy above (SEP-2575 discover
// must surface the same server identity as initialize).
clearAll()
case MCPMethodToolsList:
result.resourceTemplates, result.prompts = nil, nil
case MCPMethodToolsCall:
result.resourceTemplates, result.prompts = nil, nil
if itemName != "" {
result.tools = r.filterToolsByName(itemName)
}
case MCPMethodResourcesList, MCPMethodResourcesTemplatesList:
result.tools, result.prompts = nil, nil
case MCPMethodResourcesRead:
// Keep all resources registered - SDK handles URI template matching internally
result.tools, result.prompts = nil, nil
case MCPMethodPromptsList:
result.tools, result.resourceTemplates = nil, nil
case MCPMethodPromptsGet:
result.tools, result.resourceTemplates = nil, nil
if itemName != "" {
result.prompts = r.filterPromptsByName(itemName)
}
default:
clearAll()
}
return result
}
// ToolsetIDs returns a sorted list of unique toolset IDs from all tools in this group.
func (r *Inventory) ToolsetIDs() []ToolsetID {
return r.toolsetIDs
}
// DefaultToolsetIDs returns the IDs of toolsets marked as Default in their metadata.
// The IDs are returned in sorted order for deterministic output.
func (r *Inventory) DefaultToolsetIDs() []ToolsetID {
return r.defaultToolsetIDs
}
// ToolsetDescriptions returns a map of toolset ID to description for all toolsets.
func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string {
return r.toolsetDescriptions
}
// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as
// RegisterTools would expose them: with MCP Apps UI metadata stripped when
// the client cannot consume it. Useful for documentation generators and
// diagnostics that need the same view of the tool surface the server would
// register.
//
// The strip applies when EITHER of the following is true:
//
// - The remote_mcp_ui_apps feature flag is not enabled in ctx (server-side gate).
// - The client explicitly did not advertise the io.modelcontextprotocol/ui
// extension capability (per the 2026-01-26 MCP Apps spec, servers SHOULD
// check client capabilities before exposing UI-enabled tools). When the
// capability is unknown (e.g. stdio paths that do not populate the
// context flag) the feature-flag gate is the sole source of truth.
func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool {
tools := r.AvailableTools(ctx)
if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) {
tools = stripMCPAppsMetadata(tools)
}
return tools
}
// shouldStripMCPAppsMetadata centralises the strip decision so the same logic
// is exercised by tests and by RegisterTools.
func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bool {
if !featureFlagEnabled {
return true
}
// Feature flag is on. Respect the client capability if it is known.
if supported, ok := ghcontext.HasUISupport(ctx); ok && !supported {
return true
}
return false
}
// RegisterTools registers all available tools with the server using the provided dependencies.
// The context is used for feature flag evaluation and client capability checks.
//
// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools when
// either the MCP Apps feature flag is not enabled for this request, or the
// client did not advertise the io.modelcontextprotocol/ui extension. The
// strip happens here (rather than at Build() time) so the per-request
// context is in scope — HTTP feature checkers that read insiders mode or
// user identity from ctx would otherwise see context.Background() and
// falsely report the flag off, even when the actual request arrived on the
// /insiders route.
func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) {
for _, tool := range r.ToolsForRegistration(ctx) {
tool.RegisterFunc(s, deps)
}
}
// RegisterResourceTemplates registers all available resource templates with the server.
// The context is used for feature flag evaluation.
// Icons are automatically applied from the toolset metadata if not already set.
func (r *Inventory) RegisterResourceTemplates(ctx context.Context, s *mcp.Server, deps any) {
for _, res := range r.AvailableResourceTemplates(ctx) {
// Make a shallow copy to avoid mutating the original
templateCopy := res.Template
// Apply icons from toolset metadata if not already set
if len(templateCopy.Icons) == 0 {
templateCopy.Icons = res.Toolset.Icons()
}
s.AddResourceTemplate(&templateCopy, res.Handler(deps))
}
}
// RegisterPrompts registers all available prompts with the server.
// The context is used for feature flag evaluation.
// Icons are automatically applied from the toolset metadata if not already set.
func (r *Inventory) RegisterPrompts(ctx context.Context, s *mcp.Server) {
for _, prompt := range r.AvailablePrompts(ctx) {
// Make a shallow copy to avoid mutating the original
promptCopy := prompt.Prompt
// Apply icons from toolset metadata if not already set
if len(promptCopy.Icons) == 0 {
promptCopy.Icons = prompt.Toolset.Icons()
}
s.AddPrompt(&promptCopy, prompt.Handler)
}
}
// RegisterAll registers all available tools, resources, and prompts with the server.
// The context is used for feature flag evaluation.
func (r *Inventory) RegisterAll(ctx context.Context, s *mcp.Server, deps any) {
r.RegisterTools(ctx, s, deps)
r.RegisterResourceTemplates(ctx, s, deps)
r.RegisterPrompts(ctx, s)
}
// ResolveToolAliases resolves deprecated tool aliases to their canonical names.
// It logs a warning to stderr for each deprecated alias that is resolved.
// Returns:
// - resolved: tool names with aliases replaced by canonical names
// - aliasesUsed: map of oldName → newName for each alias that was resolved
func (r *Inventory) ResolveToolAliases(toolNames []string) (resolved []string, aliasesUsed map[string]string) {
resolved = make([]string, 0, len(toolNames))
aliasesUsed = make(map[string]string)
for _, toolName := range toolNames {
if canonicalName, isAlias := r.deprecatedAliases[toolName]; isAlias {
fmt.Fprintf(os.Stderr, "Warning: tool %q is deprecated, use %q instead\n", toolName, canonicalName)
aliasesUsed[toolName] = canonicalName
resolved = append(resolved, canonicalName)
} else {
resolved = append(resolved, toolName)
}
}
return resolved, aliasesUsed
}
// FindToolByName searches all tools for one matching the given name.
// Returns the tool, its toolset ID, and an error if not found.
// This searches ALL tools regardless of filters.
func (r *Inventory) FindToolByName(toolName string) (*ServerTool, ToolsetID, error) {
for i := range r.tools {
if r.tools[i].Tool.Name == toolName {
return &r.tools[i], r.tools[i].Toolset.ID, nil
}
}
return nil, "", NewToolDoesNotExistError(toolName)
}
// HasToolset checks if any tool/resource/prompt belongs to the given toolset.
func (r *Inventory) HasToolset(toolsetID ToolsetID) bool {
return r.toolsetIDSet[toolsetID]
}
// AllTools returns all tools without any filtering, sorted deterministically.
func (r *Inventory) AllTools() []ServerTool {
result := slices.Clone(r.tools)
// Sort deterministically: by toolset ID, then by tool name
sort.Slice(result, func(i, j int) bool {
if result[i].Toolset.ID != result[j].Toolset.ID {
return result[i].Toolset.ID < result[j].Toolset.ID
}
return result[i].Tool.Name < result[j].Tool.Name
})
return result
}
// AvailableToolsets returns the unique toolsets that have tools, in sorted order.
// This is the ordered intersection of toolsets with reality - only toolsets that
// actually contain tools are returned, sorted by toolset ID.
// Optional exclude parameter filters out specific toolset IDs from the result.
func (r *Inventory) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata {
tools := r.AllTools()
if len(tools) == 0 {
return nil
}
// Build exclude set for O(1) lookup
excludeSet := make(map[ToolsetID]bool, len(exclude))
for _, id := range exclude {
excludeSet[id] = true
}
var result []ToolsetMetadata
var lastID ToolsetID
for _, tool := range tools {
if tool.Toolset.ID != lastID {
lastID = tool.Toolset.ID
if !excludeSet[lastID] {
result = append(result, tool.Toolset)
}
}
}
return result
}
// EnabledToolsets returns the unique toolsets that are enabled based on current filters.
// This is similar to AvailableToolsets but respects the enabledToolsets filter.
// Returns toolsets in sorted order by toolset ID.
func (r *Inventory) EnabledToolsets() []ToolsetMetadata {
// Get all available toolsets first (already sorted by ID)
allToolsets := r.AvailableToolsets()
// If no filter is set, all toolsets are enabled
if r.enabledToolsets == nil {
return allToolsets
}
// Filter to only enabled toolsets
var result []ToolsetMetadata
for _, ts := range allToolsets {
if r.enabledToolsets[ts.ID] {
result = append(result, ts)
}
}
return result
}
func (r *Inventory) Instructions() string {
return r.instructions
}
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
package inventory
import "github.com/modelcontextprotocol/go-sdk/mcp"
// ResourceHandlerFunc is a function that takes dependencies and returns an MCP resource handler.
// This allows resources to be defined statically while their handlers are generated
// on-demand with the appropriate dependencies.
type ResourceHandlerFunc func(deps any) mcp.ResourceHandler
// ServerResourceTemplate pairs a resource template with its toolset metadata.
type ServerResourceTemplate struct {
Template mcp.ResourceTemplate
// HandlerFunc generates the handler when given dependencies.
// This allows resources to be passed around without handlers being set up,
// and handlers are only created when needed.
HandlerFunc ResourceHandlerFunc
// Toolset identifies which toolset this resource belongs to
Toolset ToolsetMetadata
// FeatureFlagEnable specifies a feature flag that must be enabled for this resource
// to be available. If set and the flag is not enabled, the resource is omitted.
FeatureFlagEnable string
// FeatureFlagDisable specifies feature flags that, when any is enabled, cause this
// resource to be omitted. Used to disable resources when a feature flag is on.
FeatureFlagDisable []string
}
// HasHandler returns true if this resource has a handler function.
func (sr *ServerResourceTemplate) HasHandler() bool {
return sr.HandlerFunc != nil
}
// Handler returns a resource handler by calling HandlerFunc with the given dependencies.
// Panics if HandlerFunc is nil - all resources should have handlers.
func (sr *ServerResourceTemplate) Handler(deps any) mcp.ResourceHandler {
if sr.HandlerFunc == nil {
panic("HandlerFunc is nil for resource: " + sr.Template.Name)
}
return sr.HandlerFunc(deps)
}
// NewServerResourceTemplate creates a new ServerResourceTemplate with toolset metadata.
func NewServerResourceTemplate(toolset ToolsetMetadata, resourceTemplate mcp.ResourceTemplate, handlerFn ResourceHandlerFunc) ServerResourceTemplate {
return ServerResourceTemplate{
Template: resourceTemplate,
HandlerFunc: handlerFn,
Toolset: toolset,
}
}
+224
View File
@@ -0,0 +1,224 @@
package inventory
import (
"context"
"encoding/json"
"fmt"
"maps"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// HandlerFunc is a function that takes dependencies and returns an MCP tool handler.
// This allows tools to be defined statically while their handlers are generated
// on-demand with the appropriate dependencies.
// The deps parameter is typed as `any` to avoid circular dependencies - callers
// should define their own typed dependencies struct and type-assert as needed.
type HandlerFunc func(deps any) mcp.ToolHandler
// ToolsetID is a unique identifier for a toolset.
// Using a distinct type provides compile-time type safety.
type ToolsetID string
// ToolsetMetadata contains metadata about the toolset a tool belongs to.
type ToolsetMetadata struct {
// ID is the unique identifier for the toolset (e.g., "repos", "issues")
ID ToolsetID
// Description provides a human-readable description of the toolset
Description string
// Default indicates this toolset should be enabled by default
Default bool
// Icon is the name of the Octicon to use for tools in this toolset.
// 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.
// Icons are provided in both 16x16 and 24x24 sizes.
func (tm ToolsetMetadata) Icons() []mcp.Icon {
return octicons.Icons(tm.Icon)
}
// ServerTool represents an MCP tool with metadata and a handler generator function.
// The tool definition is static, while the handler is generated on-demand
// when the tool is registered with a server.
// Tools are now self-describing with their toolset membership and read-only status
// derived from the Tool.Annotations.ReadOnlyHint field.
type ServerTool struct {
// Tool is the MCP tool definition containing name, description, schema, etc.
Tool mcp.Tool
// Toolset contains metadata about which toolset this tool belongs to.
Toolset ToolsetMetadata
// HandlerFunc generates the handler when given dependencies.
// This allows tools to be passed around without handlers being set up,
// and handlers are only created when needed.
HandlerFunc HandlerFunc
// FeatureFlagEnable specifies a feature flag that must be enabled for this tool
// to be available. If set and the flag is not enabled, the tool is omitted.
FeatureFlagEnable string
// FeatureFlagDisable specifies feature flags that, when any is enabled, cause this
// tool to be omitted. Used to disable tools when a feature flag is on.
FeatureFlagDisable []string
// Enabled is an optional function called at build/filter time to determine
// if this tool should be available. If nil, the tool is considered enabled
// (subject to FeatureFlagEnable/FeatureFlagDisable checks).
// The context carries request-scoped information for the consumer to use.
// Returns (enabled, error). On error, the tool should be treated as disabled.
Enabled func(ctx context.Context) (bool, error)
// RequiredScopes specifies the minimum OAuth scopes required for this tool.
// These are the scopes that must be present for the tool to function.
RequiredScopes []string
// AcceptedScopes specifies all OAuth scopes that can be used with this tool.
// This includes the required scopes plus any higher-level scopes that provide
// the necessary permissions due to scope hierarchy.
AcceptedScopes []string
}
// IsReadOnly returns true if this tool is marked as read-only via annotations.
func (st *ServerTool) IsReadOnly() bool {
return st.Tool.Annotations != nil && st.Tool.Annotations.ReadOnlyHint
}
// HasHandler returns true if this tool has a handler function.
func (st *ServerTool) HasHandler() bool {
return st.HandlerFunc != nil
}
// Handler returns a tool handler by calling HandlerFunc with the given dependencies.
// Panics if HandlerFunc is nil - all tools should have handlers.
func (st *ServerTool) Handler(deps any) mcp.ToolHandler {
if st.HandlerFunc == nil {
panic("HandlerFunc is nil for tool: " + st.Tool.Name)
}
return st.HandlerFunc(deps)
}
// RegisterFunc registers the tool with the server using the provided dependencies.
// Icons are automatically applied from the toolset metadata if not already set.
// A shallow copy of the tool is made to avoid mutating the original ServerTool.
// Panics if the tool has no handler - all tools should have handlers.
func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) {
handler := st.Handler(deps) // This will panic if HandlerFunc is nil
// Make a shallow copy of the tool to avoid mutating the original
toolCopy := st.Tool
// Apply icons from toolset metadata if tool doesn't have icons set
if len(toolCopy.Icons) == 0 {
toolCopy.Icons = st.Toolset.Icons()
}
// Project routing-relevant params to standard MCP-Param-* headers (SEP-2243)
// so a remote proxy can read owner/repo from headers instead of re-parsing the
// JSON-RPC body. No-op for tools without these params.
AnnotateHeaderParams(&toolCopy)
s.AddTool(&toolCopy, handler)
}
// HeaderParams maps tool input properties to the MCP-Param-* header name a
// header-aware proxy reads, avoiding a second parse of the request body. New
// routing-relevant params should be added here so projection stays automatic
// for every tool; the enforcement test in pkg/github guards full coverage.
var HeaderParams = map[string]string{"owner": "owner", "repo": "repo"}
// AnnotateHeaderParams returns a copy of tool whose routing-relevant input
// properties (per HeaderParams) carry an "x-mcp-header" annotation, which the
// SDK projects onto Mcp-Param-{name} request headers. It never mutates the
// input tool's schema or any map shared with the original tool definition:
// callers shallow-copy ServerTool.Tool, so the *jsonschema.Schema (and its
// per-property Extra maps) are shared, and per-request registration must not
// race on them. Only the schema, its Properties map, and the specific property
// schemas/Extra maps that gain an annotation are cloned.
func AnnotateHeaderParams(tool *mcp.Tool) {
schema, ok := tool.InputSchema.(*jsonschema.Schema)
if !ok || schema == nil {
return
}
// Collect params that actually need an annotation, so a tool without
// owner/repo (or already annotated) is left untouched and unCloned.
var toAnnotate []string
for prop := range HeaderParams {
if ps := schema.Properties[prop]; ps != nil {
if _, exists := ps.Extra["x-mcp-header"]; !exists {
toAnnotate = append(toAnnotate, prop)
}
}
}
if len(toAnnotate) == 0 {
return
}
// Clone only what we mutate: a fresh schema value, a fresh Properties map,
// and fresh property schemas with fresh Extra maps. The original schema and
// its maps are never written to, so concurrent per-request registration is
// race-free and deterministic.
schemaCopy := *schema
schemaCopy.Properties = maps.Clone(schema.Properties)
for _, prop := range toAnnotate {
propCopy := *schemaCopy.Properties[prop]
extra := make(map[string]any, len(propCopy.Extra)+1)
maps.Copy(extra, propCopy.Extra)
extra["x-mcp-header"] = HeaderParams[prop]
propCopy.Extra = extra
schemaCopy.Properties[prop] = &propCopy
}
tool.InputSchema = &schemaCopy
}
// NewServerToolWithContextHandler creates a ServerTool with a handler that receives deps via context.
// This is the preferred approach for tools because it doesn't create closures at registration time,
// which is critical for performance in servers that create a new instance per request.
//
// The handler function is stored directly without wrapping in a deps closure.
// Dependencies should be injected into context before calling tool handlers.
func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out]) ServerTool {
return ServerTool{
Tool: tool,
Toolset: toolset,
// HandlerFunc ignores deps - deps are retrieved from context at call time
HandlerFunc: func(_ any) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var arguments In
if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)},
},
IsError: true,
}, nil
}
resp, _, err := handler(ctx, req, arguments)
return resp, err
}
},
}
}
// NewServerTool creates a ServerTool with a raw handler that receives deps via context.
// This is the preferred constructor for tools that use mcp.ToolHandler directly because
// it doesn't create closures at registration time, which is critical for performance in
// servers that create a new instance per request.
//
// The handler function is stored directly without wrapping in a deps closure.
// Dependencies should be injected into context before calling tool handlers.
func NewServerTool(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool {
return ServerTool{
Tool: tool,
Toolset: toolset,
// HandlerFunc ignores deps - deps are retrieved from context at call time
HandlerFunc: func(_ any) mcp.ToolHandler {
return handler
},
}
}
+138
View File
@@ -0,0 +1,138 @@
package inventory
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewServerToolWithContextHandler_InvalidArguments_ReturnsIsError(t *testing.T) {
type expectedArgs struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
tool := NewServerToolWithContextHandler(
mcp.Tool{Name: "test_context_tool"},
testToolsetMetadata("test"),
func(_ context.Context, _ *mcp.CallToolRequest, _ expectedArgs) (*mcp.CallToolResult, any, error) {
t.Fatal("handler should not be called with invalid arguments")
return nil, nil, nil
},
)
handler := tool.HandlerFunc(nil)
result, err := handler(context.Background(), &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: "test_context_tool",
Arguments: json.RawMessage(`{not valid json`),
},
})
require.NoError(t, err)
require.NotNil(t, result)
assert.True(t, result.IsError)
assert.Len(t, result.Content, 1)
textContent, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok)
assert.Contains(t, textContent.Text, "invalid arguments")
}
func TestNewServerToolWithContextHandler_ValidArguments_Succeeds(t *testing.T) {
type expectedArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
tool := NewServerToolWithContextHandler(
mcp.Tool{Name: "test_tool"},
testToolsetMetadata("test"),
func(_ context.Context, _ *mcp.CallToolRequest, args expectedArgs) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "success: " + args.Owner + "/" + args.Repo},
},
}, nil, nil
},
)
handler := tool.HandlerFunc(nil)
goodArgs, _ := json.Marshal(map[string]any{"owner": "octocat", "repo": "hello-world"})
result, err := handler(context.Background(), &mcp.CallToolRequest{
Params: &mcp.CallToolParamsRaw{
Name: "test_tool",
Arguments: goodArgs,
},
})
require.NoError(t, err)
require.NotNil(t, result)
assert.False(t, result.IsError)
textContent, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok)
assert.Equal(t, "success: octocat/hello-world", textContent.Text)
}
func TestAnnotateHeaderParams(t *testing.T) {
tool := &mcp.Tool{InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {Type: "string"},
"repo": {Type: "string"},
"detail": {Type: "string"},
},
}}
AnnotateHeaderParams(tool)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Equal(t, "owner", schema.Properties["owner"].Extra["x-mcp-header"])
assert.Equal(t, "repo", schema.Properties["repo"].Extra["x-mcp-header"])
assert.Nil(t, schema.Properties["detail"].Extra)
// No-op for tools without owner/repo and when InputSchema is not a *jsonschema.Schema
AnnotateHeaderParams(&mcp.Tool{InputSchema: &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"x": {}}}})
AnnotateHeaderParams(&mcp.Tool{InputSchema: json.RawMessage(`{}`)})
}
func TestAnnotateHeaderParams_DoesNotMutateOriginal(t *testing.T) {
orig := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}, "repo": {Type: "string"}},
}
tool := &mcp.Tool{InputSchema: orig}
AnnotateHeaderParams(tool)
// Original schema and its property Extra maps must be untouched.
require.Nil(t, orig.Properties["owner"].Extra, "must not mutate original owner schema")
require.Nil(t, orig.Properties["repo"].Extra, "must not mutate original repo schema")
// Returned copy carries the annotation.
got := tool.InputSchema.(*jsonschema.Schema)
require.NotSame(t, orig, got, "must replace InputSchema with a copy")
require.Equal(t, "owner", got.Properties["owner"].Extra["x-mcp-header"])
}
func TestAnnotateHeaderParams_ConcurrentRegistrationIsRaceFree(t *testing.T) {
// Shared base schema, as ServerTool.Tool is shallow-copied per registration.
base := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}, "repo": {Type: "string"}},
}
var wg sync.WaitGroup
for range 64 {
wg.Go(func() {
tool := mcp.Tool{InputSchema: base} // shallow copy shares *Schema
AnnotateHeaderParams(&tool)
got := tool.InputSchema.(*jsonschema.Schema)
require.Equal(t, "repo", got.Properties["repo"].Extra["x-mcp-header"])
})
}
wg.Wait()
require.Nil(t, base.Properties["owner"].Extra, "shared base must remain unmutated")
}