372 lines
15 KiB
Go
372 lines
15 KiB
Go
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
|
|
}
|