Metadata-Version: 2.5
Name: agent-dispatcher-mcp
Version: 0.1.0
Summary: FastMCP server that delegates work from Claude Code to OpenAI Codex CLI and Google Gemini CLI as headless subprocesses.
Author-email: maherphine <maherphine@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,claude,claude-code,codex,gemini,llm,mcp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: fastmcp>=3.4.4
Requires-Dist: platformdirs>=4.0
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: pytest>=9.1.1; extra == 'dev'
Requires-Dist: twine>=6.0; extra == 'dev'
Description-Content-Type: text/markdown

# agent-dispatcher-mcp

[![CI](https://github.com/maherphine/agent-dispatcher-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/maherphine/agent-dispatcher-mcp/actions/workflows/ci.yml)

MCP server that lets Claude Code delegate work to OpenAI Codex CLI and
Google Gemini CLI, running headlessly as subprocesses under your existing
ChatGPT / Google subscriptions (not pay-per-token API keys, except Gemini
which currently requires a `GEMINI_API_KEY`).

## Tools

- `delegate_to_codex(prompt, cwd=".", timeout_s=270, sandbox="read-only", output_schema=None, task_category=None, max_prompt_chars=None, cache_ttl_s=None, recommendation_id=None, max_transient_retries=1, session_id=None)`
  Runs `codex exec`. Best for parallelizable, mechanical work: tests,
  mechanical refactors, second-opinion code review. Pass `output_schema` (a
  JSON Schema dict) to get a `"structured"` field back with Codex's answer
  parsed as JSON, instead of scraping prose. Pass `task_category` (one of
  `"mechanical"`, `"review"`, `"large_context_analysis"`, `"other"` — see
  `recommend_route` below) to tag this call in the ledger. The prompt is
  always safe-tier compressed (see "Prompt compression" below); `read-only`
  calls are also cached by default (see "Delegate result caching" below).
  Transient backend errors (not rate limits, not timeouts) are retried
  automatically within the call's own timeout budget (see "Retry on
  transient errors" below); the result's `"retry_count"` reports how many
  retries were actually used. Pass a prior result's `"session_id"` to
  continue that conversation instead of resending context from scratch (see
  "Session resume" below).
- `delegate_to_gemini(prompt, cwd=".", timeout_s=270, approval_mode="plan", task_category=None, max_prompt_chars=None, cache_ttl_s=None, recommendation_id=None, max_transient_retries=1)`
  Runs `gemini -p`. Best for huge-context jobs: whole-codebase analysis,
  long logs, large docs. Same compression/caching/retry behavior as
  `delegate_to_codex`, scoped to `approval_mode="plan"` calls for caching.
- `start_codex_job(...)` / `start_gemini_job(...)` — same args as the two
  above (plus a much longer default `timeout_s`, 1800s), but return
  immediately with `{"job_id", "status": "running"}` instead of blocking.
  Use these instead of the synchronous tools when a task could plausibly
  run longer than ~4 minutes, since `delegate_to_*` is bounded by the MCP
  client's own call timeout (see "Important" below), not just this
  server's internal one.
- `check_job(job_id)` — poll a background job. Returns
  `{"status": "running", "tool", "elapsed_s"}` while in flight, or
  `{"status": "done"|"error", "tool", ...delegate result fields}` once
  finished. Jobs are kept in memory only (lost on server restart) and
  pruned 1h after completion.
- `check_budgets()` — local best-effort read of recent rate-limit hits per
  platform (neither CLI exposes a real quota-remaining API), plus real
  `tokens_used_in_window` for codex (parsed from its own "tokens used"
  summary line; always 0 for gemini, which doesn't print an equivalent).
- `get_delegation_log(limit=20, tool=None)` — recent call history from the
  SQLite ledger (`ledger.sqlite3`), so Claude doesn't blindly resend work
  that already failed.
- `recommend_route(task_category)` — advisory routing recommendation
  (`"codex"`, `"gemini"`, or `"claude"`) for one of the four fixed
  categories: `"mechanical"`, `"review"`, `"large_context_analysis"`,
  `"other"`. Falls back to a static default policy (mirroring the project's
  original hand-written heuristic) until at least 5 historical samples exist
  for a (category, platform) pair in the ledger, then prefers whichever
  platform has the better measured success rate for that category. Ranks on
  `success_rate` only — Codex's `tokens_used` and Gemini's `duration_s`
  aren't a comparable unit, so they're reported separately per platform, not
  blended into one score. `"other"` always recommends `"claude"` (there's no
  ledger baseline for Claude doing a task itself). This tool never delegates
  anything itself — it's advisory, and `success_rate` has no signal on
  whether an answer was actually *good*, only that the subprocess exited
  cleanly. Pass `task_category` and the returned `recommendation_id` through
  to whichever `delegate_to_*`/`start_*_job` call follows, so the ledger
  keeps accumulating comparable data and `adherence_stats` can report
  whether the recommendation was actually followed.
- `adherence_stats(task_category=None)` — how often a delegate call's
  platform actually matched `recommend_route`'s recommendation for that
  call. Descriptive only, not fed back into `recommend_route`'s own ranking.
  Only calls that actually supplied a `recommendation_id` count toward the
  denominator; calls that never consulted `recommend_route` are excluded,
  not counted as non-adherence.
- `usage_report(days=7, task_category=None)` — human-readable summary of
  recent usage: per-platform success rate, avg duration, total/avg tokens
  used, cache hit rate, rate-limit/transient-error counts, plus adherence
  stats for the same window, plus (when not filtered to one category) a
  breakdown of call volume per category. The raw ledger otherwise only
  exists as individual rows you'd have to query by hand.

Both delegates default to **read-only / plan mode** — they can inspect the
target directory but not write files. Claude applies any resulting changes
itself as diffs, rather than letting multiple agents write to the same tree
concurrently.

### Prompt compression

Every prompt is run through a **safe tier** before sending: non-lossy
whitespace normalization (collapsing blank-line runs, stripping trailing
whitespace) that skips content inside triple-backtick fenced code blocks,
since blank lines and trailing whitespace can be meaningful there (diff
context, whitespace-sensitive fixtures) even though they never are in prose.
This always runs; there's no way to disable it because there's no plausible
input where it changes task meaning.

A **lossy tier** (hard truncation) is opt-in only, via `max_prompt_chars`.
There's no default cap — only opt in when you know truncation is safe for
that specific prompt, since cutting Claude-authored instructions can
silently change what a delegate is asked to do.

### Delegate result caching

`sandbox="read-only"` (codex) / `approval_mode="plan"` (gemini) calls —
today's defaults — are cached for 15 minutes by default, keyed by an exact
hash of `(tool, prompt actually sent, resolved cwd, mode, output_schema)`.
An identical repeat call returns the cached result (`"cached": true`,
`"cache_age_s"`) instead of re-running. Any other mode never reads or writes
the cache — a write-capable call's side effects must never be silently
skipped by a cache hit. Only a fully successful result is ever cached (not
rate-limited, not a transient error, not timed out). Pass `cache_ttl_s=0` to
force a fresh run for one call while still refreshing the cache for later
callers. A cache hit reuses a previously-drawn sample; it does not
re-verify the answer live, since Codex/Gemini are non-deterministic.

### Retry on transient errors

A transient backend error (e.g. Gemini's "high demand" 503) is retried
automatically, up to `max_transient_retries` times (default 1). Retries
happen **within** the call's own `timeout_s` budget, never additive to it —
this matters because `delegate_to_*` is bounded by the MCP client's own
call timeout, which uncapped retries could blow straight through. A retry
is skipped if less than ~20s of budget remains, or if the outcome is a real
rate limit (retrying immediately won't clear one) or a timeout (which
already consumed the full attempt budget). The result's `"retry_count"`
field reports how many retries were actually used; only the final outcome
gets a ledger row (intermediate failed attempts within one logical call
aren't separately recorded). Set `max_transient_retries=0` to disable.

### Session resume

Every `delegate_to_codex`/`start_codex_job` result includes a `"session_id"`
(extracted from Codex's own startup banner, which prints `session id:
<UUID>` on every call, fresh or resumed). Pass that id back in as
`session_id` on a later call to continue the same Codex conversation instead
of resending full context — this is a direct token saving, the whole point
of this project.

**Codex only.** Gemini's CLI has no stable per-session id, only `--resume
latest`/`--resume <index>` — a fragile positional handle that any concurrent
Gemini session on the same machine can shift out from under you, silently
continuing the wrong conversation. There's no `session_id` param on
`delegate_to_gemini`/`start_gemini_job`.

A resumed call (`session_id` given):
- Ignores `cwd`/`sandbox` — `codex exec resume <id>` is a distinct
  subcommand that doesn't accept `-C`/`-s`/`--color`; it continues wherever
  the original session was configured. Check the result's
  `"cwd_sandbox_ignored"` field (`true` on resume, `false` on a fresh call).
- Is never cached, even with `sandbox="read-only"` — a resumed call's
  meaning depends on prior turns a cache key can't represent.
- Retries the same way a fresh call does: a transient blip during a resume
  attempt means nothing in the session was mutated, so retrying with the
  same `session_id` is safe (unlike Gemini's per-attempt session rotation,
  Codex reuses one `session_id` across all retries of a call).

## Setup

Install with [pipx](https://pipx.pypa.io/) (recommended for end users) --
this puts the `agent-dispatcher-mcp` console script on PATH in its own
isolated environment, which is what lets `claude mcp add` invoke it by bare
name with no absolute paths:

```bash
pipx install .
```

(Once published, this becomes `pipx install agent-dispatcher-mcp`.)

Auth (one-time, done by you, not by Claude):
- Codex: `codex login` (uses your ChatGPT account)
- Gemini: set `GEMINI_API_KEY` as a persistent user environment variable
  (Google login is also supported, but this project currently assumes an
  API key)

Register with Claude Code:

```bash
claude mcp add dispatcher -- agent-dispatcher-mcp
```

**Important:** also set a per-server `timeout` (milliseconds) in the resulting
`.mcp.json` entry. Claude Code's own MCP client can cut a tool call off well
before this server's internal `timeout_s` (default 270s) elapses, and
`codex exec`/`gemini -p` against a real repo routinely take 60-90s+, so the
client-side default is too tight. Example entry:

```json
{
  "mcpServers": {
    "dispatcher": {
      "command": "agent-dispatcher-mcp",
      "args": [],
      "timeout": 300000
    }
  }
}
```

Restart the Claude Code session after editing `.mcp.json` -- server configs
(including `timeout`) are only read at session start.

The SQLite ledger/cache live in a per-user data directory (via
[platformdirs](https://github.com/tox-dev/platformdirs) --
`%LOCALAPPDATA%\agent-dispatcher-mcp\ledger.sqlite3` on Windows,
`~/.local/share/agent-dispatcher-mcp/ledger.sqlite3` on Linux,
`~/Library/Application Support/agent-dispatcher-mcp/ledger.sqlite3` on
macOS), not next to the installed package. Override with the
`AGENT_DISPATCHER_DATA_DIR` environment variable if needed.

## Development

```bash
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]"   # Windows; editable install + pytest
```

An editable install (`pip install -e .`) does **not** put the console script
on PATH the way `pipx install` does -- for pointing a dev's own Claude Code
session at uncommitted changes, register with the venv python directly
instead: `claude mcp add dispatcher -- "<repo>\.venv\Scripts\python.exe" -m agent_dispatcher_mcp.server`.

## Tests

```bash
.venv/Scripts/python.exe -m pytest tests/
```

Covers the pure logic only (executable resolution, rate-limit vs
transient-error classification, ledger/cache read-writes, prompt
compression, caching, and adherence tracking) -- no real codex/gemini
subprocess calls, so it's fast and doesn't burn API/subscription quota.

## Manual verification (real CLIs, slower)

```bash
.venv/Scripts/python.exe tests/manual_check.py
```

## Releasing (publishing to PyPI)

Publishing is automated via `.github/workflows/publish.yml`, triggered by
pushing a `v*.*.*` tag. It runs the test suite, builds the sdist/wheel,
`twine check`s them, and uploads to PyPI using
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) --
no PyPI API token is ever generated or stored as a GitHub secret.

**One-time setup on PyPI (only you can do this -- requires login):**
1. Create a PyPI account at <https://pypi.org/account/register/> if you
   don't have one, with 2FA enabled (PyPI requires it).
2. Since `agent-dispatcher-mcp` hasn't been published yet, register a
   ["pending publisher"](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
   for it at <https://pypi.org/manage/account/publishing/> with:
   - PyPI Project Name: `agent-dispatcher-mcp`
   - Owner: `maherphine`
   - Repository name: `agent-dispatcher-mcp`
   - Workflow name: `publish.yml`
   - Environment name: `pypi`
   This works even though the GitHub repo is private -- trusted publishing
   checks the OIDC claims from the workflow run (repo/owner/workflow/
   environment), not repo visibility.
3. Optionally, in the GitHub repo's Settings -> Environments, create an
   environment named `pypi` and add required reviewers -- this adds a manual
   approval gate before any publish job can run, independent of PyPI's own
   trusted-publisher check.

**To cut a release** (after the above is done once):
```bash
git tag v0.1.0
git push origin v0.1.0
```
Then watch it with `gh run watch` like any other workflow. Bump the
`version` in `pyproject.toml` before tagging a new release -- PyPI rejects
re-uploading an existing version number.

## Notable bugs found via live testing (fixed)

The pure-logic test suite deliberately never shells out to real `codex`/
`gemini`, which is great for speed but has a real blind spot: it can't
catch bugs in how this server interprets the *real* CLIs' actual output.
Both of these were found by live testing, not the test suite, and are worth
knowing about since they silently distorted real usage data for a while:

- **A successful call could get misclassified as rate-limited/a transient
  error.** `_record_and_summarize` used to scan a call's entire output for
  error patterns regardless of whether it actually succeeded. If a
  delegate's own *correct* answer happened to discuss rate limiting or HTTP
  status codes as subject matter (e.g. asked to summarize this very
  codebase, or any code that handles API errors), its accurate answer
  tripped the classifier -- a false `rate_limited=True` reading then marked
  the platform unavailable in `check_budgets()` for its full assumed window
  (up to 24h for Gemini), suppressing real, working usage without any
  actual error occurring. Fixed: classification now only runs on non-success
  outcomes -- a call that exited 0 cleanly cannot coherently be "currently
  rate limited."
- **Codex's "tokens used" summary line is printed to stderr, not stdout.**
  `tokens_used` extraction only ever scanned `stdout`, so it silently
  stayed `None` for every real Codex call. Confirmed via direct testing:
  `stdout` is just the raw answer text; the whole banner/progress/summary
  output (including "tokens used") goes to `stderr`. Fixed: extraction now
  checks both streams.
- **`codex exec resume` rejects `--color`.** Unlike the fresh `codex exec`
  path, the `resume` subcommand errors with `unexpected argument '--color'
  found` if it's passed -- caught during pre-flight live testing before the
  session-resume feature was wired up, not guessed at from `--help` output
  alone. Session resume's real conversational continuity (not just "the
  subprocess exited 0") was also verified live -- `tests/manual_check.py`
  asks Codex to remember a number in one call, then resumes with
  `session_id` and confirms the follow-up actually recalls it -- since the
  pure-logic suite's mocked fixtures can't prove real cross-call state
  persists on the CLI's side.

## Known quirks (Windows)

- **npm CLI shims need PATHEXT resolution.** `codex`/`gemini` install as
  `.CMD` shims. Plain `asyncio.create_subprocess_exec("codex", ...)` fails
  with `FileNotFoundError` because Win32 `CreateProcess` only auto-appends
  `.exe`, not `.cmd`. Fixed by resolving the executable via `shutil.which()`
  first (`_resolve_executable` in `server.py`).
- **Timeouts must kill the whole process tree.** Both CLIs re-exec
  themselves as a child `node`/`codex` process on Windows. Killing only the
  immediate child leaves the grandchild alive holding the stdout/stderr
  pipes open, so a naive "kill then read remaining output" hangs forever.
  Fixed with `taskkill /PID <pid> /T /F` (`_kill_process_tree`), plus a
  bounded secondary read so a delegate call can never hang the caller
  indefinitely, no matter what.
- **Gemini's first run per working directory can be very slow** due to an
  upstream bug where it tries to replay/reload a huge range of historical
  (mostly nonexistent) session files, spamming `EMFILE: too many open
  files`. Passing an explicit `--session-id` (a fresh UUID) avoids
  triggering that replay — already wired into the default flags used here.
- Gemini's API has also been observed returning transient `503
  UNAVAILABLE` ("high demand") errors independent of the above — that's on
  Google's side, not this server; `check_budgets`/the ledger will surface
  repeated failures but can't distinguish rate-limiting from a backend
  outage beyond pattern-matching the error text.

## License

MIT — see [LICENSE](LICENSE).
