An MCP (Model Context Protocol) server that wraps the [Antigravity CLI](https://antigravity.google) (`agy`), so any MCP client (Claude Code, Cursor, Cline, and others) can run `agy` prompts, peer reviews, and follow-up turns as native tools.
> Status: feature complete (stdio and HTTP transports, async and sync job lifecycle, model and session discovery, per-run controls and structured output, cross-platform builds) and verified against a live agy, with 1.1.15 the minimum supported version.
## Why
Driving `agy` from a shell for automation has two recurring problems:
- `agy -p` (print mode) reads stdin even when the prompt is passed with `-p`. If stdin is an open pipe that never closes, it blocks indefinitely. The fix is to always close stdin (``), so threads survive across calls without keeping a process warm.
**A delegated run can modify your files.** `agy` is launched with
`--dangerously-skip-permissions`, so the agent it starts can read *and write* anywhere under
`cwd` and under every directory passed in `dirs`, without prompting. For a review that must
not touch the repo, say so explicitly in the prompt. The tools declare this on the wire: `agy_run` and
`agy_run_sync` are annotated `destructiveHint: true` / `openWorldHint: true`, `agy_cancel` is
`destructiveHint: true` / `idempotentHint: true`, and `agy_status`, `agy_wait`, `agy_usage`, `list_models`,
`list_agents` and `list_sessions` are `readOnlyHint: true`. Annotations are hints, so a client is free to
ignore them; one that does gate confirmation on them may stop prompting for the read-only
tools.
**Project rules load by default.** agy-mcp passes the run's `cwd` as an `--add-dir`, which makes it an agy workspace, so the project's `AGENTS.md` applies to the delegated run, including one at the repository root when `cwd` is a subdirectory ([#188](https://github.com/tphakala/agy-mcp/issues/188)). Whether the flag is needed depends on the agy version. MEASURED against agy 1.2.9 (issue #188): in print mode, running `agy` inside `cwd` did not make it a workspace, so no rules loaded without `--add-dir`, and `agy`'s `/hooks` listing showed the hooks in the workspace's `.agents/hooks.json` as enabled only with it. MEASURED against agy 1.2.16: `cwd` is a workspace without `--add-dir`. Its `AGENTS.md` (including the repository root's, from a subdirectory) is applied and its `.agents/hooks.json` hooks run, with or without the flag. Which release changed this is not measured. So `project_rules: false` only omits the implicit `--add-dir`: on agy versions that need the flag that keeps the rules and hooks out, and on 1.2.16 it does not, and agy 1.2.16 has no flag that turns them off. Do not rely on it to isolate a run from a repository you do not trust. Each directory in `dirs` is passed as given and is a workspace in its own right, so listing `cwd` there makes it a workspace even with `project_rules: false`.
Two transports run the same core:
- **stdio** (default): zero-config, one line in your MCP client config.
- **Streamable HTTP** (opt-in): a long-lived, loopback-only daemon for multi-client use. See [HTTP mode](#http-mode).
## Requirements
- **agy 1.1.15 or newer**, on `PATH` or configured explicitly via `AGY_MCP_AGY_PATH`. This is a hard floor, not a recommendation. The floor is 1.1.15 because that release fixed streamed text corrupting non-ASCII characters into replacement characters in the `--output-format stream-json` text deltas. Those deltas are the text agy-mcp falls back to for a run cut short before agy's result event, or one agy completes with an empty response: on an older agy that text is lossy for non-ASCII content (another language, emoji, box drawing, unicode in code), and agy-mcp cannot repair bytes it never received intact. Earlier, lower floors it subsumes: 1.1.8 added `--output-format` to print mode and agy-mcp drives every job through the `stream-json` format it introduced; 1.1.10 is the first release where the run-shaping flags agy-mcp forwards in headless `-p` (`--model` and `--effort`) are honored rather than silently ignored; 1.1.12 added the machine-readable `models` envelope (`command.data.models`) that `list_models` decodes into ids and labels instead of tab-splitting text, and fixed `--mode` being ignored in headless `-p`; and 1.1.14 made a failed language server exit non-zero instead of reporting success, so agy-mcp records that failure as failed (all per agy's changelog). Older builds are refused rather than degraded. agy 1.2.9 or newer is worth having if you run jobs in parallel: per its changelog it fixed a conversation-database corruption risk in which checking the database file for write access could drop file locks that concurrent agy processes held on the same file. Whether parallel agy-mcp jobs actually share such a file has not been measured.
The version is checked once per process, the first time a tool actually needs agy, and the verdict is cached. A binary that is too old is reported as `agy 1.1.15 or newer is required ...; found 1.1.7 at /usr/local/bin/agy`. A failed check is deliberately not cached, so upgrading agy is picked up without restarting the server.
A missing `agy` does not stop the server from starting. `initialize`, `tools/list`, and `list_sessions` never exec it, so the lookup is deferred: the server starts, logs a warning to stderr, serves discovery normally, and the tools that do need the binary (`agy_run`, `agy_run_sync`, `agy_usage`, `list_models`, `list_agents`) fail per call with `agy not found on PATH; set AGY_MCP_AGY_PATH`. An `agy` installed later is picked up without restarting the server. An explicit `AGY_MCP_AGY_PATH` is treated differently: it is a claim about one specific binary, so a typo or a non-executable target still fails fast at startup.
- Go 1.27+ to build.
- The server builds and runs on Linux, macOS, and Windows. Job supervision (running agy as managed jobs via `agy_run` / `agy_run_sync` / `agy_status` / `agy_cancel`) is implemented on **Linux** and **macOS** (process groups, SIGTERM cancel, an advisory flock) and on **Windows** (Job Objects, `OpenProcess` + process creation time, `LockFileEx`); stdio/HTTP serving, `list_models`, and `list_sessions` work identically everywhere.
Windows behaves the same as Linux with two documented differences:
- **Cancel and hard timeout are hard kills.** Linux sends agy `SIGTERM` and waits a 10s grace before `SIGKILL`; Windows has no equivalent signal for a detached process, so `TerminateJobObject` ends the whole process tree immediately with no flush window.
- **Cancel is delivered via a sentinel file, not a signal.** The manager writes a `cancel` file into the job directory and the supervisor polls for it (a few hundred ms), since Windows cannot signal an arbitrary process. Liveness uses the process creation time (an absolute wall-clock value) to defend against PID recycling, which subsumes the Linux boot-id check across reboots.
> Note: every job spawns a fresh `agy` process, which on startup launches whatever MCP servers are configured in agy's own `mcp_config.json`. Peer-review and automation runs usually do not need those servers, and a slow or hanging one adds latency to every job. agy 1.0.7+ bounds this with a per-server launch `timeout` (set `-1` to disable it). If startup is slow, give the unneeded servers a `timeout` in agy's `mcp_config.json`, or point agy at a trimmed config.
>
> Note (continuation): a follow-up turn runs `agy --conversation -p `, and the job result is the `response` field of agy's terminal `result` event. Verified against 1.1.10: a resumed run returns only the new turn and echoes the same conversation id, with `num_turns` incremented.
>
> Note (auth): agy-mcp passes its own process environment through to every spawned `agy`, so agy's normal OAuth credentials are used by default. For headless or daemon deployments (HTTP mode, cron) where no browser is available, set `USE_ADC=1` in the server's environment to have agy authenticate via Google Application Default Credentials instead of the interactive sign-in flow (agy 1.0.11+). No agy-mcp flag or code change is needed; unset it to fall back to the other sign-in methods.
## Install
### Homebrew (macOS / Linux)
```bash
brew install tphakala/tap/agy-mcp
```
This installs the prebuilt release binary (macOS and Linux, amd64 and arm64) from [tphakala/homebrew-tap](https://github.com/tphakala/homebrew-tap); upgrade later with `brew upgrade agy-mcp`. The tap formula is regenerated on every release, so upgrades track new versions as they ship.
### Go install
```bash
go install github.com/tphakala/agy-mcp/v2@latest
```
The module path carries a `/v2` suffix from v2.0.0 on, as Go requires for a major version. `go install github.com/tphakala/agy-mcp@latest` still resolves, but to the last v1 release.
## Use with Claude Code (stdio)
```bash
claude mcp add agy -- agy-mcp
```
Or add to your MCP client config:
```json
{
"mcpServers": {
"agy": { "command": "agy-mcp" }
}
}
```
## Tools
- `agy_run(prompt, model?, effort?, mode?, agent?, sandbox?, dirs?, project_rules?, conversation_id?, continue_latest?, cwd?, timeout?, json_schema?, idempotency_key?, priority?)` -> `{ job_id, conversation_id?, state, quota? }`
- `agy_run_sync(prompt, model?, effort?, mode?, agent?, sandbox?, dirs?, project_rules?, conversation_id?, continue_latest?, cwd?, timeout?, json_schema?, idempotency_key?, priority?, wait?)` -> `{ job_id, state, elapsed, result?, error?, failure_reason?, recovery?, retryable?, error_id?, conversation_id?, model?, partial?, num_turns?, usage?, step_type?, quota?, note? }`
- `agy_status(job_id)` -> `{ state, elapsed, result?, error?, failure_reason?, recovery?, retryable?, error_id?, conversation_id?, model?, partial?, num_turns?, usage?, step_type?, quota? }`
- `agy_wait(job_id, wait?)` -> `{ job_id, state, elapsed, result?, error?, failure_reason?, recovery?, retryable?, error_id?, conversation_id?, model?, partial?, num_turns?, usage?, step_type?, quota?, note? }`
- `agy_cancel(job_id)` -> `{ state }`
- `agy_usage(max_age?)` -> `{ checked_at, groups }`, each group `{ name, description?, level, remaining_percent, window, reset_time?, buckets }`
- `list_models()` -> `{ models, model_details }`
- `list_agents(cwd?)` -> `{ agents }`
- `list_sessions(dir?)` -> `{ sessions }`
`list_sessions` without `dir` omits the ephemeral per-call workspaces the agy-openai-shim helper creates: the server never starts a run in one and on a busy cache they can outnumber the real workspaces many times over ([#167](https://github.com/tphakala/agy-mcp/issues/167)). Pass a `dir` to fetch a specific workspace, transient or not; the filter is canonicalized (made absolute, with best-effort symlink resolution) before matching.
`models` holds ids alone, so its entries can be passed straight to `model`; `model_details` pairs each id with the display label `agy` prints for it, in the same order, for showing a readable name. Through v2.1.0 `models` carried each `agy models` row whole, which was a tab-joined `idlabel` string that `agy` does not accept as a model at all, so a client had to split it and pick a column ([#135](https://github.com/tphakala/agy-mcp/issues/135)).
`usage` is agy's own token accounting (`input_tokens`, `output_tokens`, `thinking_tokens`, `cache_read_tokens`, `total_tokens`), and together with `num_turns` it appears once a run reports a terminal result.
`model` echoes the model id agy-mcp resolved for the run (the one you passed, or `AGY_MCP_DEFAULT_MODEL`, reduced to its id), so a caller can confirm which model actually ran. It is absent when no model was pinned and `agy` ran on its own default.
A run that ends badly still hands back whatever it managed to say, so `result` is set on `failed` and `cancelled` jobs too and is worth reading rather than discarding. `partial` is what says whether to trust it as the final answer, and it follows from where the text came from (and from `agy`'s stderr notices, described below) rather than from the state. When such a run produced no text at all but still named a conversation, `recovery` usually carries a short note on continuing that conversation instead of restating the task. A `background_aborted` run gets no such note, because simply carrying on with the same task would launch the same background command again; its `error` says to re-run with the verification in the foreground instead. A `quota_exhausted` failure is an exception the other way: its `recovery` note advises waiting for the reset and retrying even when no conversation was named (see `failure_reason` below). When `retryable` is `false` (see below), the note instead says to fix the cause rather than continue the conversation, and a `quota_exhausted` note keeps its wait-for-reset advice without `conversation_id` but adds that the cause may need fixing first when `error` names no reset time.
`failure_reason` classifies a `failed` job into one stable, machine-readable category so a caller can branch on the cause without scraping `error`. It is present only when `state` is `failed` (a cancelled job is already named by its state, and a running or done one has no failure), and the set is closed, so treat any value you do not recognize as `unknown`. The values are `quota_exhausted` (agy hit a provider quota, rate-limit or AI credits wall), `timeout` (the run outlived its timeout: agy-mcp killed it, or on agy 1.1.28 and later agy's own `--print-timeout` expired mid-turn and agy returned only what it had, possibly nothing), `spawn_failed` (the agy binary could not be started, or agy itself exited 127, which shares the same exit sentinel), `agy_error` (agy itself reported an error, exited non-zero, or returned an indeterminate result), `interrupted` (the job process vanished without writing a result), `background_aborted` (agy ended the run itself, by a clean exit or in a job recovered from its result after its supervisor died, but its stderr shows it went idle with outstanding background shell tasks and killed them at exit, so the response is only progress narration rather than completed work; re-run with any verification in the foreground), and `unknown` (a failure fitting none of the above, for example its output could not be read). `quota_exhausted` is the one that is transient rather than fatal (unless agy reports it with `retryable` false, see below): `error` keeps agy's message, including the reset window when agy gives one (agy-mcp does not parse it out; `agy_usage` reports quota reset times), and `recovery` says to wait for the reset and then retry, reusing `conversation_id` when one was named, unless agy reported the error with `retryable` false. So a caller that hits a quota wall can back off until it clears instead of treating the run as a hard failure.
`retryable` and `error_id` come from the structured `AGY_ERROR:` line agy writes to stderr when it fails a request (MEASURED against agy 1.2.9 and 1.3.1 for a model not available in the account's region: exit 3, `retryable: false`; [#183](https://github.com/tphakala/agy-mcp/issues/183)). They are present only on `failed` jobs whose stderr carried a well-formed line; absent means agy gave no verdict, not that the failure is permanent. When that line's canonical status is `RESOURCE_EXHAUSTED`, the job is reported as `quota_exhausted` even if the message wording is not recognized; this follows agy's status naming and has not been checked against a captured quota error. agy's raw `short_error` text carries the cloud project and region, so it is not copied into these fields, and when `error` falls back to the stderr tail (a non-zero exit whose payload gave no message, or exit 127) each line in that tail that starts with `AGY_ERROR:` is reduced to its canonical status, for example `AGY_ERROR: NOT_FOUND (code 404)`, or left out when it cannot be read; `failure_reason` is still classified from the unreduced text.
It is true when the text was reconstructed from the streamed events, which happens when agy never reported a terminal result (a clean exit whose final event never arrived, a cancel, a timeout, a supervisor that died mid-stream) and also when agy did report one but the authoritative result channel was empty, so fallback text is all that remains. It is likewise true when agy reported a terminal result that was not a success, in which case the text is only what the run had produced by the time it stopped, and when agy marked the result a success but its stderr shows it ended the run early (its own `--print-timeout` expired mid-turn, or it killed outstanding background tasks). That applies even after a hard kill: a response agy wrote when its own `--print-timeout` expired is flagged partial. For a non-schema run, a SUCCESS `response` is authoritative; for a `json_schema` run, only SUCCESS `structured_output` is authoritative, and a retained `response` is diagnostic/partial when that structured result is missing. An authoritative result stays non-partial even if the process is then cancelled or killed; the exceptions are a timed-out run whose stderr shows `agy`'s own `--print-timeout` had already expired, and a job recovered from its result after its supervisor died whose stderr shows `agy` ended the run early.
Parameter and result fields carry their own descriptions in the tool schemas, so a client sees
them without consulting this file. The constraints worth knowing up front: `conversation_id`
and `continue_latest` are mutually exclusive (setting `continue_latest` true alongside a
`conversation_id` is an error); `timeout` is a Go duration and a value above 24h is rejected
outright, and on expiry the job normally ends in state `failed` with `failure_reason: timeout`,
because agy-mcp kills the `agy` process tree (on agy 1.1.28 and later, if `agy`'s own
`--print-timeout` fires first, `agy` stops the turn itself and any text it had comes back as a
`partial` result; on older agy that expiry is reported as `agy_error`); `effort`, when set, must be
`low`, `medium` or `high`, and `mode`, when set, must be `accept-edits`, with any other
value rejected before the run starts. `plan` is refused because agy ignores `--mode plan` while
slash command expansion is disabled, which agy-mcp does on every run so prompts stay literal, so the
run would apply edits instead of only planning (MEASURED against agy 1.4.6,
[#219](https://github.com/tphakala/agy-mcp/issues/219)); to keep a run from editing, say so in the
prompt. `wait` defaults
to 90s and is silently clamped to that same ceiling. That 90s default is kept below the roughly
120s per-call timeout common MCP clients (Claude Code included) impose, so agy-mcp's still-running result reaches
the caller inline instead of being lost to a client-synthesized timeout (set `AGY_MCP_SYNC_WAIT_CAP`
to a Go duration to raise or lower the ceiling for a client with a different per-call limit), and it
bounds only the inline wait, never the job itself.
`agy_cancel` is asynchronous, so it usually returns `running` and the job settles to `cancelled`
a moment later. A `prompt` is sent to agy literally: a leading slash-command or skill name
(a prompt beginning with `/`) is treated as text rather than expanded, so caller input means
exactly what it says regardless of the installed agy version.
A fresh `agy_run` (no `conversation_id`, no `continue_latest`) reports the conversation agy
created for it. agy names the conversation in the `init` event of its stream, which arrives
about a second after the process starts, so `agy_run` waits briefly (up to 2s) for it and
returns a real `conversation_id`. If the wait expires the field comes back empty and
`agy_status` supplies it moments later; either way the thread can be continued.
`agy_run_sync` does not pay that wait: it takes its `conversation_id` from the status it
is already polling. That is also what makes its `wait` cap honest, because the
conversation-id wait used to run before the inline wait's deadline was set, so a short
`wait` could be overrun by up to the 2s budget. The trade is that a `wait` shorter than
agy's init latency now returns an empty `conversation_id`, which `agy_status` supplies.
**Fresh runs are not serialized.** Any number of them can run in one directory at the same
time, bounded only by the global concurrency cap. Earlier versions refused a second fresh run
in a directory that already had one, because the conversation id had to be inferred by diffing
agy's shared conversation cache and that inference was only sound while nothing else could
touch the same entry. Reading the id from agy's own stream removes the ambiguity and the
restriction with it.
What is still serialized is a **conversation**: two runs continuing the same `conversation_id`
cannot overlap, because concurrent agy sessions on one conversation trigger a known session-lock
hang in agy itself. The second is refused with a conflict error rather than queued. That gate is
rebuilt at startup from jobs whose supervisor outlived a server restart, so it holds across
restarts.
Conversation serialization also holds across separate `agy-mcp` processes, which matters in
stdio mode, where each MCP client session spawns its own process sharing one
`AGY_MCP_STATE_DIR`. A per-conversation advisory lock (`flock` on a file under
`/locks/`) stops two sibling sessions from continuing the same conversation at once.
The global concurrency cap, by contrast, is enforced per process: with N client sessions each
capped at the configured limit, up to N times that many runs can be in flight. Lock files are
left in place by design (unlinking a `flock` file races), so `locks/` keeps one empty file per
conversation ever locked.
Two caveats, both now scoped to continuations only. `AGY_MCP_STATE_DIR` must live on a local
filesystem that supports `flock` (not NFS, where `flock` may be a no-op or fail); a continuation
is refused rather than started if the lock cannot be taken, so cross-process exclusion can never
silently lapse. And across a server restart there is a brief window, while the manager process is
down, before it re-takes the locks for jobs whose supervisor outlived it; a sibling process that
continues the same conversation during that window is not blocked. The in-process gate is always
restored at startup, so this gap is limited to the restart window itself. Fresh runs take no lock
at all and are unaffected by either caveat.
### Quota
`agy_usage` runs `agy --output-format json -p /usage` and returns the quota per group (the Gemini pool, shared by every Gemini model, Flash and Pro alike, is one group; Claude and GPT models are another, MEASURED against agy 1.2.16; only Gemini models are matched to a group, so a run on any other model is never refused). That invocation starts no agent turn and spends no model quota (agy changelog 1.1.11; MEASURED against agy 1.2.16, which also measured that adding `--disable-slash-commands` turns `/usage` into a model prompt, so the probe never carries it, and a reply that shows a model turn switches probing off for the rest of the server process). The probe runs in its own session because agy 1.2.x otherwise stops on SIGTTOU when it shares a controlling terminal.
Each group has a `level` from its tightest window (usually the 5h or the weekly limit): `exhausted` at 0, `critical` below 5 percent remaining, `low` below 25 percent, otherwise `ok`. A window whose `reset_time` has passed counts as full, so a stale reading never reports a quota that has since refilled as low. `remaining_percent` is rounded down and the level is the authority. Thresholds are set with `AGY_MCP_USAGE_LOW` and `AGY_MCP_USAGE_CRITICAL`.
Readings are cached in memory per server process (60 seconds by default; `max_age` sets how old a reading may be, from 10 seconds up, and a younger reading is reused), concurrent callers share one probe, and a failed probe is remembered for 30 seconds. With `AGY_MCP_USAGE_INTERVAL` above zero (5 minutes by default) the server also refreshes the reading in the background. `agy_run`, `agy_run_sync`, `agy_status` and `agy_wait` results carry a compact `quota` field read from that cache; it never waits on agy and is absent when there is no reading younger than twice the interval (at least 10 minutes). Several servers (one per client session) each probe on their own.
`priority: optional` on `agy_run` or `agy_run_sync` marks work that may be skipped, such as a peer review or a second opinion. When the run's model belongs to a group at `low`, `critical` or `exhausted`, the run is refused before any job starts, as a tool error naming the group, level, remaining percent and reset time. The refusal creates no job, so `failure_reason` is unchanged; report the skip instead of retrying. The guard fails open: no reading, a reading that is too old, a model outside a known group (or an omitted model with no `AGY_MCP_DEFAULT_MODEL`), or a failed probe with no trusted earlier reading never refuses (an earlier reading inside that window still applies), and `priority: normal` (the default) always runs. `priority` is not part of the `idempotency_key` match: a retry whose key already names a job returns that job whatever its priority and the current quota.
## Completion wake for Claude Code
Claude Code does not surface MCP server notifications to the model: progress notifications are UI-only, and there is no server-initiated push that can wake the model when an async job finishes. Polling `agy_status` after `agy_run` is the protocol-level baseline. agy-mcp ships a hook bridge that turns job completion into a real wake instead, built on Claude Code's `asyncRewake` hook mechanism: a background PostToolUse hook that exits with code 2 wakes the model with the hook's stderr as a system reminder.
Add to your Claude Code `settings.json`:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "mcp__agy__agy_run(_sync)?",
"hooks": [
{ "type": "command", "command": "agy-mcp hook-wait", "asyncRewake": true, "timeout": 3700 }
]
}
]
}
}
```
How it behaves:
- After every `agy_run`, the hook waits (in the background, off the session's critical path) for the job's completion sentinel and then wakes Claude with a one-line message naming the `job_id` and final state, prompting the model to call `agy_status` when the result is actually ready.
- `agy_run_sync` calls that returned their result inline produce no wake; a sync call that overran its wait cap (and so returned a still-running job) gets the same completion wake as `agy_run`.
- A finished job produces no wake when a tool has already returned its outcome to the session: `agy_wait` and `agy_status` record a `collected` marker in the job directory when they return a terminal state, and hook-wait exits 0 silently if the marker is present. The marker is written as the tool returns the outcome, so hook-wait allows up to one second for it to appear before waking. The marker records that the tool returned the outcome, not that the client received it, so a call abandoned in transit can leave a finished job without a wake. `agy_cancel` records a separate `dismissed` marker when it asks a job to stop, and hook-wait honours it only if the job then ends `cancelled`, so a job that finishes on its own before the stop lands still wakes. One narrow case follows from this: a job that had already produced its complete answer and then exits non-zero under the cancel signal still reads as `cancelled` (with that answer kept, see `partial`), so it gets no wake either; read a job you cancelled with `agy_status` before re-running it. Every other finished job still wakes, including a job nobody collected, a job cancelled without an `agy_cancel` request, and one whose wait overran while it was still running. The marker is per job, not per session, so a job collected by one session (or subagent) is not woken for in another.
- When the PostToolUse payload names the calling subagent (`agent_type`), the wake message says the job was started by that subagent, so a parent session can tell it is not its own. Per Claude Code's hooks documentation the field is present only inside a subagent call. MEASURED against Claude Code 2.1.289: absent from a main-thread payload, `general-purpose` in a foreground or background subagent's payload, for both a Bash and an `mcp__agy__` tool call.
- On timeout (default 1h, `-timeout` to change) the hook still wakes Claude, reporting the job as still running, so a long job is never silently lost, provided the outer hook `timeout` exceeds hook-wait's own `-timeout` (see the `settings.json` note below). An externally interrupted wait (a SIGINT or SIGTERM delivered to hook-wait) also wakes, with a distinct "wait interrupted" message pointing at `agy_wait` and `agy_status`, rather than exiting silently and dropping the owed wake. On any internal error the hook exits 0 silently; it can never disrupt the tool call it observes. The hook-level `timeout` in `settings.json` (3700 above) must exceed hook-wait's own `-timeout` (1h by default) by more than the one-second marker grace plus a poll interval (a quarter of a second), because a job that finishes on the last poll spends both after the timeout; the 100 seconds of margin above is ample. With that margin hook-wait's internal timeout always fires first and reports the wake itself; if the outer `timeout` is equal or lower, Claude Code kills the hook process first and the exit-2 wake is lost, silently defeating that guarantee.
- The `agy` in the matcher pattern `mcp__agy__agy_run(_sync)?` is the server name you passed to `claude mcp add agy`; if you registered the server under a different name, change both the `claude mcp add` name and that `agy` segment of the matcher.
- Claude Code runs hooks with the user's shell environment, not the MCP server entry's own env block, so if the server is registered with a custom `AGY_MCP_STATE_DIR` the same value must also be exported in the shell (or passed inline in the hook command) or hook-wait will silently look in the wrong state directory and never wake.
Two related subcommands, useful beyond Claude Code:
- `agy-mcp wait-job [-timeout 1h] ` blocks until the job is terminal and prints the final state word (`done`, `failed`, or `cancelled`) to stdout. Exit codes: 0 terminal, 1 error, 2 usage, 3 timeout, 130 interrupted. It needs only the job state directory, not the agy binary, so it works in minimal environments.
- `agy-mcp hook-wait [-timeout 1h]` is the hook entrypoint described above: it reads the PostToolUse payload from stdin, so it is not useful to invoke by hand, but it is a single self-contained binary call, no shell wrapper or jq required, and it works on Linux, macOS, and Windows. The file-based wake contract is exercised by tests on all three platforms; the signal-interrupt wake (SIGINT/SIGTERM) is a POSIX behavior and is tested there.
## Preflight (`agy-mcp doctor`)
`agy-mcp doctor` runs the read-only checks a run depends on and prints a one-line verdict for each, so a broken setup names its own cause instead of surfacing as a downstream tool error (for example `list_models` failing when `agy` is missing from PATH or not authenticated). It checks that the `agy` binary resolves, meets the version floor, and is reachable and authenticated (a model listing); that the state directory is writable; names where each effective setting came from (an environment variable or the default) without printing any secret value; and reports any stale jobs a prior crash left behind. It never starts a job or mutates job state.
The exit code is meant for a setup script or CI: `0` when nothing is broken (a stale-job warning does not count, and a fresh install with no jobs passes), `1` when a check failed, `2` on a usage error. A `WARN` line (leftover jobs the periodic GC will reap) is information, not a failure.
On POSIX, both wait subcommands install their SIGINT/SIGTERM handler only after parsing flags, resolving the job state directory and building the wait manager (and hook-wait also reads its payload from stdin first), so a signal sent immediately after launch can land before the handler exists and kill the process outright, losing the interrupt exit code. A parent that intends to interrupt a wait can close that window by setting `AGY_MCP_WAIT_READY_FILE`: the subcommand creates that file the moment the handler is in place, so waiting for it to appear makes the signal deliverable. Leave it unset and nothing is written. Three caveats, because existence is the entire signal: the path must be absolute (a relative one resolves against each process's own working directory, and hook-wait runs from the session cwd), it must be fresh and unique per invocation (a file left from an earlier run satisfies the wait immediately and hands back the race), and the parent still needs its own timeout, since both subcommands have exit paths that return before any handler is armed. An existing file at that path is refused rather than overwritten, so pointing the variable at something that matters destroys nothing; that protects the file, not the handshake, which is why the path has to be fresh.
MCP clients other than Claude Code get the same no-polling benefit in-protocol: call `agy_wait` with a `job_id` from `agy_run` (or from an `agy_run_sync` that outlived its inline wait) and the tool blocks (bounded by `wait`, default and cap 90s, raise or lower with `AGY_MCP_SYNC_WAIT_CAP`) until the job finishes. Re-issue `agy_wait` with the same `job_id` if a single bounded call is not enough; each call blocks another window rather than re-running the job.
## HTTP mode
```bash
agy-mcp -http 127.0.0.1:8765
```
HTTP mode is opt-in and only accepts a loopback bind address (`localhost`, `127.0.0.1`, or `::1`). A non-loopback address (including `:8765`, which binds all interfaces) is refused at startup, so it cannot be accidentally exposed.
Two extra hardening layers are always available:
- **Origin/CSRF protection** is always on. A state-changing cross-origin browser request (one carrying a cross-site `Sec-Fetch-Site` or a mismatched `Origin`) is rejected with `403`. Normal non-browser MCP clients (Claude Code, Cursor, the go-sdk client) send no `Origin` header and are unaffected.
- **Optional bearer token.** Set `-http-token ` (or `AGY_MCP_HTTP_TOKEN`) to require `Authorization: Bearer ` on every request; a missing or wrong token gets `401`. The flag overrides the env var. Off by default, so the bare loopback mode stays unauthenticated.
```bash
agy-mcp -http 127.0.0.1:8765 -http-token "$(openssl rand -hex 32)"
```
## Upgrading from v2.1
**Behaviour change to check your client against:** `list_models` used to return each `agy models` row whole in `models`, as a tab-joined `idlabel` string. It now returns the id alone, and pairs each id with its display label in the new `model_details` field. No type on the wire changed, only the values, so a client that renders `models` to a human now shows a slug and should read `model_details[].label` instead, and one that split each entry on the tab will find nothing to split. A stored old value passed back as `model` still works: the server reduces a whole row to its id before running it.
## Upgrading from v1
v2 requires agy 1.1.15 and drives it through `--output-format stream-json`. The upgrade is safe to do in place, but two things are worth knowing.
**Stop the server before replacing the binary.** The supervisor is the same `agy-mcp` binary re-executed as `agy-mcp run-job `, so replacing it under a live v1 server pairs an old manager with a new supervisor. The supervisor detects that case and asks agy for the event stream anyway, so no result is lost, but a v1 manager and a v2 process sharing one `AGY_MCP_STATE_DIR` briefly disagree about cross-process locking, and a v1 run's conversation id can be misattributed in that window.
**Jobs already in the state directory keep working.** A job dir written by v1 loads cleanly (the two removed `meta.json` fields are simply ignored) and its result is still reported in full. Two degradations apply until those jobs age out of the 24h TTL:
- A v1 fresh run may report no `conversation_id`. v1 recovered it by diffing agy's conversation cache, and v2 has no such path. Recover the thread with `list_sessions` if you need it.
- Cancelled and interrupted v1 jobs behave as before; only the id is affected.
**Behaviour changes to check your client against:** `agy_run` now blocks up to 2s waiting for agy to name the conversation, instead of returning instantly; fresh runs in the same directory no longer conflict, so anything that relied on that conflict error as a mutex now gets real concurrency (and agy runs with `--dangerously-skip-permissions`, so concurrent runs edit one working tree); and `list_models` is now refused on an agy older than 1.1.15, even though `agy models` itself would work there.
## Configuration
| Env | Default | Meaning |
|-----|---------|---------|
| `AGY_MCP_AGY_PATH` | `agy` on PATH | path to the agy binary |
| `AGY_MCP_STATE_DIR` | `$XDG_STATE_HOME/agy-mcp` | job state directory |
| `AGY_MCP_DEFAULT_MODEL` | agy default | default model, as an id (`gemini-3.1-pro-high`), not a display label |
| `AGY_MCP_HTTP_TOKEN` | (none) | optional bearer token for HTTP mode; empty = unauthenticated |
| `AGY_MCP_SYNC_WAIT_CAP` | `90s` | inline-wait ceiling for `agy_run_sync` / `agy_wait` (the default when no `wait` is given, and the clamp for a larger one); the 90s default stays below common MCP clients' ~120s per-call timeout, and this variable raises or lowers it; a Go duration that does not parse as positive is ignored |
| `AGY_MCP_USAGE_INTERVAL` | `5m` | how often the server refreshes its quota reading in the background (a Go duration of at least 1m); `0` disables background probing, leaving only explicit `agy_usage` calls to probe; a bad value fails startup |
| `AGY_MCP_USAGE_LOW` | `25` | remaining quota percent (`25` or `25%`) below which a group reports `low`; a bad value, or `AGY_MCP_USAGE_CRITICAL` above it, fails startup |
| `AGY_MCP_USAGE_CRITICAL` | `5` | remaining quota percent below which a group reports `critical` |
| `AGY_MCP_WAIT_READY_FILE` | (none) | absolute path `wait-job` / `hook-wait` create once their SIGINT/SIGTERM handler is installed, so a parent can signal without racing it; must be fresh per invocation, an existing file is refused rather than overwritten; empty = nothing is written |
## Development
Build, test and lint commands, the code layout and the conventions a change follows are in [AGENTS.md](AGENTS.md).
## License
MIT. See [LICENSE](LICENSE).