Files
LocalAI/pkg/mcp/localaitools/coverage_test.go
T
Ettore Di Giacinto ca8b398650 chore(mcp): drop stale welcome-JSON comments
Assisted-by: Claude:claude-sonnet-5-5 go-test
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
(cherry picked from commit 92283865d720f7cee08391ed2ea4816578538174)
2026-11-03 19:24:13 +00:00

194 lines
12 KiB
Go

package localaitools
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// toolToHTTPRoute is the canonical mapping between MCP tools and the
// LocalAI admin REST endpoints they wrap. The httpapi.Client MUST hit the
// listed route for the tool; the inproc.Client may bypass HTTP and call
// services directly, but the on-the-wire shape is documented here so the
// two sides stay aligned.
//
// Updating the map is REQUIRED when:
// - You add a Tool* constant (tools.go).
// - You change which REST endpoint the httpapi.Client calls.
//
// The TestToolHTTPRouteMappingComplete spec below FAILS until every Tool*
// is in the map. That is the drift detector — see
// .agents/localai-assistant-mcp.md for the contributor contract.
//
// "(none)" is a deliberate sentinel for tools whose data is not exposed
// over a single REST endpoint (e.g. system_info aggregates data the
// inproc client picks up directly from services). The httpapi.Client may
// compose it from several routes; the test still requires an entry so
// the contributor explicitly acknowledges the asymmetry.
var toolToHTTPRoute = map[string]string{
// Read-only tools.
ToolGallerySearch: "GET /models/available",
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",
ToolListBackendLibraryUpdates: "GET /backends/library/updates",
ToolSystemInfo: "GET /version + GET /system (composed)",
ToolListNodes: "GET /api/nodes",
ToolListScheduling: "GET /api/nodes/scheduling",
ToolGetScheduling: "GET /api/nodes/scheduling/:model",
ToolVRAMEstimate: "POST /api/models/vram-estimate",
ToolGetBranding: "GET /api/branding",
ToolGetUsageStats: "GET /api/usage (or /api/usage/all when all=true)",
ToolGetPIIEvents: "GET /api/pii/events",
ToolGetMiddlewareStatus: "GET /api/middleware/status",
ToolGetRouterDecisions: "GET /api/router/decisions",
ToolGetRouterCorpusStats: "GET /api/router/:name/corpus/stats",
ToolListAliases: "GET /api/aliases",
ToolListVoiceProfiles: "GET /api/voice-profiles",
ToolListSharedFolders: "GET /api/chats/admin/shared-folders",
ToolListSharedFolderEvents: "GET /api/chats/admin/shared-folders/events",
ToolListChatTools: "GET /api/chats/admin/tools",
ToolListHostExecEvents: "GET /api/chats/admin/host-exec/events",
ToolGetChatEgress: "GET /api/chats/admin/egress",
ToolListEgressEvents: "GET /api/chats/admin/egress/events",
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",
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",
ToolCreateSharedFolder: "POST /api/chats/admin/shared-folders",
ToolRenameSharedFolder: "PATCH /api/chats/admin/shared-folders/:id",
ToolDeleteSharedFolder: "DELETE /api/chats/admin/shared-folders/:id",
ToolRestoreSharedFolder: "POST /api/chats/admin/shared-folders/restore",
ToolSetSharedFolderMember: "PUT /api/chats/admin/shared-folders/:id/members/:principal",
ToolRemoveSharedFolderMember: "DELETE /api/chats/admin/shared-folders/:id/members/:principal",
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",
// chats_egress_allow_private is deliberately not a tool (uiOnly): see
// adminRoutesWithoutTool.
ToolSetChatEgressCeiling: "POST /api/settings (chats_egress_ceiling)",
// DELETE /api/chats/files/trash and DELETE /api/chats/files/trash/:rev are
// deliberately not tools. They delete a user's own files for good and
// touch only that user's Trash, so they are the user's act, not
// administration, and an assistant must not be able to make a deletion
// permanent. The file tools of a chat stop at the Trash for the same
// reason.
}
// adminRoutesWithoutTool lists the administrator routes and settings that
// deliberately have no MCP tool, each with the reason. A route listed here
// must not be wrapped by a tool too (the spec below checks it); to add a tool
// for one, move it to toolToHTTPRoute.
var adminRoutesWithoutTool = map[string]string{
// The administrator's oversight of other users' saved agents, triggers
// and connectors. Each acts on another person's private automation
// (disable or delete it). It stays a deliberate click on the
// administrator's page, where the owner and the item are on screen, and
// not something a model proposes from a chat about the instance.
"GET /api/chats/admin/agents": "oversight of other users' agents: a person's act on the admin page",
"DELETE /api/chats/admin/agents/:id": "deletes another user's agent: a person's act on the admin page",
"GET /api/chats/admin/triggers": "oversight of other users' triggers: a person's act on the admin page",
"GET /api/chats/admin/triggers/:id/runs": "the runs of another user's trigger: read on the admin page only",
"POST /api/chats/admin/triggers/:id/disable": "stops another user's trigger: a person's act on the admin page",
"DELETE /api/chats/admin/triggers/:id": "deletes another user's trigger: a person's act on the admin page",
"GET /api/chats/admin/connectors": "oversight of other users' connectors: a person's act on the admin page",
"POST /api/chats/admin/connectors/:id/disable": "stops another user's connector: a person's act on the admin page",
"DELETE /api/chats/admin/connectors/:id": "deletes another user's connector: a person's act on the admin page",
// A diagnostic button of Settings > Tools, not a setting to manage.
"POST /api/chats/admin/web/test": "a diagnostic button: it runs one search and changes nothing",
// Settings of the chats that change what commands may do on this machine
// or which add-on serves a tool. They are read with list_chat_tools; the
// change is a person's act in Settings > Tools.
"POST /api/settings (chats_host_exec)": "lets chats run commands on the server with no isolation: a person's act in Settings > Tools",
"POST /api/settings (chats_tool_providers)": "chooses which add-on serves a tool or skill: set in Settings > Tools",
"POST /api/settings (chats_egress_allow_private)": "needs the typed phrase in chats_egress_private_confirm, a person's act",
"POST /api/settings (chats_web_*)": "the web tools settings: set in Settings > Tools, with Test search",
// Read by get_chat_egress. Opening a host or a registry to every chat
// is a decision made on the Settings > Tools list editor, like the
// private switch.
"POST /api/settings (chats_egress_extra_registries)": "opens registries to every chat: set on the Settings > Tools list editor",
"POST /api/settings (chats_egress_allowed_hosts)": "opens hosts to every chat: set on the Settings > Tools list editor",
// Settings of every user's chats. A chat about the instance has no need
// to change them, and the first one is a limit a model must not raise
// for itself. A person sets them in Settings.
"POST /api/settings (chats_max_iterations)": "the step limit of every chat turn: a model must not raise its own limit; set in Settings > Tools",
"POST /api/settings (chats_work_folder)": "where every chat keeps its helper files: set in Settings > Tools",
"POST /api/settings (chats_index_files)": "reads the text of every user's documents into the server's store: a person's choice in Settings > Tools",
"POST /api/settings (chats_index_max_text_bytes)": "how much text the index keeps per document, which sizes the database: set in Settings > Tools",
"POST /api/settings (chats_saved_agents)": "lets every chat start the user's saved agents: set in Settings > Tools",
"POST /api/settings (chats_suggestions)": "the default of each user's own choice of assistant offers: set in Settings > Agents",
"POST /api/settings (chats_vision_model)": "the helper model of describe_image: set in Settings > Agents, with the model picker",
"POST /api/settings (chats_transcription_model)": "the helper model of transcribe_audio: set in Settings > Agents, with the model picker",
}
// allKnownTools is the union of expectedFullCatalog (defined in
// server_test.go). Keeping a single source of truth — the slice from
// server_test — and asserting the route map covers every entry catches
// the case "you added a Tool* but forgot to register it as MCP" indirectly
// (it'd be missing from expectedFullCatalog, which has its own assertion
// in TestServerRegistersExpectedToolCatalog).
var _ = Describe("Tool ↔ HTTP route coverage map", func() {
It("has an entry for every Tool* in the published catalog", func() {
for _, name := range expectedFullCatalog {
_, ok := toolToHTTPRoute[name]
Expect(ok).To(BeTrue(),
"Tool %q is in expectedFullCatalog but not in toolToHTTPRoute. "+
"When adding an MCP tool, update toolToHTTPRoute in coverage_test.go "+
"with the REST endpoint the httpapi.Client calls (or '(none)' with a reason).",
name)
}
})
It("does not document tools that no longer exist in the catalog", func() {
catalog := map[string]struct{}{}
for _, name := range expectedFullCatalog {
catalog[name] = struct{}{}
}
for name := range toolToHTTPRoute {
_, ok := catalog[name]
Expect(ok).To(BeTrue(),
"toolToHTTPRoute documents %q but the tool is not registered. "+
"Remove the stale entry.",
name)
}
})
It("gives a reason for every administrator route without a tool, and wraps none of them", func() {
wrapped := map[string]bool{}
for _, route := range toolToHTTPRoute {
wrapped[route] = true
}
for route, why := range adminRoutesWithoutTool {
Expect(why).NotTo(BeEmpty(), route)
Expect(wrapped[route]).To(BeFalse(), "%s has a tool and a skip reason; keep one", route)
}
})
// Deliberate non-test: we don't enumerate admin REST routes here. That
// would require booting Application or parsing core/http/routes/localai.go,
// both of which are brittle. The contract for "new admin REST endpoint
// → MCP tool" is enforced by the PR checklist in
// .agents/api-endpoints-and-auth.md, not by this test.
})