From 95034f114729f70acbad3692d26471556568551d Mon Sep 17 00:00:00 2001 From: Stefan Walcz Date: Mon, 19 Oct 2026 23:43:47 +0200 Subject: [PATCH] feat(ui): offer model MCP servers to non-admin chat users (#12660) The chat page derived MCP availability from GET /api/models/config-json/:name, which is admin-only. With auth enabled a regular user holding the `mcp` feature (default ON) therefore never saw the MCP Servers/Prompts/Resources tabs, although every endpoint behind them (/v1/mcp/servers|prompts|resources/:model) is gated only by that feature. /api/models/capabilities, which the chat already loads for its model picker and which honours the per-user model allowlist, now reports `mcp: true` for models whose config declares remote or stdio MCP servers (never when MCP is disabled on the instance). For non-admins the chat combines that flag with hasFeature('mcp') and clears a stale MCP selection otherwise. Admins keep the config-json path unchanged; the model info panel and automatic context size stay admin-only on purpose, since they need the full model config. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Stefan Walcz --- .../react-ui/e2e/mcp-server-status.spec.js | 54 +++++++++++++ core/http/react-ui/src/pages/Chat.jsx | 27 ++++++- core/http/routes/ui_api.go | 8 ++ .../routes/ui_api_capabilities_mcp_test.go | 79 +++++++++++++++++++ docs/content/features/authentication.md | 2 + docs/content/features/mcp.md | 2 + 6 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 core/http/routes/ui_api_capabilities_mcp_test.go diff --git a/core/http/react-ui/e2e/mcp-server-status.spec.js b/core/http/react-ui/e2e/mcp-server-status.spec.js index 2fa0b7a79..20f34706a 100644 --- a/core/http/react-ui/e2e/mcp-server-status.spec.js +++ b/core/http/react-ui/e2e/mcp-server-status.spec.js @@ -91,3 +91,57 @@ test('server-list request errors are shown in the MCP menu', async ({ page }) => await page.getByRole('button', { name: 'Servers', exact: true }).click() await expect(page.getByRole('alert')).toContainText('invalid MCP configuration: missing mcpServers map') }) + +// Non-admin users cannot read /api/models/config-json (admin-only). Their MCP +// availability comes from the `mcp` flag of /api/models/capabilities plus the +// user's `mcp` feature permission. +async function mockNonAdmin(page, permissions) { + await page.route('**/api/auth/status', route => route.fulfill({ + json: { + authEnabled: true, + staticApiKeyRequired: false, + providers: ['local'], + hasUsers: true, + user: { id: 'u-member', name: 'Member', email: 'member@example.com', role: 'user', permissions }, + }, + })) + let configJsonRequests = 0 + await page.route('**/api/models/config-json/**', route => { + configJsonRequests++ + return route.fulfill({ status: 403, json: { error: 'admin access required' } }) + }) + await page.route('**/api/models/capabilities', route => route.fulfill({ + json: { data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'], mcp: true }] }, + })) + await page.route('**/v1/mcp/servers/test-model', route => route.fulfill({ + json: { model: 'test-model', servers: [{ name: 'weather', type: 'remote', tools: ['get_weather'] }] }, + })) + return () => configJsonRequests +} + +test('non-admin user with the mcp feature can select model MCP servers', async ({ page }) => { + await seedModelChat(page) + const configJsonRequests = await mockNonAdmin(page, { chat: true, mcp: true }) + + 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: 'weather' }) + await expect(server).toBeVisible() + await expect(server.getByRole('checkbox')).toBeEnabled() + expect(configJsonRequests()).toBe(0) +}) + +test('non-admin user without the mcp feature gets no model MCP servers', async ({ page }) => { + await seedModelChat(page) + await mockNonAdmin(page, { chat: true, mcp: false }) + + 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 expect(page.getByRole('button', { name: 'Client', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Servers', exact: true })).toHaveCount(0) +}) diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index ecaa5ab8c..4381e4d5d 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -113,7 +113,8 @@ function ChatPage() { const navigate = useNavigate() const location = useLocation() const { t } = useTranslation('chat') - const { isAdmin } = useAuth() + const { isAdmin, hasFeature } = useAuth() + const canUseMcp = hasFeature('mcp') const { operations } = useOperations() const { chats, activeChat, activeChatId, isStreaming, streamingChatId, streamingContent, @@ -252,10 +253,30 @@ function ChatPage() { prevArtifactCountRef.current = artifacts.length }, [artifacts]) - // Check MCP availability and fetch model config (admin-only endpoint) + // Non-admins cannot read the model config (config-json is admin-only), so + // their MCP availability comes from the `mcp` flag of the chat model list + // plus the user's `mcp` feature. Context size and the model-info panel stay + // admin-only: they need the full config. + const activeModelHasMcp = useMemo( + () => !!chatModels.find(m => m.id === activeChat?.model)?.mcp, + [chatModels, activeChat?.model] + ) + useEffect(() => { + if (isAdmin) return + setModelInfo(null) + if (!activeChat?.model || chatModelsLoading) { setMcpAvailable(false); return } + const hasMcp = canUseMcp && activeModelHasMcp + setMcpAvailable(hasMcp) + if (!hasMcp && (activeChat.mcpMode || activeChat.mcpServers?.length)) { + updateChatSettings(activeChat.id, { mcpMode: false, mcpServers: [] }) + } + }, [activeChat?.model, isAdmin, canUseMcp, activeModelHasMcp, chatModelsLoading]) + + // Admins: check MCP availability and fetch model config (admin-only endpoint) useEffect(() => { const model = activeChat?.model - if (!model || !isAdmin) { setMcpAvailable(false); setModelInfo(null); return } + if (!isAdmin) return + if (!model) { setMcpAvailable(false); setModelInfo(null); return } let cancelled = false modelsApi.getConfigJson(model).then(cfg => { if (cancelled) return diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index 5a68c40ff..b53371381 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -840,6 +840,13 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model // Source="registry-only" marks models adopted from the cluster that // have no local config yet (ghosts that the reconciler discovered). Source string `json:"source,omitempty"` + // MCP reports that the model config declares MCP servers and MCP is + // enabled on this instance. Non-admin chat users cannot read the + // model config (config-json is admin-only), so this is how the chat + // UI learns it may offer server selection to users holding the + // `mcp` feature. Server names and commands stay behind + // /v1/mcp/servers/:model, which is gated by that feature. + MCP bool `json:"mcp,omitempty"` } // Join with the node registry when we have one (distributed mode). A @@ -882,6 +889,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model Pinned: cfg.IsPinned(), VoiceCloning: config.VoiceCloningForModel(&cfg), LoadedOn: loadedByModel[cfg.Name], + MCP: !appConfig.DisableMCP && cfg.MCP.HasMCPServers(), }) } for _, name := range modelsWithoutConfig { diff --git a/core/http/routes/ui_api_capabilities_mcp_test.go b/core/http/routes/ui_api_capabilities_mcp_test.go new file mode 100644 index 000000000..3a5998113 --- /dev/null +++ b/core/http/routes/ui_api_capabilities_mcp_test.go @@ -0,0 +1,79 @@ +package routes_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/http/routes" + "github.com/mudler/LocalAI/core/services/galleryop" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The chat UI derives MCP availability for non-admin users from this flag, +// because the full model config (config-json) is admin-only. +var _ = Describe("UI model capabilities MCP flag", func() { + capabilitiesMCP := func(disableMCP bool) map[string]bool { + dir := GinkgoT().TempDir() + st, err := system.GetSystemState(system.WithModelPath(dir)) + Expect(err).ToNot(HaveOccurred()) + cl := config.NewModelConfigLoader(dir) + for name, extra := range map[string]string{ + "remote-mcp": "mcp:\n remote: |\n mcpServers:\n weather:\n url: http://weather.invalid/mcp\n", + "stdio-mcp": "mcp:\n stdio: |\n mcpServers:\n files:\n command: /bin/true\n", + "plain": "", + } { + path := filepath.Join(dir, name+".yaml") + Expect(os.WriteFile(path, []byte("name: "+name+"\nbackend: llama-cpp\nknown_usecases: [chat]\n"+extra), 0600)).To(Succeed()) + Expect(cl.ReadModelConfig(path)).To(Succeed()) + } + ac := config.NewApplicationConfig() + ac.SystemState = st + ac.DisableMCP = disableMCP + ml := model.NewModelLoader(st) + gs := galleryop.NewGalleryService(ac, ml) + e := echo.New() + routes.RegisterUIAPIRoutes(e, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next }) + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil)) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + + var response struct { + Data []struct { + ID string `json:"id"` + MCP bool `json:"mcp"` + } `json:"data"` + } + Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed()) + got := map[string]bool{} + for _, entry := range response.Data { + got[entry.ID] = entry.MCP + } + return got + } + + It("flags models whose config declares remote or stdio MCP servers", func() { + Expect(capabilitiesMCP(false)).To(Equal(map[string]bool{ + "remote-mcp": true, + "stdio-mcp": true, + "plain": false, + })) + }) + + It("flags no model when MCP is disabled on the instance", func() { + Expect(capabilitiesMCP(true)).To(Equal(map[string]bool{ + "remote-mcp": false, + "stdio-mcp": false, + "plain": false, + })) + }) +}) diff --git a/docs/content/features/authentication.md b/docs/content/features/authentication.md index 40e528a09..8cb8e47a2 100644 --- a/docs/content/features/authentication.md +++ b/docs/content/features/authentication.md @@ -288,6 +288,8 @@ When auth is enabled, the React UI sidebar dynamically shows/hides sections base Admin-only pages are also protected at the router level - navigating directly to an admin URL redirects non-admin users to the home page. +In Chat, a non-admin user holding the `mcp` feature can select the MCP servers of a model whose configuration declares them (**MCP → Servers**, plus that model's MCP prompts and resources). Users without the `mcp` feature only see client-side MCP. The model information panel and the automatic context size stay admin-only, because they read the full model configuration. + ### Sign-in, invite and account pages The sign-in screen offers only what `GET /api/auth/status` reports: diff --git a/docs/content/features/mcp.md b/docs/content/features/mcp.md index e8b452b0a..6e5b6f000 100644 --- a/docs/content/features/mcp.md +++ b/docs/content/features/mcp.md @@ -291,6 +291,8 @@ Returns a model wrapper and one status entry for every configured server: 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. +When authentication is enabled, the Servers tab is offered to admins and to every user holding the `mcp` feature (on by default for new users). Chat learns that a model has MCP servers from the `mcp` flag of `GET /api/models/capabilities`, so non-admin users do not need access to the model configuration. With `LOCALAI_DISABLE_MCP=true` the flag is never set. + ### MCP Prompts MCP servers can provide reusable prompt templates. LocalAI supports discovering and expanding prompts from MCP servers.