Skip to content

Agents and MCP

poly ships its own agent integration rather than expecting one to be bolted on: a Claude/Codex plugin 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.

/plugin marketplace add Goldziher/poly
/plugin install poly@poly

That 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, add the Goldziher/poly marketplace through your client’s plugin manager; the manifest is at .codex-plugin/plugin.json.

The plugin assumes poly is already on PATH — it does not bundle the binary — and its version tracks the binary version in lock-step.

Any MCP-capable client can run poly directly:

mcp.json
{
"mcpServers": {
"poly": { "command": "poly", "args": ["mcp"] }
}
}

poly mcp --config <PATH> pins a fallback config file for requests that do not name one.

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.

Tool Mirrors Returns
lint poly lint Diagnostics per file.
format_check poly fmt --check Formatting drift, without writing.
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 [rules] 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).
Tool Mirrors
lint_fix poly lint --fix
format_write poly fmt --fix
cache_clean poly cache clean

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.

  • lint / format_check / lint_fix / format_writepaths (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 a poly.toml), format ("json" by default, or "toon").
  • rulesdirs (empty means [rules] dirs from the config), config, test (bool), format.
  • config_showconfig, format.
  • cache_stats / cache_clean / versionformat only.
  • workspace_lint / workspace_lint_fixconfig, format, jobs, no_cache. No paths.

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

lint / lint_fix / format_check / format_write results tell three outcomes apart:

  • Checked — the file has a results entry with no skipped or error set. Diagnostics may still be empty; that is a clean file, not a missing one.
  • Skipped (skipped field) — poly correctly declined the file, for example a template dialect no backend handles. Not a failure.
  • Errored (error field) — 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.

If you are not going through MCP, the same discipline applies with --format json or --format toon:

  1. Check, and read the machine-readable report:

    Terminal window
    poly lint --format json --no-workspace . > findings.json
  2. Read the exit code, not just the payload. 0 is clean, 1 means error-severity findings, and 2 means 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.

  3. Fix, then re-check:

    Terminal window
    poly lint --fix .
    poly fmt --fix .
    poly lint --format json --no-workspace .

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