From eba08c195a23458c40dcaed53a872e6dfbde7a85 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 20 Jun 2026 10:01:23 +0000 Subject: [PATCH] feat(mcp): add set_alias and list_aliases tools Expose model-alias management over the LocalAI Assistant MCP surface: list_aliases (read-only, GET /api/aliases) and set_alias (mutating). SetAlias is swap-first: PATCH /api/models/config-json/:name swaps an existing alias's target (validated, non-destructive) and a 404 falls back to POST /models/import to create a fresh {name, alias} config. The inproc client mirrors this via ConfigService.PatchConfig + a create path modeled on ImportModelEndpoint. Deletion reuses delete_model. Assisted-by: Claude:claude-opus-4 [Claude Code] Signed-off-by: Ettore Di Giacinto --- .../endpoints/mcp/localai_assistant_test.go | 6 ++ pkg/mcp/localaitools/client.go | 8 ++ pkg/mcp/localaitools/coverage_test.go | 2 + pkg/mcp/localaitools/dto.go | 8 ++ pkg/mcp/localaitools/fakes_test.go | 18 ++++ pkg/mcp/localaitools/httpapi/client.go | 36 ++++++++ pkg/mcp/localaitools/httpapi/client_test.go | 86 +++++++++++++++++++ pkg/mcp/localaitools/httpapi/routes.go | 2 + pkg/mcp/localaitools/inproc/client.go | 76 ++++++++++++++++ pkg/mcp/localaitools/inproc/client_test.go | 77 +++++++++++++++++ pkg/mcp/localaitools/server.go | 1 + pkg/mcp/localaitools/server_test.go | 5 ++ pkg/mcp/localaitools/tools.go | 5 ++ pkg/mcp/localaitools/tools_aliases.go | 48 +++++++++++ 14 files changed, 378 insertions(+) create mode 100644 pkg/mcp/localaitools/tools_aliases.go diff --git a/core/http/endpoints/mcp/localai_assistant_test.go b/core/http/endpoints/mcp/localai_assistant_test.go index 26cd2878f..8de7355c6 100644 --- a/core/http/endpoints/mcp/localai_assistant_test.go +++ b/core/http/endpoints/mcp/localai_assistant_test.go @@ -51,6 +51,12 @@ func (stubClient) EditModelConfig(_ context.Context, _ string, _ map[string]any) return nil } func (stubClient) ReloadModels(_ context.Context) error { return nil } +func (stubClient) SetAlias(_ context.Context, _, _ string) error { + return nil +} +func (stubClient) ListAliases(_ context.Context) ([]localaitools.AliasInfo, error) { + return nil, nil +} func (stubClient) ListBackends(_ context.Context) ([]localaitools.Backend, error) { return []localaitools.Backend{{Name: "stub-backend", Installed: true}}, nil } diff --git a/pkg/mcp/localaitools/client.go b/pkg/mcp/localaitools/client.go index 5ac519aca..f6f6114be 100644 --- a/pkg/mcp/localaitools/client.go +++ b/pkg/mcp/localaitools/client.go @@ -38,6 +38,14 @@ type LocalAIClient interface { ReloadModels(ctx context.Context) error ImportModelURI(ctx context.Context, req ImportModelURIRequest) (*ImportModelURIResponse, error) + // ---- Model aliases ---- + // SetAlias creates the alias `name` pointing at `target`, or swaps an + // existing alias's target. The server validates that `target` is an + // existing, non-alias, enabled model. Deletion reuses DeleteModel. + SetAlias(ctx context.Context, name, target string) error + // ListAliases returns every configured alias and its target. + ListAliases(ctx context.Context) ([]AliasInfo, error) + // ---- Backends ---- // ListBackends returns installed backends. The shape stays a thin // localaitools.Backend rather than gallery.SystemBackend because the diff --git a/pkg/mcp/localaitools/coverage_test.go b/pkg/mcp/localaitools/coverage_test.go index ddf5e9c1d..39a2ab544 100644 --- a/pkg/mcp/localaitools/coverage_test.go +++ b/pkg/mcp/localaitools/coverage_test.go @@ -41,6 +41,7 @@ var toolToHTTPRoute = map[string]string{ ToolGetPIIEvents: "GET /api/pii/events", ToolGetMiddlewareStatus: "GET /api/middleware/status", ToolGetRouterDecisions: "GET /api/router/decisions", + ToolListAliases: "GET /api/aliases", // Mutating tools. ToolInstallModel: "POST /models/apply", @@ -53,6 +54,7 @@ var toolToHTTPRoute = map[string]string{ 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)", } // allKnownTools is the union of expectedFullCatalog (defined in diff --git a/pkg/mcp/localaitools/dto.go b/pkg/mcp/localaitools/dto.go index 77e9a9065..f8aa98eee 100644 --- a/pkg/mcp/localaitools/dto.go +++ b/pkg/mcp/localaitools/dto.go @@ -52,6 +52,14 @@ type ModelConfigView struct { JSON map[string]any `json:"json,omitempty" jsonschema:"Parsed JSON view of the same config (convenience for diffing)."` } +// AliasInfo is one alias -> target pair, the shape list_aliases returns and +// GET /api/aliases emits. Kept aligned with localai.AliasInfo so the +// MCP wire output matches the REST endpoint by construction. +type AliasInfo struct { + Name string `json:"name"` + Target string `json:"target"` +} + // InstallModelRequest is the input for install_model. type InstallModelRequest struct { GalleryName string `json:"gallery_name,omitempty" jsonschema:"The gallery the model lives in (from gallery_search). Optional when ModelName is unique across galleries."` diff --git a/pkg/mcp/localaitools/fakes_test.go b/pkg/mcp/localaitools/fakes_test.go index 3d76ae8b9..388245ad2 100644 --- a/pkg/mcp/localaitools/fakes_test.go +++ b/pkg/mcp/localaitools/fakes_test.go @@ -32,6 +32,8 @@ type fakeClient struct { 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 listBackends func() ([]Backend, error) listKnownBackends func() ([]schema.KnownBackend, error) @@ -143,6 +145,22 @@ func (f *fakeClient) EditModelConfig(_ context.Context, name string, patch map[s return nil } +func (f *fakeClient) SetAlias(_ context.Context, name, target string) error { + f.record("SetAlias", []any{name, target}) + if f.setAlias != nil { + return f.setAlias(name, target) + } + return nil +} + +func (f *fakeClient) ListAliases(_ context.Context) ([]AliasInfo, error) { + f.record("ListAliases", nil) + if f.listAliases != nil { + return f.listAliases() + } + return []AliasInfo{}, nil +} + func (f *fakeClient) ReloadModels(_ context.Context) error { f.record("ReloadModels", nil) if f.reloadModels != nil { diff --git a/pkg/mcp/localaitools/httpapi/client.go b/pkg/mcp/localaitools/httpapi/client.go index d2947a5b1..2a3ef2fc9 100644 --- a/pkg/mcp/localaitools/httpapi/client.go +++ b/pkg/mcp/localaitools/httpapi/client.go @@ -338,6 +338,42 @@ func (c *Client) ReloadModels(ctx context.Context) error { return c.do(ctx, http.MethodPost, routeModelsReload, nil, nil) } +// ---- Model aliases ---- + +// SetAlias is swap-first: it PATCHes the alias config (a deep-merge that +// validates the target and preserves any other fields), and only creates a +// fresh config when the PATCH reports the model doesn't exist yet. We prefer +// PATCH over POST /models/import for existing names because import rewrites +// the whole file, whereas PATCH gives a reliable 404 not-found signal +// (ErrHTTPNotFound) to branch on and never clobbers an existing config. +func (c *Client) SetAlias(ctx context.Context, name, target string) error { + if name == "" { + return errors.New("name is required") + } + if target == "" { + return errors.New("target is required") + } + err := c.do(ctx, http.MethodPatch, routeModelConfigJSON(name), map[string]any{"alias": target}, nil) + if err == nil { + return nil + } + if !errors.Is(err, ErrHTTPNotFound) { + return err + } + // No such config yet: create it. The import endpoint validates the alias + // target server-side, same as the PATCH path. + return c.do(ctx, http.MethodPost, routeModelImport, map[string]any{"name": name, "alias": target}, nil) +} + +func (c *Client) ListAliases(ctx context.Context) ([]localaitools.AliasInfo, error) { + // /api/aliases returns []{name,target} directly — pass it through. + var out []localaitools.AliasInfo + if err := c.do(ctx, http.MethodGet, routeAliases, nil, &out); err != nil { + return nil, err + } + return out, nil +} + // ---- Backends ---- func (c *Client) ListBackends(ctx context.Context) ([]localaitools.Backend, error) { diff --git a/pkg/mcp/localaitools/httpapi/client_test.go b/pkg/mcp/localaitools/httpapi/client_test.go index 6e6fc3972..319ceffee 100644 --- a/pkg/mcp/localaitools/httpapi/client_test.go +++ b/pkg/mcp/localaitools/httpapi/client_test.go @@ -166,6 +166,92 @@ var _ = Describe("httpapi.Client against the LocalAI admin REST surface", func() }) }) +var _ = Describe("Model aliases", func() { + Describe("ListAliases", func() { + It("passes the GET /api/aliases payload through unchanged", func() { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal(http.MethodGet)) + Expect(r.URL.Path).To(Equal("/api/aliases")) + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"name": "gpt-4", "target": "qwen"}, + }) + })) + DeferCleanup(srv.Close) + + out, err := New(srv.URL, "").ListAliases(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(HaveLen(1)) + Expect(out[0].Name).To(Equal("gpt-4")) + Expect(out[0].Target).To(Equal("qwen")) + }) + }) + + Describe("SetAlias", func() { + It("swaps an existing alias via PATCH without falling back to import", func() { + var patched, imported bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/api/models/config-json/gpt-4": + patched = true + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + Expect(body).To(HaveKeyWithValue("alias", "qwen")) + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + case r.URL.Path == "/models/import": + imported = true + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "unexpected", http.StatusTeapot) + } + })) + DeferCleanup(srv.Close) + + Expect(New(srv.URL, "").SetAlias(context.Background(), "gpt-4", "qwen")).To(Succeed()) + Expect(patched).To(BeTrue(), "PATCH should be attempted first") + Expect(imported).To(BeFalse(), "import must not run when PATCH succeeds") + }) + + It("creates a fresh alias via import when PATCH reports the model is missing", func() { + var imported bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPatch: + http.Error(w, "model configuration not found", http.StatusNotFound) + case r.Method == http.MethodPost && r.URL.Path == "/models/import": + imported = true + var body map[string]any + Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed()) + Expect(body).To(HaveKeyWithValue("name", "gpt-4")) + Expect(body).To(HaveKeyWithValue("alias", "qwen")) + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + default: + http.Error(w, "unexpected", http.StatusTeapot) + } + })) + DeferCleanup(srv.Close) + + Expect(New(srv.URL, "").SetAlias(context.Background(), "gpt-4", "qwen")).To(Succeed()) + Expect(imported).To(BeTrue(), "import should create the alias on a 404") + }) + + It("surfaces a non-404 PATCH error without attempting import", func() { + var imported bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/models/import" { + imported = true + } + http.Error(w, "target is an alias", http.StatusBadRequest) + })) + DeferCleanup(srv.Close) + + err := New(srv.URL, "").SetAlias(context.Background(), "gpt-4", "bad") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("target is an alias")) + Expect(imported).To(BeFalse(), "a 400 swap error must not trigger create") + }) + }) +}) + var _ = Describe("ErrHTTPNotFound", func() { Context("on a clean 404 status", func() { var ( diff --git a/pkg/mcp/localaitools/httpapi/routes.go b/pkg/mcp/localaitools/httpapi/routes.go index 79504dc1b..cc552b728 100644 --- a/pkg/mcp/localaitools/httpapi/routes.go +++ b/pkg/mcp/localaitools/httpapi/routes.go @@ -16,6 +16,8 @@ const ( routeModelsAvail = "/models/available" routeModelsGall = "/models/galleries" routeModelsImport = "/models/import-uri" + routeModelImport = "/models/import" + routeAliases = "/api/aliases" routeModelsReload = "/models/reload" routeBackends = "/backends" routeBackendsKnown = "/backends/known" diff --git a/pkg/mcp/localaitools/inproc/client.go b/pkg/mcp/localaitools/inproc/client.go index 6e047d751..acf222e1e 100644 --- a/pkg/mcp/localaitools/inproc/client.go +++ b/pkg/mcp/localaitools/inproc/client.go @@ -9,6 +9,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" "github.com/google/uuid" "github.com/mudler/LocalAI/core/config" @@ -25,7 +27,9 @@ import ( localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools" "github.com/mudler/LocalAI/pkg/model" "github.com/mudler/LocalAI/pkg/system" + "github.com/mudler/LocalAI/pkg/utils" "github.com/mudler/LocalAI/pkg/vram" + "gopkg.in/yaml.v3" ) // Client implements localaitools.LocalAIClient by calling LocalAI services @@ -298,6 +302,78 @@ func (c *Client) ReloadModels(_ context.Context) error { return c.ConfigLoader.LoadModelConfigsFromPath(c.SystemState.Model.ModelsPath) } +// ---- Model aliases ---- + +// SetAlias is swap-first to match the httpapi client: PatchConfig swaps an +// existing alias's target (validating it and preserving other fields) and +// returns ErrNotFound when the config doesn't exist yet, which is the signal +// to create it. createAlias mirrors the create path of ImportModelEndpoint. +func (c *Client) SetAlias(ctx context.Context, name, target string) error { + if name == "" { + return errors.New("name is required") + } + if target == "" { + return errors.New("target is required") + } + _, err := c.modelAdmin.PatchConfig(ctx, name, map[string]any{"alias": target}) + if err == nil { + return nil + } + if !errors.Is(err, modeladmin.ErrNotFound) { + return err + } + return c.createAlias(name, target) +} + +// createAlias writes a fresh `{name, alias}` config to disk and reloads, +// mirroring localai.ImportModelEndpoint's create path: validate, validate the +// alias target, verify the path is trusted, write, reload, best-effort preload. +func (c *Client) createAlias(name, target string) error { + if c.SystemState == nil { + return errors.New("system state not available") + } + cfg := config.ModelConfig{Name: name, Alias: target} + if valid, vErr := cfg.Validate(); !valid { + if vErr != nil { + return vErr + } + return errors.New("invalid alias configuration") + } + if err := c.ConfigLoader.ValidateAliasTarget(&cfg); err != nil { + return err + } + modelsPath := c.SystemState.Model.ModelsPath + if err := utils.VerifyPath(name+".yaml", modelsPath); err != nil { + return fmt.Errorf("model path not trusted: %w", err) + } + // Marshal only the user-provided fields (not the full struct with Go + // zero values), matching what the import endpoint persists for an alias. + yamlData, err := yaml.Marshal(map[string]any{"name": name, "alias": target}) + if err != nil { + return fmt.Errorf("marshal alias config: %w", err) + } + if err := os.WriteFile(filepath.Join(modelsPath, name+".yaml"), yamlData, 0644); err != nil { + return fmt.Errorf("write alias config: %w", err) + } + if err := c.ConfigLoader.LoadModelConfigsFromPath(modelsPath, c.AppConfig.ToConfigLoaderOptions()...); err != nil { + return fmt.Errorf("reload configs: %w", err) + } + // Preload is best-effort — a failure here doesn't undo the create. + _ = c.ConfigLoader.Preload(modelsPath) + return nil +} + +func (c *Client) ListAliases(_ context.Context) ([]localaitools.AliasInfo, error) { + // Mirror localai.ListAliasesEndpoint: every config whose Alias is set. + out := []localaitools.AliasInfo{} + for _, cfg := range c.ConfigLoader.GetAllModelsConfigs() { + if cfg.IsAlias() { + out = append(out, localaitools.AliasInfo{Name: cfg.Name, Target: cfg.Alias}) + } + } + return out, nil +} + // ---- Backends ---- func (c *Client) ListBackends(_ context.Context) ([]localaitools.Backend, error) { diff --git a/pkg/mcp/localaitools/inproc/client_test.go b/pkg/mcp/localaitools/inproc/client_test.go index 1da00602a..e385897c7 100644 --- a/pkg/mcp/localaitools/inproc/client_test.go +++ b/pkg/mcp/localaitools/inproc/client_test.go @@ -3,6 +3,8 @@ package inproc import ( "context" "errors" + "os" + "path/filepath" "time" . "github.com/onsi/ginkgo/v2" @@ -47,3 +49,78 @@ var _ = Describe("inproc.Client cancellation", func() { Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got: %v", err) }) }) + +var _ = Describe("inproc.Client model aliases", func() { + var ( + ctx context.Context + tempDir string + cl *config.ModelConfigLoader + c *Client + seedModel func(name, body string) + ) + + BeforeEach(func() { + ctx = context.Background() + tempDir = GinkgoT().TempDir() + systemState, err := system.GetSystemState(system.WithModelPath(tempDir)) + Expect(err).ToNot(HaveOccurred()) + appConfig := config.NewApplicationConfig(config.WithSystemState(systemState)) + cl = config.NewModelConfigLoader(tempDir) + // Gallery/model loaders are unused by the alias methods, so nil is fine. + c = New(appConfig, systemState, cl, nil, nil) + + seedModel = func(name, body string) { + Expect(os.WriteFile(filepath.Join(tempDir, name+".yaml"), []byte(body), 0644)).To(Succeed()) + Expect(cl.LoadModelConfigsFromPath(tempDir)).To(Succeed()) + } + }) + + Describe("ListAliases", func() { + It("returns only configs whose alias field is set", func() { + seedModel("real", "name: real\nbackend: llama-cpp\n") + seedModel("gpt-4", "name: gpt-4\nalias: real\n") + + out, err := c.ListAliases(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ConsistOf(localaitools.AliasInfo{Name: "gpt-4", Target: "real"})) + }) + + It("returns an empty slice when there are no aliases", func() { + seedModel("real", "name: real\nbackend: llama-cpp\n") + out, err := c.ListAliases(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(BeEmpty()) + }) + }) + + Describe("SetAlias", func() { + It("creates a new alias config on disk when the name is unused", func() { + seedModel("real", "name: real\nbackend: llama-cpp\n") + + Expect(c.SetAlias(ctx, "gpt-4", "real")).To(Succeed()) + + Expect(filepath.Join(tempDir, "gpt-4.yaml")).To(BeAnExistingFile()) + out, err := c.ListAliases(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ConsistOf(localaitools.AliasInfo{Name: "gpt-4", Target: "real"})) + }) + + It("swaps an existing alias's target in place", func() { + seedModel("real", "name: real\nbackend: llama-cpp\n") + seedModel("other", "name: other\nbackend: llama-cpp\n") + seedModel("gpt-4", "name: gpt-4\nalias: real\n") + + Expect(c.SetAlias(ctx, "gpt-4", "other")).To(Succeed()) + + out, err := c.ListAliases(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(out).To(ConsistOf(localaitools.AliasInfo{Name: "gpt-4", Target: "other"})) + }) + + It("rejects an alias whose target does not exist", func() { + err := c.SetAlias(ctx, "gpt-4", "missing") + Expect(err).To(HaveOccurred()) + Expect(filepath.Join(tempDir, "gpt-4.yaml")).ToNot(BeAnExistingFile()) + }) + }) +}) diff --git a/pkg/mcp/localaitools/server.go b/pkg/mcp/localaitools/server.go index fd9f5da00..4b662f66b 100644 --- a/pkg/mcp/localaitools/server.go +++ b/pkg/mcp/localaitools/server.go @@ -43,6 +43,7 @@ func NewServer(client LocalAIClient, opts Options) *mcp.Server { }) registerModelTools(srv, client, opts) + registerAliasTools(srv, client, opts) registerBackendTools(srv, client, opts) registerConfigTools(srv, client, opts) registerSystemTools(srv, client, opts) diff --git a/pkg/mcp/localaitools/server_test.go b/pkg/mcp/localaitools/server_test.go index eb1579449..052ca1e8b 100644 --- a/pkg/mcp/localaitools/server_test.go +++ b/pkg/mcp/localaitools/server_test.go @@ -88,10 +88,12 @@ var expectedFullCatalog = sortedStrings( ToolInstallModel, ToolListBackends, ToolListGalleries, + ToolListAliases, ToolListInstalledModels, ToolListKnownBackends, ToolListNodes, ToolReloadModels, + ToolSetAlias, ToolSetBranding, ToolSystemInfo, ToolToggleModelPinned, @@ -110,6 +112,7 @@ var expectedReadOnlyCatalog = sortedStrings( ToolGetPIIEvents, ToolGetRouterDecisions, ToolGetUsageStats, + ToolListAliases, ToolListBackends, ToolListGalleries, ToolListInstalledModels, @@ -165,6 +168,8 @@ var _ = Describe("Tool dispatch", func() { {ToolReloadModels, struct{}{}, "ReloadModels"}, {ToolToggleModelState, map[string]any{"name": "foo", "action": "enable"}, "ToggleModelState"}, {ToolToggleModelPinned, map[string]any{"name": "foo", "action": "pin"}, "ToggleModelPinned"}, + {ToolSetAlias, map[string]any{"name": "gpt-4", "target": "real"}, "SetAlias"}, + {ToolListAliases, struct{}{}, "ListAliases"}, } for _, c := range cases { diff --git a/pkg/mcp/localaitools/tools.go b/pkg/mcp/localaitools/tools.go index c7bf620c3..263bd791e 100644 --- a/pkg/mcp/localaitools/tools.go +++ b/pkg/mcp/localaitools/tools.go @@ -36,6 +36,11 @@ const ( ToolToggleModelState = "toggle_model_state" ToolToggleModelPinned = "toggle_model_pinned" ToolSetBranding = "set_branding" + ToolSetAlias = "set_alias" + + // ToolListAliases is read-only but lives here so the alias tools stay + // grouped; the catalog tests assert its read-only placement. + ToolListAliases = "list_aliases" ) // DefaultServerName is the MCP Implementation.Name surfaced when diff --git a/pkg/mcp/localaitools/tools_aliases.go b/pkg/mcp/localaitools/tools_aliases.go new file mode 100644 index 000000000..6b75619c1 --- /dev/null +++ b/pkg/mcp/localaitools/tools_aliases.go @@ -0,0 +1,48 @@ +package localaitools + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// registerAliasTools wires the conversational alias-management tools. An +// alias redirects all traffic for one model name to another configured +// model; list_aliases enumerates them, set_alias creates or swaps the +// target. Deletion reuses the existing delete_model tool, which works on +// any config including an alias. +func registerAliasTools(s *mcp.Server, client LocalAIClient, opts Options) { + mcp.AddTool(s, &mcp.Tool{ + Name: ToolListAliases, + Description: "List every configured model alias and the target model it routes to.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) { + aliases, err := client.ListAliases(ctx) + if err != nil { + return errorResult(err), nil, nil + } + return jsonResult(aliases), nil, nil + }) + + if opts.DisableMutating { + return + } + + mcp.AddTool(s, &mcp.Tool{ + Name: ToolSetAlias, + Description: "Create a model alias (name -> target) or swap an existing alias's target. The target must be an existing, non-alias, enabled model. Requires user confirmation per safety rule 1.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, args struct { + Name string `json:"name" jsonschema:"The alias name clients will call."` + Target string `json:"target" jsonschema:"The existing model the alias routes to."` + }) (*mcp.CallToolResult, any, error) { + if args.Name == "" { + return errorResultf("name is required"), nil, nil + } + if args.Target == "" { + return errorResultf("target is required"), nil, nil + } + if err := client.SetAlias(ctx, args.Name, args.Target); err != nil { + return errorResult(err), nil, nil + } + return jsonResult(AliasInfo{Name: args.Name, Target: args.Target}), nil, nil + }) +}