Architecture
arcane-mcp follows the catalog-first design of gitlab-mcp-server, scaled to an API that publishes a machine-readable contract.
One catalog, three surfaces
Section titled “One catalog, three surfaces”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:
| Surface | Package | Tools | Call shape |
|---|---|---|---|
| dynamic (default) | internal/tools/dynamic | arcane_find_action, arcane_execute_action | {"action":"container.restart","params":{...},"confirm":false} |
| meta | internal/tools/meta | arcane_<domain> | {"action":"restart","params":{...},"confirm":false} |
| individual | internal/tools/individual | arcane_<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.goapi/actions.yaml ────────┘ + hand-written overlay (actions.go) │ internal/tools.BuildCatalog │ ┌───────────────────────────────┼───────────────────────────────┐ dynamic meta individual find / execute (2 tools) one tool per domain one tool per actionWhere the catalog comes from
Section titled “Where the catalog comes from”api/arcane.openapi.json Arcane's own OpenAPI document (Huma-generated), vendoredapi/actions.yaml operationId → domain.action, plus safety overridesapi/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_actionsinternal/tools/<pkg>/zz_generated_actions.go ID constants + declarationsinternal/tools/<pkg>/actions.go hand-owned overlayinternal/tools/zz_generated_domains.go the list of domains │ ▼ internal/tools.BuildCatalogactioncatalog.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.yamlnames every operation and overrides the classification where a verb misleads. - Input schemas are not hand-written:
internal/openapiderives 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 optionalenvironmentIdthat defaults toARCANE_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 inapi/coverage-allowlist.yaml. - Overlays (
actions.goin each domain package) add what a spec cannot know: usage notes that disambiguate neighbours (project.downvsproject.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.
Execution
Section titled “Execution”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 answers | The action returns |
|---|---|
{success, data, pagination} JSON | data 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.
Authentication
Section titled “Authentication”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.
Parity checks
Section titled “Parity checks”| Check | What it proves |
|---|---|
go run ./cmd/gen_actions -check | The generated declarations match the vendored spec and the inventory files |
go run ./cmd/audit_1to1 | Every OpenAPI operation and extra route maps to a catalog action (or the allowlist), and every action covers a route |
internal/wiring tests | Every 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/e2e | Scenarios run against live Arcane instances, each as a subtest per surface |
go run ./cmd/gen_tool_reference -check | This site’s tool reference matches the catalog |
Upgrading to a new Arcane release
Section titled “Upgrading to a new Arcane release”ARCANE_URL=http://<new arcane> make update-spec # fetch the new OpenAPI documentmake generate # refuses until every new operation is named in api/actions.yamlmake audit-1to1 # every route mappedmake gen-tool-reference # refresh this site's referenceThen run the end-to-end suite against an estate of the new release.