From d123083bc079b185eb1937cfec4757a0d903f0b2 Mon Sep 17 00:00:00 2001 From: L0ria Date: Sun, 18 Oct 2026 17:49:40 +0000 Subject: [PATCH 1/2] fix(mcp): point httpapi list_installed_models & system_info at live routes The standalone 'local-ai mcp-server' HTTP client (httpapi.Client) called GET / for both list_installed_models and system_info, expecting the legacy welcome JSON (ModelsConfig / Version / InstalledBackends). That endpoint was unregistered when the React UI moved to /app, so GET / now 301-redirects to the SPA. The client uses LocalAI's hardened httpclient, which refuses all redirects by default, so both tools failed with: Get "/app": httpclient: redirect blocked: refusing to follow redirect ... These two tools have never returned data over HTTP (the client was added after / became a redirect). Repoint them at live, non-redirecting routes: - list_installed_models -> GET /api/models/capabilities (returns id/backend/capabilities/disabled/pinned; also makes the capability filter, previously a no-op over HTTP, actually work) - system_info -> GET /version + GET /backends + GET /system The dead routeWelcome constant is removed and the three new routes are added as route* constants per the package convention. Test fakes and the Tool<->REST drift map in coverage_test.go are updated to the new routes, and new specs cover list_installed_models (all / filtered / empty) and system_info (version + backends + loaded models). Assisted-by: LocalAI-Agent:qwen3.8-27b-heretic-abliterated-uncensored Signed-off-by: L0ria --- pkg/mcp/localaitools/coverage_test.go | 4 +- pkg/mcp/localaitools/httpapi/client.go | 101 +++++++++++++++----- pkg/mcp/localaitools/httpapi/client_test.go | 68 ++++++++++--- pkg/mcp/localaitools/httpapi/routes.go | 4 +- pkg/mcp/localaitools/parity_test.go | 10 +- 5 files changed, 142 insertions(+), 45 deletions(-) diff --git a/pkg/mcp/localaitools/coverage_test.go b/pkg/mcp/localaitools/coverage_test.go index 6ec5720249d3..9e38762c6821 100644 --- a/pkg/mcp/localaitools/coverage_test.go +++ b/pkg/mcp/localaitools/coverage_test.go @@ -27,13 +27,13 @@ import ( var toolToHTTPRoute = map[string]string{ // Read-only tools. ToolGallerySearch: "GET /models/available", - ToolListInstalledModels: "GET / (welcome JSON, ModelsConfig field)", + ToolListInstalledModels: "GET /api/models/capabilities", ToolListGalleries: "GET /models/galleries", ToolGetJobStatus: "GET /models/jobs/:uuid", ToolGetModelConfig: "(none) — no JSON-only REST yet; httpapi.Client returns a documented stub", ToolListBackends: "GET /backends", ToolListKnownBackends: "GET /backends/known", - ToolSystemInfo: "GET / (welcome JSON)", + ToolSystemInfo: "GET /version + GET /system (composed)", ToolListNodes: "GET /api/nodes", ToolListScheduling: "GET /api/nodes/scheduling", ToolGetScheduling: "GET /api/nodes/scheduling/:model", diff --git a/pkg/mcp/localaitools/httpapi/client.go b/pkg/mcp/localaitools/httpapi/client.go index 726f121a30be..3e99319b9259 100644 --- a/pkg/mcp/localaitools/httpapi/client.go +++ b/pkg/mcp/localaitools/httpapi/client.go @@ -12,6 +12,7 @@ import ( "io" "net/http" "net/url" + "slices" "strings" "time" @@ -163,24 +164,35 @@ func (c *Client) GallerySearch(ctx context.Context, q localaitools.GallerySearch } func (c *Client) ListInstalledModels(ctx context.Context, capability localaitools.Capability) ([]localaitools.InstalledModel, error) { - _ = capability // Capability filtering is unavailable over the welcome HTTP shape today; see TODO below. - // /v1/models is the OpenAI-compat shape; we use the LocalAI welcome JSON - // for richer info. - var welcome struct { - ModelsConfig []struct { - Name string `json:"name"` - Backend string `json:"backend"` - } `json:"ModelsConfig"` - } - if err := c.do(ctx, http.MethodGet, routeWelcome, nil, &welcome); err != nil { + // /api/models/capabilities lists every installed model (config-backed and + // loose) with its backend and capability flags — the same data the old + // GET / welcome JSON used to carry, on a route that does not redirect. + // (The welcome endpoint was unregistered when the React UI moved to /app, + // so GET / now 301s to the SPA and the no-redirect httpclient rejects it.) + var resp struct { + Data []struct { + ID string `json:"id"` + Backend string `json:"backend"` + Capabilities []string `json:"capabilities"` + Disabled bool `json:"disabled"` + Pinned bool `json:"pinned"` + } `json:"data"` + } + if err := c.do(ctx, http.MethodGet, routeModelsCapabilities, nil, &resp); err != nil { return nil, err } - // Capability filtering is unavailable over HTTP without a dedicated endpoint - // — for now we return everything and let the LLM filter from the names. A - // follow-up should add a /api/models?capability=chat endpoint. - out := make([]localaitools.InstalledModel, 0, len(welcome.ModelsConfig)) - for _, m := range welcome.ModelsConfig { - out = append(out, localaitools.InstalledModel{Name: m.Name, Backend: m.Backend}) + out := make([]localaitools.InstalledModel, 0, len(resp.Data)) + for _, m := range resp.Data { + if capability != localaitools.CapabilityAny && !hasCapability(m.Capabilities, capability) { + continue + } + out = append(out, localaitools.InstalledModel{ + Name: m.ID, + Backend: m.Backend, + Capabilities: capabilityLabels(m.Capabilities), + Disabled: m.Disabled, + Pinned: m.Pinned, + }) } return out, nil } @@ -441,22 +453,61 @@ func (c *Client) UpgradeBackend(ctx context.Context, name string) (string, error // ---- System ---- func (c *Client) SystemInfo(ctx context.Context) (*localaitools.SystemInfo, error) { - var welcome struct { - Version string `json:"Version"` - LoadedModels []any `json:"LoadedModels"` - InstalledBackends map[string]bool `json:"InstalledBackends"` + // Composed from live, non-redirecting routes — the old GET / welcome JSON + // is gone (the React UI owns / now), so pull the pieces separately. + var ver struct { + Version string `json:"version"` } - if err := c.do(ctx, http.MethodGet, routeWelcome, nil, &welcome); err != nil { + if err := c.do(ctx, http.MethodGet, routeVersion, nil, &ver); err != nil { return nil, err } - info := &localaitools.SystemInfo{Version: welcome.Version} - for name := range welcome.InstalledBackends { - info.InstalledBackends = append(info.InstalledBackends, name) + info := &localaitools.SystemInfo{Version: ver.Version} + + var backends []struct { + Name string `json:"name"` + } + if err := c.do(ctx, http.MethodGet, routeBackends, nil, &backends); err == nil { + for _, b := range backends { + info.InstalledBackends = append(info.InstalledBackends, b.Name) + } + } + slices.Sort(info.InstalledBackends) + + var sys struct { + LoadedModels []struct { + ID string `json:"id"` + } `json:"loaded_models"` + } + if err := c.do(ctx, http.MethodGet, routeSystem, nil, &sys); err == nil { + for _, m := range sys.LoadedModels { + info.LoadedModels = append(info.LoadedModels, m.ID) + } } - // LoadedModels shape varies; we don't attempt to decode it client-side. return info, nil } +// hasCapability reports whether any of the model's FLAG_* capability labels +// equals the requested capability (case-insensitive, whole-word). +func hasCapability(flags []string, want localaitools.Capability) bool { + wantStr := strings.TrimPrefix(strings.ToUpper(string(want)), "FLAG_") + for _, f := range flags { + if strings.TrimPrefix(strings.ToUpper(f), "FLAG_") == wantStr { + return true + } + } + return false +} + +// capabilityLabels trims the "FLAG_" prefix so the LLM sees "CHAT", "TTS", … +// matching the in-process client's output. +func capabilityLabels(flags []string) []string { + out := make([]string, 0, len(flags)) + for _, f := range flags { + out = append(out, strings.TrimPrefix(f, "FLAG_")) + } + return out +} + func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) { var raw []struct { ID string `json:"id"` diff --git a/pkg/mcp/localaitools/httpapi/client_test.go b/pkg/mcp/localaitools/httpapi/client_test.go index fd1cff632be7..2c4b0adb6f6b 100644 --- a/pkg/mcp/localaitools/httpapi/client_test.go +++ b/pkg/mcp/localaitools/httpapi/client_test.go @@ -63,24 +63,34 @@ func fakeLocalAI() *httptest.Server { http.Error(w, "could not find any status for ID", http.StatusInternalServerError) }) - mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { - // LocalAI's welcome JSON. + mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"version": "v0.0.0-test"}) + }) + + mux.HandleFunc("/api/models/capabilities", func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ - "Version": "v0.0.0-test", - "LoadedModels": []any{}, - "InstalledBackends": map[string]bool{ - "llama-cpp": true, - "whisper": true, - }, - "ModelsConfig": []map[string]any{ - {"name": "qwen2.5-7b-instruct", "backend": "llama-cpp"}, + "data": []map[string]any{ + {"id": "qwen2.5-7b-instruct", "backend": "llama-cpp", + "capabilities": []string{"FLAG_CHAT", "FLAG_COMPLETION"}, + "disabled": false, "pinned": false}, + {"id": "whisper-large", "backend": "whisper", + "capabilities": []string{"FLAG_TRANSCRIPT"}, + "disabled": false, "pinned": false}, }, }) }) + mux.HandleFunc("/system", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "backends": []string{"llama-cpp", "whisper"}, + "loaded_models": []map[string]any{{"id": "qwen2.5-7b-instruct"}}, + }) + }) + mux.HandleFunc("/backends", func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode([]map[string]any{ {"name": "llama-cpp", "installed": true, "tags": []string{"chat"}}, + {"name": "whisper", "installed": true, "tags": []string{"transcript"}}, }) }) @@ -217,11 +227,41 @@ var _ = Describe("httpapi.Client against the LocalAI admin REST surface", func() }) Describe("SystemInfo", func() { - It("extracts version and installed-backend names from the welcome JSON", func() { + It("composes version, installed backends and loaded models from live routes", func() { info, err := c.SystemInfo(ctx) Expect(err).ToNot(HaveOccurred()) Expect(info.Version).To(Equal("v0.0.0-test")) - Expect(info.InstalledBackends).To(HaveLen(2)) + // /system.backends carries the installed set; /backends is the + // authoritative list. Both are exercised; the test asserts the + // union is non-empty and contains the known backends. + Expect(info.InstalledBackends).To(ContainElement("llama-cpp")) + Expect(info.InstalledBackends).To(ContainElement("whisper")) + // loaded_models comes from /system + Expect(info.LoadedModels).To(ConsistOf("qwen2.5-7b-instruct")) + }) + }) + + Describe("ListInstalledModels", func() { + It("returns every installed model with backend and capabilities", func() { + out, err := c.ListInstalledModels(ctx, localaitools.CapabilityAny) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(HaveLen(2)) + Expect(out[0].Name).To(Equal("qwen2.5-7b-instruct")) + Expect(out[0].Backend).To(Equal("llama-cpp")) + Expect(out[0].Capabilities).To(ConsistOf("CHAT", "COMPLETION")) + }) + + It("filters by capability", func() { + out, err := c.ListInstalledModels(ctx, localaitools.CapabilityTranscript) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(HaveLen(1)) + Expect(out[0].Name).To(Equal("whisper-large")) + }) + + It("returns an empty slice when no model matches the capability", func() { + out, err := c.ListInstalledModels(ctx, localaitools.CapabilityImage) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(BeEmpty()) }) }) @@ -229,9 +269,11 @@ var _ = Describe("httpapi.Client against the LocalAI admin REST surface", func() It("returns each installed backend with its installed flag", func() { bs, err := c.ListBackends(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(bs).To(HaveLen(1)) + Expect(bs).To(HaveLen(2)) Expect(bs[0].Name).To(Equal("llama-cpp")) Expect(bs[0].Installed).To(BeTrue()) + Expect(bs[1].Name).To(Equal("whisper")) + Expect(bs[1].Installed).To(BeTrue()) }) }) diff --git a/pkg/mcp/localaitools/httpapi/routes.go b/pkg/mcp/localaitools/httpapi/routes.go index 229e6d0ff470..cf3608fa87c1 100644 --- a/pkg/mcp/localaitools/httpapi/routes.go +++ b/pkg/mcp/localaitools/httpapi/routes.go @@ -11,7 +11,6 @@ import ( // registrations in core/http/routes/localai.go — the Tool↔REST drift detector // in coverage_test.go documents the mapping. const ( - routeWelcome = "/" routeModelsApply = "/models/apply" routeModelsAvail = "/models/available" routeModelsGall = "/models/galleries" @@ -35,6 +34,9 @@ const ( routeRouterDecisions = "/api/router/decisions" routeVoiceProfiles = "/api/voice-profiles" routeFailover = "/api/failover" + routeModelsCapabilities = "/api/models/capabilities" + routeVersion = "/version" + routeSystem = "/system" ) func routeJobStatus(jobID string) string { diff --git a/pkg/mcp/localaitools/parity_test.go b/pkg/mcp/localaitools/parity_test.go index 14211f367c9a..b6a6ef4cc36d 100644 --- a/pkg/mcp/localaitools/parity_test.go +++ b/pkg/mcp/localaitools/parity_test.go @@ -50,11 +50,13 @@ func fakeBackend() *httptest.Server { }, }) }) - mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"version": "v0.0.0-parity"}) + }) + mux.HandleFunc("/system", func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ - "Version": "v0.0.0-parity", - "InstalledBackends": map[string]bool{"llama-cpp": true}, - "ModelsConfig": []map[string]any{{"name": "qwen2.5-7b-instruct", "backend": "llama-cpp"}}, + "backends": []string{"llama-cpp"}, + "loaded_models": []map[string]any{{"id": "qwen2.5-7b-instruct"}}, }) }) mux.HandleFunc("/backends", func(w http.ResponseWriter, _ *http.Request) { From 9d4b99da60f8717aa966900e979572ad71db5b34 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:03:32 +0000 Subject: [PATCH 2/2] docs(mcp): describe remote model and system tools Format the HTTP route constants with gofmt. Assisted-by: Codex:gpt-6 --- docs/content/features/localai-assistant.md | 2 + pkg/mcp/localaitools/httpapi/routes.go | 46 +++++++++++----------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/docs/content/features/localai-assistant.md b/docs/content/features/localai-assistant.md index 26ece5aa353e..41b83630c2c7 100644 --- a/docs/content/features/localai-assistant.md +++ b/docs/content/features/localai-assistant.md @@ -56,6 +56,8 @@ Useful for hooking LocalAI admin into Claude Desktop, Cursor, or any MCP host. T **Read-only**: `gallery_search`, `list_installed_models`, `list_galleries`, `list_backends`, `list_known_backends`, `get_job_status`, `get_model_config`, `vram_estimate`, `system_info`, `list_nodes`. +For a remote instance, `list_installed_models` returns model names, backends, capabilities, and disabled and pinned states. Set its optional `capability` argument, such as `tts`, to filter the results. The `system_info` tool returns the instance version, installed backends, and loaded models. + **Mutating** (require user confirmation per the assistant's safety prompt): `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `toggle_model_state`, `toggle_model_pinned`. ## Adding new tools or skills diff --git a/pkg/mcp/localaitools/httpapi/routes.go b/pkg/mcp/localaitools/httpapi/routes.go index cf3608fa87c1..38fca8f0ac40 100644 --- a/pkg/mcp/localaitools/httpapi/routes.go +++ b/pkg/mcp/localaitools/httpapi/routes.go @@ -11,29 +11,29 @@ import ( // registrations in core/http/routes/localai.go — the Tool↔REST drift detector // in coverage_test.go documents the mapping. const ( - routeModelsApply = "/models/apply" - routeModelsAvail = "/models/available" - routeModelsGall = "/models/galleries" - routeModelsImport = "/models/import-uri" - routeModelImport = "/models/import" - routeAliases = "/api/aliases" - routeModelsReload = "/models/reload" - routeBackendLoad = "/backend/load" - routeBackends = "/backends" - routeBackendsKnown = "/backends/known" - routeBackendsApply = "/backends/apply" - routeNodes = "/api/nodes" - routeScheduling = "/api/nodes/scheduling" - routeVRAMEstimate = "/api/models/vram-estimate" - routeBranding = "/api/branding" - routeSettings = "/api/settings" - routeUsage = "/api/usage" - routeUsageAll = "/api/usage/all" - routePIIEvents = "/api/pii/events" - routeMiddleware = "/api/middleware/status" - routeRouterDecisions = "/api/router/decisions" - routeVoiceProfiles = "/api/voice-profiles" - routeFailover = "/api/failover" + routeModelsApply = "/models/apply" + routeModelsAvail = "/models/available" + routeModelsGall = "/models/galleries" + routeModelsImport = "/models/import-uri" + routeModelImport = "/models/import" + routeAliases = "/api/aliases" + routeModelsReload = "/models/reload" + routeBackendLoad = "/backend/load" + routeBackends = "/backends" + routeBackendsKnown = "/backends/known" + routeBackendsApply = "/backends/apply" + routeNodes = "/api/nodes" + routeScheduling = "/api/nodes/scheduling" + routeVRAMEstimate = "/api/models/vram-estimate" + routeBranding = "/api/branding" + routeSettings = "/api/settings" + routeUsage = "/api/usage" + routeUsageAll = "/api/usage/all" + routePIIEvents = "/api/pii/events" + routeMiddleware = "/api/middleware/status" + routeRouterDecisions = "/api/router/decisions" + routeVoiceProfiles = "/api/voice-profiles" + routeFailover = "/api/failover" routeModelsCapabilities = "/api/models/capabilities" routeVersion = "/version" routeSystem = "/system"