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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-06-20 10:01:23 +00:00
parent f7ad5074d9
commit eba08c195a
14 changed files with 378 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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."`

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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 (

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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())
})
})
})

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
})
}