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 }