chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user