TA

tphakala/agy-mcp

Developer tools
20 stars 0 forks Quality 55 Trend 31

agy-mcp: Model Context Protocol (MCP) server for Google Antigravity CLI (agy) with async supervision and completion wakes.

Overview

An MCP (Model Context Protocol) server that wraps the Antigravity CLI (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. 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 (</dev/null), which is easy to forget. - A review can run for many minutes. A single blocking call ties up the caller and can exceed a client's tool-call timeout with nothing recoverable.

README

An MCP (Model Context Protocol) server that wraps the Antigravity CLI (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 (</dev/null), which is easy to forget.
  • A review can run for many minutes. A single blocking call ties up the caller and can exceed a client’s tool-call timeout with nothing recoverable.

agy-mcp solves both by running agy as managed, asynchronous jobs behind a small, typed tool surface, and by capturing output to disk, with a completed run’s result and metadata fsync’d, so the result survives a client disconnect, a server restart, or a crash.

Every job runs agy --output-format stream-json, and the supervisor decodes that event stream as it arrives. That is where the conversation id, the response text, the failure message, and the token accounting all come from, so agy-mcp never has to infer any of them.

What it provides

  • agy_run / agy_status / agy_cancel: start an agy prompt, poll for completion, cancel if needed.
  • agy_run_sync: start a prompt and wait for it inline (bounded, with MCP progress notifications); returns the job_id to wait on or poll if it outlives the wait cap.
  • agy_wait: block until an already-started job finishes (bounded, with MCP progress notifications); one call replaces an agy_status poll loop.
  • list_models: enumerate available agy models, as the ids the model parameter accepts plus their display labels.
  • list_agents: enumerate available agy agents, as the names the agent parameter accepts. Unlike list_models there is no id/label split, because agy takes an agent name verbatim; an empty list just means no agents are configured. Pass cwd (the directory you give agy_run) to include that repository’s project agents from .agents/agents/; agy lists them only for a workspace directory, so without cwd they are absent.
  • agy_usage: report the remaining agy quota per quota group (for example the Gemini pool) with a level of ok, low, critical or exhausted. It spends no model quota. See Quota.
  • list_sessions: list known conversations so review threads can be continued. An unfiltered call omits the ephemeral per-call workspaces the agy-openai-shim helper creates; pass a dir to fetch a specific one.

agy_run and agy_run_sync also take optional per-run controls: pick the model, reasoning effort (low/medium/high), execution mode (including a plan-only pass), a named agent, sandbox terminal restrictions, and a json_schema to constrain the result to structured output. Those run-shaping values are forwarded to agy; idempotency_key is handled only by agy-mcp and is never forwarded.

For retry-safe job creation, supply idempotency_key when a transport failure could leave it ambiguous whether the run started. Reusing the same key with the same normalized request returns the existing job instead of starting another; using the key for a different request is rejected. The binding lasts while that job remains in the job store (24 hours by default), so this is transport-retry protection rather than a permanent business-level deduplication key.

For json_schema runs, agy-mcp returns AGY’s terminal structured_output and fails closed when a successful terminal event lacks that field. It deliberately does not run a second local JSON Schema validator; schema conformance remains AGY’s responsibility, while agy-mcp’s responsibility is preserving and selecting the correct structured-result channel.

A delegated run should not rely on launching a long background shell command (a build, a test suite, a typecheck) and then waiting for it. In headless -p mode agy has no interactive loop to await one: after its agent goes idle it waits a bounded time for outstanding background tasks, then terminates any still running and exits, so a command that outlives that wait never returns its output to the agent and the run’s answer is only progress narration. Before agy 1.2.9 the wait was about 5 seconds. From 1.2.9 it lasts until the run’s --print-timeout deadline (agy-mcp passes the job timeout), capped at 30 minutes per agy’s changelog, so a short background command now completes but one that outlives the job’s budget is still cut off. The flip side is that a background command that never exits on its own, such as one waiting on an interactive prompt, holds the whole job open for as long as that wait lasts. Have the agent run any such verification in the foreground, or pass the results in the prompt. When agy does abort a run this way it still exits 0 cleanly (with a SUCCESS payload in the run MEASURED against agy 1.2.7); agy-mcp detects the abort from agy’s stderr and reports the run as failed with failure_reason: background_aborted, carrying the narration as a partial result, so a caller can tell it apart from a real answer rather than consuming a phantom success. If agy-mcp’s own hard kill lands before agy gets there, the run is reported with failure_reason: timeout instead.

A delegated run should likewise avoid spawning an internal subagent to do the real work and then waiting for it. When agy’s agent delegates to a subagent, the subagent’s output is not streamed on the parent run’s stdout, so agy can finish and report a SUCCESS payload whose response is only the parent’s progress narration (“waiting for the reviewer subagent to complete”) while the actual result is lost. Unlike the background-shell abort above, this leaves no distinguishing signal in agy’s output: the parent’s own token accounting counts the subagent’s generation as ordinary output tokens, which is indistinguishable from a normal tool-heavy run that ends in a short answer, so agy-mcp cannot reclassify it without also downgrading genuine short answers. Keep the work in the parent run instead: instruct the agent to write its findings directly rather than delegate, or continue the same conversation_id with that instruction to recover the answer on the next turn.

Pass model as an id (gemini-3.1-pro-high), the form list_models returns in its models field, not as the display label agy prints beside it (Gemini 3.1 Pro (High)). agy takes either form on its own, but every display label in the agy 1.1.11 catalog was refused once effort was set too, at every effort. The same applies to AGY_MCP_DEFAULT_MODEL. Not every id accepts every effort either, and some accept none, so omit effort unless you need a specific one.

Session continuation rides agy’s own durable conversation store (--conversation ), 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). 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.

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)

brew install tphakala/tap/agy-mcp

This installs the prebuilt release binary (macOS and Linux, amd64 and arm64) from 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

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)

claude mcp add agy -- agy-mcp

Or add to your MCP client config:

{
  "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). 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).

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). 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 or plan, with any other value rejected before the run starts; 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:

{
  "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

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.
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.

License

MIT. See LICENSE.

View the full README on GitHub

Install

This server does not publish a one-line install command.

Open the repository installation guide New to MCP? Start with the mcpskill.org guide →

Configuration

{ "mcpServers": { "agy": { "command": "agy-mcp" } } }