Serve workflows as MCP tools

This guide turns a .jh file into an MCP server, so any MCP client (Claude Code, Claude Desktop, Cursor) can call the file’s workflows as tools. A workflow is a tested procedure with several steps and built-in repair (ensure, catch, recover, and artifacts). When you expose that workflow as a tool, an agent can invoke the procedure instead of writing its own shell commands.

You don’t need an SDK project or a build step. jaiph mcp ./tools.jh reuses the same compile-time validation, runner, and .jaiph/runs/ artifacts as jaiph run.

Prerequisites

1. Serve a file over stdio

jaiph mcp ./tools.jh

The server speaks newline-delimited JSON-RPC 2.0 over stdio, which is the MCP stdio transport. It runs until stdin closes or it receives SIGINT or SIGTERM. jaiph --mcp ./tools.jh is an equivalent alias.

MCP over the network. jaiph serve exposes the same tools over MCP Streamable HTTP at POST /mcp, alongside its REST API. It shares one run registry, concurrency cap, sandbox posture, hot reload, and bearer auth with the stdio server. Use jaiph mcp for a stdio client on the same machine, and use jaiph serve when an MCP client must reach the workflows over HTTP. Everything below applies to both transports the same way: exposure rules, descriptions, input schema, result shape, progress, and cancel.

Add --workspace <dir> to set the import resolution root. By default Jaiph auto-detects it from the file’s directory, the same as jaiph run.

Add --env KEY=VALUE to define a variable in every tool call’s environment, or --env KEY to forward the host’s current value. The flag is repeatable. Jaiph resolves the pairs once at startup and then applies them to every call for the server’s lifetime. A bare --env KEY whose value is missing on the host fails with E_ENV_MISSING before the server starts. In a Docker sandbox, --env is the per-key consent that copies a host variable into the container as is, bypassing the credential allowlist. Use it for any config value or secret a workflow needs that the backend allowlist does not already forward (see Safety posture).

stdout carries only protocol JSON. From the moment the server starts, stdout is the JSON-RPC channel. Every banner, warning, reload notice, and compile diagnostic goes to stderr. If the file has compile errors, the server prints file:line:col CODE message lines to stderr and exits 1 with nothing on stdout.

2. Register the server with a client

For Claude Code:

claude mcp add mytools -- jaiph mcp ./tools.jh

Clients that configure MCP servers with JSON (Claude Desktop’s claude_desktop_config.json, Cursor’s .cursor/mcp.json) use the same command and arguments:

{
  "mcpServers": {
    "mytools": {
      "command": "jaiph",
      "args": ["mcp", "./tools.jh"]
    }
  }
}

Any client that launches a command and speaks the MCP stdio transport works the same way. Point it at jaiph mcp <file.jh>. The client sends initialize, then tools/list, then tools/call, and the server needs no other configuration.

3. Choose which workflows are exposed

Not every workflow in the file becomes a tool. deriveTools applies these rules to the entry file only, and never exposes imported modules:

  1. If the file declares one or more export workflow statements, Jaiph exposes exactly those workflows. export marks the module’s public API. Use it to publish a deliberate set of tools and hide helper workflows.
  2. Otherwise Jaiph exposes every top-level workflow, except channel route targets. A channel route target is a workflow wired as an inbox handler through channel name -> handler. These workflows are message handlers rather than tools, so Jaiph skips them and logs a warning.
  3. Jaiph treats default specially. It exposes default only when it is the only candidate, under a tool name taken from the file’s basename (deploy.jh becomes deploy). When other workflows exist, Jaiph skips default, so it stays the jaiph run entrypoint rather than a public tool.

The tool name for a named workflow is the workflow name itself. For a lone default, Jaiph builds the name from the file basename. It strips the .jh suffix, replaces any character outside [A-Za-z0-9_-] with _, and truncates the result to 128 characters.

Jaiph logs every skip and exclusion as a warning on stderr at load time, and never on stdout.

4. Write tool descriptions as comments

The description an agent reads when it decides whether to call a tool comes from the # comment lines directly above the workflow. Jaiph drops shebang lines (#!…), strips the leading # from each remaining line, and joins the lines with newlines. A client relies on the description to pick a tool, so write it for the calling agent.

# Deploy the application to the named environment.
# Runs the test suite first and aborts the deploy if it fails.
export workflow deploy(environment) {
  ensure tests_pass()
  run `./deploy.sh ${environment}`()
  return "deployed to ${environment}"
}

If a workflow has no leading comment, the description falls back to Run the "<name>" workflow from <basename>.

5. Understand the input schema

Every Jaiph parameter is a string, so each tool’s input schema is a flat object of string properties. Every parameter is required, and no additional properties are allowed. The deploy workflow above produces this schema:

{
  "type": "object",
  "properties": { "environment": { "type": "string" } },
  "required": ["environment"],
  "additionalProperties": false
}

A workflow with no parameters produces the same shape with an empty properties and no required key.

6. Call a tool and read the result

On tools/call, the server maps the arguments object to positional workflow arguments in declared order and runs the workflow. It runs in a Docker sandbox or on the host, chosen by the same env settings as jaiph run (see Safety posture). The result is a text content block:

