mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-17 18:22:50 -04:00
fix: Show MCP connection errors in the UI (#11495)
* fix(mcp): surface configured server failures Keep model-configured MCP servers visible when discovery or connection setup fails, propagate status through distributed discovery, and let the Chat UI show actionable errors while retrying unavailable servers. Add model-editor metadata for remote and stdio configuration and document the expected format, deployment networking boundary, and alternate MCP scopes. Assisted-by: Codex:gpt-5 Ordino golangci-lint Signed-off-by: Richard Palethorpe <io@richiejp.com> * build(compose): match CUDA development image Configure the API image with the cublas, CUDA 13, auth-tagged build settings used by the local development Makefile invocation, including the 24-way Docker build. Assisted-by: Codex:gpt-5 Ordino Signed-off-by: Richard Palethorpe <io@richiejp.com> * revert: keep host build settings out of compose The CUDA development deployment is managed from ~/docker/localai, not the repository example Compose file. Restore the generic example and keep machine-specific build settings in the host deployment. Assisted-by: Codex:gpt-5 Ordino Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(docker): exclude local agent artifacts Keep Claude worktrees and locally installed verification tools out of the Docker build context. These host-only directories added roughly 1.9 GB to every root image build. Assisted-by: Codex:gpt-5 Ordino Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
1 parent
b2ff2b5477
commit
5c63969760
16 files changed
+467
-60
No files matched your search
@@ -59,7 +59,9 @@ backend/rust/*/target
|
||||
backend-images
|
||||
local-backends
|
||||
local-ai
|
||||
.claude
|
||||
.crush
|
||||
.tools
|
||||
protoc
|
||||
tests
|
||||
|
||||
|
||||
@@ -351,6 +351,7 @@ func handleMCPDiscoveryRequest(data []byte, reply func([]byte)) {
|
||||
Tools: s.Tools,
|
||||
Prompts: s.Prompts,
|
||||
Resources: s.Resources,
|
||||
Error: s.Error,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -752,6 +752,24 @@ func DefaultRegistry() map[string]FieldMetaOverride {
|
||||
Order: 72,
|
||||
},
|
||||
|
||||
// --- MCP ---
|
||||
"mcp.remote": {
|
||||
Section: "mcp",
|
||||
Label: "Remote MCP Servers",
|
||||
Description: "YAML or JSON string containing an mcpServers map of named remote Streamable HTTP endpoints. Each entry requires url; token optionally enables Bearer authentication.",
|
||||
Component: "code-editor",
|
||||
Language: "yaml",
|
||||
Order: 130,
|
||||
},
|
||||
"mcp.stdio": {
|
||||
Section: "mcp",
|
||||
Label: "MCP STDIO Servers",
|
||||
Description: "YAML or JSON string containing an mcpServers map of named local commands. Each entry requires command and may include args and env.",
|
||||
Component: "code-editor",
|
||||
Language: "yaml",
|
||||
Order: 131,
|
||||
},
|
||||
|
||||
// --- TTS ---
|
||||
"tts.voice_cloning": {
|
||||
Section: "tts",
|
||||
|
||||
@@ -185,8 +185,6 @@ var grandfatheredUnregistered = []string{
|
||||
"lora_scales",
|
||||
"main_gpu",
|
||||
"max_model_len",
|
||||
"mcp.remote",
|
||||
"mcp.stdio",
|
||||
"mirostat",
|
||||
"mirostat_eta",
|
||||
"mirostat_tau",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package meta_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/config/meta"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -26,3 +29,30 @@ var _ = Describe("alias field metadata", func() {
|
||||
Expect(found).To(BeTrue(), "DefaultSections should include an alias section")
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("MCP field metadata", func() {
|
||||
var fields map[string]meta.FieldMeta
|
||||
|
||||
BeforeEach(func() {
|
||||
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
|
||||
fields = make(map[string]meta.FieldMeta, len(md.Fields))
|
||||
for _, field := range md.Fields {
|
||||
fields[field.Path] = field
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("registers embedded MCP configuration as YAML code",
|
||||
func(path, label, transportDetail string) {
|
||||
f, ok := fields[path]
|
||||
Expect(ok).To(BeTrue(), "%s should be present in generated metadata", path)
|
||||
Expect(f.Section).To(Equal("mcp"))
|
||||
Expect(f.Label).To(Equal(label))
|
||||
Expect(f.Description).To(ContainSubstring("mcpServers"))
|
||||
Expect(f.Description).To(ContainSubstring(transportDetail))
|
||||
Expect(f.Component).To(Equal("code-editor"))
|
||||
Expect(f.Language).To(Equal("yaml"))
|
||||
},
|
||||
Entry("remote servers", "mcp.remote", "Remote MCP Servers", "Streamable HTTP"),
|
||||
Entry("stdio servers", "mcp.stdio", "MCP STDIO Servers", "local commands"),
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ package localai
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
@@ -37,7 +38,11 @@ func MCPServersEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applicat
|
||||
|
||||
remote, stdio, err := cfg.MCP.MCPConfigFromYAML()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse MCP config: %w", err)
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]any{
|
||||
"model": modelName,
|
||||
"servers": []any{},
|
||||
"error": fmt.Sprintf("failed to parse MCP config: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// In distributed mode, route discovery through NATS to an agent worker
|
||||
@@ -45,7 +50,10 @@ func MCPServersEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applicat
|
||||
if natsClient != nil {
|
||||
resp, err := mcpTools.DiscoverMCPToolsRemote(c.Request().Context(), natsClient, cfg.Name, remote, stdio)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote MCP discovery failed: %w", err)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"model": modelName,
|
||||
"servers": unavailableMCPServers(remote, stdio, fmt.Sprintf("remote discovery failed: %v", err)),
|
||||
})
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"model": modelName,
|
||||
@@ -88,14 +96,21 @@ func MCPServersEndpointFromMiddleware(natsClient mcpTools.MCPNATSClient) echo.Ha
|
||||
|
||||
remote, stdio, err := cfg.MCP.MCPConfigFromYAML()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse MCP config: %w", err)
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]any{
|
||||
"model": cfg.Name,
|
||||
"servers": []any{},
|
||||
"error": fmt.Sprintf("failed to parse MCP config: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// In distributed mode, route discovery through NATS to an agent worker.
|
||||
if natsClient != nil {
|
||||
resp, err := mcpTools.DiscoverMCPToolsRemote(c.Request().Context(), natsClient, cfg.Name, remote, stdio)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remote MCP discovery failed: %w", err)
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"model": cfg.Name,
|
||||
"servers": unavailableMCPServers(remote, stdio, fmt.Sprintf("remote discovery failed: %v", err)),
|
||||
})
|
||||
}
|
||||
return c.JSON(200, map[string]any{
|
||||
"model": cfg.Name,
|
||||
@@ -119,3 +134,24 @@ func MCPServersEndpointFromMiddleware(natsClient mcpTools.MCPNATSClient) echo.Ha
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func unavailableMCPServers(
|
||||
remote config.MCPGenericConfig[config.MCPRemoteServers],
|
||||
stdio config.MCPGenericConfig[config.MCPSTDIOServers],
|
||||
errMessage string,
|
||||
) []mcpTools.MCPServerInfo {
|
||||
servers := make([]mcpTools.MCPServerInfo, 0, len(remote.Servers)+len(stdio.Servers))
|
||||
for name := range remote.Servers {
|
||||
servers = append(servers, mcpTools.MCPServerInfo{Name: name, Type: "remote", Tools: []string{}, Error: errMessage})
|
||||
}
|
||||
for name := range stdio.Servers {
|
||||
servers = append(servers, mcpTools.MCPServerInfo{Name: name, Type: "stdio", Tools: []string{}, Error: errMessage})
|
||||
}
|
||||
sort.Slice(servers, func(i, j int) bool {
|
||||
if servers[i].Type != servers[j].Type {
|
||||
return servers[i].Type < servers[j].Type
|
||||
}
|
||||
return servers[i].Name < servers[j].Name
|
||||
})
|
||||
return servers
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package localai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestUnavailableMCPServersIncludesEveryConfiguredServer(t *testing.T) {
|
||||
g := gomega.NewWithT(t)
|
||||
remote := config.MCPGenericConfig[config.MCPRemoteServers]{
|
||||
Servers: config.MCPRemoteServers{
|
||||
"zeta": {URL: "http://zeta/mcp"},
|
||||
"alpha": {URL: "http://alpha/mcp"},
|
||||
},
|
||||
}
|
||||
stdio := config.MCPGenericConfig[config.MCPSTDIOServers]{
|
||||
Servers: config.MCPSTDIOServers{
|
||||
"worker": {Command: "mcp-worker"},
|
||||
},
|
||||
}
|
||||
|
||||
servers := unavailableMCPServers(remote, stdio, "remote discovery failed: timeout")
|
||||
g.Expect(servers).To(gomega.HaveLen(3))
|
||||
g.Expect([]string{
|
||||
servers[0].Type + ":" + servers[0].Name,
|
||||
servers[1].Type + ":" + servers[1].Name,
|
||||
servers[2].Type + ":" + servers[2].Name,
|
||||
}).To(gomega.Equal([]string{"remote:alpha", "remote:zeta", "stdio:worker"}))
|
||||
for _, server := range servers {
|
||||
g.Expect(server.Error).To(gomega.Equal("remote discovery failed: timeout"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MCP server status discovery", func() {
|
||||
It("keeps configured servers visible when connection fails and retries them", func() {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
DeferCleanup(server.Close)
|
||||
|
||||
modelName := "unavailable-mcp-status-test"
|
||||
DeferCleanup(func() { CloseMCPSessions(modelName) })
|
||||
remote := config.MCPGenericConfig[config.MCPRemoteServers]{
|
||||
Servers: config.MCPRemoteServers{
|
||||
"ordino": {URL: server.URL},
|
||||
},
|
||||
}
|
||||
|
||||
sessions, err := NamedSessionsFromMCPConfig(modelName, remote, config.MCPGenericConfig[config.MCPSTDIOServers]{}, nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(sessions).To(HaveLen(1))
|
||||
Expect(sessions[0].Name).To(Equal("ordino"))
|
||||
Expect(sessions[0].Type).To(Equal("remote"))
|
||||
Expect(sessions[0].Session).To(BeNil())
|
||||
Expect(sessions[0].Error).To(ContainSubstring("connection failed"))
|
||||
|
||||
servers, err := ListMCPServers(context.Background(), sessions)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(servers).To(HaveLen(1))
|
||||
Expect(servers[0].Name).To(Equal("ordino"))
|
||||
Expect(servers[0].Error).To(ContainSubstring("connection failed"))
|
||||
|
||||
tools, err := DiscoverMCPTools(context.Background(), sessions)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(tools).To(BeEmpty())
|
||||
|
||||
_, err = NamedSessionsFromMCPConfig(modelName, remote, config.MCPGenericConfig[config.MCPSTDIOServers]{}, nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(requests.Load()).To(BeNumerically(">=", 2), "failed sessions should be retried instead of cached forever")
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,13 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,6 +31,7 @@ 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.
|
||||
@@ -46,6 +49,7 @@ type MCPServerInfo struct {
|
||||
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.
|
||||
@@ -75,9 +79,10 @@ type sessionCache struct {
|
||||
}
|
||||
|
||||
type namedSessionCache struct {
|
||||
mu sync.Mutex
|
||||
cache map[string][]NamedSession
|
||||
cancels map[string]context.CancelFunc
|
||||
mu sync.Mutex
|
||||
cache map[string][]NamedSession
|
||||
cancels map[string]context.CancelFunc
|
||||
configHashes map[string][sha256.Size]byte
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -87,8 +92,9 @@ var (
|
||||
}
|
||||
|
||||
namedCache = namedSessionCache{
|
||||
cache: make(map[string][]NamedSession),
|
||||
cancels: make(map[string]context.CancelFunc),
|
||||
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)
|
||||
@@ -258,6 +264,24 @@ func SessionsFromMCPConfig(
|
||||
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(
|
||||
@@ -269,7 +293,18 @@ func NamedSessionsFromMCPConfig(
|
||||
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.
|
||||
@@ -278,6 +313,10 @@ func NamedSessionsFromMCPConfig(
|
||||
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
|
||||
@@ -285,12 +324,7 @@ func NamedSessionsFromMCPConfig(
|
||||
}
|
||||
}
|
||||
if !alive {
|
||||
// Close dead sessions and recreate
|
||||
if cancel, ok := namedCache.cancels[name]; ok {
|
||||
cancel()
|
||||
}
|
||||
delete(namedCache.cache, name)
|
||||
delete(namedCache.cancels, name)
|
||||
closeNamedSessionsLocked(name, allSessions)
|
||||
exists = false
|
||||
allSessions = nil
|
||||
}
|
||||
@@ -310,6 +344,11 @@ func NamedSessionsFromMCPConfig(
|
||||
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)
|
||||
@@ -331,6 +370,11 @@ func NamedSessionsFromMCPConfig(
|
||||
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)
|
||||
@@ -343,6 +387,7 @@ func NamedSessionsFromMCPConfig(
|
||||
|
||||
namedCache.cache[name] = allSessions
|
||||
namedCache.cancels[name] = cancel
|
||||
namedCache.configHashes[name] = configHash
|
||||
}
|
||||
|
||||
if len(enabledServers) == 0 {
|
||||
@@ -369,6 +414,9 @@ func DiscoverMCPTools(ctx context.Context, sessions []NamedSession) ([]MCPToolIn
|
||||
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)
|
||||
@@ -547,12 +595,19 @@ func ListMCPServers(ctx context.Context, sessions []NamedSession) ([]MCPServerIn
|
||||
var result []MCPServerInfo
|
||||
for _, ns := range sessions {
|
||||
info := MCPServerInfo{
|
||||
Name: ns.Name,
|
||||
Type: ns.Type,
|
||||
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)
|
||||
@@ -579,6 +634,12 @@ func ListMCPServers(ctx context.Context, sessions []NamedSession) ([]MCPServerIn
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -599,6 +660,9 @@ func DiscoverMCPPrompts(ctx context.Context, sessions []NamedSession) ([]MCPProm
|
||||
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)
|
||||
@@ -652,6 +716,9 @@ func DiscoverMCPResources(ctx context.Context, sessions []NamedSession) ([]MCPRe
|
||||
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)
|
||||
@@ -765,7 +832,11 @@ func CloseMCPSessions(modelName string) {
|
||||
namedCache.mu.Lock()
|
||||
if sessions, ok := namedCache.cache[modelName]; ok {
|
||||
for _, ns := range sessions {
|
||||
ns.Session.Close()
|
||||
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)
|
||||
}
|
||||
@@ -773,6 +844,7 @@ func CloseMCPSessions(modelName string) {
|
||||
cancel()
|
||||
delete(namedCache.cancels, modelName)
|
||||
}
|
||||
delete(namedCache.configHashes, modelName)
|
||||
namedCache.mu.Unlock()
|
||||
|
||||
xlog.Debug("Closed MCP sessions for model", "model", modelName)
|
||||
@@ -797,7 +869,11 @@ func CloseAllMCPSessions() {
|
||||
namedCache.mu.Lock()
|
||||
for name, sessions := range namedCache.cache {
|
||||
for _, ns := range sessions {
|
||||
ns.Session.Close()
|
||||
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()
|
||||
@@ -805,6 +881,7 @@ func CloseAllMCPSessions() {
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from './coverage-fixtures.js'
|
||||
|
||||
async function seedModelChat(page) {
|
||||
await page.addInitScript(() => {
|
||||
const now = Date.now()
|
||||
localStorage.setItem('localai_chats_data', JSON.stringify({
|
||||
chats: [{
|
||||
id: 'mcp-status-chat',
|
||||
name: 'MCP status',
|
||||
model: 'test-model',
|
||||
history: [],
|
||||
systemPrompt: '',
|
||||
mcpMode: false,
|
||||
mcpServers: [],
|
||||
clientMCPServers: [],
|
||||
temperature: null,
|
||||
topP: null,
|
||||
topK: null,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
contextSize: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
activeChatId: 'mcp-status-chat',
|
||||
lastSaved: now,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function mockMCPModel(page) {
|
||||
await page.route('**/api/models/capabilities', route => route.fulfill({
|
||||
json: { data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] },
|
||||
}))
|
||||
await page.route('**/api/models/config-json/test-model', route => route.fulfill({
|
||||
json: {
|
||||
name: 'test-model',
|
||||
mcp: { remote: 'mcpServers:\n ordino:\n url: http://ordino:8080/mcp' },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
test('configured MCP server remains visible with its discovery error', async ({ page }) => {
|
||||
await seedModelChat(page)
|
||||
await mockMCPModel(page)
|
||||
|
||||
let discoveryRequests = 0
|
||||
await page.route('**/v1/mcp/servers/test-model', route => {
|
||||
discoveryRequests++
|
||||
return route.fulfill({
|
||||
json: {
|
||||
model: 'test-model',
|
||||
servers: [{
|
||||
name: 'ordino',
|
||||
type: 'remote',
|
||||
tools: [],
|
||||
error: 'connection failed: lookup ordino.internal: no such host',
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-mcp-dropdown > button').click()
|
||||
await page.getByRole('button', { name: 'Servers', exact: true }).click()
|
||||
|
||||
const server = page.locator('.chat-mcp-server-item', { hasText: 'ordino' })
|
||||
await expect(server).toBeVisible()
|
||||
await expect(server).toContainText('lookup ordino.internal: no such host')
|
||||
await expect(server.locator('.chat-mcp-server-status--error')).toBeVisible()
|
||||
await expect(server.getByRole('checkbox')).toBeDisabled()
|
||||
|
||||
await page.getByRole('button', { name: 'Client', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Servers', exact: true }).click()
|
||||
await expect.poll(() => discoveryRequests).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('server-list request errors are shown in the MCP menu', async ({ page }) => {
|
||||
await seedModelChat(page)
|
||||
await mockMCPModel(page)
|
||||
await page.route('**/v1/mcp/servers/test-model', route => route.fulfill({
|
||||
status: 500,
|
||||
json: { message: 'invalid MCP configuration: missing mcpServers map' },
|
||||
}))
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-mcp-dropdown > button').click()
|
||||
await page.getByRole('button', { name: 'Servers', exact: true }).click()
|
||||
await expect(page.getByRole('alert')).toContainText('invalid MCP configuration: missing mcpServers map')
|
||||
})
|
||||
@@ -4747,11 +4747,15 @@ button.collapsible-header:focus-visible {
|
||||
animation: dropdownIn 120ms ease-out;
|
||||
}
|
||||
.chat-mcp-dropdown-loading,
|
||||
.chat-mcp-dropdown-empty {
|
||||
.chat-mcp-dropdown-empty,
|
||||
.chat-mcp-dropdown-error {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.chat-mcp-dropdown-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.chat-mcp-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4788,6 +4792,9 @@ button.collapsible-header:focus-visible {
|
||||
.chat-mcp-server-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
.chat-mcp-server-item--error {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.chat-mcp-server-item input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -4805,10 +4812,33 @@ button.collapsible-header:focus-visible {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chat-mcp-server-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-mcp-server-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-mcp-server-status--connected {
|
||||
background: var(--color-success);
|
||||
}
|
||||
.chat-mcp-server-status--error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
.chat-mcp-server-tools {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.chat-mcp-server-error {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-error);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Client MCP status indicators */
|
||||
.chat-client-mcp-status {
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function UnifiedMCPDropdown({
|
||||
serverMCPAvailable = false,
|
||||
mcpServerList = [],
|
||||
mcpServersLoading = false,
|
||||
serverListError = '',
|
||||
selectedServers = [],
|
||||
onToggleServer,
|
||||
onSelectAllServers,
|
||||
@@ -103,6 +104,7 @@ export default function UnifiedMCPDropdown({
|
||||
}, [onClientRemoved])
|
||||
|
||||
const totalBadge = (selectedServers?.length || 0) + (clientMCPActiveIds?.length || 0) + (selectedResources?.length || 0)
|
||||
const selectableServers = mcpServerList.filter(server => !server.error)
|
||||
|
||||
const tabs = []
|
||||
if (serverMCPAvailable) tabs.push({ key: 'servers', label: 'Servers' })
|
||||
@@ -143,26 +145,38 @@ export default function UnifiedMCPDropdown({
|
||||
{activeTab === 'servers' && serverMCPAvailable && (
|
||||
mcpServersLoading ? (
|
||||
<div className="chat-mcp-dropdown-loading"><i className="fas fa-spinner fa-spin" /> Loading servers...</div>
|
||||
) : serverListError ? (
|
||||
<div className="chat-mcp-dropdown-error" role="alert">Failed to discover MCP servers: {serverListError}</div>
|
||||
) : mcpServerList.length === 0 ? (
|
||||
<div className="chat-mcp-dropdown-empty">No MCP servers configured</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="chat-mcp-dropdown-header">
|
||||
<span>MCP Servers</span>
|
||||
<button type="button" className="chat-mcp-select-all" onClick={onSelectAllServers}>
|
||||
{mcpServerList.every(s => selectedServers.includes(s.name)) ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
{selectableServers.length > 0 && (
|
||||
<button type="button" className="chat-mcp-select-all" onClick={onSelectAllServers}>
|
||||
{selectableServers.every(s => selectedServers.includes(s.name)) ? 'Deselect all' : 'Select all'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mcpServerList.map(server => (
|
||||
<label key={server.name} className="chat-mcp-server-item">
|
||||
<label key={server.name} className={`chat-mcp-server-item${server.error ? ' chat-mcp-server-item--error' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedServers.includes(server.name)}
|
||||
onChange={() => onToggleServer(server.name)}
|
||||
disabled={!!server.error}
|
||||
/>
|
||||
<div className="chat-mcp-server-info">
|
||||
<span className="chat-mcp-server-name">{server.name}</span>
|
||||
<span className="chat-mcp-server-tools">{server.tools?.length || 0} tools</span>
|
||||
<span className="chat-mcp-server-name-row">
|
||||
<span className={`chat-mcp-server-status chat-mcp-server-status--${server.error ? 'error' : 'connected'}`} />
|
||||
<span className="chat-mcp-server-name">{server.name}</span>
|
||||
</span>
|
||||
{server.error ? (
|
||||
<span className="chat-mcp-server-error" title={server.error}>{server.error}</span>
|
||||
) : (
|
||||
<span className="chat-mcp-server-tools">{server.tools?.length || 0} tools</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -332,7 +332,7 @@ export default function Chat() {
|
||||
const [mcpAvailable, setMcpAvailable] = useState(false)
|
||||
const [mcpServerList, setMcpServerList] = useState([])
|
||||
const [mcpServersLoading, setMcpServersLoading] = useState(false)
|
||||
const [mcpServerCache, setMcpServerCache] = useState({})
|
||||
const [mcpServerListError, setMcpServerListError] = useState('')
|
||||
const [mcpPromptList, setMcpPromptList] = useState([])
|
||||
const [mcpPromptsLoading, setMcpPromptsLoading] = useState(false)
|
||||
const [mcpPromptArgsDialog, setMcpPromptArgsDialog] = useState(null)
|
||||
@@ -425,22 +425,30 @@ export default function Chat() {
|
||||
const fetchMcpServers = useCallback(async () => {
|
||||
const model = activeChat?.model
|
||||
if (!model) return
|
||||
if (mcpServerCache[model]) {
|
||||
setMcpServerList(mcpServerCache[model])
|
||||
return
|
||||
}
|
||||
setMcpServersLoading(true)
|
||||
setMcpServerListError('')
|
||||
try {
|
||||
const data = await mcpApi.listServers(model)
|
||||
const servers = data?.servers || []
|
||||
setMcpServerList(servers)
|
||||
setMcpServerCache(prev => ({ ...prev, [model]: servers }))
|
||||
} catch (_e) {
|
||||
|
||||
// A previously selected server may become unavailable between requests.
|
||||
// Remove it from request metadata while leaving it visible with its error.
|
||||
if (activeChat) {
|
||||
const unavailable = new Set(servers.filter(server => server.error).map(server => server.name))
|
||||
const current = activeChat.mcpServers || []
|
||||
const availableSelection = current.filter(name => !unavailable.has(name))
|
||||
if (availableSelection.length !== current.length) {
|
||||
updateChatSettings(activeChat.id, { mcpServers: availableSelection })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setMcpServerList([])
|
||||
setMcpServerListError(e.body?.message || e.message || 'Failed to discover MCP servers')
|
||||
} finally {
|
||||
setMcpServersLoading(false)
|
||||
}
|
||||
}, [activeChat?.model, mcpServerCache])
|
||||
}, [activeChat, updateChatSettings])
|
||||
|
||||
const toggleMcpServer = useCallback((serverName) => {
|
||||
if (!activeChat) return
|
||||
@@ -1465,10 +1473,11 @@ export default function Chat() {
|
||||
serverMCPAvailable={mcpAvailable}
|
||||
mcpServerList={mcpServerList}
|
||||
mcpServersLoading={mcpServersLoading}
|
||||
serverListError={mcpServerListError}
|
||||
selectedServers={activeChat.mcpServers || []}
|
||||
onToggleServer={toggleMcpServer}
|
||||
onSelectAllServers={() => {
|
||||
const allNames = mcpServerList.map(s => s.name)
|
||||
const allNames = mcpServerList.filter(s => !s.error).map(s => s.name)
|
||||
const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n))
|
||||
updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames })
|
||||
}}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function Home() {
|
||||
const [mcpAvailable, setMcpAvailable] = useState(false)
|
||||
const [mcpServerList, setMcpServerList] = useState([])
|
||||
const [mcpServersLoading, setMcpServersLoading] = useState(false)
|
||||
const [mcpServerCache, setMcpServerCache] = useState({})
|
||||
const [mcpServerListError, setMcpServerListError] = useState('')
|
||||
const [mcpSelectedServers, setMcpSelectedServers] = useState([])
|
||||
const [clientMCPSelectedIds, setClientMCPSelectedIds] = useState([])
|
||||
const [assistantAvailable, setAssistantAvailable] = useState(false)
|
||||
@@ -174,22 +174,21 @@ export default function Home() {
|
||||
|
||||
const fetchMcpServers = useCallback(async () => {
|
||||
if (!selectedModel) return
|
||||
if (mcpServerCache[selectedModel]) {
|
||||
setMcpServerList(mcpServerCache[selectedModel])
|
||||
return
|
||||
}
|
||||
setMcpServersLoading(true)
|
||||
setMcpServerListError('')
|
||||
try {
|
||||
const data = await mcpApi.listServers(selectedModel)
|
||||
const servers = data?.servers || []
|
||||
setMcpServerList(servers)
|
||||
setMcpServerCache(prev => ({ ...prev, [selectedModel]: servers }))
|
||||
} catch (_e) {
|
||||
const unavailable = new Set(servers.filter(server => server.error).map(server => server.name))
|
||||
setMcpSelectedServers(prev => prev.filter(name => !unavailable.has(name)))
|
||||
} catch (e) {
|
||||
setMcpServerList([])
|
||||
setMcpServerListError(e.body?.message || e.message || 'Failed to discover MCP servers')
|
||||
} finally {
|
||||
setMcpServersLoading(false)
|
||||
}
|
||||
}, [selectedModel, mcpServerCache])
|
||||
}, [selectedModel])
|
||||
|
||||
const toggleMcpServer = useCallback((serverName) => {
|
||||
setMcpSelectedServers(prev =>
|
||||
@@ -350,10 +349,11 @@ export default function Home() {
|
||||
serverMCPAvailable={mcpAvailable}
|
||||
mcpServerList={mcpServerList}
|
||||
mcpServersLoading={mcpServersLoading}
|
||||
serverListError={mcpServerListError}
|
||||
selectedServers={mcpSelectedServers}
|
||||
onToggleServer={toggleMcpServer}
|
||||
onSelectAllServers={() => {
|
||||
const allNames = mcpServerList.map(s => s.name)
|
||||
const allNames = mcpServerList.filter(s => !s.error).map(s => s.name)
|
||||
const allSelected = allNames.every(n => mcpSelectedServers.includes(n))
|
||||
setMcpSelectedServers(allSelected ? [] : allNames)
|
||||
}}
|
||||
|
||||
@@ -44,6 +44,7 @@ type MCPServerInfo struct {
|
||||
Tools []string `json:"tools"`
|
||||
Prompts []string `json:"prompts,omitempty"`
|
||||
Resources []string `json:"resources,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// MCPToolDef is a serializable tool definition (function schema) that can
|
||||
|
||||
@@ -87,12 +87,16 @@ agent:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
In the interactive model editor, **Remote MCP Servers** and **MCP STDIO Servers** are YAML editors. Enter only the embedded `mcpServers` document shown inside the `remote: |` or `stdio: |` block above; the editor supplies the outer model configuration keys.
|
||||
|
||||
#### Remote Servers (`remote`)
|
||||
Configure HTTP-based MCP servers:
|
||||
|
||||
- **`url`**: The MCP server endpoint URL
|
||||
- **`token`**: Bearer token for authentication (optional)
|
||||
|
||||
Remote model MCP connections originate from the LocalAI process. If LocalAI runs in Docker, the URL must therefore resolve and be reachable **from the LocalAI container**, not only from the host browser. For another service in the same Compose project, use its Compose service name and container port. Host-only DNS names, VPN DNS, and private routes must also be made available inside the container.
|
||||
|
||||
#### STDIO Servers (`stdio`)
|
||||
Configure local command-based MCP servers:
|
||||
|
||||
@@ -180,23 +184,29 @@ You can list available MCP servers and their tools for a given model:
|
||||
curl http://localhost:8080/v1/mcp/servers/my-mcp-model
|
||||
```
|
||||
|
||||
Returns:
|
||||
Returns a model wrapper and one status entry for every configured server:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "weather-api",
|
||||
"type": "remote",
|
||||
"tools": ["get_weather", "get_forecast"]
|
||||
},
|
||||
{
|
||||
"name": "search-engine",
|
||||
"type": "remote",
|
||||
"tools": ["web_search", "image_search"]
|
||||
}
|
||||
]
|
||||
{
|
||||
"model": "my-mcp-model",
|
||||
"servers": [
|
||||
{
|
||||
"name": "weather-api",
|
||||
"type": "remote",
|
||||
"tools": ["get_weather", "get_forecast"]
|
||||
},
|
||||
{
|
||||
"name": "search-engine",
|
||||
"type": "remote",
|
||||
"tools": [],
|
||||
"error": "connection failed: ..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The Chat **MCP → Servers** tab uses this endpoint. A configured server remains listed when it is unavailable, shows the discovery error, and cannot be selected until a later discovery attempt succeeds. Reopening the dropdown or returning to the Servers tab retries discovery.
|
||||
|
||||
### MCP Prompts
|
||||
|
||||
MCP servers can provide reusable prompt templates. LocalAI supports discovering and expanding prompts from MCP servers.
|
||||
@@ -548,7 +558,7 @@ In addition to server-side MCP (where the backend connects to MCP servers), Loca
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Add servers in the UI**: Click the "Client MCP" button in the chat header and add MCP server URLs
|
||||
1. **Add servers in the UI**: Click **MCP** in the chat header, open the **Client** tab, and add MCP server URLs
|
||||
2. **Browser connects directly**: The browser uses the MCP TypeScript SDK (`StreamableHTTPClientTransport` or `SSEClientTransport`) to connect to MCP servers
|
||||
3. **Tool discovery**: Connected servers' tools are sent as `tools` in the chat request body
|
||||
4. **Browser-side execution**: When the LLM calls a client-side tool, the browser executes it against the MCP server and sends the result back in a follow-up request
|
||||
|
||||
Reference in new issue
Block a user