0019: MCP and REST heads derive everything from operations¶
Context and Problem Statement¶
Coding agents talk MCP; other systems talk HTTP. ADR 0003 promised that operations written once would appear in every head. Plugins should get both without a line of head-specific code.
Considered Options¶
- MCP and REST heads derived from operations through one shared exposure rule (chosen)
No alternatives were recorded when this decision was made.
Decision Outcome¶
Chosen option: MCP and REST heads derive everything from operations, because every module, built-in or plugin, then serves both without extra code.
- One exposure rule for all heads (
heads/_exposure.py): an operation's path (("system", "doctor")), its public parameters (injected ones removed) and its return type; modules collapse like in the CLI; an operation'sheads=(e.g.{"cli"}for interactive ones) restricts where it appears. - MCP head (official
mcpSDK,MCPServer): a tool per operation, namedsystem_doctor; input schema from theAnnotated[T, Field(...)]parameters, output schema from the return model,read_onlymapped to MCP's read-only/destructive hints,HeinzelErrorto tool errors.hz system serve mcp(stdio, or streamable HTTP). - REST head (FastAPI): an endpoint per operation at
/system/doctor,GETwhen read-only andPOSTotherwise, query parameters described by theirField, OpenAPI at/docs.HeinzelErrorand invalid arguments →400 {"error": ...}; an operation whose CLI exit code would be non-zero →503, so/system/doctoris a health probe.hz system serve api. - Arguments are validated by the kernel (
Kernel.invoke) in every head, so behaviour is identical. - The heads are imported only when served, so
hzstays fast.
Consequences¶
- Good: every module, built-in or plugin, is an MCP server and a REST API without extra code.
- Neutral: head-specific lets (MCP resources and prompts, REST webhooks) remain possible later; operations stay the default.
Confirmation¶
tests/integration/heads_test.py (test_mcp_tools, test_rest_endpoints), tests/unit/heads/exposure_test.py
(test_paths_names_and_public_parameters, test_heads_restrict_exposure),
tests/unit/kernel/kernel_test.py::test_invoke_validates_arguments_against_their_annotations; import-linter's
"Heads only know what the kernel discovered".
More Information¶
- The REST head answers only local, same-origin requests (2026-10-24, on review of the workflow contracts). It has
no authentication, and a web page in the user's browser can reach
127.0.0.1too, directly or through DNS rebinding. So it refuses with403and a message saying why any request whose Host isn'tlocalhost,127.0.0.1or[::1]on the port it listens on, unless the user lists the name in[hz] api_allowed_hosts(user scope only, for serving on another interface; on port 80 or 443 a Host without a port counts), and any request carrying an Origin that isn't its own, or, without an Origin,Sec-Fetch-Site: cross-siteorsame-site. Operations that spend money or act unattended are not REST endpoints at all:hz workflow runandhz system daemon tickdeclareheads={"cli", "mcp"}(launchd and systemd run the CLI). Tests:tests/integration/rest_security_test.py.