A workflow failure is not a protocol error. It comes back as a normal result with isError: true. Jaiph reserves protocol-level errors (JSON-RPC -32602) for calls that never start, such as an unknown tool name, a missing or non-string required argument, or an unexpected argument key.

Every call is a durable run under .jaiph/runs/ in the workspace, and you can inspect it exactly as for jaiph run. Jaiph isolates concurrent calls by giving each one its own run id and run directory, so a slow call never stalls other calls or a ping. Under the default isolated sandbox, each call also gets its own point-in-time snapshot of the workspace, so calls do not race on workspace files. Only in inplace mode (JAIPH_INPLACE=1) can two calls that change the same files race, because both write the live tree.

7. Stream progress and cancel a long call

A workflow with several steps can take a while. The server streams step-level progress to clients that ask for it, and lets a client cancel a call it no longer needs.

Receive progress notifications

Include a progressToken (a string or number of your choosing) in the call’s params._meta:

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deploy","arguments":{"environment":"staging"},"_meta":{"progressToken":"deploy-1"}}}

As the workflow runs, Jaiph sends a notifications/progress back to the client at each step boundary, carrying your token. A step boundary is a step starting or a step finishing. For example:

{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"deploy-1","progress":1,"message":"workflow deploy"}}
{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"deploy-1","progress":2,"message":"script deploy_sh"}}
{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"deploy-1","progress":3,"message":"script deploy_sh"}}

Cancel an in-flight call

To abandon a running call, send a notifications/cancelled naming its request id:

{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}

The server terminates that call’s run, meaning the whole child process tree. It sends SIGINT first, then SIGKILL after a short grace period, the same escalation jaiph run applies on Ctrl-C. In Docker mode, the server also force-removes the call’s container by name (docker rm -f) so the container does not keep running after cancellation, the same guarantee jaiph run gives on interrupt (see Sandboxing, interrupting a Docker run). Per the MCP spec, a cancelled call sends no response for that id, and Jaiph leaves the run’s .jaiph/runs/ directory in place for inspection. The server keeps serving, other in-flight calls are untouched, and a later ping or tools/call answers normally. A cancellation that arrives before the run’s child has spawned is honored as soon as the child starts.

8. Edit the file while the server runs (hot reload)

The server watches every source file in the module graph, polling about every 750 ms. When you edit and save a file:

Shutdown (drain, then cancel)

The server shuts down when stdin closes or on SIGINT or SIGTERM. Either way it first drains. It stops accepting input and waits for in-flight tool calls to finish, keeping their scripts on disk until they settle, and then cleans up and exits 0. If you don’t want to wait, send a second signal. The server then terminates every in-flight run’s child process tree (SIGINT, then SIGKILL after a short grace period) and, in Docker mode, force-removes each call’s container (docker rm -f), the same guarantee as per-call cancellation above. The killed calls report error results, and the server exits 0.

Safety posture

An exposed workflow is arbitrary shell that the connected agent can run, which is the point of the feature. Treat every exposed workflow as code the client may run at any time, and limit the exposed set with export. A tool-call argument that binds to a workflow parameter is shell-quoted before it reaches any shell step, so an argument value cannot inject extra shell commands, though the client can still run whatever the exposed workflow itself does.

Tool calls use the same env-driven Docker sandbox as jaiph run (see Sandboxing). Docker is on by default on macOS and Linux. It is off on Windows, where calls run on the host. Host-only execution under JAIPH_UNSAFE=true additionally requires explicit consent on the command line (see below). Jaiph prepares the image once when the server starts, not per call.

The workspace is isolated by default, the same as jaiph run. Each tool call’s container works on its own writable point-in-time snapshot of the workspace. Jaiph discards the edits when the container exits, and the host workspace is untouched. Concurrent calls each get their own run id and run directory.

To opt into live writes, pass --inplace (or set JAIPH_INPLACE=1) when starting the server. In inplace mode, Jaiph bind-mounts the host workspace read-write into each tool call’s container, so effects land on the host. Two calls that change the same files can still race.

Other sandbox controls:

The sandbox flags are the shared execution-policy surface of jaiph run, jaiph serve, and jaiph mcp. The precedence is CLI flags, then JAIPH_* env vars, then workflow config metadata, then defaults. Passing both --inplace and --unsafe fails with E_FLAG_CONFLICT at startup. The server resolves the posture once at startup, prints it, and applies it to every call. There is no interactive confirmation: for inplace and the default sandbox, launching the server with the flag or env var is the consent; for unsafe host-only the consent must be the explicit flag (see above). Inside a container the container is the sandbox, so an inherited JAIPH_UNSAFE=true proceeds without the flag. See Environment variables, precedence.

The agent-credential pre-flight check runs once at startup. In MCP mode, the server reports its findings as warnings even in Docker mode, because the server can outlive a credential fix and a per-call failure still surfaces to the client. Set credentials on the host so the allowlist forwards them into the container.

Verification

With the server running, a scripted stdio session drives the full handshake. Every stdout line is a JSON-RPC message:

printf '%s\n' \
 '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
 '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
 '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
 '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deploy","arguments":{"environment":"staging"}}}' \
 | jaiph mcp ./tools.jh

You should see three responses on stdout. They are the initialize result, the tools/list array with your comment-derived descriptions, and the tools/call result carrying the workflow’s return value. Startup and warning lines appear only on stderr.