MHL
META-HARNESS LANGUAGE
MHL · SERVING WORKFLOWS · v1.4 RELEASE CANDIDATE
mhl serve exposes every pipeline/workflow declared under a directory to another agent — as Model Context Protocol tools or Agent2Agent skills — without writing any server code. This page is the server side; calling out to someone else's MCP or A2A server is extension mcp / extension a2a in the reference.
Every transport publishes one tool / skill per declaration found under the directory (default .) — a pipeline, workflow, or workflow alias, each published by the file that declares it (a workflow a file only imports is not published again from there). A declaration marked internal is not published at all. Each derives its input contract from the workflow's input name: Type declarations — every input required, unknown inputs rejected — and its description from an optional description: "..." body property. A call runs the workflow through the same engine as mhl run and returns its final variable state.
mhl serve mcp <dir>newline-delimited JSON-RPC 2.0 on stdin/stdout — the form an MCP client uses when it spawns the servermhl serve mcp --http <dir>one JSON-RPC message per POST /mcp, JSON responses only (no SSE) — the form a networked MCP client usesmhl serve a2a <dir>JSON-RPC 2.0 over HTTP with an Agent Card at /.well-known/agent-card.jsonA synchronous tools/call runs in its own throwaway state directory — no checkpoint or context.vars carry-over between calls. The async path (mhl_run_* tools, below) keeps state so a run can be resumed.
Save these four self-contained workflows (no real LLM needed) into a workflows/ directory — every example on this page serves that directory.
// workflows/summarize.mh
agent Writer {
command: "echo"
args: ["${prompt}"]
}
workflow Summarize {
description: "Summarize a topic in at most `max_words` words."
input topic: string
input max_words: number
var summary = ""
step Draft { summary = Writer.run(prompt: "Summarize ${topic} in ${max_words} words.") }
}
// workflows/triage.mh
workflow Triage {
description: "Classify an incoming issue as normal or urgent."
input title: string
input body: string
var priority = "normal"
step Classify {
if (title.contains("outage") || body.contains("down")) priority = "urgent"
}
}
// workflows/report.mh — four ~1s steps, so run/status visibly advances
workflow Report {
description: "Four-step report pipeline (gather, analyze, summarize, publish)."
input topic: string
var gathered = ""
var analysis = ""
var summary = ""
var published = ""
step Gather { gathered = cmd.exec(["sh", "-c", "sleep 1; echo notes on ${topic}"]).stdout }
step Analyze { analysis = cmd.exec(["sh", "-c", "sleep 1; echo analyzed"]).stdout + " " + gathered }
step Summarize { summary = cmd.exec(["sh", "-c", "sleep 1; echo summary of ${topic}"]).stdout }
step Publish { published = "published: " + summary }
}
// workflows/approval.mh — the human-in-the-loop gate
workflow Approval {
description: "Prepare, wait for human approval, then finish."
input request: string
input approved: string // "no" on start, "yes" on resume
var summary = ""
step Prepare { summary = "ready to act on: " + request }
step Gate {
if (approved != "yes") pause("awaiting approval for: " + request)
}
step Act { summary = "acted on: " + request }
}
mhl lint ./workflows
mhl serve mcp ./workflows # stdio
mhl serve mcp --http ./workflows # http://127.0.0.1:8711/mcp
mhl serve a2a ./workflows # http://127.0.0.1:8710/
SummarizeSummarize(topic, max_words)a before-free agent step (uses echo, so no real LLM)TriageTriage(title, body)pure string logic, no agentReportReport(topic)four ~1s steps — watch mhl_run_status advance step by stepApprovalApproval(request, approved)a pause() gate — the paused / mhl_run_resume loopmhl serve mcp <dir> speaks newline-delimited JSON-RPC 2.0 on stdin/stdout — the form an MCP client uses when it spawns the server as a subprocess. One tool per declaration; tools/call returns the final variable state (isError: true on a run failure). Diagnostics go to stderr; stdout is the raw protocol stream.
Initialize, list tools, and call a workflow
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"Triage","arguments":{"title":"api outage","body":"everything is down"}}}' \
| mhl serve mcp ./workflows
Both protocol modes work on one connection: the initialize / notifications/initialized handshake (revisions 2025-11-25 / 2025-06-18 / 2025-03-26), and the stateless 2026-07-28 form where every tools/* request carries params._meta with io.modelcontextprotocol/protocolVersion + io.modelcontextprotocol/clientCapabilities and server/discover replaces initialize. A stateless request missing that _meta is rejected with -32602.
mhl serve mcp --http [--addr host:port] [--token t] [--state-dir path] [--workflows-pack file] <dir> serves the same tools over the transport a networked client uses: one JSON-RPC message per POST /mcp, JSON responses only. Default bind 127.0.0.1:8711.
--addr127.0.0.1:8711bind address; a warning is printed if it is non-loopback and no token is set--token · MHL_SERVE_TOKEN(unset)when set, every request must carry Authorization: Bearer <token> — the gateway↔server shared secret--principal-header · MHL_SERVE_PRINCIPAL_HEADER(unset)header an authenticated upstream sets the caller identity in; run ownership keys on it (needs --token). Also reaches a workflow as read-only context.principal--state-dir · MHL_SERVE_STATE_DIRper-process temp dirwhere async run/* state is kept — a real path makes runs survive a restart--drain-timeout · MHL_SERVE_DRAIN_TIMEOUT0on SIGTERM, how long in-flight async runs get to finish before they are cancelled--max-concurrent-runs · MHL_SERVE_MAX_CONCURRENT_RUNS0 (unlimited)ceiling on executing runs; an extra mhl_run_start reports state: "queued" — queue depth is in /metrics as mhl_serve_runs_queued--single-replica · MHL_SERVE_SINGLE_REPLICAfalserequired to start when an extension state is configured but lacks the cas capability — acknowledges that only one writer runs (see Multiple replicas below)--workflows-pack · MHL_SERVE_WORKFLOWS_PACK(unset)load the program and prompts from an encrypted DMPK v2 pack, decrypted only in memory, instead of <dir> — see Workflows pack belowMHL_SERVE_WORKFLOWS_KEY(unset)the pack's AES-256 key as 64 hex characters; required with a pack, an error without one. Env only, never a flagMHL_SERVE_RUN_LOCK_TTL45sper-run execution-lease TTL; heartbeat, renew budget and the reconcile stale window are derived from it. Widen on a CPU-contended hostMHL_SERVE_SESSION_TTL1hhow long an idle MCP session stays resolvable; a 60 s housekeeping sweep drops older onesMHL_SERVE_TERMINAL_RUN_TTL10mhow long a finished run's records linger in a shared extension state before the sweep deletes the whole run/<id>/ subtree — lower it on a high-throughput deployment so the shared table (and every reconcile/sweep scan) stays smallOperational endpoints, unauthenticated: GET /healthz (liveness), GET /readyz (503 while draining), GET /metrics (Prometheus text). POST /mcp/<method> (e.g. /mcp/run/resume) mirrors POST /mcp so a policy engine can gate one method. A single extension state <Name> [from "<id>"] { ... } declaration in the workflow directory routes all durable state (sessions + run/* checkpoints) through that extension instead of .mhl/state — e.g. extension state S from "mhl.state.postgres" { dsn: env("DATABASE_URL") }; from is needed only when more than one installed package serves state. The former kind name store is still accepted, in the declaration and from an older package — see extensions.
A host that ships its workflows confidential passes --workflows-pack <file> with the key in MHL_SERVE_WORKFLOWS_KEY. Every .mh, prompt … from, skill … from and schema … from file is then read from the pack — never from the disk, and never written anywhere — and <dir> is only the root that paths handed to subprocesses are composed under: a schema's path is <dir>/<its path in the pack>, so the host materializes the files an engine opens by path (schemas, scripts) at that layout. The key is removed from the process environment before anything runs, so no agent CLI or cmd.exec child inherits it and env() cannot read it; the process's own initial environment block (ps -E, /proc/<pid>/environ) still shows it to the same user. A malformed or wrong key, one tampered byte, a truncated pack or a repeated path is fatal — nothing is served. Build the pack with mhl pack <dir> -o workflows.dmpk (key from the same env var, or KEY=$(mhl pack <dir> -o workflows.dmpk --new-key)): every non-hidden file, sorted by path, written only after its workflows load from it. --http only. Format: 12-byte header "DMPK" · u8 2 · 3 zero bytes · u32 count, then per entry a 12-byte nonce, a u32 length and an AES-256-GCM ciphertext (AAD: the entry index as u32) of u16 path length · path · content, little-endian throughout.
Guards: the Origin header, when present, must be loopback (DNS-rebinding). GET /mcp is 405 (no server-to-client stream). DELETE /mcp with an Mcp-Session-Id ends that session (204, or 404 if unknown).
Standard lifecycle — initialize returns an Mcp-Session-Id header the client echoes on every later request
mhl serve mcp --http --addr 127.0.0.1:8711 ./workflows &
sid=$(curl -sD - -o /dev/null -X POST localhost:8711/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}' \
| awk 'tolower($1)=="mcp-session-id:"{print $2}' | tr -d '\r')
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"Triage","arguments":{"title":"api outage","body":"everything is down"}}}'
curl -s -X DELETE localhost:8711/mcp -H "Mcp-Session-Id: $sid" # 204 — end the session
Stateless — no session, every request carries params._meta
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,
"method":"tools/call","params":{"name":"Triage","arguments":{"title":"x","body":"y"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}'
# an unknown Mcp-Session-Id is 404; missing _meta with no session is -32602
mhl_run_start, mhl_run_status, mhl_run_cancelA plain tools/call blocks until the workflow finishes. For a long one, call mhl_run_start instead — same workflow, but it returns a runId right away; then poll mhl_run_status for the current step, the steps reached so far, and the final vars. mhl_run_cancel stops a run and mhl_run_list enumerates yours. These are ordinary tools, so any MCP client drives them — each is a tools/call like any other, and its status object comes back as structuredContent.
Start, poll, and cancel — all through tools/call
rid=$(curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"mhl_run_start",
"arguments":{"workflow":"Report","arguments":{"topic":"caching"}}}}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["structuredContent"]["runId"])')
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"mhl_run_status\",\"arguments\":{\"runId\":\"$rid\"}}}"
# structuredContent -> {"state":"working","step":"Gather","stepIndex":1,"stepTotal":4,"reached":["Gather"], ...}
# structuredContent -> {"state":"working","step":"Summarize","stepIndex":3,"stepTotal":4,"reached":["Gather","Analyze","Summarize"], ...}
# structuredContent -> {"state":"completed","reached":["Gather","Analyze","Summarize","Publish"],"vars":{ ... }}
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"mhl_run_cancel\",\"arguments\":{\"runId\":\"$rid\"}}}"
# structuredContent -> {"state":"canceled", ...}
The status object — returned as structuredContent, and mirrored in the result's text block
statealwaysworking · completed · failed · canceled · pausedstep · stepIndex · stepTotalonce a step has startedthe step the run is on, its 1-based position, and the declared step countreachedalwaysordered steps that started; on completion it becomes the authoritative executed listvarscompletedthe run's final variable stateerror · resumablefailed / canceledthe failure message, and whether mhl_run_resume can continue itreasonpausedthe pause(...) value — a human-in-the-loop hand-off; vars and resumable are also setEach run is owned by the session (Mcp-Session-Id) that started it. mhl_run_status, mhl_run_resume, mhl_run_cancel and mhl_run_list only act for that caller — any other sees unknown runId (the same answer as a genuinely unknown id, so it is not an existence oracle). Stateless callers have no session and share one anonymous owner, so stateless mode has no per-caller isolation. Terminal runs stay pollable for an hour; a server shutdown cancels any still running.
Each mhl_run_* tool is a thin bridge over a same-named custom JSON-RPC method — run/start, run/status, run/resume, run/cancel, run/list, run/logs — that takes {name, arguments} / {runId} directly and returns the bare status object with no tools/call envelope. Reach for those only when a policy gateway must match one method by name (POST /mcp/run/resume); from an ordinary MCP client, use the tools. Every per-workflow tools/list entry carries _meta.mhl.run.tool: "mhl_run_start" as a hint that it can also be driven async.
The runId from mhl_run_start is durable from that call — it is a real id, not a placeholder that a later step upgrades, and there is no separate claim id. arguments for mhl_run_start and mhl_run_resume must be JSON-serialisable and at most 256 KiB — they are persisted with the run, so pass large payloads by reference (a URL, a blob key), not inline.
pause() + mhl_run_resumeA gate step calls pause("...") to hand off to a human: the run parks in state: "paused" — not failed, not completed — resumable, with the reason and the vars-so-far in its status, and its state kept (unlike a completed run). mhl_run_resume with {runId, arguments} re-enters that same step, merging arguments over the originals — that is where the approval decision goes. pause() writes its own checkpoint, so no checkpoint {} block is required; a step that calls fail("...") instead lands in state: "failed" and needs checkpoint { strategy: "per_step" } to be resumable.
Approval workflow
workflow Approval {
description: "Prepare, wait for human approval, then finish."
input request: string
input approved: string // "no" on mhl_run_start, "yes" on mhl_run_resume
var summary = ""
step Prepare { summary = "ready to act on: " + request }
step Gate {
if (approved != "yes") {
pause("awaiting approval for: " + request)
}
}
step Act { summary = "acted on: " + request }
}
Start, inspect, and resume — all through tools/call
rid=$(curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"mhl_run_start",
"arguments":{"workflow":"Approval","arguments":{"request":"ship it","approved":"no"}}}}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["structuredContent"]["runId"])')
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"mhl_run_status\",\"arguments\":{\"runId\":\"$rid\"}}}"
# structuredContent -> {"state":"paused","step":"Gate","reached":["Prepare","Gate"],"resumable":true,"reason":"awaiting approval for: ship it"}
# a human approves
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"mhl_run_resume\",\"arguments\":{\"runId\":\"$rid\",\"arguments\":{\"approved\":\"yes\"}}}}"
# structuredContent -> {"state":"working", ...} then mhl_run_status -> {"state":"completed","vars":{ ... }}
Start the server with --state-dir <dir> (or MHL_SERVE_STATE_DIR) and a later process pointed at the same directory can mhl_run_status / mhl_run_resume a runId it never started — the first caller to name the (unguessable) id reclaims it. Without it, run state is per-process and lost on restart. Checkpoints are one small JSON file per completed step, sized by the pipeline's variable state; mhl_run_status on a live run stays a pure in-memory read.
When several server processes share one extension state that advertises the cas capability (atomic compare-and-swap), each run is guarded by a short-lived lock in the store: exactly one replica executes or resumes a given runId at a time, renewed on a heartbeat (~1/3 of the lease TTL). The lease TTL defaults to 45 s; widen it with MHL_SERVE_RUN_LOCK_TTL on a CPU-contended host so a slow renew is not mistaken for a dead replica. Each acquisition is tagged with a token, so a replica whose lease lapsed and was taken over cannot renew or release the successor's lock. A confirmed lease is required to start: if the store cannot be reached to take the lock the run fails ("could not acquire run execution lease") rather than running uncoordinated, and a run/resume whose lock state can't be read is refused. If a replica dies, its lock lapses after the lease TTL and run/status reports the run failed with resumable: true — recovery is an explicit run/resume on a live replica, never automatic; the running replica also cancels its own work if its renewals fail for long enough that the lease is about to expire. A store without cas makes the server refuse to start unless you pass --single-replica (or set MHL_SERVE_SINGLE_REPLICA) to acknowledge a one-writer deployment; a plain --state-dir with no extension state is unaffected. This lock stops two replicas from starting work on the same run, and checkpoint writes are fenced on the lease — with a store that advertises fence (e.g. PostgreSQL) a checkpoint write is one atomic statement conditioned on the lease still being this replica's, so a replica taken over after a stall has the write rejected by the store; without it, a check-then-write with a narrow race. Each replica also runs a periodic housekeeping sweep (every 60 s) that reaps idle sessions and, on a shared store, deletes the records of runs that finished more than MHL_SERVE_TERMINAL_RUN_TTL ago — so the store does not grow without bound. A store that advertises the scan capability serves the durable-intake reconcile/sweep scans and the mhl_serve_runs_pending gauge from a single indexed query instead of a per-run read.
mhl:// introspectionA tools/list entry is deliberately compact — name, description, inputSchema, plus outputSchema for a workflow with a typed result (workflow X(req: In): Out). The detail lives in read-only MCP resources, each a mhl:// URI you fetch with resources/read:
mhl://workflow/<name>application/jsonmanifest — ordered steps, typed inputs, inputSchema, for a typed signature input: {param, type} / output: {type} / outputSchema, checkpoint, parallelGroups, stepTimeouts, loop, and the declared agents/tools/memory/prompts/extensions of its programmhl://run/<runId>/logstext/plainthe run's retained step/log() output (HTTP only)mhl://run/<runId>/resultapplication/jsonthe run's current status + final vars (HTTP only)The .mh source of a workflow is deliberately not a resource: a deployment's program and prompts may be confidential, and a client needs only the interface above to call it.
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d '{"jsonrpc":"2.0","id":3,"method":"resources/list"}'
# -> one manifest entry per workflow, plus one logs+result pair per async run you own
curl -s -X POST localhost:8711/mcp -H 'content-type: application/json' -H "Mcp-Session-Id: $sid" \
-d '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"mhl://workflow/Report"}}'
# -> { "contents": [ { "uri": "...", "mimeType": "application/json", "text": "{ \"steps\": [...], ... }" } ] }
The manifest is projected from the loaded pipeline, so it never drifts from what runs. The workflow resources are available over stdio and HTTP; the mhl://run/* ones need the run registry, so they are HTTP-only and owner-gated exactly like mhl_run_status (a non-owner sees unknown resource). mhl_run_start's result also carries resource_link items pointing at the new run's logs and result.
mhl serve a2a [--addr host:port] [--token t] [--principal-header h] <dir> publishes one A2A skill per declaration: JSON-RPC 2.0 over HTTP, with an Agent Card at /.well-known/agent-card.json. message/send starts a run as a task; tasks/get polls it and tasks/cancel stops it. The skill and inputs are named explicitly in the message metadata.
Same guard surface as serve mcp --http: --token / MHL_SERVE_TOKEN requires Authorization: Bearer <token> on every JSON-RPC request (the Agent Card stays public for discovery); --principal-header / MHL_SERVE_PRINCIPAL_HEADER takes the caller identity from a header an authenticated upstream set, and needs --token (without the shared secret the header is client-spoofable). A cross-origin browser request (non-loopback Origin) is refused, and the request body is capped at 1 MiB. Binding a non-loopback address with no token prints an "unauthenticated" warning.
Start the server, discover it, and send a task
mhl serve a2a --addr 127.0.0.1:8710 ./workflows &
curl -s localhost:8710/.well-known/agent-card.json
curl -s -X POST localhost:8710/ -d '{"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"role":"user","parts":[],
"metadata":{"skill":"Triage","input":{"title":"db outage","body":"prod is down"}}}}}'
# -> { "result": { "id": "<task-id>", "status": { "state": "submitted" }, ... } }
curl -s -X POST localhost:8710/ -d '{"jsonrpc":"2.0","id":2,"method":"tasks/get","params":{"id":"<task-id>"}}'
# -> status.state "completed", artifacts[0].parts[0].text = the run's result JSON
A2A reports the task's lifecycle state (submitted / working / completed / failed / canceled), not the current step. For step-by-step progress or a resumable gate, use the MCP HTTP mhl_run_* tools above.
Point any MCP client at the command (stdio) or the URL (HTTP).
Networked client
# networked
claude mcp add --transport http mhl-local http://127.0.0.1:8711/mcp
Stdio client configuration
{
"mcpServers": {
"mhl-workflows": {
"command": "mhl",
"args": ["serve", "mcp", "/absolute/path/to/workflows"]
}
}
}
HTTP surface — drive it all with tools/call; the bracketed run/* is the raw JSON-RPC method a policy gateway can match by name
tools/list—one entry per workflow (+ the mhl_run_* tools), each with its inputSchema (and outputSchema when the workflow declares a result type — its successful structuredContent conforms to it; its errors carry text only)tools/call <workflow>{…workflow inputs}blocks; the final variable state (isError on failure)tools/call mhl_run_start (raw: run/start){workflow, arguments}{runId, state:"working"} at once; text + resource_links to the run's logs/resulttools/call mhl_run_status (raw: run/status){runId}{state, step, reached, vars?, error?, resumable?} as structuredContenttools/call mhl_run_resume (raw: run/resume){runId, arguments?}relaunch from the checkpointed step; arguments merged over the originalstools/call mhl_run_cancel (raw: run/cancel){runId}stop the run; its statustools/call mhl_run_list (raw: run/list){}the calling session's own runstools/call mhl_run_logs (raw: run/logs){runId, since?}{text, nextSince, dropped?} — the run's retained step/log() output, stream-redacted (a secret split across writes or a poll boundary is still masked; a live run withholds a trailing secret-length window until it ends)resources/list—workflow manifest+source entries, plus a logs+result pair per async run you ownresources/read{uri}{contents:[{uri, mimeType, text}]} for a mhl://workflow/… or mhl://run/… URI