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 <stefan.walcz@walcz.de>
This commit is contained in:
Stefan Walcz authored and GitHub committed 2026-10-19 23:43:47 +02:00
1 parent b56b903c92
commit 95034f1147
6 files changed
+169 -3

No files matched your search

@@ -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)
})
+24 -3
View File
@@ -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
+8
View File
@@ -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 {
@@ -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,
}))
})
})
+2
View File
@@ -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:
+2
View File
@@ -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.