From 7493cff7270cdec215297680bac57175e1933054 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 20:25:56 +0000 Subject: [PATCH] feat(mcp): install a backend from a library entry The backend library route installs a backend from an add-on library entry, and the admin REST surface and the MCP catalog must stay in step. Add the install_library_backend tool with an in-process and an HTTP client, the route map entry, the safety-rule listing and a skill recipe. Listing library entries stays on the add-on routes, which the assistant does not expose, so no list tool is added. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- .agents/localai-assistant-mcp.md | 6 ++ core/application/application.go | 3 + pkg/mcp/localaitools/client.go | 3 + pkg/mcp/localaitools/coverage_test.go | 45 +++++----- pkg/mcp/localaitools/dto.go | 15 ++++ pkg/mcp/localaitools/fakes_test.go | 87 ++++++++++--------- pkg/mcp/localaitools/httpapi/client.go | 13 +++ pkg/mcp/localaitools/httpapi/client_test.go | 19 ++++ pkg/mcp/localaitools/httpapi/routes.go | 49 ++++++----- pkg/mcp/localaitools/inproc/client.go | 22 +++++ pkg/mcp/localaitools/prompts/10_safety.md | 2 +- pkg/mcp/localaitools/prompts/20_tools.md | 1 + .../prompts/skills/install_library_backend.md | 7 ++ pkg/mcp/localaitools/server_test.go | 4 + pkg/mcp/localaitools/tools.go | 42 ++++----- pkg/mcp/localaitools/tools_backends.go | 14 +++ 16 files changed, 226 insertions(+), 106 deletions(-) create mode 100644 pkg/mcp/localaitools/prompts/skills/install_library_backend.md diff --git a/.agents/localai-assistant-mcp.md b/.agents/localai-assistant-mcp.md index deeb02837..dcf5650bf 100644 --- a/.agents/localai-assistant-mcp.md +++ b/.agents/localai-assistant-mcp.md @@ -116,6 +116,12 @@ example Tables) follow a different, code-enforced rule, documented in `tools.Consumers` (`core/services/addons/tools/consumers.go`) as route or reject. The matrix test scans the sources and fails for an unclassified file. +One backend route sits outside the add-on surface: `POST /backends/library/install` +installs a backend (not an add-on) from a library entry. It is exposed as the +`install_library_backend` tool and is a plain admin mutation under safety +rule 1. Listing library entries stays on `/api/addons` and has no assistant +tool, for the reason above. + Do not rewrite the unrelated assistant tools to match this rule. ## Distributed mode diff --git a/core/application/application.go b/core/application/application.go index 75a2a9714..041bc320a 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -681,6 +681,9 @@ func (a *Application) start() (startErr error) { assistantClient.StatsRecorder = a.statsRecorder assistantClient.FallbackUser = a.fallbackUser assistantClient.VoiceProfiles = a.voiceProfileStore + if a.backendLibrary != nil { + assistantClient.BackendLibrary = a.backendLibrary + } if a.distributed != nil && a.distributed.Unloader != nil { assistantClient.LoadStopper = a.distributed.Unloader } diff --git a/pkg/mcp/localaitools/client.go b/pkg/mcp/localaitools/client.go index ba5c2834a..962a10179 100644 --- a/pkg/mcp/localaitools/client.go +++ b/pkg/mcp/localaitools/client.go @@ -62,6 +62,9 @@ type LocalAIClient interface { // ListKnownBackends returns the same shape as REST /backends/known. ListKnownBackends(ctx context.Context) ([]schema.KnownBackend, error) InstallBackend(ctx context.Context, req InstallBackendRequest) (jobID string, err error) + // InstallLibraryBackend installs a backend from a library entry. It + // returns when the install is done. + InstallLibraryBackend(ctx context.Context, req InstallLibraryBackendRequest) (*InstallLibraryBackendResult, error) UpgradeBackend(ctx context.Context, name string) (jobID string, err error) // ---- System ---- diff --git a/pkg/mcp/localaitools/coverage_test.go b/pkg/mcp/localaitools/coverage_test.go index 6ec572024..da114455f 100644 --- a/pkg/mcp/localaitools/coverage_test.go +++ b/pkg/mcp/localaitools/coverage_test.go @@ -49,28 +49,29 @@ var toolToHTTPRoute = map[string]string{ ToolListFailoverChains: "GET /api/failover", // Mutating tools. - ToolInstallModel: "POST /models/apply", - ToolImportModelURI: "POST /models/import-uri", - ToolDeleteModel: "POST /models/delete/:name", - ToolEditModelConfig: "PATCH /api/models/config-json/:name", - ToolReloadModels: "POST /models/reload", - ToolCancelModelLoad: "POST /api/models/:id/load-cancel", - ToolLoadModel: "POST /backend/load", - ToolInstallBackend: "POST /backends/apply", - ToolUpgradeBackend: "POST /backends/upgrade/:name", - ToolToggleModelState: "PUT /models/toggle-state/:name/:action", - ToolToggleModelPinned: "PUT /models/toggle-pinned/:name/:action", - ToolSetBranding: "POST /api/settings (instance_name, instance_tagline)", - ToolSetAlias: "PATCH /api/models/config-json/:name (swap) or POST /models/import (create)", - ToolSeedRouterCorpus: "POST /api/router/:name/corpus", - ToolClearRouterCorpus: "DELETE /api/router/:name/corpus", - ToolCreateVoiceProfile: "POST /api/voice-profiles", - ToolDeleteVoiceProfile: "DELETE /api/voice-profiles/:id", - ToolSetNodeVRAMBudget: "PUT /api/nodes/:id/vram-budget", - ToolSetScheduling: "POST /api/nodes/scheduling", - ToolDeleteScheduling: "DELETE /api/nodes/scheduling/:model", - ToolPinFailoverTarget: "POST /api/failover/:chain/pin", - ToolUnpinFailoverTarget: "DELETE /api/failover/:chain/pin", + ToolInstallModel: "POST /models/apply", + ToolImportModelURI: "POST /models/import-uri", + ToolDeleteModel: "POST /models/delete/:name", + ToolEditModelConfig: "PATCH /api/models/config-json/:name", + ToolReloadModels: "POST /models/reload", + ToolCancelModelLoad: "POST /api/models/:id/load-cancel", + ToolLoadModel: "POST /backend/load", + ToolInstallBackend: "POST /backends/apply", + ToolInstallLibraryBackend: "POST /backends/library/install", + ToolUpgradeBackend: "POST /backends/upgrade/:name", + ToolToggleModelState: "PUT /models/toggle-state/:name/:action", + ToolToggleModelPinned: "PUT /models/toggle-pinned/:name/:action", + ToolSetBranding: "POST /api/settings (instance_name, instance_tagline)", + ToolSetAlias: "PATCH /api/models/config-json/:name (swap) or POST /models/import (create)", + ToolSeedRouterCorpus: "POST /api/router/:name/corpus", + ToolClearRouterCorpus: "DELETE /api/router/:name/corpus", + ToolCreateVoiceProfile: "POST /api/voice-profiles", + ToolDeleteVoiceProfile: "DELETE /api/voice-profiles/:id", + ToolSetNodeVRAMBudget: "PUT /api/nodes/:id/vram-budget", + ToolSetScheduling: "POST /api/nodes/scheduling", + ToolDeleteScheduling: "DELETE /api/nodes/scheduling/:model", + ToolPinFailoverTarget: "POST /api/failover/:chain/pin", + ToolUnpinFailoverTarget: "DELETE /api/failover/:chain/pin", } // allKnownTools is the union of expectedFullCatalog (defined in diff --git a/pkg/mcp/localaitools/dto.go b/pkg/mcp/localaitools/dto.go index 88d7159ff..a2e3bf07a 100644 --- a/pkg/mcp/localaitools/dto.go +++ b/pkg/mcp/localaitools/dto.go @@ -76,6 +76,21 @@ type InstallBackendRequest struct { BackendName string `json:"backend_name" jsonschema:"Backend identifier (e.g. llama-cpp)."` } +// InstallLibraryBackendRequest is the input for install_library_backend. +type InstallLibraryBackendRequest struct { + SourceID string `json:"source_id" jsonschema:"The library the entry comes from, as shown in the Add-ons library list."` + Name string `json:"name" jsonschema:"The backend name of the library entry."` + Version string `json:"version" jsonschema:"The version or the channel of the library entry."` +} + +// InstallLibraryBackendResult is the output of install_library_backend. The +// install runs to the end before the call returns, so there is no job id. +type InstallLibraryBackendResult struct { + Status string `json:"status"` + Name string `json:"name"` + SourceID string `json:"source_id"` +} + // Backend is the LLM-facing summary returned by list_backends. We don't // expose gallery.SystemBackend directly because it carries filesystem // paths (RunFile, IsSystem, IsMeta, the full Metadata) the LLM doesn't diff --git a/pkg/mcp/localaitools/fakes_test.go b/pkg/mcp/localaitools/fakes_test.go index d8a85491d..37812bb1b 100644 --- a/pkg/mcp/localaitools/fakes_test.go +++ b/pkg/mcp/localaitools/fakes_test.go @@ -23,45 +23,46 @@ type fakeClient struct { calls []fakeCall // Per-method overrides. Tests set these. - gallerySearch func(GallerySearchQuery) ([]gallery.Metadata, error) - listInstalledModels func(Capability) ([]InstalledModel, error) - listGalleries func() ([]config.Gallery, error) - getJobStatus func(string) (*JobStatus, error) - getModelConfig func(string) (*ModelConfigView, error) - installModel func(InstallModelRequest) (string, error) - importModelURI func(ImportModelURIRequest) (*ImportModelURIResponse, error) - deleteModel func(string) error - editModelConfig func(string, map[string]any) error - setAlias func(string, string) error - listAliases func() ([]AliasInfo, error) - reloadModels func() error - loadModel func(string) ([]string, error) - listBackends func() ([]Backend, error) - listKnownBackends func() ([]schema.KnownBackend, error) - installBackend func(InstallBackendRequest) (string, error) - upgradeBackend func(string) (string, error) - systemInfo func() (*SystemInfo, error) - listNodes func() ([]Node, error) - listScheduling func() ([]ModelSchedulingConfig, error) - getScheduling func(string) (*ModelSchedulingConfig, error) - setScheduling func(SetSchedulingRequest) (*ModelSchedulingConfig, error) - deleteScheduling func(string) error - setNodeVRAMBudget func(string, string) error - vramEstimate func(VRAMEstimateRequest) (*vram.EstimateResult, error) - toggleModelState func(string, modeladmin.Action) error - toggleModelPinned func(string, modeladmin.Action) error - getBranding func() (*Branding, error) - setBranding func(SetBrandingRequest) (*Branding, error) - listVoiceProfiles func() ([]VoiceProfile, error) - createVoiceProfile func(CreateVoiceProfileRequest) (*VoiceProfile, error) - deleteVoiceProfile func(string) error - getUsageStats func(UsageStatsQuery) (*UsageStats, error) - getPIIEvents func(PIIEventsQuery) ([]PIIEvent, error) - getMiddlewareStatus func() (*MiddlewareStatus, error) - getRouterDecisions func(RouterDecisionsQuery) ([]RouterDecision, error) - listFailoverChains func() ([]FailoverChainInfo, error) - pinFailoverTarget func(string, string) error - unpinFailoverTarget func(string) error + gallerySearch func(GallerySearchQuery) ([]gallery.Metadata, error) + listInstalledModels func(Capability) ([]InstalledModel, error) + listGalleries func() ([]config.Gallery, error) + getJobStatus func(string) (*JobStatus, error) + getModelConfig func(string) (*ModelConfigView, error) + installModel func(InstallModelRequest) (string, error) + importModelURI func(ImportModelURIRequest) (*ImportModelURIResponse, error) + deleteModel func(string) error + editModelConfig func(string, map[string]any) error + setAlias func(string, string) error + listAliases func() ([]AliasInfo, error) + reloadModels func() error + loadModel func(string) ([]string, error) + listBackends func() ([]Backend, error) + listKnownBackends func() ([]schema.KnownBackend, error) + installBackend func(InstallBackendRequest) (string, error) + installLibraryBackend func(InstallLibraryBackendRequest) (*InstallLibraryBackendResult, error) + upgradeBackend func(string) (string, error) + systemInfo func() (*SystemInfo, error) + listNodes func() ([]Node, error) + listScheduling func() ([]ModelSchedulingConfig, error) + getScheduling func(string) (*ModelSchedulingConfig, error) + setScheduling func(SetSchedulingRequest) (*ModelSchedulingConfig, error) + deleteScheduling func(string) error + setNodeVRAMBudget func(string, string) error + vramEstimate func(VRAMEstimateRequest) (*vram.EstimateResult, error) + toggleModelState func(string, modeladmin.Action) error + toggleModelPinned func(string, modeladmin.Action) error + getBranding func() (*Branding, error) + setBranding func(SetBrandingRequest) (*Branding, error) + listVoiceProfiles func() ([]VoiceProfile, error) + createVoiceProfile func(CreateVoiceProfileRequest) (*VoiceProfile, error) + deleteVoiceProfile func(string) error + getUsageStats func(UsageStatsQuery) (*UsageStats, error) + getPIIEvents func(PIIEventsQuery) ([]PIIEvent, error) + getMiddlewareStatus func() (*MiddlewareStatus, error) + getRouterDecisions func(RouterDecisionsQuery) ([]RouterDecision, error) + listFailoverChains func() ([]FailoverChainInfo, error) + pinFailoverTarget func(string, string) error + unpinFailoverTarget func(string) error } type fakeCall struct { @@ -218,6 +219,14 @@ func (f *fakeClient) InstallBackend(_ context.Context, req InstallBackendRequest return "", errNotConfigured } +func (f *fakeClient) InstallLibraryBackend(_ context.Context, req InstallLibraryBackendRequest) (*InstallLibraryBackendResult, error) { + f.record("InstallLibraryBackend", req) + if f.installLibraryBackend != nil { + return f.installLibraryBackend(req) + } + return nil, errNotConfigured +} + func (f *fakeClient) UpgradeBackend(_ context.Context, name string) (string, error) { f.record("UpgradeBackend", name) if f.upgradeBackend != nil { diff --git a/pkg/mcp/localaitools/httpapi/client.go b/pkg/mcp/localaitools/httpapi/client.go index 726f121a3..db02796d1 100644 --- a/pkg/mcp/localaitools/httpapi/client.go +++ b/pkg/mcp/localaitools/httpapi/client.go @@ -428,6 +428,19 @@ func (c *Client) InstallBackend(ctx context.Context, req localaitools.InstallBac return resp.ID, nil } +func (c *Client) InstallLibraryBackend(ctx context.Context, req localaitools.InstallLibraryBackendRequest) (*localaitools.InstallLibraryBackendResult, error) { + body := map[string]any{"sourceId": req.SourceID, "name": req.Name, "version": req.Version} + var resp struct { + Status string `json:"status"` + Name string `json:"name"` + SourceID string `json:"sourceId"` + } + if err := c.do(ctx, http.MethodPost, routeBackendsLibraryInstall, body, &resp); err != nil { + return nil, err + } + return &localaitools.InstallLibraryBackendResult{Status: resp.Status, Name: resp.Name, SourceID: resp.SourceID}, nil +} + func (c *Client) UpgradeBackend(ctx context.Context, name string) (string, error) { var resp struct { ID string `json:"uuid"` diff --git a/pkg/mcp/localaitools/httpapi/client_test.go b/pkg/mcp/localaitools/httpapi/client_test.go index fd1cff632..06327f197 100644 --- a/pkg/mcp/localaitools/httpapi/client_test.go +++ b/pkg/mcp/localaitools/httpapi/client_test.go @@ -482,3 +482,22 @@ var _ = Describe("Bearer token", func() { Expect(sawAuth).To(Equal("Bearer secret-key")) }) }) + +var _ = Describe("InstallLibraryBackend", func() { + It("posts the library entry to the backend library route", func() { + var path string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.Method + " " + r.URL.Path + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + _ = json.NewEncoder(w).Encode(map[string]any{"status": "installed", "name": "demo", "sourceId": "official"}) + })) + DeferCleanup(srv.Close) + + res, err := New(srv.URL, "").InstallLibraryBackend(context.Background(), localaitools.InstallLibraryBackendRequest{SourceID: "official", Name: "demo", Version: "1.0.0"}) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("POST /backends/library/install")) + Expect(body).To(Equal(map[string]any{"sourceId": "official", "name": "demo", "version": "1.0.0"})) + Expect(res.Status).To(Equal("installed")) + }) +}) diff --git a/pkg/mcp/localaitools/httpapi/routes.go b/pkg/mcp/localaitools/httpapi/routes.go index 229e6d0ff..a890aad3f 100644 --- a/pkg/mcp/localaitools/httpapi/routes.go +++ b/pkg/mcp/localaitools/httpapi/routes.go @@ -11,30 +11,31 @@ 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" - 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" + routeWelcome = "/" + 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" + routeBackendsLibraryInstall = "/backends/library/install" + 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" ) func routeJobStatus(jobID string) string { diff --git a/pkg/mcp/localaitools/inproc/client.go b/pkg/mcp/localaitools/inproc/client.go index b1900656b..5448b3289 100644 --- a/pkg/mcp/localaitools/inproc/client.go +++ b/pkg/mcp/localaitools/inproc/client.go @@ -56,6 +56,9 @@ type Client struct { // stopper the HTTP endpoint uses, so both paths stop work the same way. LoadStopper nodes.LoadAttemptStopper VoiceProfiles *voiceprofile.Store + // BackendLibrary backs install_library_backend. nil when the add-on + // library is off; the tool then reports it is not enabled. + BackendLibrary LibraryBackendInstaller // StatsRecorder and FallbackUser are optional — they back the // get_usage_stats tool. nil StatsRecorder makes the tool return an @@ -478,6 +481,25 @@ func (c *Client) InstallBackend(ctx context.Context, req localaitools.InstallBac return id.String(), nil } +// LibraryBackendInstaller installs a backend from a library entry. It is the +// installer the REST route uses, so both paths share one code path. +type LibraryBackendInstaller interface { + Install(ctx context.Context, sourceID, backend, version string, status func(string, string, string, float64)) error +} + +func (c *Client) InstallLibraryBackend(ctx context.Context, req localaitools.InstallLibraryBackendRequest) (*localaitools.InstallLibraryBackendResult, error) { + if req.SourceID == "" || req.Name == "" || req.Version == "" { + return nil, errors.New("source_id, name and version are required") + } + if c.BackendLibrary == nil { + return nil, errors.New("the backend library is not enabled on this instance") + } + if err := c.BackendLibrary.Install(ctx, req.SourceID, req.Name, req.Version, nil); err != nil { + return nil, err + } + return &localaitools.InstallLibraryBackendResult{Status: "installed", Name: req.Name, SourceID: req.SourceID}, nil +} + func (c *Client) UpgradeBackend(ctx context.Context, name string) (string, error) { if name == "" { return "", errors.New("name is required") diff --git a/pkg/mcp/localaitools/prompts/10_safety.md b/pkg/mcp/localaitools/prompts/10_safety.md index 1417a51cf..1cd0482bc 100644 --- a/pkg/mcp/localaitools/prompts/10_safety.md +++ b/pkg/mcp/localaitools/prompts/10_safety.md @@ -2,7 +2,7 @@ These rules are non-negotiable. The user trusts you to operate their server without unintended changes. -1. **Confirm before mutating.** Before calling any of these tools — `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `load_model`, `cancel_model_load`, `toggle_model_state`, `toggle_model_pinned`, `set_branding`, `set_alias`, `seed_router_corpus`, `clear_router_corpus`, `create_voice_profile`, `delete_voice_profile`, `set_node_vram_budget`, `set_scheduling`, `delete_scheduling`, `pin_failover_target`, `unpin_failover_target` — first state in plain language what you are about to do (which tool, which target, which arguments) and wait for the user's explicit confirmation in the next turn. "Yes", "do it", "go ahead", "proceed" all count as confirmation. Anything else does not. +1. **Confirm before mutating.** Before calling any of these tools — `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `install_library_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `load_model`, `cancel_model_load`, `toggle_model_state`, `toggle_model_pinned`, `set_branding`, `set_alias`, `seed_router_corpus`, `clear_router_corpus`, `create_voice_profile`, `delete_voice_profile`, `set_node_vram_budget`, `set_scheduling`, `delete_scheduling`, `pin_failover_target`, `unpin_failover_target` — first state in plain language what you are about to do (which tool, which target, which arguments) and wait for the user's explicit confirmation in the next turn. "Yes", "do it", "go ahead", "proceed" all count as confirmation. Anything else does not. 2. **Disambiguate before mutating.** If the user's request is ambiguous (several gallery candidates match, the model name has multiple installed versions, the backend has variants), present the candidates as a numbered list and ask the user to pick before calling any mutating tool. diff --git a/pkg/mcp/localaitools/prompts/20_tools.md b/pkg/mcp/localaitools/prompts/20_tools.md index 591c49f51..e18e39945 100644 --- a/pkg/mcp/localaitools/prompts/20_tools.md +++ b/pkg/mcp/localaitools/prompts/20_tools.md @@ -32,6 +32,7 @@ The MCP `tools/list` endpoint also exposes the full input schema for each of the - `import_model_uri` — Install a model from an arbitrary URI (HuggingFace, OCI, http(s), file://). May return `ambiguous_backend` when several backends apply; call again with `backend_preference` to disambiguate. - `delete_model` — Delete an installed model. - `install_backend` — Install a backend. +- `install_library_backend` — Install a backend from an add-on library entry by `source_id`, `name` and `version`. The call returns when the install is done, with no job id. The backend gallery is not changed. - `upgrade_backend` — Upgrade an installed backend by name. - `edit_model_config` — Patch (deep-merge) JSON into an installed model's config. - `reload_models` — Reload all model configs from disk. diff --git a/pkg/mcp/localaitools/prompts/skills/install_library_backend.md b/pkg/mcp/localaitools/prompts/skills/install_library_backend.md new file mode 100644 index 000000000..da85863e8 --- /dev/null +++ b/pkg/mcp/localaitools/prompts/skills/install_library_backend.md @@ -0,0 +1,7 @@ +# Skill: Install a backend from the add-on library + +1. The user names a library backend, or asks for one that the backend gallery does not have. You need the `source_id` (the library), the backend `name` and the `version` or channel, as the Add-ons library list shows them. Ask the user for any of the three that is missing. Do not guess them. +2. Tell the user that the backend runs native code, that the backend gallery is not changed, and that the library checks the signature of the image. +3. Wait for explicit confirmation, then call `install_library_backend` with `source_id`, `name` and `version`. The call returns when the install is done. +4. On an error, report the reason as it is. A name installed from another origin, a failed integrity check and an image over the size limit are refusals of the host. Do not retry with other values. +5. After a success, call `list_backends` to confirm the backend is installed. diff --git a/pkg/mcp/localaitools/server_test.go b/pkg/mcp/localaitools/server_test.go index cc33024ec..cbd29eba5 100644 --- a/pkg/mcp/localaitools/server_test.go +++ b/pkg/mcp/localaitools/server_test.go @@ -146,6 +146,7 @@ var _ = Describe("Tool dispatch", func() { {ToolImportModelURI, ImportModelURIRequest{URI: "Qwen/Qwen3-4B-GGUF"}, "ImportModelURI"}, {ToolDeleteModel, map[string]any{"name": "foo"}, "DeleteModel"}, {ToolInstallBackend, InstallBackendRequest{BackendName: "llama-cpp"}, "InstallBackend"}, + {ToolInstallLibraryBackend, InstallLibraryBackendRequest{SourceID: "official", Name: "demo", Version: "1.0.0"}, "InstallLibraryBackend"}, {ToolUpgradeBackend, map[string]any{"name": "llama-cpp"}, "UpgradeBackend"}, {ToolEditModelConfig, map[string]any{"name": "foo", "patch": map[string]any{"context_size": 4096}}, "EditModelConfig"}, {ToolReloadModels, struct{}{}, "ReloadModels"}, @@ -169,6 +170,9 @@ var _ = Describe("Tool dispatch", func() { installModel: func(InstallModelRequest) (string, error) { return "job-1", nil }, installBackend: func(InstallBackendRequest) (string, error) { return "job-2", nil }, upgradeBackend: func(string) (string, error) { return "job-3", nil }, + installLibraryBackend: func(InstallLibraryBackendRequest) (*InstallLibraryBackendResult, error) { + return &InstallLibraryBackendResult{Status: "installed"}, nil + }, } ctx, sess, done := connectInMemory(fc, Options{}) DeferCleanup(done) diff --git a/pkg/mcp/localaitools/tools.go b/pkg/mcp/localaitools/tools.go index ee03d196e..da1afb5d5 100644 --- a/pkg/mcp/localaitools/tools.go +++ b/pkg/mcp/localaitools/tools.go @@ -30,26 +30,27 @@ const ( // Mutating tools — guarded by Options.DisableMutating and the // LLM-side safety prompt (see prompts/10_safety.md). - ToolInstallModel = "install_model" - ToolImportModelURI = "import_model_uri" - ToolDeleteModel = "delete_model" - ToolEditModelConfig = "edit_model_config" - ToolReloadModels = "reload_models" - ToolCancelModelLoad = "cancel_model_load" - ToolLoadModel = "load_model" - ToolInstallBackend = "install_backend" - ToolUpgradeBackend = "upgrade_backend" - ToolToggleModelState = "toggle_model_state" - ToolToggleModelPinned = "toggle_model_pinned" - ToolSetBranding = "set_branding" - ToolSetAlias = "set_alias" - ToolSeedRouterCorpus = "seed_router_corpus" - ToolClearRouterCorpus = "clear_router_corpus" - ToolCreateVoiceProfile = "create_voice_profile" - ToolDeleteVoiceProfile = "delete_voice_profile" - ToolSetNodeVRAMBudget = "set_node_vram_budget" - ToolSetScheduling = "set_scheduling" - ToolDeleteScheduling = "delete_scheduling" + ToolInstallModel = "install_model" + ToolImportModelURI = "import_model_uri" + ToolDeleteModel = "delete_model" + ToolEditModelConfig = "edit_model_config" + ToolReloadModels = "reload_models" + ToolCancelModelLoad = "cancel_model_load" + ToolLoadModel = "load_model" + ToolInstallBackend = "install_backend" + ToolUpgradeBackend = "upgrade_backend" + ToolInstallLibraryBackend = "install_library_backend" + ToolToggleModelState = "toggle_model_state" + ToolToggleModelPinned = "toggle_model_pinned" + ToolSetBranding = "set_branding" + ToolSetAlias = "set_alias" + ToolSeedRouterCorpus = "seed_router_corpus" + ToolClearRouterCorpus = "clear_router_corpus" + ToolCreateVoiceProfile = "create_voice_profile" + ToolDeleteVoiceProfile = "delete_voice_profile" + ToolSetNodeVRAMBudget = "set_node_vram_budget" + ToolSetScheduling = "set_scheduling" + ToolDeleteScheduling = "delete_scheduling" // ToolPinFailoverTarget and ToolUnpinFailoverTarget live here (rather // than grouped with ToolListFailoverChains below) so mutatingToolNames // stays a contiguous scan of this block. @@ -83,6 +84,7 @@ var mutatingToolNames = []string{ ToolLoadModel, ToolInstallBackend, ToolUpgradeBackend, + ToolInstallLibraryBackend, ToolToggleModelState, ToolToggleModelPinned, ToolSetBranding, diff --git a/pkg/mcp/localaitools/tools_backends.go b/pkg/mcp/localaitools/tools_backends.go index 546e5f0ed..3b721ed3c 100644 --- a/pkg/mcp/localaitools/tools_backends.go +++ b/pkg/mcp/localaitools/tools_backends.go @@ -47,6 +47,20 @@ func registerBackendTools(s *mcp.Server, client LocalAIClient, opts Options) { return jsonResult(map[string]any{"job_id": jobID}), nil, nil }) + mcp.AddTool(s, &mcp.Tool{ + Name: ToolInstallLibraryBackend, + Description: "Install a backend from an add-on library entry (a backend entry of a library source). The library resolves the image and checks its signature, so the call names only the source, the backend and the version. The backend gallery is not changed and a backend runs native code. The call returns when the install is done. Requires user confirmation per safety rule 1.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, args InstallLibraryBackendRequest) (*mcp.CallToolResult, any, error) { + if args.SourceID == "" || args.Name == "" || args.Version == "" { + return errorResultf("source_id, name and version are required"), nil, nil + } + res, err := client.InstallLibraryBackend(ctx, args) + if err != nil { + return errorResult(err), nil, nil + } + return jsonResult(res), nil, nil + }) + mcp.AddTool(s, &mcp.Tool{ Name: ToolUpgradeBackend, Description: "Upgrade an installed backend by name. Requires user confirmation per safety rule 1.",