mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-07 05:41:07 +03:00
The previous implementation buffered stderr from the native mcp-server process and only logged the full buffer content when the process exited. This prevented real-time viewing of logs from the server. This change modifies the native process startup logic (`startProcess_locked`) to use `cmd.StderrPipe()` instead of assigning `cmd.Stderr` to a buffer. A separate goroutine is now launched within the process monitoring goroutine. This new goroutine uses a `bufio.Scanner` to continuously read lines from the stderr pipe and logs them using the Navidrome logger (`log.Info`) with an `[MCP-SERVER]` prefix. This ensures logs from the native mcp-server appear in Navidrome's logs immediately as they are written. (Note: Also includes update to McpServerPath constant to point to the native binary.) Signed-off-by: Deluan <deluan@navidrome.org>
208 lines
7.0 KiB
Go
208 lines
7.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
mcp "github.com/metoro-io/mcp-golang"
|
|
"github.com/tetratelabs/wazero"
|
|
|
|
"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"
|
|
)
|
|
|
|
// Constants used by the MCP agent
|
|
const (
|
|
McpAgentName = "mcp"
|
|
initializationTimeout = 5 * time.Second
|
|
// McpServerPath defines the location of the MCP server executable or WASM module.
|
|
// McpServerPath = "./core/agents/mcp/mcp-server/mcp-server"
|
|
McpServerPath = "./core/agents/mcp/mcp-server/mcp-server.wasm"
|
|
McpToolNameGetBio = "get_artist_biography"
|
|
McpToolNameGetURL = "get_artist_url"
|
|
)
|
|
|
|
// mcpClient interface matching the methods used from mcp.Client.
|
|
type mcpClient interface {
|
|
Initialize(ctx context.Context) (*mcp.InitializeResponse, error)
|
|
CallTool(ctx context.Context, toolName string, args any) (*mcp.ToolResponse, error)
|
|
}
|
|
|
|
// mcpImplementation defines the common interface for both native and WASM MCP agents.
|
|
// This allows the main MCPAgent to delegate calls without knowing the underlying type.
|
|
type mcpImplementation interface {
|
|
Close() error // For cleaning up resources associated with this specific implementation.
|
|
|
|
// callMCPTool is the core method implemented differently by native/wasm
|
|
callMCPTool(ctx context.Context, toolName string, args any) (string, error)
|
|
}
|
|
|
|
// MCPAgent is the public-facing agent registered with Navidrome.
|
|
// It acts as a wrapper around the actual implementation (native or WASM).
|
|
type MCPAgent struct {
|
|
// No mutex needed here if impl is set once at construction
|
|
// and the implementation handles its own internal state synchronization.
|
|
impl mcpImplementation
|
|
|
|
// Shared Wazero resources (runtime, cache) are managed externally
|
|
// and closed separately, likely during application shutdown.
|
|
}
|
|
|
|
// mcpConstructor creates the appropriate MCP implementation (native or WASM)
|
|
// and wraps it in the MCPAgent.
|
|
func mcpConstructor(ds model.DataStore) agents.Interface {
|
|
if _, err := os.Stat(McpServerPath); os.IsNotExist(err) {
|
|
log.Warn("MCP server executable/WASM not found, disabling agent", "path", McpServerPath, "error", err)
|
|
return nil
|
|
}
|
|
|
|
var agentImpl mcpImplementation
|
|
var err error
|
|
|
|
if strings.HasSuffix(McpServerPath, ".wasm") {
|
|
log.Info("Configuring MCP agent for WASM execution", "path", McpServerPath)
|
|
ctx := context.Background()
|
|
|
|
// Setup Shared Wazero Resources
|
|
var cache wazero.CompilationCache
|
|
cacheDir := filepath.Join(conf.Server.DataFolder, "cache", "wazero")
|
|
if errMkdir := os.MkdirAll(cacheDir, 0755); errMkdir != nil {
|
|
log.Error(ctx, "Failed to create Wazero cache directory, WASM caching disabled", "path", cacheDir, "error", errMkdir)
|
|
} 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)
|
|
cache = nil
|
|
}
|
|
}
|
|
|
|
runtimeConfig := wazero.NewRuntimeConfig()
|
|
if cache != nil {
|
|
runtimeConfig = runtimeConfig.WithCompilationCache(cache)
|
|
}
|
|
|
|
runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
|
|
|
|
if err = registerHostFunctions(ctx, runtime); err != nil {
|
|
_ = runtime.Close(ctx)
|
|
if cache != nil {
|
|
_ = cache.Close(ctx)
|
|
}
|
|
return nil // Fatal error: Host functions required
|
|
}
|
|
|
|
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)
|
|
_ = runtime.Close(ctx)
|
|
if cache != nil {
|
|
_ = cache.Close(ctx)
|
|
}
|
|
return nil // Fatal error: WASI required
|
|
}
|
|
|
|
// Compile the module
|
|
log.Debug(ctx, "Pre-compiling WASM module...", "path", McpServerPath)
|
|
wasmBytes, err := os.ReadFile(McpServerPath)
|
|
if err != nil {
|
|
log.Error(ctx, "Failed to read WASM module file, disabling agent", "path", McpServerPath, "error", err)
|
|
_ = runtime.Close(ctx)
|
|
if cache != nil {
|
|
_ = cache.Close(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
compiledModule, err := runtime.CompileModule(ctx, wasmBytes)
|
|
if err != nil {
|
|
log.Error(ctx, "Failed to pre-compile WASM module, disabling agent", "path", McpServerPath, "error", err)
|
|
_ = runtime.Close(ctx)
|
|
if cache != nil {
|
|
_ = cache.Close(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
agentImpl = newMCPWasm(runtime, cache, compiledModule)
|
|
log.Info(ctx, "Shared Wazero runtime, WASI, cache, host functions initialized, and module pre-compiled for MCP agent")
|
|
|
|
} else {
|
|
log.Info("Configuring MCP agent for native execution", "path", McpServerPath)
|
|
agentImpl = newMCPNative()
|
|
}
|
|
|
|
log.Info("MCP Agent implementation created successfully")
|
|
return &MCPAgent{impl: agentImpl}
|
|
}
|
|
|
|
// NewAgentForTesting is a constructor specifically for tests.
|
|
// It creates the appropriate implementation based on McpServerPath
|
|
// and injects a mock mcpClient into its ClientOverride field.
|
|
func NewAgentForTesting(mockClient mcpClient) agents.Interface {
|
|
// We need to replicate the logic from mcpConstructor to determine
|
|
// the implementation type, but without actually starting processes.
|
|
|
|
var agentImpl mcpImplementation
|
|
|
|
if strings.HasSuffix(McpServerPath, ".wasm") {
|
|
// For WASM testing, we might not need the full runtime setup,
|
|
// just the struct. Pass nil for shared resources for now.
|
|
// We rely on the mockClient being used before any real WASM interaction.
|
|
wasmImpl := newMCPWasm(nil, nil, nil)
|
|
wasmImpl.ClientOverride = mockClient
|
|
agentImpl = wasmImpl
|
|
} else {
|
|
nativeImpl := newMCPNative()
|
|
nativeImpl.ClientOverride = mockClient
|
|
agentImpl = nativeImpl
|
|
}
|
|
|
|
return &MCPAgent{impl: agentImpl}
|
|
}
|
|
|
|
func (a *MCPAgent) AgentName() string {
|
|
return McpAgentName
|
|
}
|
|
|
|
func (a *MCPAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
|
if a.impl == nil {
|
|
return "", errors.New("MCP agent implementation is nil")
|
|
}
|
|
// Construct args and call the implementation's specific tool caller
|
|
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
|
|
return a.impl.callMCPTool(ctx, McpToolNameGetBio, args)
|
|
}
|
|
|
|
func (a *MCPAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
if a.impl == nil {
|
|
return "", errors.New("MCP agent implementation is nil")
|
|
}
|
|
// Construct args and call the implementation's specific tool caller
|
|
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
|
|
return a.impl.callMCPTool(ctx, McpToolNameGetURL, args)
|
|
}
|
|
|
|
// Note: A Close method on MCPAgent itself isn't part of agents.Interface.
|
|
// Cleanup of the specific implementation happens via impl.Close().
|
|
// Cleanup of shared Wazero resources needs separate handling (e.g., on app shutdown).
|
|
|
|
// ArtistArgs defines the structure for MCP tool arguments requiring artist info.
|
|
type ArtistArgs struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Mbid string `json:"mbid,omitempty"`
|
|
}
|
|
|
|
var _ agents.ArtistBiographyRetriever = (*MCPAgent)(nil)
|
|
var _ agents.ArtistURLRetriever = (*MCPAgent)(nil)
|
|
|
|
func init() {
|
|
agents.Register(McpAgentName, mcpConstructor)
|
|
}
|