e78c36da31
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
License Check / license-check (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
358 lines
8.5 KiB
Go
358 lines
8.5 KiB
Go
package toolsets
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/github/github-mcp-server/pkg/schema"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func NewToolsetDoesNotExistError(name string) *ToolsetDoesNotExistError {
|
|
return &ToolsetDoesNotExistError{Name: name}
|
|
}
|
|
|
|
type ServerTool struct {
|
|
Tool mcp.Tool
|
|
RegisterFunc func(s *mcp.Server, schemaCache *schema.Cache)
|
|
}
|
|
|
|
func NewServerTool[In, Out any](tool mcp.Tool, handler mcp.ToolHandlerFor[In, Out]) ServerTool {
|
|
return ServerTool{Tool: tool, RegisterFunc: func(s *mcp.Server, schemaCache *schema.Cache) {
|
|
inputSchema, ok := tool.InputSchema.(*jsonschema.Schema)
|
|
if ok && inputSchema != nil {
|
|
resolvedSchema, err := schemaCache.GetCachedOrResolveSchema(tool.Name, inputSchema)
|
|
|
|
if err == nil && resolvedSchema != nil {
|
|
tool.InputSchema = resolvedSchema
|
|
}
|
|
}
|
|
|
|
mcp.AddTool(s, &tool, handler)
|
|
}}
|
|
}
|
|
|
|
type ServerResourceTemplate struct {
|
|
Template mcp.ResourceTemplate
|
|
Handler mcp.ResourceHandler
|
|
}
|
|
|
|
func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler mcp.ResourceHandler) ServerResourceTemplate {
|
|
return ServerResourceTemplate{
|
|
Template: resourceTemplate,
|
|
Handler: handler,
|
|
}
|
|
}
|
|
|
|
type ServerPrompt struct {
|
|
Prompt mcp.Prompt
|
|
Handler mcp.PromptHandler
|
|
}
|
|
|
|
func NewServerPrompt(prompt mcp.Prompt, handler mcp.PromptHandler) ServerPrompt {
|
|
return ServerPrompt{
|
|
Prompt: prompt,
|
|
Handler: handler,
|
|
}
|
|
}
|
|
|
|
// Toolset represents a collection of MCP functionality that can be enabled or disabled as a group.
|
|
type Toolset struct {
|
|
Name string
|
|
Description string
|
|
Enabled bool
|
|
readOnly bool
|
|
writeTools []ServerTool
|
|
readTools []ServerTool
|
|
// resources are not tools, but the community seems to be moving towards namespaces as a broader concept
|
|
// and in order to have multiple servers running concurrently, we want to avoid overlapping resources too.
|
|
resourceTemplates []ServerResourceTemplate
|
|
// prompts are also not tools but are namespaced similarly
|
|
prompts []ServerPrompt
|
|
schemaCache *schema.Cache
|
|
}
|
|
|
|
func (t *Toolset) GetActiveTools() []ServerTool {
|
|
if t.Enabled {
|
|
if t.readOnly {
|
|
return t.readTools
|
|
}
|
|
return append(t.readTools, t.writeTools...)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *Toolset) GetAvailableTools() []ServerTool {
|
|
if t.readOnly {
|
|
return t.readTools
|
|
}
|
|
return append(t.readTools, t.writeTools...)
|
|
}
|
|
|
|
func (t *Toolset) RegisterTools(s *mcp.Server) {
|
|
if !t.Enabled {
|
|
return
|
|
}
|
|
for _, tool := range t.readTools {
|
|
tool.RegisterFunc(s, t.schemaCache)
|
|
}
|
|
if !t.readOnly {
|
|
for _, tool := range t.writeTools {
|
|
tool.RegisterFunc(s, t.schemaCache)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Toolset) AddResourceTemplates(templates ...ServerResourceTemplate) *Toolset {
|
|
t.resourceTemplates = append(t.resourceTemplates, templates...)
|
|
return t
|
|
}
|
|
|
|
func (t *Toolset) AddPrompts(prompts ...ServerPrompt) *Toolset {
|
|
t.prompts = append(t.prompts, prompts...)
|
|
return t
|
|
}
|
|
|
|
func (t *Toolset) GetActiveResourceTemplates() []ServerResourceTemplate {
|
|
if !t.Enabled {
|
|
return nil
|
|
}
|
|
return t.resourceTemplates
|
|
}
|
|
|
|
func (t *Toolset) GetAvailableResourceTemplates() []ServerResourceTemplate {
|
|
return t.resourceTemplates
|
|
}
|
|
|
|
func (t *Toolset) RegisterResourcesTemplates(s *mcp.Server) {
|
|
if !t.Enabled {
|
|
return
|
|
}
|
|
for _, resource := range t.resourceTemplates {
|
|
s.AddResourceTemplate(&resource.Template, resource.Handler)
|
|
}
|
|
}
|
|
|
|
func (t *Toolset) RegisterPrompts(s *mcp.Server) {
|
|
if !t.Enabled {
|
|
return
|
|
}
|
|
for _, prompt := range t.prompts {
|
|
s.AddPrompt(&prompt.Prompt, prompt.Handler)
|
|
}
|
|
}
|
|
|
|
func (t *Toolset) SetReadOnly() {
|
|
// Set the toolset to read-only
|
|
t.readOnly = true
|
|
}
|
|
|
|
func (t *Toolset) AddWriteTools(tools ...ServerTool) *Toolset {
|
|
// Silently ignore if the toolset is read-only to avoid any breach of that contract
|
|
for _, tool := range tools {
|
|
if tool.Tool.Annotations.ReadOnlyHint {
|
|
panic(fmt.Sprintf("tool (%s) is incorrectly annotated as read-only", tool.Tool.Name))
|
|
}
|
|
}
|
|
if !t.readOnly {
|
|
t.writeTools = append(t.writeTools, tools...)
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (t *Toolset) AddReadTools(tools ...ServerTool) *Toolset {
|
|
for _, tool := range tools {
|
|
if !tool.Tool.Annotations.ReadOnlyHint {
|
|
panic(fmt.Sprintf("tool (%s) must be annotated as read-only", tool.Tool.Name))
|
|
}
|
|
}
|
|
t.readTools = append(t.readTools, tools...)
|
|
return t
|
|
}
|
|
|
|
type ToolsetGroup struct {
|
|
Toolsets map[string]*Toolset
|
|
everythingOn bool
|
|
readOnly bool
|
|
}
|
|
|
|
func NewToolsetGroup(readOnly bool) *ToolsetGroup {
|
|
return &ToolsetGroup{
|
|
Toolsets: make(map[string]*Toolset),
|
|
everythingOn: false,
|
|
readOnly: readOnly,
|
|
}
|
|
}
|
|
|
|
func (tg *ToolsetGroup) AddToolset(ts *Toolset) {
|
|
if tg.readOnly {
|
|
ts.SetReadOnly()
|
|
}
|
|
tg.Toolsets[ts.Name] = ts
|
|
}
|
|
|
|
func NewToolset(name string, description string, schemaCache *schema.Cache) *Toolset {
|
|
return &Toolset{
|
|
Name: name,
|
|
Description: description,
|
|
Enabled: false,
|
|
readOnly: false,
|
|
schemaCache: schemaCache,
|
|
}
|
|
}
|
|
|
|
func (tg *ToolsetGroup) IsEnabled(name string) bool {
|
|
// If everythingOn is true, all features are enabled
|
|
if tg.everythingOn {
|
|
return true
|
|
}
|
|
|
|
feature, exists := tg.Toolsets[name]
|
|
if !exists {
|
|
return false
|
|
}
|
|
return feature.Enabled
|
|
}
|
|
|
|
type EnableToolsetsOptions struct {
|
|
ErrorOnUnknown bool
|
|
}
|
|
|
|
func (tg *ToolsetGroup) EnableToolsets(names []string, options *EnableToolsetsOptions) error {
|
|
if options == nil {
|
|
options = &EnableToolsetsOptions{
|
|
ErrorOnUnknown: false,
|
|
}
|
|
}
|
|
|
|
// Special case for "all"
|
|
for _, name := range names {
|
|
if name == "all" {
|
|
tg.everythingOn = true
|
|
break
|
|
}
|
|
err := tg.EnableToolset(name)
|
|
if err != nil && options.ErrorOnUnknown {
|
|
return err
|
|
}
|
|
}
|
|
// Do this after to ensure all toolsets are enabled if "all" is present anywhere in list
|
|
if tg.everythingOn {
|
|
for name := range tg.Toolsets {
|
|
err := tg.EnableToolset(name)
|
|
if err != nil && options.ErrorOnUnknown {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tg *ToolsetGroup) EnableToolset(name string) error {
|
|
toolset, exists := tg.Toolsets[name]
|
|
if !exists {
|
|
return NewToolsetDoesNotExistError(name)
|
|
}
|
|
toolset.Enabled = true
|
|
tg.Toolsets[name] = toolset
|
|
return nil
|
|
}
|
|
|
|
func (tg *ToolsetGroup) RegisterAll(s *mcp.Server) {
|
|
for _, toolset := range tg.Toolsets {
|
|
toolset.RegisterTools(s)
|
|
toolset.RegisterResourcesTemplates(s)
|
|
toolset.RegisterPrompts(s)
|
|
}
|
|
}
|
|
|
|
func (tg *ToolsetGroup) GetToolset(name string) (*Toolset, error) {
|
|
toolset, exists := tg.Toolsets[name]
|
|
if !exists {
|
|
return nil, NewToolsetDoesNotExistError(name)
|
|
}
|
|
return toolset, nil
|
|
}
|
|
|
|
type ToolDoesNotExistError struct {
|
|
Name string
|
|
}
|
|
|
|
func (e *ToolDoesNotExistError) Error() string {
|
|
return fmt.Sprintf("tool %s does not exist", e.Name)
|
|
}
|
|
|
|
func NewToolDoesNotExistError(name string) *ToolDoesNotExistError {
|
|
return &ToolDoesNotExistError{Name: name}
|
|
}
|
|
|
|
// FindToolByName searches all toolsets (enabled or disabled) for a tool by name.
|
|
// Returns the tool, its parent toolset name, and an error if not found.
|
|
func (tg *ToolsetGroup) FindToolByName(toolName string) (*ServerTool, string, error) {
|
|
for toolsetName, toolset := range tg.Toolsets {
|
|
// Check read tools
|
|
for _, tool := range toolset.readTools {
|
|
if tool.Tool.Name == toolName {
|
|
return &tool, toolsetName, nil
|
|
}
|
|
}
|
|
// Check write tools
|
|
for _, tool := range toolset.writeTools {
|
|
if tool.Tool.Name == toolName {
|
|
return &tool, toolsetName, nil
|
|
}
|
|
}
|
|
}
|
|
return nil, "", NewToolDoesNotExistError(toolName)
|
|
}
|
|
|
|
// RegisterSpecificTools registers only the specified tools.
|
|
// Respects read-only mode (skips write tools if readOnly=true).
|
|
// Returns error if any tool is not found.
|
|
func (tg *ToolsetGroup) RegisterSpecificTools(s *mcp.Server, toolNames []string, readOnly bool, schemaCache *schema.Cache) error {
|
|
var skippedTools []string
|
|
for _, toolName := range toolNames {
|
|
tool, _, err := tg.FindToolByName(toolName)
|
|
if err != nil {
|
|
return fmt.Errorf("tool %s not found: %w", toolName, err)
|
|
}
|
|
|
|
if !tool.Tool.Annotations.ReadOnlyHint && readOnly {
|
|
// Skip write tools in read-only mode
|
|
skippedTools = append(skippedTools, toolName)
|
|
continue
|
|
}
|
|
|
|
// Register the tool
|
|
tool.RegisterFunc(s, schemaCache)
|
|
}
|
|
|
|
// Log skipped write tools if any
|
|
if len(skippedTools) > 0 {
|
|
fmt.Fprintf(os.Stderr, "Write tools skipped due to read-only mode: %s\n", strings.Join(skippedTools, ", "))
|
|
}
|
|
|
|
return nil
|
|
}
|