mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
refactor(tools): remove fantasy dependency from internal/tools
- Replace fantasy.AgentTool with plain MCPTool struct in MCPToolManager - Move fantasy adapter from internal/tools to internal/agent as mcpAgentTool - Add MCPToolManager.ExecuteTool() for framework-agnostic tool execution - Remove dead fantasy.LanguageModel field from MCPConnectionPool - Remove MCPToolManager.SetModel() (was only feeding the dead field) internal/tools is now a pure MCP client library with no LLM framework dependency. The fantasy-to-MCP bridging is confined to the agent layer where it belongs.
This commit is contained in:
@@ -245,7 +245,6 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) {
|
||||
// The mcpReady channel is closed when loading completes (success or failure).
|
||||
if agentConfig.MCPConfig != nil && len(agentConfig.MCPConfig.MCPServers) > 0 {
|
||||
toolManager := tools.NewMCPToolManager()
|
||||
toolManager.SetModel(providerResult.Model)
|
||||
if agentConfig.AuthHandler != nil {
|
||||
toolManager.SetAuthHandler(agentConfig.AuthHandler)
|
||||
}
|
||||
@@ -325,7 +324,7 @@ func (a *Agent) rebuildFantasyAgent() {
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
allTools = append(allTools, mcpToolsToAgentTools(a.toolManager.GetTools(), a.toolManager)...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
@@ -808,7 +807,7 @@ func (a *Agent) GetTools() []fantasy.AgentTool {
|
||||
allTools := make([]fantasy.AgentTool, len(a.coreTools))
|
||||
copy(allTools, a.coreTools)
|
||||
if a.toolManager != nil {
|
||||
allTools = append(allTools, a.toolManager.GetTools()...)
|
||||
allTools = append(allTools, mcpToolsToAgentTools(a.toolManager.GetTools(), a.toolManager)...)
|
||||
}
|
||||
if len(a.extraTools) > 0 {
|
||||
allTools = append(allTools, a.extraTools...)
|
||||
@@ -852,7 +851,6 @@ func (a *Agent) AddMCPServer(ctx context.Context, name string, cfg config.MCPSer
|
||||
|
||||
if a.toolManager == nil {
|
||||
a.toolManager = tools.NewMCPToolManager()
|
||||
a.toolManager.SetModel(a.model)
|
||||
if a.authHandler != nil {
|
||||
a.toolManager.SetAuthHandler(a.authHandler)
|
||||
}
|
||||
@@ -933,11 +931,6 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
|
||||
_ = a.providerCloser.Close()
|
||||
}
|
||||
|
||||
// Update model info on MCP tool manager.
|
||||
if a.toolManager != nil {
|
||||
a.toolManager.SetModel(providerResult.Model)
|
||||
}
|
||||
|
||||
// Swap fields.
|
||||
a.model = providerResult.Model
|
||||
a.providerCloser = providerResult.Closer
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/mark3labs/kit/internal/tools"
|
||||
)
|
||||
|
||||
// mcpAgentTool adapts an tools.MCPTool to the fantasy.AgentTool interface.
|
||||
// This keeps the fantasy dependency confined to the agent layer — the tools
|
||||
// package is a pure MCP client library with no LLM framework dependency.
|
||||
type mcpAgentTool struct {
|
||||
tool tools.MCPTool
|
||||
manager *tools.MCPToolManager
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
// Info returns the fantasy tool info including name, description, and parameter schema.
|
||||
func (t *mcpAgentTool) Info() fantasy.ToolInfo {
|
||||
return fantasy.ToolInfo{
|
||||
Name: t.tool.Name,
|
||||
Description: t.tool.Description,
|
||||
Parameters: t.tool.Parameters,
|
||||
Required: t.tool.Required,
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the MCP tool by delegating to the MCPToolManager.
|
||||
func (t *mcpAgentTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
result, err := t.manager.ExecuteTool(ctx, t.tool.Name, call.Input)
|
||||
if err != nil {
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("mcp tool execution failed: %w", err)
|
||||
}
|
||||
|
||||
if result.IsError {
|
||||
return fantasy.NewTextErrorResponse(result.Content), nil
|
||||
}
|
||||
return fantasy.NewTextResponse(result.Content), nil
|
||||
}
|
||||
|
||||
// ProviderOptions returns provider-specific options for this tool.
|
||||
func (t *mcpAgentTool) ProviderOptions() fantasy.ProviderOptions {
|
||||
return t.providerOptions
|
||||
}
|
||||
|
||||
// SetProviderOptions sets provider-specific options for this tool.
|
||||
func (t *mcpAgentTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
|
||||
// mcpToolsToAgentTools converts a slice of MCPTool to fantasy.AgentTool
|
||||
// implementations that route execution through the MCPToolManager.
|
||||
func mcpToolsToAgentTools(mcpTools []tools.MCPTool, manager *tools.MCPToolManager) []fantasy.AgentTool {
|
||||
agentTools := make([]fantasy.AgentTool, len(mcpTools))
|
||||
for i, t := range mcpTools {
|
||||
agentTools[i] = &mcpAgentTool{
|
||||
tool: t,
|
||||
manager: manager,
|
||||
}
|
||||
}
|
||||
return agentTools
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/mcp-go/client"
|
||||
"github.com/mark3labs/mcp-go/client/transport"
|
||||
@@ -63,7 +62,6 @@ type MCPConnectionPool struct {
|
||||
connections map[string]*MCPConnection
|
||||
config *ConnectionPoolConfig
|
||||
mu sync.RWMutex
|
||||
model fantasy.LanguageModel
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
debug bool
|
||||
@@ -75,9 +73,8 @@ type MCPConnectionPool struct {
|
||||
// NewMCPConnectionPool creates a new MCP connection pool with the specified configuration.
|
||||
// If config is nil, default configuration values will be used. The pool starts a background
|
||||
// goroutine for periodic health checks that runs until Close is called.
|
||||
// The model parameter is used for MCP servers that require sampling support.
|
||||
// Thread-safe for concurrent use immediately after creation.
|
||||
func NewMCPConnectionPool(config *ConnectionPoolConfig, model fantasy.LanguageModel, debug bool, authHandler MCPAuthHandler, tokenStoreFactory TokenStoreFactory) *MCPConnectionPool {
|
||||
func NewMCPConnectionPool(config *ConnectionPoolConfig, debug bool, authHandler MCPAuthHandler, tokenStoreFactory TokenStoreFactory) *MCPConnectionPool {
|
||||
if config == nil {
|
||||
config = DefaultConnectionPoolConfig()
|
||||
}
|
||||
@@ -86,7 +83,6 @@ func NewMCPConnectionPool(config *ConnectionPoolConfig, model fantasy.LanguageMo
|
||||
pool := &MCPConnectionPool{
|
||||
connections: make(map[string]*MCPConnection),
|
||||
config: config,
|
||||
model: model,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
debug: debug,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// mcpFantasyTool adapts an MCP tool to the fantasy.AgentTool interface.
|
||||
// It bridges the MCP tool protocol with fantasy's agent tool system, handling
|
||||
// name prefixing, schema conversion, connection pooling, and result marshaling.
|
||||
type mcpFantasyTool struct {
|
||||
toolInfo fantasy.ToolInfo
|
||||
mapping *toolMapping
|
||||
providerOptions fantasy.ProviderOptions
|
||||
}
|
||||
|
||||
// Info returns the fantasy tool info including name, description, and parameter schema.
|
||||
func (t *mcpFantasyTool) Info() fantasy.ToolInfo {
|
||||
return t.toolInfo
|
||||
}
|
||||
|
||||
// Run executes the MCP tool by routing through the connection pool.
|
||||
// It maps the prefixed tool name back to the original name, retrieves a healthy
|
||||
// connection, invokes the tool, and converts the MCP result to a fantasy ToolResponse.
|
||||
func (t *mcpFantasyTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
// Parse and validate JSON arguments
|
||||
var arguments any
|
||||
input := call.Input
|
||||
if input == "" || input == "{}" {
|
||||
arguments = nil
|
||||
} else {
|
||||
var temp any
|
||||
if err := json.Unmarshal([]byte(input), &temp); err != nil {
|
||||
return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid JSON arguments: %v", err)), nil
|
||||
}
|
||||
arguments = json.RawMessage(input)
|
||||
}
|
||||
|
||||
// Get connection from pool with health check
|
||||
conn, err := t.mapping.manager.connectionPool.GetConnectionWithHealthCheck(
|
||||
ctx, t.mapping.serverName, t.mapping.serverConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("failed to get healthy connection from pool: %w", err)
|
||||
}
|
||||
|
||||
// Call the MCP tool using the original (unprefixed) name
|
||||
result, err := conn.client.CallTool(ctx, mcp.CallToolRequest{
|
||||
Request: mcp.Request{
|
||||
Method: "tools/call",
|
||||
},
|
||||
Params: mcp.CallToolParams{
|
||||
Name: t.mapping.originalName,
|
||||
Arguments: arguments,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// Handle OAuth re-authorization: token may have expired mid-session.
|
||||
if t.mapping.manager.connectionPool.oauthFlow != nil && IsOAuthError(err) {
|
||||
if flowErr := t.mapping.manager.connectionPool.oauthFlow.RunAuthFlow(ctx, t.mapping.serverName, err); flowErr != nil {
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("OAuth re-authorization failed for tool %s: %w", t.mapping.originalName, flowErr)
|
||||
}
|
||||
// Retry the tool call after successful re-auth.
|
||||
result, err = conn.client.CallTool(ctx, mcp.CallToolRequest{
|
||||
Request: mcp.Request{
|
||||
Method: "tools/call",
|
||||
},
|
||||
Params: mcp.CallToolParams{
|
||||
Name: t.mapping.originalName,
|
||||
Arguments: arguments,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("failed to call mcp tool after re-auth: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Mark connection as unhealthy for automatic recovery
|
||||
t.mapping.manager.connectionPool.HandleConnectionError(t.mapping.serverName, err)
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("failed to call mcp tool: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal the MCP result to JSON string
|
||||
marshaledResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fantasy.ToolResponse{}, fmt.Errorf("failed to marshal mcp tool result: %w", err)
|
||||
}
|
||||
|
||||
// Return as text response, preserving error status from MCP
|
||||
if result.IsError {
|
||||
return fantasy.NewTextErrorResponse(string(marshaledResult)), nil
|
||||
}
|
||||
return fantasy.NewTextResponse(string(marshaledResult)), nil
|
||||
}
|
||||
|
||||
// ProviderOptions returns provider-specific options for this tool.
|
||||
func (t *mcpFantasyTool) ProviderOptions() fantasy.ProviderOptions {
|
||||
return t.providerOptions
|
||||
}
|
||||
|
||||
// SetProviderOptions sets provider-specific options for this tool.
|
||||
func (t *mcpFantasyTool) SetProviderOptions(opts fantasy.ProviderOptions) {
|
||||
t.providerOptions = opts
|
||||
}
|
||||
+131
-42
@@ -9,22 +9,46 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/mark3labs/kit/internal/config"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// MCPTool represents a tool discovered from an MCP server. It contains all
|
||||
// the metadata needed to present the tool to an LLM (name, description, JSON
|
||||
// schema) plus the server origin information needed to execute it.
|
||||
type MCPTool struct {
|
||||
// Name is the prefixed tool name: "serverName__toolName".
|
||||
Name string
|
||||
// Description is the human-readable tool description.
|
||||
Description string
|
||||
// Parameters is the JSON Schema properties for the tool's input.
|
||||
Parameters map[string]any
|
||||
// Required lists the required parameter names.
|
||||
Required []string
|
||||
// ServerName is the MCP server this tool belongs to.
|
||||
ServerName string
|
||||
// OriginalName is the unprefixed tool name on the MCP server.
|
||||
OriginalName string
|
||||
}
|
||||
|
||||
// MCPToolResult is the result of executing an MCP tool via ExecuteTool.
|
||||
type MCPToolResult struct {
|
||||
// Content is the JSON-encoded result from the MCP server.
|
||||
Content string
|
||||
// IsError indicates the MCP server reported a tool-level error.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// MCPToolManager manages MCP (Model Context Protocol) tools and clients across multiple servers.
|
||||
// It provides a unified interface for loading, managing, and executing tools from various MCP servers,
|
||||
// including stdio, SSE, streamable HTTP, and built-in server types. The manager handles connection
|
||||
// pooling, health checks, tool name prefixing to avoid conflicts, and sampling support for LLM interactions.
|
||||
// pooling, health checks, tool name prefixing to avoid conflicts, and OAuth re-authorization.
|
||||
// Thread-safe for concurrent tool invocations.
|
||||
type MCPToolManager struct {
|
||||
connectionPool *MCPConnectionPool
|
||||
tools []fantasy.AgentTool
|
||||
tools []MCPTool
|
||||
toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name
|
||||
mu sync.Mutex // protects tools and toolMap during parallel loading
|
||||
model fantasy.LanguageModel // LLM model for sampling
|
||||
authHandler MCPAuthHandler // OAuth handler for remote servers (nil = no OAuth)
|
||||
tokenStoreFactory TokenStoreFactory // factory for creating per-server token stores (nil = default FileTokenStore)
|
||||
config *config.Config
|
||||
@@ -36,8 +60,8 @@ type MCPToolManager struct {
|
||||
onServerLoaded func(serverName string, toolCount int, err error)
|
||||
|
||||
// onToolsChanged, if non-nil, is called after AddServer or RemoveServer
|
||||
// mutates the tool list. The agent layer uses this to trigger a
|
||||
// rebuildFantasyAgent so the LLM sees the updated tools.
|
||||
// mutates the tool list. The agent layer uses this to trigger a rebuild
|
||||
// so the LLM sees the updated tools.
|
||||
onToolsChanged func()
|
||||
}
|
||||
|
||||
@@ -46,27 +70,18 @@ type toolMapping struct {
|
||||
serverName string
|
||||
originalName string
|
||||
serverConfig config.MCPServerConfig
|
||||
manager *MCPToolManager
|
||||
}
|
||||
|
||||
// NewMCPToolManager creates a new MCP tool manager instance.
|
||||
// Returns an initialized manager with empty tool collections ready to load tools from MCP servers.
|
||||
// The manager must be configured with SetModel and LoadTools before use.
|
||||
// The manager must be configured with LoadTools before use.
|
||||
func NewMCPToolManager() *MCPToolManager {
|
||||
return &MCPToolManager{
|
||||
tools: make([]fantasy.AgentTool, 0),
|
||||
tools: make([]MCPTool, 0),
|
||||
toolMap: make(map[string]*toolMapping),
|
||||
}
|
||||
}
|
||||
|
||||
// SetModel sets the LLM model for sampling support.
|
||||
// The model is used when MCP servers request sampling operations, allowing them to
|
||||
// leverage the host's LLM capabilities for text generation tasks.
|
||||
// This method should be called before LoadTools if any MCP servers require sampling support.
|
||||
func (m *MCPToolManager) SetModel(model fantasy.LanguageModel) {
|
||||
m.model = model
|
||||
}
|
||||
|
||||
// SetAuthHandler sets the OAuth handler for remote MCP server authentication.
|
||||
// When set, remote transports (streamable HTTP, SSE) are configured with OAuth
|
||||
// support, enabling automatic authorization flows when servers require authentication.
|
||||
@@ -109,7 +124,7 @@ func (m *MCPToolManager) SetOnServerLoaded(cb func(serverName string, toolCount
|
||||
|
||||
// SetOnToolsChanged sets the callback that's invoked after AddServer or
|
||||
// RemoveServer mutates the tool list. The agent layer uses this to trigger
|
||||
// a rebuild of the fantasy agent so the LLM sees the updated tool set.
|
||||
// a rebuild so the LLM sees the updated tool set.
|
||||
func (m *MCPToolManager) SetOnToolsChanged(cb func()) {
|
||||
m.onToolsChanged = cb
|
||||
}
|
||||
@@ -182,9 +197,9 @@ func (m *MCPToolManager) RemoveServer(name string) error {
|
||||
}
|
||||
|
||||
// Remove tools belonging to this server.
|
||||
newTools := make([]fantasy.AgentTool, 0, len(m.tools))
|
||||
newTools := make([]MCPTool, 0, len(m.tools))
|
||||
for _, t := range m.tools {
|
||||
if len(t.Info().Name) < len(prefix) || t.Info().Name[:len(prefix)] != prefix {
|
||||
if len(t.Name) < len(prefix) || t.Name[:len(prefix)] != prefix {
|
||||
newTools = append(newTools, t)
|
||||
}
|
||||
}
|
||||
@@ -223,7 +238,7 @@ func (m *MCPToolManager) ensureConnectionPool() {
|
||||
if m.debugLogger == nil {
|
||||
m.debugLogger = NewSimpleDebugLogger(debug)
|
||||
}
|
||||
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), m.model, debug, m.authHandler, m.tokenStoreFactory)
|
||||
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), debug, m.authHandler, m.tokenStoreFactory)
|
||||
m.connectionPool.SetDebugLogger(m.debugLogger)
|
||||
}
|
||||
|
||||
@@ -239,7 +254,7 @@ func (m *MCPToolManager) LoadTools(ctx context.Context, cfg *config.Config) erro
|
||||
if m.debugLogger == nil {
|
||||
m.debugLogger = NewSimpleDebugLogger(cfg.Debug)
|
||||
}
|
||||
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), m.model, cfg.Debug, m.authHandler, m.tokenStoreFactory)
|
||||
m.connectionPool = NewMCPConnectionPool(DefaultConnectionPoolConfig(), cfg.Debug, m.authHandler, m.tokenStoreFactory)
|
||||
m.connectionPool.SetDebugLogger(m.debugLogger)
|
||||
|
||||
// Load all servers in parallel. Each server connection (subprocess
|
||||
@@ -321,10 +336,10 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
}
|
||||
|
||||
// Build tools locally before acquiring the lock.
|
||||
var localTools []fantasy.AgentTool
|
||||
var localTools []MCPTool
|
||||
localMap := make(map[string]*toolMapping)
|
||||
|
||||
// Convert MCP tools to fantasy AgentTools with prefixed names
|
||||
// Convert MCP tools to MCPTool structs with prefixed names
|
||||
for _, mcpTool := range listResults.Tools {
|
||||
// Filter tools based on allowedTools/excludedTools
|
||||
if len(serverConfig.AllowedTools) > 0 {
|
||||
@@ -338,7 +353,7 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert MCP InputSchema to map[string]any for fantasy ToolInfo
|
||||
// Convert MCP InputSchema to map[string]any
|
||||
marshaledSchema, err := json.Marshal(mcpTool.InputSchema)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("conv mcp tool input schema fail(marshal): %w, tool name: %s", err, mcpTool.Name)
|
||||
@@ -347,7 +362,7 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
// Fix for JSON Schema draft-07 vs draft-04 compatibility
|
||||
marshaledSchema = convertExclusiveBoundsToBoolean(marshaledSchema)
|
||||
|
||||
// Parse into map[string]any for fantasy's parameters format
|
||||
// Parse into map[string]any
|
||||
var schemaMap map[string]any
|
||||
if err := json.Unmarshal(marshaledSchema, &schemaMap); err != nil {
|
||||
return -1, fmt.Errorf("conv mcp tool input schema fail(unmarshal): %w, tool name: %s", err, mcpTool.Name)
|
||||
@@ -363,7 +378,7 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
|
||||
// Fix for issue #89: Ensure object schemas have a properties field.
|
||||
// When schema type is "object" with no properties, we keep the
|
||||
// empty parameters map — fantasy handles this fine.
|
||||
// empty parameters map.
|
||||
|
||||
if req, ok := schemaMap["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
@@ -381,22 +396,18 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
serverName: serverName,
|
||||
originalName: mcpTool.Name,
|
||||
serverConfig: serverConfig,
|
||||
manager: m,
|
||||
}
|
||||
localMap[prefixedName] = mapping
|
||||
|
||||
// Create fantasy AgentTool
|
||||
fantasyTool := &mcpFantasyTool{
|
||||
toolInfo: fantasy.ToolInfo{
|
||||
Name: prefixedName,
|
||||
Description: mcpTool.Description,
|
||||
Parameters: parameters,
|
||||
Required: required,
|
||||
},
|
||||
mapping: mapping,
|
||||
}
|
||||
|
||||
localTools = append(localTools, fantasyTool)
|
||||
// Create MCPTool
|
||||
localTools = append(localTools, MCPTool{
|
||||
Name: prefixedName,
|
||||
Description: mcpTool.Description,
|
||||
Parameters: parameters,
|
||||
Required: required,
|
||||
ServerName: serverName,
|
||||
OriginalName: mcpTool.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// Merge into the manager under the lock.
|
||||
@@ -408,9 +419,87 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string,
|
||||
return len(localTools), nil
|
||||
}
|
||||
|
||||
// GetTools returns all loaded tools as fantasy AgentTools from all configured MCP servers.
|
||||
// ExecuteTool calls an MCP tool through the connection pool, handling health
|
||||
// checks, OAuth re-authorization, and connection error tracking.
|
||||
// The inputJSON parameter is the raw JSON arguments from the LLM.
|
||||
// Returns the result content, error flag, and any execution error.
|
||||
func (m *MCPToolManager) ExecuteTool(ctx context.Context, prefixedName, inputJSON string) (*MCPToolResult, error) {
|
||||
m.mu.Lock()
|
||||
mapping, ok := m.toolMap[prefixedName]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %q not found", prefixedName)
|
||||
}
|
||||
|
||||
// Parse and validate JSON arguments
|
||||
var arguments any
|
||||
if inputJSON == "" || inputJSON == "{}" {
|
||||
arguments = nil
|
||||
} else {
|
||||
var temp any
|
||||
if err := json.Unmarshal([]byte(inputJSON), &temp); err != nil {
|
||||
return &MCPToolResult{
|
||||
Content: fmt.Sprintf("invalid JSON arguments: %v", err),
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
arguments = json.RawMessage(inputJSON)
|
||||
}
|
||||
|
||||
// Get connection from pool with health check
|
||||
conn, err := m.connectionPool.GetConnectionWithHealthCheck(
|
||||
ctx, mapping.serverName, mapping.serverConfig,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get healthy connection from pool: %w", err)
|
||||
}
|
||||
|
||||
callRequest := mcp.CallToolRequest{
|
||||
Request: mcp.Request{
|
||||
Method: "tools/call",
|
||||
},
|
||||
Params: mcp.CallToolParams{
|
||||
Name: mapping.originalName,
|
||||
Arguments: arguments,
|
||||
},
|
||||
}
|
||||
|
||||
// Call the MCP tool using the original (unprefixed) name
|
||||
result, err := conn.client.CallTool(ctx, callRequest)
|
||||
if err != nil {
|
||||
// Handle OAuth re-authorization: token may have expired mid-session.
|
||||
if m.connectionPool.oauthFlow != nil && IsOAuthError(err) {
|
||||
if flowErr := m.connectionPool.oauthFlow.RunAuthFlow(ctx, mapping.serverName, err); flowErr != nil {
|
||||
return nil, fmt.Errorf("OAuth re-authorization failed for tool %s: %w", mapping.originalName, flowErr)
|
||||
}
|
||||
// Retry the tool call after successful re-auth.
|
||||
result, err = conn.client.CallTool(ctx, callRequest)
|
||||
if err != nil {
|
||||
m.connectionPool.HandleConnectionError(mapping.serverName, err)
|
||||
return nil, fmt.Errorf("failed to call mcp tool after re-auth: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Mark connection as unhealthy for automatic recovery
|
||||
m.connectionPool.HandleConnectionError(mapping.serverName, err)
|
||||
return nil, fmt.Errorf("failed to call mcp tool: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal the MCP result to JSON string
|
||||
marshaledResult, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal mcp tool result: %w", err)
|
||||
}
|
||||
|
||||
return &MCPToolResult{
|
||||
Content: string(marshaledResult),
|
||||
IsError: result.IsError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTools returns all loaded MCP tools from all configured MCP servers.
|
||||
// Tools are returned with their prefixed names (serverName__toolName) to ensure uniqueness.
|
||||
func (m *MCPToolManager) GetTools() []fantasy.AgentTool {
|
||||
func (m *MCPToolManager) GetTools() []MCPTool {
|
||||
return m.tools
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestMCPToolManager_AddServer_Integration(t *testing.T) {
|
||||
// Verify tool names are prefixed.
|
||||
toolNames := make(map[string]bool)
|
||||
for _, tool := range tools {
|
||||
toolNames[tool.Info().Name] = true
|
||||
toolNames[tool.Name] = true
|
||||
}
|
||||
if !toolNames["echo__echo"] {
|
||||
t.Error("Expected tool 'echo__echo'")
|
||||
@@ -234,8 +234,8 @@ func TestMCPToolManager_AddRemoveMultiple_Integration(t *testing.T) {
|
||||
|
||||
// Remaining tools should all be from server-b.
|
||||
for _, tool := range tools {
|
||||
if !strings.HasPrefix(tool.Info().Name, "server-b__") {
|
||||
t.Errorf("Expected tool from server-b, got: %s", tool.Info().Name)
|
||||
if !strings.HasPrefix(tool.Name, "server-b__") {
|
||||
t.Errorf("Expected tool from server-b, got: %s", tool.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestMCPToolManager_Close_NilPool(t *testing.T) {
|
||||
// TestMCPConnectionPool_RemoveConnection_NotFound verifies that removing a
|
||||
// non-existent connection returns an error.
|
||||
func TestMCPConnectionPool_RemoveConnection_NotFound(t *testing.T) {
|
||||
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), nil, false, nil, nil)
|
||||
pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil)
|
||||
defer func() { _ = pool.Close() }()
|
||||
|
||||
err := pool.RemoveConnection("nonexistent")
|
||||
|
||||
@@ -103,14 +103,12 @@ func TestMCPToolManager_EmptyConfig(t *testing.T) {
|
||||
|
||||
// Test that we can get tool info for each tool
|
||||
for _, tool := range tools {
|
||||
info := tool.Info()
|
||||
|
||||
// Check that the tool has a valid name
|
||||
if info.Name == "" {
|
||||
if tool.Name == "" {
|
||||
t.Error("Tool has empty name")
|
||||
}
|
||||
|
||||
t.Logf("Tool: %s, Description: %s", info.Name, info.Description)
|
||||
t.Logf("Tool: %s, Description: %s", tool.Name, tool.Description)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user