Skip to content

Commit 5c63969

Browse files
authored
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>
1 parent b2ff2b5 commit 5c63969

16 files changed

Lines changed: 467 additions & 60 deletions

File tree

.dockerignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ backend/rust/*/target
5959
backend-images
6060
local-backends
6161
local-ai
62+
.claude
6263
.crush
64+
.tools
6365
protoc
6466
tests
6567

core/cli/agent_worker.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ func handleMCPDiscoveryRequest(data []byte, reply func([]byte)) {
351351
Tools: s.Tools,
352352
Prompts: s.Prompts,
353353
Resources: s.Resources,
354+
Error: s.Error,
354355
})
355356
}
356357

core/config/meta/registry.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,24 @@ func DefaultRegistry() map[string]FieldMetaOverride {
752752
Order: 72,
753753
},
754754

755+
// --- MCP ---
756+
"mcp.remote": {
757+
Section: "mcp",
758+
Label: "Remote MCP Servers",
759+
Description: "YAML or JSON string containing an mcpServers map of named remote Streamable HTTP endpoints. Each entry requires url; token optionally enables Bearer authentication.",
760+
Component: "code-editor",
761+
Language: "yaml",
762+
Order: 130,
763+
},
764+
"mcp.stdio": {
765+
Section: "mcp",
766+
Label: "MCP STDIO Servers",
767+
Description: "YAML or JSON string containing an mcpServers map of named local commands. Each entry requires command and may include args and env.",
768+
Component: "code-editor",
769+
Language: "yaml",
770+
Order: 131,
771+
},
772+
755773
// --- TTS ---
756774
"tts.voice_cloning": {
757775
Section: "tts",

core/config/meta/registry_coverage_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,6 @@ var grandfatheredUnregistered = []string{
185185
"lora_scales",
186186
"main_gpu",
187187
"max_model_len",
188-
"mcp.remote",
189-
"mcp.stdio",
190188
"mirostat",
191189
"mirostat_eta",
192190
"mirostat_tau",

core/config/meta/registry_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package meta_test
22

33
import (
4+
"reflect"
5+
6+
"github.com/mudler/LocalAI/core/config"
47
"github.com/mudler/LocalAI/core/config/meta"
58

69
. "github.com/onsi/ginkgo/v2"
@@ -26,3 +29,30 @@ var _ = Describe("alias field metadata", func() {
2629
Expect(found).To(BeTrue(), "DefaultSections should include an alias section")
2730
})
2831
})
32+
33+
var _ = Describe("MCP field metadata", func() {
34+
var fields map[string]meta.FieldMeta
35+
36+
BeforeEach(func() {
37+
md := meta.BuildForTest(reflect.TypeOf(config.ModelConfig{}), meta.DefaultRegistry())
38+
fields = make(map[string]meta.FieldMeta, len(md.Fields))
39+
for _, field := range md.Fields {
40+
fields[field.Path] = field
41+
}
42+
})
43+
44+
DescribeTable("registers embedded MCP configuration as YAML code",
45+
func(path, label, transportDetail string) {
46+
f, ok := fields[path]
47+
Expect(ok).To(BeTrue(), "%s should be present in generated metadata", path)
48+
Expect(f.Section).To(Equal("mcp"))
49+
Expect(f.Label).To(Equal(label))
50+
Expect(f.Description).To(ContainSubstring("mcpServers"))
51+
Expect(f.Description).To(ContainSubstring(transportDetail))
52+
Expect(f.Component).To(Equal("code-editor"))
53+
Expect(f.Language).To(Equal("yaml"))
54+
},
55+
Entry("remote servers", "mcp.remote", "Remote MCP Servers", "Streamable HTTP"),
56+
Entry("stdio servers", "mcp.stdio", "MCP STDIO Servers", "local commands"),
57+
)
58+
})

core/http/endpoints/localai/mcp_tools.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package localai
33
import (
44
"fmt"
55
"net/http"
6+
"sort"
67

78
"github.com/labstack/echo/v4"
89
"github.com/mudler/LocalAI/core/config"
@@ -37,15 +38,22 @@ func MCPServersEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applicat
3738

3839
remote, stdio, err := cfg.MCP.MCPConfigFromYAML()
3940
if err != nil {
40-
return fmt.Errorf("failed to parse MCP config: %w", err)
41+
return c.JSON(http.StatusUnprocessableEntity, map[string]any{
42+
"model": modelName,
43+
"servers": []any{},
44+
"error": fmt.Sprintf("failed to parse MCP config: %v", err),
45+
})
4146
}
4247

4348
// In distributed mode, route discovery through NATS to an agent worker
4449
// that can actually connect to the MCP servers.
4550
if natsClient != nil {
4651
resp, err := mcpTools.DiscoverMCPToolsRemote(c.Request().Context(), natsClient, cfg.Name, remote, stdio)
4752
if err != nil {
48-
return fmt.Errorf("remote MCP discovery failed: %w", err)
53+
return c.JSON(http.StatusOK, map[string]any{
54+
"model": modelName,
55+
"servers": unavailableMCPServers(remote, stdio, fmt.Sprintf("remote discovery failed: %v", err)),
56+
})
4957
}
5058
return c.JSON(200, map[string]any{
5159
"model": modelName,
@@ -88,14 +96,21 @@ func MCPServersEndpointFromMiddleware(natsClient mcpTools.MCPNATSClient) echo.Ha
8896

8997
remote, stdio, err := cfg.MCP.MCPConfigFromYAML()
9098
if err != nil {
91-
return fmt.Errorf("failed to parse MCP config: %w", err)
99+
return c.JSON(http.StatusUnprocessableEntity, map[string]any{
100+
"model": cfg.Name,
101+
"servers": []any{},
102+
"error": fmt.Sprintf("failed to parse MCP config: %v", err),
103+
})
92104
}
93105

94106
// In distributed mode, route discovery through NATS to an agent worker.
95107
if natsClient != nil {
96108
resp, err := mcpTools.DiscoverMCPToolsRemote(c.Request().Context(), natsClient, cfg.Name, remote, stdio)
97109
if err != nil {
98-
return fmt.Errorf("remote MCP discovery failed: %w", err)
110+
return c.JSON(http.StatusOK, map[string]any{
111+
"model": cfg.Name,
112+
"servers": unavailableMCPServers(remote, stdio, fmt.Sprintf("remote discovery failed: %v", err)),
113+
})
99114
}
100115
return c.JSON(200, map[string]any{
101116
"model": cfg.Name,
@@ -119,3 +134,24 @@ func MCPServersEndpointFromMiddleware(natsClient mcpTools.MCPNATSClient) echo.Ha
119134
})
120135
}
121136
}
137+
138+
func unavailableMCPServers(
139+
remote config.MCPGenericConfig[config.MCPRemoteServers],
140+
stdio config.MCPGenericConfig[config.MCPSTDIOServers],
141+
errMessage string,
142+
) []mcpTools.MCPServerInfo {
143+
servers := make([]mcpTools.MCPServerInfo, 0, len(remote.Servers)+len(stdio.Servers))
144+
for name := range remote.Servers {
145+
servers = append(servers, mcpTools.MCPServerInfo{Name: name, Type: "remote", Tools: []string{}, Error: errMessage})
146+
}
147+
for name := range stdio.Servers {
148+
servers = append(servers, mcpTools.MCPServerInfo{Name: name, Type: "stdio", Tools: []string{}, Error: errMessage})
149+
}
150+
sort.Slice(servers, func(i, j int) bool {
151+
if servers[i].Type != servers[j].Type {
152+
return servers[i].Type < servers[j].Type
153+
}
154+
return servers[i].Name < servers[j].Name
155+
})
156+
return servers
157+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package localai
2+
3+
import (
4+
"testing"
5+
6+
"github.com/mudler/LocalAI/core/config"
7+
"github.com/onsi/gomega"
8+
)
9+
10+
func TestUnavailableMCPServersIncludesEveryConfiguredServer(t *testing.T) {
11+
g := gomega.NewWithT(t)
12+
remote := config.MCPGenericConfig[config.MCPRemoteServers]{
13+
Servers: config.MCPRemoteServers{
14+
"zeta": {URL: "http://zeta/mcp"},
15+
"alpha": {URL: "http://alpha/mcp"},
16+
},
17+
}
18+
stdio := config.MCPGenericConfig[config.MCPSTDIOServers]{
19+
Servers: config.MCPSTDIOServers{
20+
"worker": {Command: "mcp-worker"},
21+
},
22+
}
23+
24+
servers := unavailableMCPServers(remote, stdio, "remote discovery failed: timeout")
25+
g.Expect(servers).To(gomega.HaveLen(3))
26+
g.Expect([]string{
27+
servers[0].Type + ":" + servers[0].Name,
28+
servers[1].Type + ":" + servers[1].Name,
29+
servers[2].Type + ":" + servers[2].Name,
30+
}).To(gomega.Equal([]string{"remote:alpha", "remote:zeta", "stdio:worker"}))
31+
for _, server := range servers {
32+
g.Expect(server.Error).To(gomega.Equal("remote discovery failed: timeout"))
33+
}
34+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"sync/atomic"
8+
9+
"github.com/mudler/LocalAI/core/config"
10+
11+
. "github.com/onsi/ginkgo/v2"
12+
. "github.com/onsi/gomega"
13+
)
14+
15+
var _ = Describe("MCP server status discovery", func() {
16+
It("keeps configured servers visible when connection fails and retries them", func() {
17+
var requests atomic.Int32
18+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
19+
requests.Add(1)
20+
http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable)
21+
}))
22+
DeferCleanup(server.Close)
23+
24+
modelName := "unavailable-mcp-status-test"
25+
DeferCleanup(func() { CloseMCPSessions(modelName) })
26+
remote := config.MCPGenericConfig[config.MCPRemoteServers]{
27+
Servers: config.MCPRemoteServers{
28+
"ordino": {URL: server.URL},
29+
},
30+
}
31+
32+
sessions, err := NamedSessionsFromMCPConfig(modelName, remote, config.MCPGenericConfig[config.MCPSTDIOServers]{}, nil)
33+
Expect(err).NotTo(HaveOccurred())
34+
Expect(sessions).To(HaveLen(1))
35+
Expect(sessions[0].Name).To(Equal("ordino"))
36+
Expect(sessions[0].Type).To(Equal("remote"))
37+
Expect(sessions[0].Session).To(BeNil())
38+
Expect(sessions[0].Error).To(ContainSubstring("connection failed"))
39+
40+
servers, err := ListMCPServers(context.Background(), sessions)
41+
Expect(err).NotTo(HaveOccurred())
42+
Expect(servers).To(HaveLen(1))
43+
Expect(servers[0].Name).To(Equal("ordino"))
44+
Expect(servers[0].Error).To(ContainSubstring("connection failed"))
45+
46+
tools, err := DiscoverMCPTools(context.Background(), sessions)
47+
Expect(err).NotTo(HaveOccurred())
48+
Expect(tools).To(BeEmpty())
49+
50+
_, err = NamedSessionsFromMCPConfig(modelName, remote, config.MCPGenericConfig[config.MCPSTDIOServers]{}, nil)
51+
Expect(err).NotTo(HaveOccurred())
52+
Expect(requests.Load()).To(BeNumerically(">=", 2), "failed sessions should be retried instead of cached forever")
53+
})
54+
})

0 commit comments

Comments
 (0)