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:
Richard Palethorpe authored and GitHub committed 2026-08-13 22:25:58 +02:00
1 parent b2ff2b5477
commit 5c63969760
16 files changed
+467 -60

No files matched your search

+2
View File
@@ -59,7 +59,9 @@ backend/rust/*/target
backend-images
local-backends
local-ai
.claude
.crush
.tools
protoc
tests
+1
View File
@@ -351,6 +351,7 @@ func handleMCPDiscoveryRequest(data []byte, reply func([]byte)) {
Tools: s.Tools,
Prompts: s.Prompts,
Resources: s.Resources,
Error: s.Error,
})
}
+18
View File
@@ -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",
+30
View File
@@ -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"),
)
})
+40 -4
View File
@@ -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")
})
})
+92 -15
View File
@@ -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')
})
+31 -1
View File
@@ -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>
))}
+18 -9
View File
@@ -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 })
}}
+9 -9
View File
@@ -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)
}}
+1
View File
@@ -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
+24 -14
View File
@@ -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