mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 22:33:54 -04:00
mcp.tools.execute and mcp.discovery were the only NATS subjects that combined a queue group with a reply, and no carrier in this design provides both. They never needed one: a queue group is a way of choosing a subscriber, and choosing is a query. The frontend now lists the approved, non-draining agent nodes, asks the node_connections table in one joined statement which of those tunnels a live replica holds, prefers one this replica holds so the call skips the relay hop, and issues an ordinary control RPC on the path task 4 already mounted. A peer-held tunnel is reached through the relay. That is a choice a broker's hidden balancing could not make. The selection reads presence and nothing else. It is filtered only on node type and on the two statuses an operator controls, never on a health verdict written on another clock, because refusing a worker that is connected and answering is the same defect as picking one that is gone. An empty fleet answers ErrNoAgentWorker, which is deliberately neither ErrWorkerUnroutable nor anything cluster.IsWorkerAnswer accepts: nothing was asked of any worker, so no reap guard may act on it. A reply carrying an Error is the worker's own answer and is returned unchanged; it is never offered to a second worker, which would turn "this MCP server rejected your arguments" into "the fleet is broken" and could run a tool twice. A call that never reached a worker is retried against a different pick, at most three times, and whatever error is finally returned is returned unwrapped so its identity survives the loop. MCP prompts and resources now answer 501 in distributed mode instead of an empty 200. They are served only from sessions the frontend holds, and in distributed mode it holds none. That gap predates the removal of the bus and is not closed by it; this only stops it being silent. Agent workers keep every other subject, including nodes.<id>.backend.stop. Their minted JWT loses the two MCP subjects and keeps a non-empty allow list, because NATS reads an empty one as no restriction at all. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1021 lines
31 KiB
Go
1021 lines
31 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
mcpRemote "github.com/mudler/LocalAI/core/services/mcp"
|
|
|
|
"github.com/mudler/LocalAI/pkg/functions"
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
"github.com/mudler/LocalAI/pkg/signals"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// NamedSession pairs an MCP session with its server name and type.
|
|
type NamedSession struct {
|
|
Name string
|
|
Type string // "remote" or "stdio"
|
|
Session *mcp.ClientSession
|
|
Error string
|
|
}
|
|
|
|
// MCPToolInfo holds a discovered MCP tool along with its origin session.
|
|
type MCPToolInfo struct {
|
|
ServerName string
|
|
ToolName string
|
|
Function functions.Function
|
|
Session *mcp.ClientSession
|
|
}
|
|
|
|
// MCPServerInfo describes an MCP server and its available tools, prompts, and resources.
|
|
type MCPServerInfo struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Tools []string `json:"tools"`
|
|
Prompts []string `json:"prompts,omitempty"`
|
|
Resources []string `json:"resources,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// MCPPromptInfo holds a discovered MCP prompt along with its origin session.
|
|
type MCPPromptInfo struct {
|
|
ServerName string
|
|
PromptName string
|
|
Description string
|
|
Title string
|
|
Arguments []*mcp.PromptArgument
|
|
Session *mcp.ClientSession
|
|
}
|
|
|
|
// MCPResourceInfo holds a discovered MCP resource along with its origin session.
|
|
type MCPResourceInfo struct {
|
|
ServerName string
|
|
Name string
|
|
URI string
|
|
Description string
|
|
MIMEType string
|
|
Session *mcp.ClientSession
|
|
}
|
|
|
|
type sessionCache struct {
|
|
mu sync.Mutex
|
|
cache map[string][]*mcp.ClientSession
|
|
cancels map[string]context.CancelFunc
|
|
}
|
|
|
|
type namedSessionCache struct {
|
|
mu sync.Mutex
|
|
cache map[string][]NamedSession
|
|
cancels map[string]context.CancelFunc
|
|
configHashes map[string][sha256.Size]byte
|
|
}
|
|
|
|
var (
|
|
cache = sessionCache{
|
|
cache: make(map[string][]*mcp.ClientSession),
|
|
cancels: make(map[string]context.CancelFunc),
|
|
}
|
|
|
|
namedCache = namedSessionCache{
|
|
cache: make(map[string][]NamedSession),
|
|
cancels: make(map[string]context.CancelFunc),
|
|
configHashes: make(map[string][sha256.Size]byte),
|
|
}
|
|
|
|
client = mcp.NewClient(&mcp.Implementation{Name: "LocalAI", Version: "v1.0.0"}, nil)
|
|
)
|
|
|
|
// AgentControl is the frontend's port onto the MCP verbs an agent worker serves
|
|
// over the tunnel it holds. *nodes.AgentControlClient is the only production
|
|
// implementation, and it is what decides WHICH agent worker answers.
|
|
//
|
|
// The contract a caller here relies on, and the reason these functions do no
|
|
// classification of their own: an implementation returns the worker's own Error
|
|
// field AS A GO ERROR, so a nil error means the verb succeeded. Re-reading the
|
|
// Error field at every call site would be the same rule written twice, and the
|
|
// copy that gets forgotten is the one that reports a failed tool call as an
|
|
// empty success.
|
|
//
|
|
// It replaces an interface over NATS request-reply. What that carried was a
|
|
// subject and a queue group, which between them chose a worker; the choosing is
|
|
// now a query and the carrying is an ordinary control RPC.
|
|
type AgentControl interface {
|
|
ExecuteMCPTool(ctx context.Context, req mcpRemote.MCPToolRequest) (*mcpRemote.MCPToolResponse, error)
|
|
DiscoverMCPTools(ctx context.Context, req mcpRemote.MCPDiscoveryRequest) (*mcpRemote.MCPDiscoveryResponse, error)
|
|
}
|
|
|
|
// MetadataKeyLocalAIAssistant is the request-metadata key the chat handler
|
|
// inspects to decide whether to wire the in-process admin MCP server. UI
|
|
// callers MUST use this constant rather than the raw string.
|
|
const MetadataKeyLocalAIAssistant = "localai_assistant"
|
|
|
|
// LocalAIAssistantFromMetadata reports whether the request opted into the
|
|
// "LocalAI Assistant" chat modality (admin in-process MCP tool surface).
|
|
// The MetadataKeyLocalAIAssistant key is consumed so it doesn't leak to
|
|
// the backend. Truthy values: "1", "true", "yes" (case-insensitive).
|
|
func LocalAIAssistantFromMetadata(metadata map[string]string) bool {
|
|
raw, ok := metadata[MetadataKeyLocalAIAssistant]
|
|
if !ok {
|
|
return false
|
|
}
|
|
delete(metadata, MetadataKeyLocalAIAssistant)
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "1", "true", "yes":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MCPServersFromMetadata extracts the MCP server list from the metadata map
|
|
// and returns the list. The "mcp_servers" key is consumed (deleted from the map)
|
|
// so it doesn't leak to the backend.
|
|
func MCPServersFromMetadata(metadata map[string]string) []string {
|
|
raw, ok := metadata["mcp_servers"]
|
|
if !ok || raw == "" {
|
|
return nil
|
|
}
|
|
delete(metadata, "mcp_servers")
|
|
servers := strings.Split(raw, ",")
|
|
for i := range servers {
|
|
servers[i] = strings.TrimSpace(servers[i])
|
|
}
|
|
return servers
|
|
}
|
|
|
|
// connectMCP performs the MCP initialize handshake with a bounded timeout.
|
|
//
|
|
// Without this bound, an unreachable remote server (capped only by the 360s
|
|
// httpClient timeout) or a stdio server whose handshake never completes blocks
|
|
// the caller indefinitely. Because the session cache mutex is held across
|
|
// connection setup, one stalled server also wedges every other MCP request for
|
|
// the same model, which surfaces in the UI as the server widget "spinning
|
|
// forever" (mudler/LocalAI#10880) - most visibly for cloud-proxy models, whose
|
|
// chat path never warms the session cache in the background.
|
|
//
|
|
// The session, once established, stays bound to the shared ctx (it is cancelled
|
|
// later via the cached cancel func on eviction/shutdown), so we cannot pass a
|
|
// WithTimeout context to Connect: firing the timeout would tear a healthy
|
|
// session down. Instead we run Connect on the shared ctx in a goroutine and stop
|
|
// waiting after the timeout. We deliberately do NOT cancel here - the ctx is
|
|
// shared with sibling servers that may already have connected. A genuinely
|
|
// stalled goroutine holds only that shared ctx and is reaped when the model's
|
|
// sessions are cancelled on eviction/shutdown.
|
|
func connectMCP(ctx context.Context, transport mcp.Transport, timeout time.Duration) (*mcp.ClientSession, error) {
|
|
type result struct {
|
|
session *mcp.ClientSession
|
|
err error
|
|
}
|
|
// Buffered so the goroutine can always send and exit, even after we stop
|
|
// waiting on the timeout branch.
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
s, err := client.Connect(ctx, transport, nil)
|
|
done <- result{session: s, err: err}
|
|
}()
|
|
select {
|
|
case r := <-done:
|
|
return r.session, r.err
|
|
case <-time.After(timeout):
|
|
return nil, fmt.Errorf("timed out after %s establishing MCP session (server unreachable?)", timeout)
|
|
}
|
|
}
|
|
|
|
func SessionsFromMCPConfig(
|
|
name string,
|
|
remote config.MCPGenericConfig[config.MCPRemoteServers],
|
|
stdio config.MCPGenericConfig[config.MCPSTDIOServers],
|
|
) ([]*mcp.ClientSession, error) {
|
|
cache.mu.Lock()
|
|
defer cache.mu.Unlock()
|
|
|
|
sessions, exists := cache.cache[name]
|
|
|
|
// Verify cached sessions are still alive.
|
|
if exists {
|
|
pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer pingCancel()
|
|
alive := true
|
|
for _, s := range sessions {
|
|
if err := s.Ping(pingCtx, nil); err != nil {
|
|
xlog.Warn("MCP session dead, evicting cache", "name", name, "error", err)
|
|
alive = false
|
|
break
|
|
}
|
|
}
|
|
if !alive {
|
|
if cancel, ok := cache.cancels[name]; ok {
|
|
cancel()
|
|
}
|
|
delete(cache.cache, name)
|
|
delete(cache.cancels, name)
|
|
exists = false
|
|
}
|
|
}
|
|
|
|
if exists {
|
|
return sessions, nil
|
|
}
|
|
|
|
allSessions := []*mcp.ClientSession{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Get the list of all the tools that the Agent will be esposed to
|
|
for _, server := range remote.Servers {
|
|
xlog.Debug("[MCP remote server] Configuration", "server", server)
|
|
// Create HTTP client with custom roundtripper for bearer token injection
|
|
httpClient := httpclient.New(
|
|
httpclient.WithTimeout(config.DefaultMCPToolTimeout),
|
|
httpclient.WithTransport(newBearerTokenRoundTripper(server.Token, httpclient.HardenedTransport())),
|
|
)
|
|
|
|
transport := &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: httpClient}
|
|
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
|
|
if err != nil {
|
|
xlog.Error("Failed to connect to MCP server", "error", err, "url", server.URL)
|
|
continue
|
|
}
|
|
xlog.Debug("[MCP remote server] Connected to MCP server", "url", server.URL)
|
|
cache.cache[name] = append(cache.cache[name], mcpSession)
|
|
allSessions = append(allSessions, mcpSession)
|
|
}
|
|
|
|
for _, server := range stdio.Servers {
|
|
xlog.Debug("[MCP stdio server] Configuration", "server", server)
|
|
command := exec.Command(server.Command, server.Args...)
|
|
command.Env = os.Environ()
|
|
for key, value := range server.Env {
|
|
command.Env = append(command.Env, key+"="+value)
|
|
}
|
|
transport := &mcp.CommandTransport{Command: command}
|
|
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
|
|
if err != nil {
|
|
xlog.Error("Failed to start MCP server", "error", err, "command", command)
|
|
continue
|
|
}
|
|
xlog.Debug("[MCP stdio server] Connected to MCP server", "command", command)
|
|
cache.cache[name] = append(cache.cache[name], mcpSession)
|
|
allSessions = append(allSessions, mcpSession)
|
|
}
|
|
|
|
cache.cancels[name] = cancel
|
|
|
|
return allSessions, nil
|
|
}
|
|
|
|
// closeNamedSessionsLocked removes one model's named sessions while the
|
|
// named-cache mutex is held. Failed connection entries have no live session.
|
|
func closeNamedSessionsLocked(name string, sessions []NamedSession) {
|
|
for _, ns := range sessions {
|
|
if ns.Session != nil {
|
|
if err := ns.Session.Close(); err != nil {
|
|
xlog.Debug("Failed to close MCP session", "server", ns.Name, "error", err)
|
|
}
|
|
}
|
|
}
|
|
if cancel, ok := namedCache.cancels[name]; ok {
|
|
cancel()
|
|
}
|
|
delete(namedCache.cache, name)
|
|
delete(namedCache.cancels, name)
|
|
delete(namedCache.configHashes, name)
|
|
}
|
|
|
|
// NamedSessionsFromMCPConfig returns sessions with their server names preserved.
|
|
// If enabledServers is non-empty, only servers with matching names are returned.
|
|
func NamedSessionsFromMCPConfig(
|
|
name string,
|
|
remote config.MCPGenericConfig[config.MCPRemoteServers],
|
|
stdio config.MCPGenericConfig[config.MCPSTDIOServers],
|
|
enabledServers []string,
|
|
) ([]NamedSession, error) {
|
|
namedCache.mu.Lock()
|
|
defer namedCache.mu.Unlock()
|
|
|
|
configJSON, _ := json.Marshal(struct {
|
|
Remote config.MCPGenericConfig[config.MCPRemoteServers] `json:"remote"`
|
|
Stdio config.MCPGenericConfig[config.MCPSTDIOServers] `json:"stdio"`
|
|
}{Remote: remote, Stdio: stdio})
|
|
configHash := sha256.Sum256(configJSON)
|
|
|
|
allSessions, exists := namedCache.cache[name]
|
|
if exists && namedCache.configHashes[name] != configHash {
|
|
closeNamedSessionsLocked(name, allSessions)
|
|
exists = false
|
|
allSessions = nil
|
|
}
|
|
|
|
// If cached, verify sessions are still alive via Ping.
|
|
// Dead sessions (e.g. exited stdio containers) are evicted so they get recreated.
|
|
if exists {
|
|
pingCtx, pingCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer pingCancel()
|
|
alive := true
|
|
for _, ns := range allSessions {
|
|
if ns.Session == nil {
|
|
alive = false
|
|
break
|
|
}
|
|
if err := ns.Session.Ping(pingCtx, nil); err != nil {
|
|
xlog.Warn("MCP session dead, evicting cache", "server", ns.Name, "error", err)
|
|
alive = false
|
|
break
|
|
}
|
|
}
|
|
if !alive {
|
|
closeNamedSessionsLocked(name, allSessions)
|
|
exists = false
|
|
allSessions = nil
|
|
}
|
|
}
|
|
|
|
if !exists {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
for serverName, server := range remote.Servers {
|
|
xlog.Debug("[MCP remote server] Configuration", "name", serverName, "server", server)
|
|
httpClient := httpclient.New(
|
|
httpclient.WithTimeout(config.DefaultMCPToolTimeout),
|
|
httpclient.WithTransport(newBearerTokenRoundTripper(server.Token, httpclient.HardenedTransport())),
|
|
)
|
|
|
|
transport := &mcp.StreamableClientTransport{Endpoint: server.URL, HTTPClient: httpClient}
|
|
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
|
|
if err != nil {
|
|
xlog.Error("Failed to connect to MCP server", "error", err, "name", serverName, "url", server.URL)
|
|
allSessions = append(allSessions, NamedSession{
|
|
Name: serverName,
|
|
Type: "remote",
|
|
Error: fmt.Sprintf("connection failed: %v", err),
|
|
})
|
|
continue
|
|
}
|
|
xlog.Debug("[MCP remote server] Connected", "name", serverName, "url", server.URL)
|
|
allSessions = append(allSessions, NamedSession{
|
|
Name: serverName,
|
|
Type: "remote",
|
|
Session: mcpSession,
|
|
})
|
|
}
|
|
|
|
for serverName, server := range stdio.Servers {
|
|
xlog.Debug("[MCP stdio server] Configuration", "name", serverName, "server", server)
|
|
command := exec.Command(server.Command, server.Args...)
|
|
command.Env = os.Environ()
|
|
for key, value := range server.Env {
|
|
command.Env = append(command.Env, key+"="+value)
|
|
}
|
|
transport := &mcp.CommandTransport{Command: command}
|
|
mcpSession, err := connectMCP(ctx, transport, config.DefaultMCPDiscoveryTimeout)
|
|
if err != nil {
|
|
xlog.Error("Failed to start MCP server", "error", err, "name", serverName, "command", command)
|
|
allSessions = append(allSessions, NamedSession{
|
|
Name: serverName,
|
|
Type: "stdio",
|
|
Error: fmt.Sprintf("startup failed: %v", err),
|
|
})
|
|
continue
|
|
}
|
|
xlog.Debug("[MCP stdio server] Connected", "name", serverName, "command", command)
|
|
allSessions = append(allSessions, NamedSession{
|
|
Name: serverName,
|
|
Type: "stdio",
|
|
Session: mcpSession,
|
|
})
|
|
}
|
|
|
|
namedCache.cache[name] = allSessions
|
|
namedCache.cancels[name] = cancel
|
|
namedCache.configHashes[name] = configHash
|
|
}
|
|
|
|
if len(enabledServers) == 0 {
|
|
return allSessions, nil
|
|
}
|
|
|
|
enabled := make(map[string]bool, len(enabledServers))
|
|
for _, s := range enabledServers {
|
|
enabled[s] = true
|
|
}
|
|
var filtered []NamedSession
|
|
for _, ns := range allSessions {
|
|
if enabled[ns.Name] {
|
|
filtered = append(filtered, ns)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
// DiscoverMCPTools queries each session for its tools and converts them to functions.Function.
|
|
// Deduplicates by tool name (first server wins).
|
|
func DiscoverMCPTools(ctx context.Context, sessions []NamedSession) ([]MCPToolInfo, error) {
|
|
seen := make(map[string]bool)
|
|
var result []MCPToolInfo
|
|
|
|
for _, ns := range sessions {
|
|
if ns.Session == nil {
|
|
continue
|
|
}
|
|
toolsResult, err := ns.Session.ListTools(ctx, nil)
|
|
if err != nil {
|
|
xlog.Error("Failed to list tools from MCP server", "error", err, "server", ns.Name)
|
|
continue
|
|
}
|
|
for _, tool := range toolsResult.Tools {
|
|
if seen[tool.Name] {
|
|
continue
|
|
}
|
|
seen[tool.Name] = true
|
|
|
|
f := functions.Function{
|
|
Name: tool.Name,
|
|
Description: tool.Description,
|
|
}
|
|
|
|
// Convert InputSchema to map[string]any for functions.Function
|
|
if tool.InputSchema != nil {
|
|
schemaBytes, err := json.Marshal(tool.InputSchema)
|
|
if err == nil {
|
|
var params map[string]any
|
|
if err := json.Unmarshal(schemaBytes, ¶ms); err == nil {
|
|
f.Parameters = params
|
|
} else {
|
|
xlog.Warn("Failed to unmarshal MCP tool input schema", "tool", tool.Name, "error", err)
|
|
}
|
|
}
|
|
}
|
|
if f.Parameters == nil {
|
|
f.Parameters = map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{},
|
|
}
|
|
}
|
|
|
|
result = append(result, MCPToolInfo{
|
|
ServerName: ns.Name,
|
|
ToolName: tool.Name,
|
|
Function: f,
|
|
Session: ns.Session,
|
|
})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ExecuteMCPToolCall finds the matching tool and executes it.
|
|
func ExecuteMCPToolCall(ctx context.Context, tools []MCPToolInfo, toolName string, arguments string) (string, error) {
|
|
var toolInfo *MCPToolInfo
|
|
for i := range tools {
|
|
if tools[i].ToolName == toolName {
|
|
toolInfo = &tools[i]
|
|
break
|
|
}
|
|
}
|
|
if toolInfo == nil {
|
|
return "", fmt.Errorf("MCP tool %q not found", toolName)
|
|
}
|
|
|
|
var args map[string]any
|
|
if arguments != "" {
|
|
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
|
return "", fmt.Errorf("failed to parse arguments for tool %q: %w", toolName, err)
|
|
}
|
|
}
|
|
|
|
result, err := toolInfo.Session.CallTool(ctx, &mcp.CallToolParams{
|
|
Name: toolName,
|
|
Arguments: args,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("MCP tool %q call failed: %w", toolName, err)
|
|
}
|
|
|
|
// Extract text content from result
|
|
var texts []string
|
|
for _, content := range result.Content {
|
|
if tc, ok := content.(*mcp.TextContent); ok {
|
|
texts = append(texts, tc.Text)
|
|
}
|
|
}
|
|
if len(texts) == 0 {
|
|
// Fallback: marshal the whole result
|
|
data, _ := json.Marshal(result.Content)
|
|
return string(data), nil
|
|
}
|
|
if len(texts) == 1 {
|
|
return texts[0], nil
|
|
}
|
|
combined, _ := json.Marshal(texts)
|
|
return string(combined), nil
|
|
}
|
|
|
|
// ExecuteMCPToolCallRemote runs one MCP tool on an agent worker.
|
|
//
|
|
// Used in distributed mode, where the frontend holds no MCP sessions of its
|
|
// own: an agent worker is what can create them (stdio servers under docker),
|
|
// so the frontend serialises the model's MCP configuration and asks one.
|
|
//
|
|
// The budget is applied HERE, as a context deadline, and that is the whole of
|
|
// the change in where it lives. On the bus it was the request-reply timeout,
|
|
// which was the only thing bounding a worker that never answered; the control
|
|
// RPC carries no deadline of its own (see nodes.ControlClient.clientFor), so
|
|
// without this a tool call whose worker went quiet holds the caller until the
|
|
// tunnel's own keepalive notices, which is far longer than any caller expects.
|
|
func ExecuteMCPToolCallRemote(
|
|
ctx context.Context,
|
|
agent AgentControl,
|
|
modelName string,
|
|
remote config.MCPGenericConfig[config.MCPRemoteServers],
|
|
stdio config.MCPGenericConfig[config.MCPSTDIOServers],
|
|
toolName, arguments string,
|
|
) (string, error) {
|
|
if agent == nil {
|
|
return "", fmt.Errorf("no agent control client is configured for distributed MCP: this frontend cannot reach an agent worker to run tool %q", toolName)
|
|
}
|
|
|
|
var args map[string]any
|
|
if arguments != "" {
|
|
if err := json.Unmarshal([]byte(arguments), &args); err != nil {
|
|
return "", fmt.Errorf("invalid tool arguments JSON: %w", err)
|
|
}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, config.DefaultMCPToolTimeout)
|
|
defer cancel()
|
|
|
|
resp, err := agent.ExecuteMCPTool(ctx, mcpRemote.MCPToolRequest{
|
|
ModelName: modelName,
|
|
ToolName: toolName,
|
|
Arguments: args,
|
|
RemoteServers: remote,
|
|
StdioServers: stdio,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("the MCP tool call could not be run on an agent worker: %w", err)
|
|
}
|
|
return resp.Result, nil
|
|
}
|
|
|
|
// DiscoverMCPToolsRemote asks an agent worker which MCP servers and tool
|
|
// schemas a model's configuration reaches.
|
|
func DiscoverMCPToolsRemote(
|
|
ctx context.Context,
|
|
agent AgentControl,
|
|
modelName string,
|
|
remote config.MCPGenericConfig[config.MCPRemoteServers],
|
|
stdio config.MCPGenericConfig[config.MCPSTDIOServers],
|
|
) (*mcpRemote.MCPDiscoveryResponse, error) {
|
|
if agent == nil {
|
|
return nil, fmt.Errorf("no agent control client is configured for distributed MCP: this frontend cannot reach an agent worker to discover the tools of model %q", modelName)
|
|
}
|
|
|
|
// Its own budget, and its own constant. Discovery opens every configured
|
|
// MCP server, which a tool call on an already-open session does not, so the
|
|
// two are not the same wait and never were.
|
|
ctx, cancel := context.WithTimeout(ctx, config.DefaultMCPDiscoveryTimeout)
|
|
defer cancel()
|
|
|
|
resp, err := agent.DiscoverMCPTools(ctx, mcpRemote.MCPDiscoveryRequest{
|
|
ModelName: modelName,
|
|
RemoteServers: remote,
|
|
StdioServers: stdio,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("MCP discovery could not be run on an agent worker: %w", err)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// ListMCPServers returns server info with tool, prompt, and resource names for each session.
|
|
func ListMCPServers(ctx context.Context, sessions []NamedSession) ([]MCPServerInfo, error) {
|
|
var result []MCPServerInfo
|
|
for _, ns := range sessions {
|
|
info := MCPServerInfo{
|
|
Name: ns.Name,
|
|
Type: ns.Type,
|
|
Tools: []string{},
|
|
Error: ns.Error,
|
|
}
|
|
if ns.Session == nil {
|
|
result = append(result, info)
|
|
continue
|
|
}
|
|
toolsResult, err := ns.Session.ListTools(ctx, nil)
|
|
if err != nil {
|
|
xlog.Error("Failed to list tools from MCP server", "error", err, "server", ns.Name)
|
|
info.Error = fmt.Sprintf("failed to list tools: %v", err)
|
|
} else {
|
|
for _, tool := range toolsResult.Tools {
|
|
info.Tools = append(info.Tools, tool.Name)
|
|
}
|
|
}
|
|
|
|
promptsResult, err := ns.Session.ListPrompts(ctx, nil)
|
|
if err != nil {
|
|
xlog.Debug("Failed to list prompts from MCP server", "error", err, "server", ns.Name)
|
|
} else {
|
|
for _, p := range promptsResult.Prompts {
|
|
info.Prompts = append(info.Prompts, p.Name)
|
|
}
|
|
}
|
|
|
|
resourcesResult, err := ns.Session.ListResources(ctx, nil)
|
|
if err != nil {
|
|
xlog.Debug("Failed to list resources from MCP server", "error", err, "server", ns.Name)
|
|
} else {
|
|
for _, r := range resourcesResult.Resources {
|
|
info.Resources = append(info.Resources, r.URI)
|
|
}
|
|
}
|
|
|
|
result = append(result, info)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Type != result[j].Type {
|
|
return result[i].Type < result[j].Type
|
|
}
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
return result, nil
|
|
}
|
|
|
|
// IsMCPTool checks if a tool name is in the MCP tool list.
|
|
func IsMCPTool(tools []MCPToolInfo, name string) bool {
|
|
for _, t := range tools {
|
|
if t.ToolName == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DiscoverMCPPrompts queries each session for its prompts.
|
|
// Deduplicates by prompt name (first server wins).
|
|
func DiscoverMCPPrompts(ctx context.Context, sessions []NamedSession) ([]MCPPromptInfo, error) {
|
|
seen := make(map[string]bool)
|
|
var result []MCPPromptInfo
|
|
|
|
for _, ns := range sessions {
|
|
if ns.Session == nil {
|
|
continue
|
|
}
|
|
promptsResult, err := ns.Session.ListPrompts(ctx, nil)
|
|
if err != nil {
|
|
xlog.Error("Failed to list prompts from MCP server", "error", err, "server", ns.Name)
|
|
continue
|
|
}
|
|
for _, p := range promptsResult.Prompts {
|
|
if seen[p.Name] {
|
|
continue
|
|
}
|
|
seen[p.Name] = true
|
|
result = append(result, MCPPromptInfo{
|
|
ServerName: ns.Name,
|
|
PromptName: p.Name,
|
|
Description: p.Description,
|
|
Title: p.Title,
|
|
Arguments: p.Arguments,
|
|
Session: ns.Session,
|
|
})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetMCPPrompt finds and expands a prompt by name using the discovered prompts list.
|
|
func GetMCPPrompt(ctx context.Context, prompts []MCPPromptInfo, name string, args map[string]string) ([]*mcp.PromptMessage, error) {
|
|
var info *MCPPromptInfo
|
|
for i := range prompts {
|
|
if prompts[i].PromptName == name {
|
|
info = &prompts[i]
|
|
break
|
|
}
|
|
}
|
|
if info == nil {
|
|
return nil, fmt.Errorf("MCP prompt %q not found", name)
|
|
}
|
|
|
|
result, err := info.Session.GetPrompt(ctx, &mcp.GetPromptParams{
|
|
Name: name,
|
|
Arguments: args,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("MCP prompt %q get failed: %w", name, err)
|
|
}
|
|
return result.Messages, nil
|
|
}
|
|
|
|
// DiscoverMCPResources queries each session for its resources.
|
|
// Deduplicates by URI (first server wins).
|
|
func DiscoverMCPResources(ctx context.Context, sessions []NamedSession) ([]MCPResourceInfo, error) {
|
|
seen := make(map[string]bool)
|
|
var result []MCPResourceInfo
|
|
|
|
for _, ns := range sessions {
|
|
if ns.Session == nil {
|
|
continue
|
|
}
|
|
resourcesResult, err := ns.Session.ListResources(ctx, nil)
|
|
if err != nil {
|
|
xlog.Error("Failed to list resources from MCP server", "error", err, "server", ns.Name)
|
|
continue
|
|
}
|
|
for _, r := range resourcesResult.Resources {
|
|
if seen[r.URI] {
|
|
continue
|
|
}
|
|
seen[r.URI] = true
|
|
result = append(result, MCPResourceInfo{
|
|
ServerName: ns.Name,
|
|
Name: r.Name,
|
|
URI: r.URI,
|
|
Description: r.Description,
|
|
MIMEType: r.MIMEType,
|
|
Session: ns.Session,
|
|
})
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ReadMCPResource reads a resource by URI from the matching session.
|
|
func ReadMCPResource(ctx context.Context, resources []MCPResourceInfo, uri string) (string, error) {
|
|
var info *MCPResourceInfo
|
|
for i := range resources {
|
|
if resources[i].URI == uri {
|
|
info = &resources[i]
|
|
break
|
|
}
|
|
}
|
|
if info == nil {
|
|
return "", fmt.Errorf("MCP resource %q not found", uri)
|
|
}
|
|
|
|
result, err := info.Session.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri})
|
|
if err != nil {
|
|
return "", fmt.Errorf("MCP resource %q read failed: %w", uri, err)
|
|
}
|
|
|
|
var texts []string
|
|
for _, c := range result.Contents {
|
|
if c.Text != "" {
|
|
texts = append(texts, c.Text)
|
|
}
|
|
}
|
|
return strings.Join(texts, "\n"), nil
|
|
}
|
|
|
|
// MCPPromptFromMetadata extracts the prompt name and arguments from metadata.
|
|
// The "mcp_prompt" and "mcp_prompt_args" keys are consumed (deleted from the map).
|
|
func MCPPromptFromMetadata(metadata map[string]string) (string, map[string]string) {
|
|
name, ok := metadata["mcp_prompt"]
|
|
if !ok || name == "" {
|
|
return "", nil
|
|
}
|
|
delete(metadata, "mcp_prompt")
|
|
|
|
var args map[string]string
|
|
if raw, ok := metadata["mcp_prompt_args"]; ok && raw != "" {
|
|
json.Unmarshal([]byte(raw), &args)
|
|
delete(metadata, "mcp_prompt_args")
|
|
}
|
|
return name, args
|
|
}
|
|
|
|
// MCPResourcesFromMetadata extracts resource URIs from metadata.
|
|
// The "mcp_resources" key is consumed (deleted from the map).
|
|
func MCPResourcesFromMetadata(metadata map[string]string) []string {
|
|
raw, ok := metadata["mcp_resources"]
|
|
if !ok || raw == "" {
|
|
return nil
|
|
}
|
|
delete(metadata, "mcp_resources")
|
|
uris := strings.Split(raw, ",")
|
|
for i := range uris {
|
|
uris[i] = strings.TrimSpace(uris[i])
|
|
}
|
|
return uris
|
|
}
|
|
|
|
// PromptMessageToText extracts text from a PromptMessage's Content.
|
|
func PromptMessageToText(msg *mcp.PromptMessage) string {
|
|
if tc, ok := msg.Content.(*mcp.TextContent); ok {
|
|
return tc.Text
|
|
}
|
|
// Fallback: marshal content
|
|
data, _ := json.Marshal(msg.Content)
|
|
return string(data)
|
|
}
|
|
|
|
// CloseMCPSessions closes all MCP sessions for a given model and removes them from the cache.
|
|
// This should be called when a model is unloaded or shut down.
|
|
func CloseMCPSessions(modelName string) {
|
|
// Close sessions in the unnamed cache
|
|
cache.mu.Lock()
|
|
if sessions, ok := cache.cache[modelName]; ok {
|
|
for _, s := range sessions {
|
|
s.Close()
|
|
}
|
|
delete(cache.cache, modelName)
|
|
}
|
|
if cancel, ok := cache.cancels[modelName]; ok {
|
|
cancel()
|
|
delete(cache.cancels, modelName)
|
|
}
|
|
cache.mu.Unlock()
|
|
|
|
// Close sessions in the named cache
|
|
namedCache.mu.Lock()
|
|
if sessions, ok := namedCache.cache[modelName]; ok {
|
|
for _, ns := range sessions {
|
|
if ns.Session != nil {
|
|
if err := ns.Session.Close(); err != nil {
|
|
xlog.Debug("Failed to close MCP session", "server", ns.Name, "error", err)
|
|
}
|
|
}
|
|
}
|
|
delete(namedCache.cache, modelName)
|
|
}
|
|
if cancel, ok := namedCache.cancels[modelName]; ok {
|
|
cancel()
|
|
delete(namedCache.cancels, modelName)
|
|
}
|
|
delete(namedCache.configHashes, modelName)
|
|
namedCache.mu.Unlock()
|
|
|
|
xlog.Debug("Closed MCP sessions for model", "model", modelName)
|
|
}
|
|
|
|
// CloseAllMCPSessions closes all cached MCP sessions across all models.
|
|
// This should be called during graceful shutdown.
|
|
func CloseAllMCPSessions() {
|
|
cache.mu.Lock()
|
|
for name, sessions := range cache.cache {
|
|
for _, s := range sessions {
|
|
s.Close()
|
|
}
|
|
if cancel, ok := cache.cancels[name]; ok {
|
|
cancel()
|
|
}
|
|
}
|
|
cache.cache = make(map[string][]*mcp.ClientSession)
|
|
cache.cancels = make(map[string]context.CancelFunc)
|
|
cache.mu.Unlock()
|
|
|
|
namedCache.mu.Lock()
|
|
for name, sessions := range namedCache.cache {
|
|
for _, ns := range sessions {
|
|
if ns.Session != nil {
|
|
if err := ns.Session.Close(); err != nil {
|
|
xlog.Debug("Failed to close MCP session", "server", ns.Name, "error", err)
|
|
}
|
|
}
|
|
}
|
|
if cancel, ok := namedCache.cancels[name]; ok {
|
|
cancel()
|
|
}
|
|
}
|
|
namedCache.cache = make(map[string][]NamedSession)
|
|
namedCache.cancels = make(map[string]context.CancelFunc)
|
|
namedCache.configHashes = make(map[string][sha256.Size]byte)
|
|
namedCache.mu.Unlock()
|
|
|
|
xlog.Debug("Closed all MCP sessions")
|
|
}
|
|
|
|
func init() {
|
|
signals.RegisterGracefulTerminationHandler(func() {
|
|
CloseAllMCPSessions()
|
|
})
|
|
}
|
|
|
|
// bearerTokenRoundTripper is a custom roundtripper that injects a bearer token
|
|
// into HTTP requests
|
|
type bearerTokenRoundTripper struct {
|
|
token string
|
|
base http.RoundTripper
|
|
}
|
|
|
|
// RoundTrip implements the http.RoundTripper interface
|
|
func (rt *bearerTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
if rt.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+rt.token)
|
|
}
|
|
return rt.base.RoundTrip(req)
|
|
}
|
|
|
|
// newBearerTokenRoundTripper creates a new roundtripper that injects the given token
|
|
func newBearerTokenRoundTripper(token string, base http.RoundTripper) http.RoundTripper {
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return &bearerTokenRoundTripper{
|
|
token: token,
|
|
base: base,
|
|
}
|
|
}
|
|
|
|
// MCPContextResult holds the results of MCP prompt and resource discovery
|
|
// so callers can inject them into their message slices.
|
|
type MCPContextResult struct {
|
|
// PromptMessages are schema.Message values converted from MCP prompts,
|
|
// intended to be prepended to the conversation.
|
|
PromptMessages []schema.Message
|
|
|
|
// ResourceSuffix is the formatted text of all discovered MCP resources,
|
|
// intended to be appended to the last user message's content.
|
|
// Empty string when no resources were requested or found.
|
|
ResourceSuffix string
|
|
}
|
|
|
|
// InjectMCPContext discovers MCP prompts and resources from the given named sessions
|
|
// and returns them in a form ready for injection into any endpoint's message list.
|
|
func InjectMCPContext(
|
|
ctx context.Context,
|
|
namedSessions []NamedSession,
|
|
mcpPromptName string,
|
|
mcpPromptArgs map[string]string,
|
|
mcpResourceURIs []string,
|
|
) (*MCPContextResult, error) {
|
|
result := &MCPContextResult{}
|
|
|
|
if mcpPromptName != "" {
|
|
prompts, discErr := DiscoverMCPPrompts(ctx, namedSessions)
|
|
if discErr != nil {
|
|
xlog.Error("Failed to discover MCP prompts", "error", discErr)
|
|
} else {
|
|
promptMsgs, getErr := GetMCPPrompt(ctx, prompts, mcpPromptName, mcpPromptArgs)
|
|
if getErr != nil {
|
|
xlog.Error("Failed to get MCP prompt", "error", getErr)
|
|
} else {
|
|
for _, pm := range promptMsgs {
|
|
result.PromptMessages = append(result.PromptMessages, schema.Message{
|
|
Role: string(pm.Role),
|
|
Content: PromptMessageToText(pm),
|
|
})
|
|
}
|
|
xlog.Debug("MCP prompt discovered", "prompt", mcpPromptName, "messages", len(result.PromptMessages))
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(mcpResourceURIs) > 0 {
|
|
resources, discErr := DiscoverMCPResources(ctx, namedSessions)
|
|
if discErr != nil {
|
|
xlog.Error("Failed to discover MCP resources", "error", discErr)
|
|
} else {
|
|
var resourceTexts []string
|
|
for _, uri := range mcpResourceURIs {
|
|
content, readErr := ReadMCPResource(ctx, resources, uri)
|
|
if readErr != nil {
|
|
xlog.Error("Failed to read MCP resource", "error", readErr, "uri", uri)
|
|
continue
|
|
}
|
|
name := uri
|
|
for _, r := range resources {
|
|
if r.URI == uri {
|
|
name = r.Name
|
|
break
|
|
}
|
|
}
|
|
resourceTexts = append(resourceTexts, fmt.Sprintf("--- MCP Resource: %s ---\n%s", name, content))
|
|
}
|
|
if len(resourceTexts) > 0 {
|
|
result.ResourceSuffix = "\n\n" + strings.Join(resourceTexts, "\n\n")
|
|
xlog.Debug("MCP resources discovered", "count", len(resourceTexts))
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// AppendResourceSuffix appends the resource suffix from an MCPContextResult
|
|
// to the last message's content in the given message slice.
|
|
func AppendResourceSuffix(messages []schema.Message, suffix string) {
|
|
if suffix == "" || len(messages) == 0 {
|
|
return
|
|
}
|
|
lastIdx := len(messages) - 1
|
|
switch ct := messages[lastIdx].Content.(type) {
|
|
case string:
|
|
messages[lastIdx].Content = ct + suffix
|
|
default:
|
|
messages[lastIdx].Content = fmt.Sprintf("%v%s", ct, suffix)
|
|
}
|
|
}
|