Skip to content

Architecture

arcane-mcp follows the catalog-first design of gitlab-mcp-server, scaled to an API that publishes a machine-readable contract.

Every Arcane capability is declared once, as a toolutil.ActionSpec with a canonical ID domain.action (container.restart, swarm_service.scale). The surfaces are projections of that catalog, never parallel registrations, so they cannot drift apart:

SurfacePackageToolsCall shape
dynamic (default)internal/tools/dynamicarcane_find_action, arcane_execute_action{"action":"container.restart","params":{...},"confirm":false}
metainternal/tools/metaarcane_<domain>{"action":"restart","params":{...},"confirm":false}
individualinternal/tools/individualarcane_<domain>_<action>{...params, "confirm":false}

All three call toolutil.Dispatch, which applies the same rules in the same order: validate arguments against the action’s input schema; for a destructive action without confirm: true, ask the user through MCP elicitation when the client supports it and safe mode is off, otherwise refuse; answer with a preview in safe mode; run under the action deadline (ARCANE_MCP_ACTION_TIMEOUT) and render; write one audit record. Errors are returned as tool results with isError and a next step, never as protocol errors.

internal/wiring builds the server for the configured surface; internal/tools/manifest adds the arcane://tools and arcane://tools/{id} resources, internal/resources the Docker-state resources and workflow guides (Resources), internal/prompts the workflow prompts (Prompts), and internal/completions the suggestions for their arguments and for the resource-template variables, taken from the catalog’s list actions with the same client and cached for a few seconds (Completions). Read-only mode and exclusions are applied once, when actioncatalog.Build filters the catalog, so no surface has to remember them.

api/arcane.openapi.json ─┐
api/extra-routes.yaml ───┼─► cmd/gen_actions ─► internal/tools/<domain>/zz_generated_actions.go
api/actions.yaml ────────┘ + hand-written overlay (actions.go)
│
internal/tools.BuildCatalog
│
┌───────────────────────────────┼───────────────────────────────┐
dynamic meta individual
find / execute (2 tools) one tool per domain one tool per action
api/arcane.openapi.json Arcane's own OpenAPI document (Huma-generated), vendored
api/actions.yaml operationId → domain.action, plus safety overrides
api/extra-routes.yaml routes Arcane serves outside OpenAPI (from its source)
api/coverage-allowlist.yaml routes deliberately not exposed, with reasons
│
▼ go run ./cmd/gen_actions
internal/tools/<pkg>/zz_generated_actions.go ID constants + declarations
internal/tools/<pkg>/actions.go hand-owned overlay
internal/tools/zz_generated_domains.go the list of domains
│
▼ internal/tools.BuildCatalog
actioncatalog.Catalog (validated, filtered by read-only / exclusions)
  • Arcane’s REST API is declared with Huma, so the OpenAPI document it serves is generated from the same code that routes requests. It is vendored in api/, and every action’s input schema is derived from it.
  • Generated declarations carry the ID, operationId, route, Arcane’s summary and description, and the mutating / destructive / idempotent classification. api/actions.yaml names every operation and overrides the classification where a verb misleads.
  • Input schemas are not hand-written: internal/openapi derives each action’s schema from the operation when the catalog is built. Path, query, header and cookie parameters and the JSON body’s properties become one flat object; referenced components are copied under $defs. The {id} path parameter of /environments/{id}/... becomes an optional environmentId that defaults to ARCANE_ENVIRONMENT_ID.
  • Routes outside the OpenAPI document — WebSockets, pprof, the webhook trigger, the federated token exchange — were enumerated from Arcane’s source and are declared in api/extra-routes.yaml. The few not exposed (agent wire protocol, browser telemetry relay, test-only helpers) are listed with reasons in api/coverage-allowlist.yaml.
  • Overlays (actions.go in each domain package) add what a spec cannot know: usage notes that disambiguate neighbours (project.down vs project.destroy), aliases (docker ps), related actions, and handlers for actions with behaviour of their own. Applying an overlay fails on an ID that no longer exists, so a rename upstream cannot leave a stale refinement behind.

Most actions run through the generic executor toolutil.ExecuteREST: arguments are bound back to the path template, query string, headers, cookies and body (JSON, multipart with file objects, or a raw octet-stream) using the bindings the schema was built from. toolutil.ReadResponse then shapes the answer:

Arcane answersThe action returns
{success, data, pagination} JSONdata as structured content (arrays under items), pagination alongside
application/x-json-stream progress (pull, build, up, redeploy)consumed to the end; status, activity ID, errors and the last progress lines
a text download (logs, YAML, PEM)the text, capped by ARCANE_MCP_MAX_DOWNLOAD_BYTES
a binary download (tarballs, backups, profiles)metadata plus an embedded blob resource

Hand-written handlers (internal/tools/livestream and domain overlays) cover the routes outside OpenAPI: WebSocket logs and stats, the container terminal behind container.exec, the SSE feed, the diagnostics streams, pprof, the anonymous webhook trigger and the RFC 8693 federated token exchange. Streams and files describes what each returns.

internal/arcane.Client sends X-API-Key when an API key is configured. With ARCANE_USERNAME/ARCANE_PASSWORD it logs in, keeps the access and refresh tokens, and on a 401 refreshes (or logs in again) and replays the request once. Operations whose OpenAPI security accepts only a Bearer session (passkeys, MFA) use the session even when an API key is configured.

In HTTP mode the server keeps one MCP server per distinct Arcane key (the client’s X-Arcane-API-Key, or the server’s own), all sharing one built catalog.

CheckWhat it proves
go run ./cmd/gen_actions -checkThe generated declarations match the vendored spec and the inventory files
go run ./cmd/audit_1to1Every OpenAPI operation and extra route maps to a catalog action (or the allowlist), and every action covers a route
internal/wiring testsEvery action is registered on the meta and individual surfaces and resolvable by dynamic execute; the same call gives the same result on all three
test/e2eScenarios run against live Arcane instances, each as a subtest per surface
go run ./cmd/gen_tool_reference -checkThis site’s tool reference matches the catalog
Terminal window
ARCANE_URL=http://<new arcane> make update-spec # fetch the new OpenAPI document
make generate # refuses until every new operation is named in api/actions.yaml
make audit-1to1 # every route mapped
make gen-tool-reference # refresh this site's reference

Then run the end-to-end suite against an estate of the new release.