From 5c639697607189f50239eb2400d9c2ffaebff9e6 Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Thu, 13 Aug 2026 21:25:58 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 --------- Signed-off-by: Richard Palethorpe --- .dockerignore | 2 + core/cli/agent_worker.go | 1 + core/config/meta/registry.go | 18 +++ core/config/meta/registry_coverage_test.go | 2 - core/config/meta/registry_test.go | 30 +++++ core/http/endpoints/localai/mcp_tools.go | 44 ++++++- .../localai/mcp_tools_internal_test.go | 34 ++++++ core/http/endpoints/mcp/server_status_test.go | 54 +++++++++ core/http/endpoints/mcp/tools.go | 107 +++++++++++++++--- .../react-ui/e2e/mcp-server-status.spec.js | 93 +++++++++++++++ core/http/react-ui/src/App.css | 32 +++++- .../src/components/UnifiedMCPDropdown.jsx | 26 ++++- core/http/react-ui/src/pages/Chat.jsx | 27 +++-- core/http/react-ui/src/pages/Home.jsx | 18 +-- core/services/mcp/remote.go | 1 + docs/content/features/mcp.md | 38 ++++--- 16 files changed, 467 insertions(+), 60 deletions(-) create mode 100644 core/http/endpoints/localai/mcp_tools_internal_test.go create mode 100644 core/http/endpoints/mcp/server_status_test.go create mode 100644 core/http/react-ui/e2e/mcp-server-status.spec.js diff --git a/.dockerignore b/.dockerignore index befd3706a..4de42deef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -59,7 +59,9 @@ backend/rust/*/target backend-images local-backends local-ai +.claude .crush +.tools protoc tests diff --git a/core/cli/agent_worker.go b/core/cli/agent_worker.go index cf05be9bd..ced179e50 100644 --- a/core/cli/agent_worker.go +++ b/core/cli/agent_worker.go @@ -351,6 +351,7 @@ func handleMCPDiscoveryRequest(data []byte, reply func([]byte)) { Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources, + Error: s.Error, }) } diff --git a/core/config/meta/registry.go b/core/config/meta/registry.go index 1bd97e461..aa25e57ad 100644 --- a/core/config/meta/registry.go +++ b/core/config/meta/registry.go @@ -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", diff --git a/core/config/meta/registry_coverage_test.go b/core/config/meta/registry_coverage_test.go index 816b226a6..55b2a3b3e 100644 --- a/core/config/meta/registry_coverage_test.go +++ b/core/config/meta/registry_coverage_test.go @@ -185,8 +185,6 @@ var grandfatheredUnregistered = []string{ "lora_scales", "main_gpu", "max_model_len", - "mcp.remote", - "mcp.stdio", "mirostat", "mirostat_eta", "mirostat_tau", diff --git a/core/config/meta/registry_test.go b/core/config/meta/registry_test.go index e9d998609..c3f1a494d 100644 --- a/core/config/meta/registry_test.go +++ b/core/config/meta/registry_test.go @@ -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"), + ) +}) diff --git a/core/http/endpoints/localai/mcp_tools.go b/core/http/endpoints/localai/mcp_tools.go index 6cc239463..f5db27bd7 100644 --- a/core/http/endpoints/localai/mcp_tools.go +++ b/core/http/endpoints/localai/mcp_tools.go @@ -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 +} diff --git a/core/http/endpoints/localai/mcp_tools_internal_test.go b/core/http/endpoints/localai/mcp_tools_internal_test.go new file mode 100644 index 000000000..13df25989 --- /dev/null +++ b/core/http/endpoints/localai/mcp_tools_internal_test.go @@ -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")) + } +} diff --git a/core/http/endpoints/mcp/server_status_test.go b/core/http/endpoints/mcp/server_status_test.go new file mode 100644 index 000000000..3ea4db112 --- /dev/null +++ b/core/http/endpoints/mcp/server_status_test.go @@ -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") + }) +}) diff --git a/core/http/endpoints/mcp/tools.go b/core/http/endpoints/mcp/tools.go index af4ebe197..0b4931a05 100644 --- a/core/http/endpoints/mcp/tools.go +++ b/core/http/endpoints/mcp/tools.go @@ -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") diff --git a/core/http/react-ui/e2e/mcp-server-status.spec.js b/core/http/react-ui/e2e/mcp-server-status.spec.js new file mode 100644 index 000000000..2fa0b7a79 --- /dev/null +++ b/core/http/react-ui/e2e/mcp-server-status.spec.js @@ -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') +}) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index c80b3e83a..c1665e62d 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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 { diff --git a/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx b/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx index 7482f6c6e..a903a13fd 100644 --- a/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx +++ b/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx @@ -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 ? (
Loading servers...
+ ) : serverListError ? ( +
Failed to discover MCP servers: {serverListError}
) : mcpServerList.length === 0 ? (
No MCP servers configured
) : ( <>
MCP Servers - + {selectableServers.length > 0 && ( + + )}
{mcpServerList.map(server => ( -