mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 00:59:28 -04:00
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:
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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user