mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-07 22:01:08 +03:00
- Moved the native process handling logic from mcp_agent.go to a new file mcp_process_native.go for better organization. - Introduced a new file mcp_host_functions.go to define and register host functions for WASM modules. - Updated MCPAgent to ensure proper initialization and cleanup of both native and WASM processes, enhancing code clarity and maintainability. - Added comments to clarify the purpose of changes and ensure future developers understand the structure.
509 lines
19 KiB
Go
509 lines
19 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
mcp "github.com/metoro-io/mcp-golang"
|
|
"github.com/metoro-io/mcp-golang/transport/stdio"
|
|
"github.com/tetratelabs/wazero"
|
|
"github.com/tetratelabs/wazero/api"
|
|
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
)
|
|
|
|
// Exported constants for testing
|
|
const (
|
|
McpAgentName = "mcp"
|
|
McpServerPath = "/Users/deluan/Development/navidrome/plugins-mcp/mcp-server.wasm"
|
|
McpToolNameGetBio = "get_artist_biography"
|
|
McpToolNameGetURL = "get_artist_url"
|
|
initializationTimeout = 10 * time.Second
|
|
)
|
|
|
|
// mcpClient interface matching the methods used from mcp.Client
|
|
// Allows for mocking in tests.
|
|
type mcpClient interface {
|
|
Initialize(ctx context.Context) (*mcp.InitializeResponse, error)
|
|
CallTool(ctx context.Context, toolName string, args any) (*mcp.ToolResponse, error)
|
|
}
|
|
|
|
// MCPAgent interacts with an external MCP server for metadata retrieval.
|
|
// It keeps a single instance of the server process running and attempts restart on failure.
|
|
// Supports both native executables and WASM modules (via Wazero).
|
|
type MCPAgent struct {
|
|
mu sync.Mutex
|
|
|
|
// Runtime state
|
|
stdin io.WriteCloser
|
|
client mcpClient
|
|
cmd *exec.Cmd // Stores the native process command
|
|
wasmModule api.Module // Stores the instantiated WASM module
|
|
|
|
// Shared Wazero resources (created once)
|
|
wasmRuntime api.Closer // Shared Wazero Runtime (implements Close(context.Context))
|
|
wasmCache wazero.CompilationCache // Shared Compilation Cache (implements Close(context.Context))
|
|
|
|
// WASM resources per instance (cleaned up by monitoring goroutine)
|
|
wasmCompiled api.Closer // Stores the compiled WASM module for closing
|
|
|
|
// ClientOverride allows injecting a mock client for testing.
|
|
// This field should ONLY be set in test code.
|
|
ClientOverride mcpClient
|
|
}
|
|
|
|
func mcpConstructor(ds model.DataStore) agents.Interface {
|
|
// Check if the MCP server executable exists
|
|
if _, err := os.Stat(McpServerPath); os.IsNotExist(err) {
|
|
log.Warn("MCP server executable/WASM not found, disabling agent", "path", McpServerPath, "error", err)
|
|
return nil
|
|
}
|
|
|
|
a := &MCPAgent{}
|
|
|
|
// If it's a WASM path, pre-initialize the shared Wazero runtime, cache, and host functions.
|
|
if strings.HasSuffix(McpServerPath, ".wasm") {
|
|
ctx := context.Background() // Use background context for setup
|
|
cacheDir := filepath.Join(conf.Server.DataFolder, "cache", "wazero")
|
|
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
|
log.Error(ctx, "Failed to create Wazero cache directory, WASM caching disabled", "path", cacheDir, "error", err)
|
|
} else {
|
|
cache, err := wazero.NewCompilationCacheWithDir(cacheDir)
|
|
if err != nil {
|
|
log.Error(ctx, "Failed to create Wazero compilation cache, WASM caching disabled", "path", cacheDir, "error", err)
|
|
} else {
|
|
a.wasmCache = cache
|
|
log.Info(ctx, "Wazero compilation cache enabled", "path", cacheDir)
|
|
}
|
|
}
|
|
|
|
// Create runtime config, adding cache if it was created successfully
|
|
runtimeConfig := wazero.NewRuntimeConfig()
|
|
if a.wasmCache != nil {
|
|
runtimeConfig = runtimeConfig.WithCompilationCache(a.wasmCache)
|
|
}
|
|
|
|
// Create the shared runtime
|
|
runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
|
|
|
|
// --- Register Host Functions --- Must happen BEFORE WASI instantiation if WASI needs them?
|
|
// Actually, WASI instantiation is separate from host func instantiation.
|
|
if err := registerHostFunctions(ctx, runtime); err != nil {
|
|
// Error already logged by registerHostFunctions
|
|
_ = runtime.Close(ctx)
|
|
if a.wasmCache != nil {
|
|
_ = a.wasmCache.Close(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
// --- End Host Function Registration ---
|
|
|
|
a.wasmRuntime = runtime // Store the runtime closer
|
|
|
|
// Instantiate WASI onto the shared runtime. If this fails, the agent is unusable for WASM.
|
|
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
|
|
log.Error(ctx, "Failed to instantiate WASI on shared Wazero runtime, MCP WASM agent disabled", "error", err)
|
|
// Close runtime and cache if WASI fails
|
|
_ = runtime.Close(ctx)
|
|
if a.wasmCache != nil {
|
|
_ = a.wasmCache.Close(ctx)
|
|
}
|
|
return nil // Cannot proceed if WASI fails
|
|
}
|
|
log.Info(ctx, "Shared Wazero runtime, WASI, and host functions initialized for MCP agent")
|
|
}
|
|
|
|
log.Info("MCP Agent created, server will be started on first request", "serverPath", McpServerPath)
|
|
return a
|
|
}
|
|
|
|
func (a *MCPAgent) AgentName() string {
|
|
return McpAgentName
|
|
}
|
|
|
|
// cleanup closes existing resources (stdin, server process/module).
|
|
// MUST be called while holding the mutex.
|
|
func (a *MCPAgent) cleanup() {
|
|
log.Debug(context.Background(), "Cleaning up MCP agent instance resources...")
|
|
if a.stdin != nil {
|
|
_ = a.stdin.Close()
|
|
a.stdin = nil
|
|
}
|
|
// Clean up native process if it exists
|
|
if a.cmd != nil && a.cmd.Process != nil {
|
|
log.Debug(context.Background(), "Killing native MCP process", "pid", a.cmd.Process.Pid)
|
|
if err := a.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
|
log.Error(context.Background(), "Failed to kill native process", "pid", a.cmd.Process.Pid, "error", err)
|
|
}
|
|
// Wait might return an error if already killed/exited, ignore it.
|
|
_ = a.cmd.Wait()
|
|
a.cmd = nil
|
|
}
|
|
// Clean up WASM module instance if it exists
|
|
if a.wasmModule != nil {
|
|
log.Debug(context.Background(), "Closing WASM module instance")
|
|
ctxClose, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
if err := a.wasmModule.Close(ctxClose); err != nil {
|
|
// Ignore context deadline exceeded as it means close was successful but slow
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
log.Error(context.Background(), "Failed to close WASM module instance", "error", err)
|
|
}
|
|
}
|
|
cancel()
|
|
a.wasmModule = nil
|
|
}
|
|
// Clean up compiled module ref for this instance
|
|
if a.wasmCompiled != nil {
|
|
log.Debug(context.Background(), "Closing compiled WASM module ref")
|
|
// Use background context, Close should be quick
|
|
if err := a.wasmCompiled.Close(context.Background()); err != nil {
|
|
log.Error(context.Background(), "Failed to close compiled WASM module ref", "error", err)
|
|
}
|
|
a.wasmCompiled = nil
|
|
}
|
|
|
|
// DO NOT close shared wasmRuntime or wasmCache here.
|
|
|
|
// Mark client as invalid
|
|
a.client = nil
|
|
}
|
|
|
|
// ensureClientInitialized starts the MCP server process (native or WASM)
|
|
// and initializes the client if needed. Attempts restart on failure.
|
|
func (a *MCPAgent) ensureClientInitialized(ctx context.Context) (err error) {
|
|
// --- Use override if provided (for testing) ---
|
|
if a.ClientOverride != nil {
|
|
a.mu.Lock()
|
|
if a.client == nil {
|
|
a.client = a.ClientOverride
|
|
log.Debug(ctx, "Using provided MCP client override for testing")
|
|
}
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// --- Check if already initialized (critical section) ---
|
|
a.mu.Lock()
|
|
if a.client != nil {
|
|
a.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// --- Client is nil, proceed with initialization *while holding the lock* ---
|
|
defer a.mu.Unlock()
|
|
|
|
log.Info(ctx, "Initializing MCP client and starting/restarting server process...", "serverPath", McpServerPath)
|
|
|
|
// Clean up any old resources *before* starting new ones
|
|
a.cleanup()
|
|
|
|
var hostStdinWriter io.WriteCloser
|
|
var hostStdoutReader io.ReadCloser
|
|
var startErr error
|
|
var isWasm bool
|
|
|
|
if strings.HasSuffix(McpServerPath, ".wasm") {
|
|
isWasm = true
|
|
// Check if shared runtime exists (it should if constructor succeeded for WASM)
|
|
if a.wasmRuntime == nil {
|
|
startErr = errors.New("shared Wazero runtime not initialized")
|
|
} else {
|
|
var mod api.Module
|
|
var compiled api.Closer // Store compiled module ref per instance
|
|
hostStdinWriter, hostStdoutReader, mod, compiled, startErr = a.startWasmModule(ctx)
|
|
if startErr == nil {
|
|
a.wasmModule = mod
|
|
a.wasmCompiled = compiled // Store compiled ref for cleanup
|
|
} else {
|
|
// Ensure potential partial resources from startWasmModule are closed on error
|
|
// startWasmModule's deferred cleanup should handle pipes and compiled module.
|
|
// Mod instance might need closing if instantiation partially succeeded before erroring.
|
|
if mod != nil {
|
|
_ = mod.Close(ctx)
|
|
}
|
|
// Do not close shared runtime here
|
|
}
|
|
}
|
|
} else {
|
|
isWasm = false
|
|
var nativeCmd *exec.Cmd
|
|
hostStdinWriter, hostStdoutReader, nativeCmd, startErr = a.startNativeProcess(ctx)
|
|
if startErr == nil {
|
|
a.cmd = nativeCmd
|
|
}
|
|
}
|
|
|
|
if startErr != nil {
|
|
log.Error(ctx, "Failed to start MCP server process/module", "isWasm", isWasm, "error", startErr)
|
|
// Ensure pipes are closed if start failed (start functions might have deferred closes, but belt-and-suspenders)
|
|
if hostStdinWriter != nil {
|
|
_ = hostStdinWriter.Close()
|
|
}
|
|
if hostStdoutReader != nil {
|
|
_ = hostStdoutReader.Close()
|
|
}
|
|
// a.cleanup() was already called, specific resources (cmd/wasmModule) are nil
|
|
return fmt.Errorf("failed to start MCP server: %w", startErr)
|
|
}
|
|
|
|
// --- Initialize MCP client --- (Ensure stdio transport import)
|
|
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
|
|
clientImpl := mcp.NewClient(transport)
|
|
|
|
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
|
|
defer cancel()
|
|
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
|
|
err = fmt.Errorf("failed to initialize MCP client: %w", initErr)
|
|
log.Error(ctx, "MCP client initialization failed after process/module start", "isWasm", isWasm, "error", err)
|
|
// Cleanup the newly started process/module and pipes as init failed
|
|
a.cleanup() // This should handle cmd/wasmModule
|
|
// Close the pipes directly as cleanup() doesn't know about them
|
|
if hostStdinWriter != nil {
|
|
_ = hostStdinWriter.Close()
|
|
}
|
|
if hostStdoutReader != nil {
|
|
_ = hostStdoutReader.Close()
|
|
}
|
|
return err // defer mu.Unlock() will run
|
|
}
|
|
|
|
// --- Initialization successful, update agent state (still holding lock) ---
|
|
a.stdin = hostStdinWriter // This is the pipe the agent writes to
|
|
a.client = clientImpl
|
|
// cmd or wasmModule/Runtime/Compiled are already set by the start helpers
|
|
|
|
log.Info(ctx, "MCP client initialized successfully", "isWasm", isWasm)
|
|
// defer mu.Unlock() runs here
|
|
return nil // Success
|
|
}
|
|
|
|
// startNativeProcess was moved to mcp_process_native.go
|
|
|
|
// startWasmModule loads and starts the MCP server as a WASM module using the agent's shared Wazero runtime.
|
|
func (a *MCPAgent) startWasmModule(ctx context.Context) (hostStdinWriter io.WriteCloser, hostStdoutReader io.ReadCloser, mod api.Module, compiled api.Closer, err error) {
|
|
log.Debug(ctx, "Loading WASM MCP server module", "path", McpServerPath)
|
|
wasmBytes, err := os.ReadFile(McpServerPath)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("read wasm file: %w", err)
|
|
}
|
|
|
|
// Create pipes for stdio redirection
|
|
wasmStdinReader, hostStdinWriter, err := os.Pipe()
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("wasm stdin pipe: %w", err)
|
|
}
|
|
// Defer close pipes on error exit
|
|
defer func() {
|
|
if err != nil {
|
|
_ = wasmStdinReader.Close()
|
|
_ = hostStdinWriter.Close()
|
|
// hostStdoutReader and wasmStdoutWriter handled below
|
|
}
|
|
}()
|
|
|
|
hostStdoutReader, wasmStdoutWriter, err := os.Pipe()
|
|
if err != nil {
|
|
_ = wasmStdinReader.Close() // Close previous pipe
|
|
_ = hostStdinWriter.Close() // Close previous pipe
|
|
return nil, nil, nil, nil, fmt.Errorf("wasm stdout pipe: %w", err)
|
|
}
|
|
// Defer close pipes on error exit
|
|
defer func() {
|
|
if err != nil {
|
|
_ = hostStdoutReader.Close()
|
|
_ = wasmStdoutWriter.Close()
|
|
}
|
|
}()
|
|
|
|
// Use the SHARDED runtime from the agent struct
|
|
runtime, ok := a.wasmRuntime.(wazero.Runtime)
|
|
if !ok || runtime == nil {
|
|
return nil, nil, nil, nil, errors.New("wasmRuntime is not initialized or not a wazero.Runtime")
|
|
}
|
|
// WASI is already instantiated on the shared runtime
|
|
|
|
config := wazero.NewModuleConfig().
|
|
WithStdin(wasmStdinReader).
|
|
WithStdout(wasmStdoutWriter).
|
|
WithStderr(os.Stderr).
|
|
WithArgs(McpServerPath).
|
|
// Grant access to the host filesystem. Needed for DNS lookup (/etc/resolv.conf)
|
|
// and potentially other operations depending on the module.
|
|
// SECURITY: This grants broad access; consider more restricted FS if needed.
|
|
WithFS(os.DirFS("/"))
|
|
|
|
log.Debug(ctx, "Compiling WASM module (using cache if enabled)...")
|
|
// Compile module using the shared runtime (which uses the configured cache)
|
|
compiledModule, err := runtime.CompileModule(ctx, wasmBytes)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, fmt.Errorf("compile wasm module: %w", err)
|
|
}
|
|
// Defer closing compiled module in case of errors later in this function.
|
|
// Caller (ensureClientInitialized) is responsible for closing on success.
|
|
shouldCloseCompiledOnError := true
|
|
defer func() {
|
|
if shouldCloseCompiledOnError && compiledModule != nil {
|
|
_ = compiledModule.Close(context.Background())
|
|
}
|
|
}()
|
|
|
|
log.Info(ctx, "Instantiating WASM module (will run _start)...")
|
|
var instance api.Module
|
|
instanceErrChan := make(chan error, 1)
|
|
go func() {
|
|
var instantiateErr error
|
|
// Use context.Background() for the module's main execution context
|
|
instance, instantiateErr = runtime.InstantiateModule(context.Background(), compiledModule, config)
|
|
instanceErrChan <- instantiateErr
|
|
}()
|
|
|
|
// Wait briefly for immediate instantiation errors
|
|
select {
|
|
case instantiateErr := <-instanceErrChan:
|
|
if instantiateErr != nil {
|
|
log.Error(ctx, "Failed to instantiate WASM module", "error", instantiateErr)
|
|
// compiledModule closed by defer
|
|
// pipes closed by defer
|
|
return nil, nil, nil, nil, fmt.Errorf("instantiate wasm module: %w", instantiateErr)
|
|
}
|
|
// If instantiateErr is nil here, the module exited immediately without error. Log it.
|
|
log.Warn(ctx, "WASM module instantiation returned (exited?) immediately without error.")
|
|
// Proceed to start monitoring, but return the (already closed) instance
|
|
// Pipes will be closed by the successful return path.
|
|
case <-time.After(2 * time.Second):
|
|
log.Debug(ctx, "WASM module instantiation likely blocking (server running), proceeding...")
|
|
}
|
|
|
|
// Start a monitoring goroutine for WASM module exit/error
|
|
go func(modToMonitor api.Module, compiledToClose api.Closer, errChan chan error) {
|
|
// This will block until the instance created by InstantiateModule exits or errors.
|
|
instantiateErr := <-errChan
|
|
|
|
a.mu.Lock()
|
|
log.Warn("WASM module exited/errored", "error", instantiateErr)
|
|
// Check if the module currently stored in the agent is the one we were monitoring.
|
|
// Use the central cleanup which handles nil checks.
|
|
if a.wasmModule == modToMonitor {
|
|
a.cleanup() // This will close the module instance and compiled ref
|
|
log.Info("MCP agent state cleaned up after WASM module exit/error")
|
|
} else {
|
|
// This case can happen if cleanup was called manually or if a new instance
|
|
// was started before the old one finished exiting.
|
|
log.Debug("WASM module exited, but state already updated or module mismatch. Explicitly closing compiled ref if needed.")
|
|
// Manually close the compiled module ref associated with this specific instance
|
|
// as cleanup() won't if a.wasmModule doesn't match or is nil.
|
|
if compiledToClose != nil {
|
|
_ = compiledToClose.Close(context.Background())
|
|
}
|
|
}
|
|
a.mu.Unlock()
|
|
}(instance, compiledModule, instanceErrChan) // Pass necessary refs
|
|
|
|
// Success: prevent deferred cleanup of compiled module, return resources needed by caller
|
|
shouldCloseCompiledOnError = false
|
|
return hostStdinWriter, hostStdoutReader, instance, compiledModule, nil // Return instance and compiled module
|
|
}
|
|
|
|
// ArtistArgs defines the structure for MCP tool arguments requiring artist info.
|
|
// Exported for use in tests.
|
|
type ArtistArgs struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Mbid string `json:"mbid,omitempty"`
|
|
}
|
|
|
|
// callMCPTool is a helper to perform the common steps of calling an MCP tool.
|
|
func (a *MCPAgent) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
|
|
// Ensure the client is initialized and the server is running (attempts restart if needed)
|
|
if err := a.ensureClientInitialized(ctx); err != nil {
|
|
log.Error(ctx, "MCP agent initialization/restart failed, cannot call tool", "tool", toolName, "error", err)
|
|
return "", fmt.Errorf("MCP agent not ready: %w", err)
|
|
}
|
|
|
|
// Lock to safely access the shared client resource
|
|
a.mu.Lock()
|
|
|
|
// Check if the client is valid *after* ensuring initialization and acquiring lock.
|
|
if a.client == nil {
|
|
a.mu.Unlock() // Release lock before returning error
|
|
log.Error(ctx, "MCP client became invalid after initialization check (server process likely died)", "tool", toolName)
|
|
return "", fmt.Errorf("MCP agent process is not running")
|
|
}
|
|
|
|
// Keep a reference to the client while locked
|
|
currentClient := a.client
|
|
a.mu.Unlock() // *Release lock before* making the potentially blocking MCP call
|
|
|
|
// Call the tool using the client reference
|
|
log.Debug(ctx, "Calling MCP tool", "tool", toolName, "args", args)
|
|
response, err := currentClient.CallTool(ctx, toolName, args)
|
|
if err != nil {
|
|
// Handle potential pipe closures or other communication errors
|
|
log.Error(ctx, "Failed to call MCP tool", "tool", toolName, "error", err)
|
|
// Check if the error indicates a broken pipe, suggesting the server died
|
|
if errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "broken pipe") || strings.Contains(err.Error(), "EOF") {
|
|
log.Warn(ctx, "MCP tool call failed, possibly due to server process exit. State will be reset.", "tool", toolName)
|
|
// State reset is handled by the monitoring goroutine, just return error
|
|
return "", fmt.Errorf("MCP agent process communication error: %w", err)
|
|
}
|
|
return "", fmt.Errorf("failed to call MCP tool '%s': %w", toolName, err)
|
|
}
|
|
|
|
// Process the response
|
|
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
|
|
log.Warn(ctx, "MCP tool returned empty or invalid response structure", "tool", toolName)
|
|
return "", agents.ErrNotFound
|
|
}
|
|
|
|
// Check if the returned text content itself indicates an error from the MCP tool
|
|
resultText := response.Content[0].TextContent.Text
|
|
if strings.HasPrefix(resultText, "handler returned an error:") {
|
|
log.Warn(ctx, "MCP tool returned an error message in its response", "tool", toolName, "mcpError", resultText)
|
|
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
|
|
}
|
|
|
|
// Return the successful text content
|
|
log.Debug(ctx, "Received response from MCP agent", "tool", toolName, "length", len(resultText))
|
|
return resultText, nil
|
|
}
|
|
|
|
// GetArtistBiography retrieves the artist biography by calling the external MCP server.
|
|
func (a *MCPAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
|
args := ArtistArgs{
|
|
ID: id,
|
|
Name: name,
|
|
Mbid: mbid,
|
|
}
|
|
return a.callMCPTool(ctx, McpToolNameGetBio, args)
|
|
}
|
|
|
|
// GetArtistURL retrieves the artist URL by calling the external MCP server.
|
|
func (a *MCPAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
args := ArtistArgs{
|
|
ID: id,
|
|
Name: name,
|
|
Mbid: mbid,
|
|
}
|
|
return a.callMCPTool(ctx, McpToolNameGetURL, args)
|
|
}
|
|
|
|
// Ensure MCPAgent implements the required interfaces
|
|
var _ agents.ArtistBiographyRetriever = (*MCPAgent)(nil)
|
|
var _ agents.ArtistURLRetriever = (*MCPAgent)(nil)
|
|
|
|
func init() {
|
|
agents.Register(McpAgentName, mcpConstructor)
|
|
}
|