agent-dispatcher-mcp
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)Runscodex exec. Best for parallelizable, mechanical work: tests, mechanical refactors, second-opinion code review. Passoutput_schema(a JSON Schema dict) to get a"structured"field back with Codex's answer parsed as JSON, instead of scraping prose. Passtask_category(one of"mechanical","review","large_context_analysis","other"— seerecommend_routebelow) to tag this call in the ledger. The prompt is always safe-tier compressed (see "Prompt compression" below);read-onlycalls 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)Runsgemini -p. Best for huge-context jobs: whole-codebase analysis, long logs, large docs. Same compression/caching/retry behavior asdelegate_to_codex, scoped toapproval_mode="plan"calls for caching.start_codex_job(...)/start_gemini_job(...)— same args as the two above (plus a much longer defaulttimeout_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, sincedelegate_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"|"cancelled", "tool", ...delegate result fields}once finished. Jobs are kept in memory only (lost on server restart) and pruned 1h after completion.cancel_job(job_id)— cancel a running background job instead of waiting out its full timeout (up to 1800s). Kills the underlying subprocess tree the same way a timeout does. Cancellation is requested, not instantaneous — this call returns immediately with{"status": "cancelling"}; pollcheck_jobafterward for the terminal{"status": "cancelled", ...}. Returns{"status": "not_found"}for an unknown job_id, or{"status": <done|error|cancelled>, "already_finished": true}if the job had already finished before the cancel request arrived.list_recent_sessions(tool="codex", limit=10)— list resumable Codex conversations, most recently used first, as{"session_id", "last_used_ago_s", "calls", "ever_resumed", "task_category"}. Lets you pick a session to resume viadelegate_to_codex'ssession_idparam without having had to keep it around from the original result. Codex only —tool="gemini"always returns[](a real answer, not an error; see "Session resume" below for why Gemini has no resumable sessions at all).check_budgets()— local best-effort read of recent rate-limit hits per platform (neither CLI exposes a real quota-remaining API), plus realtokens_used_in_windowfor 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 the leading platform's 95% Wilson score confidence interval for success rate stops overlapping the other platform's (see "Routing confidence" below), then prefers whichever platform has the statistically confident edge for that category. Ranks onsuccess_rateonly — Codex'stokens_usedand Gemini'sduration_saren'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, andsuccess_ratehas no signal on whether an answer was actually good, only that the subprocess exited cleanly. Passtask_categoryand the returnedrecommendation_idthrough to whicheverdelegate_to_*/start_*_jobcall follows, so the ledger keeps accumulating comparable data andadherence_statscan report whether the recommendation was actually followed.adherence_stats(task_category=None)— how often a delegate call's platform actually matchedrecommend_route's recommendation for that call. Descriptive only, not fed back intorecommend_route's own ranking. Only calls that actually supplied arecommendation_idcount toward the denominator; calls that never consultedrecommend_routeare 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 (trueon resume,falseon 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_idis safe (unlike Gemini's per-attempt session rotation, Codex reuses onesession_idacross all retries of a call).
Routing confidence (Wilson score intervals)
recommend_route decides when to trust the ledger's measured success rates
over the static default using a 95% Wilson score confidence
interval
per platform, not a flat sample-count cutoff or a flat percentage-point gap.
A platform is only recommended as "data-driven" when its interval's lower
bound exceeds every other candidate's interval's upper bound -- i.e. the two
platforms' plausible-success-rate ranges don't overlap at all.
This self-regulates with sample size in a way a flat threshold can't: a noisy 4-out-of-5 split produces a wide interval that (correctly) almost never separates from anything, while a real but modest edge backed by hundreds of samples can be trusted even though its raw percentage-point gap is much smaller than a flat tie-epsilon would have allowed through.
One deliberate tradeoff: comparing two individual platforms' confidence intervals for non-overlap is a well-known more conservative standard than a proper two-sample statistical test -- it demands more separation than a two-sample test would require at the same nominal 95% confidence. That's an intentional bias toward not declaring a winner on ambiguous data, consistent with this project's existing "don't trust a noisy small-sample leader" philosophy from before this change.
Setup
Install with pipx (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:
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_KEYas a persistent user environment variable (Google login is also supported, but this project currently assumes an API key)
Register with Claude Code:
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:
{
"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 --
%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
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
.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)
.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 checks them, and uploads to PyPI using
trusted publishing (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):
- Create a PyPI account at https://pypi.org/account/register/ if you don't have one, with 2FA enabled (PyPI requires it).
- Since
agent-dispatcher-mcphasn't been published yet, register a "pending publisher" 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:
pypiThis 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.
- PyPI Project Name:
- Optionally, in the GitHub repo's Settings -> Environments, create an
environment named
pypiand 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):
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_summarizeused 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 falserate_limited=Truereading then marked the platform unavailable incheck_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_usedextraction only ever scannedstdout, so it silently stayedNonefor every real Codex call. Confirmed via direct testing:stdoutis just the raw answer text; the whole banner/progress/summary output (including "tokens used") goes tostderr. Fixed: extraction now checks both streams. codex exec resumerejects--color. Unlike the freshcodex execpath, theresumesubcommand errors withunexpected argument '--color' foundif it's passed -- caught during pre-flight live testing before the session-resume feature was wired up, not guessed at from--helpoutput alone. Session resume's real conversational continuity (not just "the subprocess exited 0") was also verified live --tests/manual_check.pyasks Codex to remember a number in one call, then resumes withsession_idand 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.cancel_job's subprocess-tree kill was verified live, not just mocked. The pure-logic tests forcancel_jobmock_do_codexentirely, which proves the job-registry plumbing (theasyncio.Taskgets cancelled,check_jobreports"cancelled") but can't prove the real subprocess actually dies rather than being orphaned -- the exact class of bug the original timeout-handling code in this same function had to solve. Verified live: started a realcodex execjob, cancelled it mid-flight, and confirmed viatasklistthat its PID was actually gone immediately after_kill_process_treeran.
Known quirks (Windows)
- npm CLI shims need PATHEXT resolution.
codex/geminiinstall as.CMDshims. Plainasyncio.create_subprocess_exec("codex", ...)fails withFileNotFoundErrorbecause Win32CreateProcessonly auto-appends.exe, not.cmd. Fixed by resolving the executable viashutil.which()first (_resolve_executableinserver.py). - Timeouts must kill the whole process tree. Both CLIs re-exec
themselves as a child
node/codexprocess 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 withtaskkill /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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_dispatcher_mcp-0.2.0.tar.gz.
File metadata
- Download URL: agent_dispatcher_mcp-0.2.0.tar.gz
- Upload date:
- Size: 54.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c4c2865c4906c3d0097553bccfa4ac34b7304fedc2528f4ba57c643b24e55b8e
|
|
| MD5 |
1bcbefc991aba2f3916916164b3b3178
|
|
| BLAKE2b-256 |
d42a17631df071a11347afe37ec816270a1169ee3f00ef114f5f555cefe8f446
|
Provenance
The following attestation bundles were made for agent_dispatcher_mcp-0.2.0.tar.gz:
Publisher:
publish.yml on maherphine/agent-dispatcher-mcp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_dispatcher_mcp-0.2.0.tar.gz -
Subject digest:
c4c2865c4906c3d0097553bccfa4ac34b7304fedc2528f4ba57c643b24e55b8e - Sigstore transparency entry: 2427779117
- Sigstore integration time:
-
Permalink:
maherphine/agent-dispatcher-mcp@8435814b8a5d3148df13812f7800bc01fb8eb0a0 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/maherphine
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8435814b8a5d3148df13812f7800bc01fb8eb0a0 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agent_dispatcher_mcp-0.2.0-py3-none-any.whl.
File metadata
- Download URL: agent_dispatcher_mcp-0.2.0-py3-none-any.whl
- Upload date:
- Size: 39.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8aa973fa9d55b0718ce7d1c88c65ccd1a8e8cb179bda9b2045718f18a4530aa3
|
|
| MD5 |
3fa61fa82a2d706818294275aa55bbff
|
|
| BLAKE2b-256 |
d00c492f33b2f11979fc6bbd7c2408e7ff39e8812272a4136968c4a211a7c3f7
|
Provenance
The following attestation bundles were made for agent_dispatcher_mcp-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on maherphine/agent-dispatcher-mcp
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_dispatcher_mcp-0.2.0-py3-none-any.whl -
Subject digest:
8aa973fa9d55b0718ce7d1c88c65ccd1a8e8cb179bda9b2045718f18a4530aa3 - Sigstore transparency entry: 2427779544
- Sigstore integration time:
-
Permalink:
maherphine/agent-dispatcher-mcp@8435814b8a5d3148df13812f7800bc01fb8eb0a0 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/maherphine
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8435814b8a5d3148df13812f7800bc01fb8eb0a0 -
Trigger Event:
push
-
Statement type: