Agents and MCP
poly ships its own agent integration rather than expecting one to be bolted on: plugins for Claude Code, Codex, OpenCode, Pi, Hermes and more, and a stdio MCP server exposing the same lint, format, cache, rules and config surface as the CLI — with structured output an agent can consume directly instead of parsing terminal text.
The operating rule
Section titled “The operating rule”The plugin
Section titled “The plugin”/plugin marketplace add Goldziher/poly/plugin install poly@polyThat registers poly mcp as a stdio server, plus 5 skills and 2 slash commands
(/poly-check, /poly-fix) that teach an agent poly’s tiered backend model and when to reach for
lint versus format versus hooks.
For Codex, the same repository is the marketplace:
codex plugin marketplace add Goldziher/polycodex plugin add poly@polyThe plugin assumes poly is already on PATH — it does not bundle the binary — and its version
tracks the binary version in lock-step.
OpenCode and Pi
Section titled “OpenCode and Pi”Both install @goldziher/opencode-poly from npm, which CI publishes with every release:
opencode plugin add @goldziher/opencode-polypi install npm:@goldziher/opencode-polyThe package contains 5 skills and the /poly-check and /poly-fix prompts. Pi runs poly
through its shell tool; it does not register an MCP server. Pi can also install straight from
Git — pi install git:github.com/Goldziher/poly, or pi install -l … to save it in the current
project’s configuration.
Hermes
Section titled “Hermes”Install poly-hermes-plugin from PyPI into the Python environment Hermes runs in, then enable
poly under plugins.enabled in that installation’s config.yaml:
uv pip install --python /path/to/hermes/venv/bin/python poly-hermes-pluginHermes gets one poly_call tool that bridges all fourteen MCP tools (list_tools shows their
schemas), and the skills load by namespace, e.g. poly:poly-mcp.
Other runtimes
Section titled “Other runtimes”Cursor, Gemini, Kimi, Factory and Copilot bundles are generated alongside these and attached to
every GitHub release as poly-plugins-v<version>.tar.gz; the
plugin README lists each entry
point. The skills alone also install with npx skills add Goldziher/poly --skill <name>.
As a plain MCP server
Section titled “As a plain MCP server”Any MCP-capable client can run poly directly:
{ "mcpServers": { "poly": { "command": "poly", "args": ["mcp"] } }}poly mcp --config <PATH> pins a fallback config file for requests that do not name one.
The release binary links the server in (the bundled-mcp feature, on by default). A core build
without it serves MCP through the downloadable mcp component instead: poly mcp installs it on
first use (unless --offline or [components] install = "never" forbids it) and hands it stdio, and
the component does its work through services the poly binary provides, so that binary carries no
async runtime.
The fourteen tools
Section titled “The fourteen tools”Every tool returns typed structured_content against a declared output schema, plus one text
block mirroring the CLI’s --format json — or the compact TOON encoding when asked for it.
Read-only — never touch the tree
Section titled “Read-only — never touch the tree”| Tool | Mirrors | Returns |
|---|---|---|
lint |
poly lint --workspace never |
Diagnostics per file (per-file tier only; the whole-project phase is workspace_lint). May download a component. |
format_check |
poly fmt |
Formatting drift, without writing. May download a component. |
components |
poly components list / status |
The downloadable engine components and where each stands; with paths, which ones they need, which are missing and which a run would download. Never downloads. |
cache_stats |
poly cache stats |
Result-cache footprint per namespace: entries, bytes, format version. |
rules |
poly rules list / test |
Every ast-grep rule a run would apply — the built-in pack plus [engines.astgrep] rule_dirs — with language, source (builtin/user), default_severity, the severity under the resolved config, and enabled. Optionally runs the *-test.yml snippets. |
config_show |
poly config show |
The merged effective configuration. Network-free: remote extends bases are not fetched. |
version |
poly --version |
Which binary is serving this session — version, build id, channel, executable, pid, uptime — whether that executable is still the file on disk, and the full engine→version map (host-toolchain backends excluded). |
Mutating — writes to the tree
Section titled “Mutating — writes to the tree”| Tool | Mirrors |
|---|---|
lint_fix |
poly lint --fix (may download a component) |
format_write |
poly fmt --fix (may download a component) |
cache_clean |
poly cache clean |
components_install |
poly components install (downloads; writes only the component cache) |
components_uninstall |
poly components uninstall / prune (refuses a component loaded in this process) |
Whole-project — long-running async tasks
Section titled “Whole-project — long-running async tasks”| Tool | Mirrors |
|---|---|
workspace_lint |
the whole-project phase, check mode |
workspace_lint_fix |
the whole-project phase, fix mode |
Both run cargo clippy / cargo-sort / cargo-machete / cargo-deny and any configured inline
whole-project jobs over the whole repository — they take no paths. Because that is a multi-minute
operation, both are exposed as async Tasks: the call returns a task handle and the client polls
tasks/get, optionally tasks/cancel, with an unlimited TTL. A client that does not declare the
tasks capability gets a synchronous, blocking result from the same call instead, so the tools work
either way.
There is no hooks tool: poly hooks is CLI-only.
Components and downloads
Section titled “Components and downloads”In a core build the engines are downloadable components; a bundled build links them in and
nothing is ever missing. lint, format_check, lint_fix and format_write are open-world:
a run that needs a missing component downloads it first, governed by their install parameter —
config (the default, following [components] install), auto, or never (download nothing and
skip the files a missing component would have handled). A server started with --offline never
downloads, whatever install says.
A run that needed any downloadable component carries a components block (used,
installed_now, missing). A missing component that fails the run sets isError; one skipped
under install: "never" does not, but still appears in skipped and components.missing.
When the client declares the tasks capability and the call would download — a run, or
components_install — it returns a Task and the client polls tasks/get; the status message
names the components. There is no byte-level progress and no notifications/progress, because the
core can only answer a frontend’s call, not report during it. Otherwise the call blocks and
returns the result.
Parameters
Section titled “Parameters”lint/format_check/lint_fix/format_write—paths(files or directories; empty means the current directory),exclude(gitignore-style globs merged with[discovery] exclude; unanchored globs match at any depth),config(path to apoly.toml),install("config"by default,"auto"or"never"),format("json"by default, or"toon").components—paths,config,install(the policywould_downloadis judged under),format.components_install—names(components or presets),languages,paths,config,dry_run,format.components_uninstall—names,stale(also remove unreferenced, long-unused archives),format.rules—dirs(empty meansrule_dirsfrom the config),config,test(bool),format.config_show—config,format.cache_stats/cache_clean/version—formatonly.workspace_lint/workspace_lint_fix—config,format,jobs,no_cache. Nopaths.
format selects only the paired text block; structured_content is always JSON.
Treat the read-only tools as safe to call freely, and gate the mutating tools behind explicit intent since they change files.
Every result identifies the binary that answered
Section titled “Every result identifies the binary that answered”Each result carries a poly block (version, build id, channel, executable, pid, flavor —
bundled, core or partial — components_lock — a digest of the embedded component lock, or
null — and engines, a blake3 digest of every compiled-in backend and the upstream version it wraps) in
structured_content and in _meta, because an MCP caller has no poly --version to fall back on.
The version tool returns the full engine→version map the digest summarizes.
The server fingerprints its own executable at startup and re-checks it per request: if the binary is
replaced or deleted underneath a long-lived server, every tool but version fails rather than
answering with superseded behaviour.
Three per-file outcomes, not two
Section titled “Three per-file outcomes, not two”lint / lint_fix / format_check / format_write results tell three outcomes apart:
- Checked — counted in
summary.checked. A checked file with findings has aresultsentry; a checked, clean file has no entry at all, so absence fromresultsalone proves nothing. - Skipped (
skippedfield) — poly correctly declined the file, for example a template dialect no backend handles. Not a failure. - Errored (
errorfield) — poly failed to process a file it accepted: unreadable file, backend crash, bad engine config. Deliberately distinct from a skip, which is a decision rather than a failure.
Each result also carries run-level errors and skipped arrays and a summary:
{ "results": [...], "errors": [], "skipped": [{ "path": "main.zig", "reason": "no lint rules for Zig" }], "summary": { "checked": 12, "skipped": 1, "errored": 0 }, "configs": [{ "hash": "1/9f2c4a1b7e3d05f8" }]}configs fingerprints every configuration that governed the run; each result’s config field
indexes into it (omitted when 0). It is what tells a caller whether two clean reports from
different calls are actually comparable — a poly.toml, a poly.local.toml, a nested config or an
extends base can move underneath an identical-looking request.
errors and skipped duplicate the corresponding records in results on purpose, so a caller can
gate on “did this run actually check what I gave it” without scanning every record. When errors is
non-empty the tool result’s CallToolResult.is_error (isError over the wire) is set true.
skipped deliberately does not set it — a skip is coverage information, not a failure, and
flagging it would make the error signal useless.
That is exactly why summary matters: a run where everything was skipped reports no diagnostics and
is not an error, so isError alone cannot tell it apart from a clean pass. Gate on
summary.checked.
The counts are not a partition of results and cannot be derived from it — a file checked and found
clean produces no record at all, and a file poly has no rules for can be both a skip and a result,
since the cross-cutting backends still run over it.
This is the same document poly lint --format json prints, so an agent and a CI script read
coverage the same way.
Driving poly from the CLI instead
Section titled “Driving poly from the CLI instead”If you are not going through MCP, the same discipline applies with --format json or
--format toon:
-
Check, and read the machine-readable report:
Terminal window poly lint --format json --workspace never . > findings.json -
Read the exit code, not just the payload.
0is clean,1means error-severity findings, and2means the run verified less than it claims. The whole-project phase’s own pass/fail is written to stderr so stdout stays a single valid document. -
Fix, then re-check:
Terminal window poly lint --fix .poly fmt --fix .poly lint --format json --workspace never .
--format toon is compact enough to hand to an agent without burning its context window;
--format json is there when you want a document to parse with a schema.