package inventory
import (
"context"
"fmt"
"os"
"slices"
"sort"
"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()
// - Runtime toolset enabling for dynamic toolsets mode
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
}
// 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"
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: Empty (capabilities are set via ServerOptions, not registration)
// - MCPMethodToolsList: All available tools (no resources/prompts)
// - MCPMethodToolsCall: Only the named tool
// - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts)
// - MCPMethodResourcesRead: Only the named resource template
// - 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,
}
// Helper to clear all item types
clearAll := func() {
result.tools = []ServerTool{}
result.resourceTemplates = []ServerResourceTemplate{}
result.prompts = []ServerPrompt{}
}
switch method {
case MCPMethodInitialize:
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:
result.tools, result.prompts = nil, nil
if itemName != "" {
result.resourceTemplates = r.filterResourcesByURI(itemName)
}
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
}
// RegisterTools registers all available tools with the server using the provided dependencies.
// The context is used for feature flag evaluation.
func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) {
for _, tool := range r.AvailableTools(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
}