diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index b385118..e3b3c69 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -85,7 +85,13 @@ export default defineConfig({
{ label: 'Installation', slug: 'start/installation' },
{ label: 'Distribution (all channels)', slug: 'start/distribution' },
{ label: 'Quickstart', slug: 'start/quickstart' },
+ ],
+ },
+ {
+ label: 'AI agents',
+ items: [
{ label: 'Quickstart for AI agents', slug: 'start/agents' },
+ { label: 'MCP server', slug: 'guides/mcp' },
],
},
{
diff --git a/website/src/components/Home.astro b/website/src/components/Home.astro
index 97a8218..59b6717 100644
--- a/website/src/components/Home.astro
+++ b/website/src/components/Home.astro
@@ -61,7 +61,7 @@ const features: { t: string; b: string; d: string }[] = [
},
{
t: 'Built for AI agents',
- b: 'Self-documenting --help on every command and clean JSON output make pdcli easy for agents to discover and drive — a ready-made tool surface for your LLM workflows.',
+ b: 'Runs as an MCP server — pdcli mcp serve — read-only by default, giving Claude and other hosts a safe, typed tool surface. For shell-driven agents there is also self-documenting --help, clean JSON, and deterministic exit codes.',
d: '',
},
{
@@ -456,6 +456,11 @@ const showcaseTerm = `$ pdcli +212 new ~18 updates !3 conflicts
dry run · nothing written · custom fields matched by name`,
},
+ {
+ cmd: 'pdcli mcp serve',
+ out: ` ✓ pdcli MCP server ready — 45 tools (read-only)
+ connect Claude · --allow-writes to expose create/update`,
+ },
];
const cmdEl = document.getElementById('clr-aterm-cmd');
diff --git a/website/src/content/docs/automation/github-actions.mdx b/website/src/content/docs/automation/github-actions.mdx
index 5accd21..01ee6c0 100644
--- a/website/src/content/docs/automation/github-actions.mdx
+++ b/website/src/content/docs/automation/github-actions.mdx
@@ -48,7 +48,7 @@ jobs:
- uses: actions/setup-node@v5
with:
node-version: 20
- - run: npm install -g @wavyx/pdcli@0.20.0
+ - run: npm install -g @wavyx/pdcli@0.22.0
# Preflight: config + keychain checks, zero network egress. Exits 78 on a
# config problem, failing the job before it spends any API budget.
@@ -73,7 +73,7 @@ vars.
jobs:
deals:
runs-on: ubuntu-latest
- container: ghcr.io/wavyx/pdcli:0.20.0
+ container: ghcr.io/wavyx/pdcli:0.22.0
env:
PDCLI_COMPANY_DOMAIN: acme
PDCLI_API_TOKEN: ${{ secrets.PIPEDRIVE_API_TOKEN }}
@@ -82,7 +82,7 @@ jobs:
- run: pdcli deal list --status open --output json --jq '.[].id'
```
-Pin the tag (`:0.20.0`) rather than `latest` for reproducible runs. See
+Pin the tag (`:0.22.0`) rather than `latest` for reproducible runs. See
[Distribution](/pdcli/start/distribution/) for the image details.
## Gate on the exit code
diff --git a/website/src/content/docs/concepts/api-model.mdx b/website/src/content/docs/concepts/api-model.mdx
index d928eb9..ed16e47 100644
--- a/website/src/content/docs/concepts/api-model.mdx
+++ b/website/src/content/docs/concepts/api-model.mdx
@@ -13,7 +13,7 @@ Pipedrive runs two API versions. `pdcli` routes each topic to the right one.
| Topic | API |
| ----- | --- |
| deals, persons, organizations, products, pipelines, stages, activities, projects, fields, search | **v2** |
-| leads, notes, files, filters, webhooks, goals, users | **v1** |
+| leads, notes, files, filters, webhooks, goals, users, mail | **v1** |
Core CRM is on **v2** because Pipedrive deprecated ~59 v1 core endpoints after 2025-12-31, so
pdcli never builds core CRUD on v1. The v1-only topics above have no v2 equivalent yet and are
@@ -62,6 +62,13 @@ seats, resets at midnight server time). On a `429`, pdcli reads `x-ratelimit-res
(falling back to `Retry-After`, then a 2s default) to decide how long to wait before
retrying.
+Inspect the remaining budget with **`pdcli quota`** (alias `ratelimit`): it reports the
+daily token budget remaining/limit and gates CI with `--min`/`--threshold` (exit `75` when
+too low), so a batch can check headroom first — `pdcli quota --min 5000 && pdcli sync
+warehouse`. The budget is **company-wide** (shared across every integration on the
+account), so treat the reading as a hint, not a reservation. (This is unrelated to the
+sales-goal "quota" in the [analytics guide](/pdcli/guides/analytics/).)
+
### Backoff and the hard stop
On a `429`, pdcli waits for the window indicated by `x-ratelimit-reset` (falling back to
diff --git a/website/src/content/docs/guides/api.mdx b/website/src/content/docs/guides/api.mdx
index fdd1ee8..cc5df70 100644
--- a/website/src/content/docs/guides/api.mdx
+++ b/website/src/content/docs/guides/api.mdx
@@ -70,9 +70,17 @@ When you hit v2 endpoints directly, remember the v2 conventions:
- **Bodies are JSON only.** v2 doesn't take form-encoded data.
- Lists use **cursor** pagination (`cursor`/`limit`, `limit` max 500); v1 lists use
**offset** pagination (`start`/`limit`). See
- [How pdcli talks to Pipedrive](/pdcli/concepts/api-model/). `pdcli api` makes a single
- request — it does **not** auto-paginate — so pass `cursor`/`start` yourself for more
- pages.
+ [How pdcli talks to Pipedrive](/pdcli/concepts/api-model/). By default `pdcli api`
+ makes a single request; add **`--paginate`** (alias `--all`) to follow every page and
+ print one concatenated array. It works on `GET` only and infers the pager from the
+ path (`/api/v1/` offset vs `/api/v2/` cursor); `--limit` caps the total. Note the shape
+ shift for `--jq`: with `--paginate` you filter the bare item array (`.[]`), without it
+ the full envelope (`.data[]`).
+
+```bash
+# every open deal across all pages, as one array
+pdcli api GET "/api/v2/deals?status=open" --paginate --jq '.[].id'
+```
## When to reach for it
diff --git a/website/src/content/docs/guides/bulk.mdx b/website/src/content/docs/guides/bulk.mdx
index ea5c18a..58a8468 100644
--- a/website/src/content/docs/guides/bulk.mdx
+++ b/website/src/content/docs/guides/bulk.mdx
@@ -221,3 +221,21 @@ is indexed. For repeatable sync, key on a stable external ID and let the index s
between runs; a periodic `backup diff` or a search will surface any duplicates that slip
through.
:::
+
+### Just checking: `lookup`
+
+When a script wants to **branch on existence itself** rather than have pdcli decide, use
+`lookup` — a read-only exact-match resolver. It exits `0` with the matching row(s) when
+found and **`3`** when nothing matches, so create-vs-update becomes a plain shell branch:
+
+```bash
+if pdcli lookup deal --field "PO Number" --value PO-1234 --first --jq .id >/tmp/id; then
+ pdcli deal update "$(cat /tmp/id)" --body '{"value":5000}' # found → update
+else
+ pdcli deal create --body '{"title":"PO-1234","value":5000}' # exit 3 → create
+fi
+```
+
+It resolves a human field name to its custom-field key for you (deal, person, org, product,
+lead). Matching is **case-sensitive**, and — like upsert — the search index is eventually
+consistent, so a `lookup`-then-`create` loop can still double-create in fast pipelines.
diff --git a/website/src/content/docs/guides/mcp.mdx b/website/src/content/docs/guides/mcp.mdx
new file mode 100644
index 0000000..b57d460
--- /dev/null
+++ b/website/src/content/docs/guides/mcp.mdx
@@ -0,0 +1,130 @@
+---
+title: MCP server
+description: Expose pdcli to Claude and other AI hosts as a Model Context Protocol server — read-only by default, with writes gated behind an explicit flag.
+---
+
+`pdcli mcp serve` runs pdcli as a [Model Context Protocol](https://modelcontextprotocol.io)
+(MCP) server over stdio, so an MCP host — Claude Desktop, Claude Code, or any other
+client — can drive your Pipedrive account as a set of typed tools.
+
+It is the same CLI you already trust, wrapped in a tool surface. Every tool call
+re-invokes `pdcli` itself as a child process under your auth profile, so the tools
+honor the same host-lock, the same keychain credentials, and the same rate-limit
+handling as the commands you run by hand. Nothing new touches your token.
+
+## Safe by default
+
+The design goal is that connecting the server can't hurt you:
+
+- **Read-only out of the box.** The default tool set is a curated **45 read-only
+ tools** — core entity list/get, `search`, deal intelligence
+ (`deal context`/`history`/`summary`), every metric, `funnel`, `digest`, `audit`,
+ `rep scorecard`, `user me`, and more. No tool that writes is even registered
+ unless you ask for it.
+- **Writes are opt-in.** Every command is classified `read`, `write`, or
+ `destructive`. Writes and destructive operations are exposed **only** under
+ `--allow-writes`.
+- **Dangerous surfaces are excluded entirely** — they never appear as tools, even
+ with `--all-tools`. That includes the raw `api` escape hatch, `auth:*`,
+ `config:*`, `alias:*`, `profile:*` (all of which manage your local machine, not
+ CRM data), `doctor`, `watch`, `changes` (advances a stateful watermark),
+ `sync warehouse`, `backup` (but `backup diff`, a zero-API local read, is kept),
+ `webhook listen`, and `mcp serve` itself.
+
+## Quick start
+
+Register the server with Claude Code in one line:
+
+```bash
+claude mcp add pipedrive -- pdcli mcp serve
+```
+
+Or add it to an `.mcp.json` (Claude Code project config, Claude Desktop, or any
+MCP host) by hand:
+
+```json
+{
+ "mcpServers": {
+ "pipedrive": {
+ "command": "pdcli",
+ "args": ["mcp", "serve"]
+ }
+ }
+}
+```
+
+That's the read-only server. To let the host create and update records, add the
+flag to the `args` — `["mcp", "serve", "--allow-writes"]` — or to the
+`claude mcp add` command after the `--`.
+
+Authenticate pdcli once (`pdcli auth login`, or an env-var token) before starting a
+host; the server itself needs no credentials — its child processes resolve them.
+
+## The tool model
+
+Tool selection happens in two stages: **scope**, then the **write gate**.
+
+**Scope** decides which commands are candidates:
+
+| Flag | Tools exposed |
+| ----------------------- | ------------------------------------------------------------------- |
+| _(none)_ | the curated core set — 45 read tools, small enough not to overwhelm a host |
+| `--topics deal,person` | every command under those topics, instead of the curated set |
+| `--all-tools` | every non-excluded command |
+
+**The write gate** then filters that scope: reads are always exposed; `write` and
+`destructive` tools appear only with `--allow-writes`. With the default scope,
+`--allow-writes` adds ~14 core write tools — the create/update commands on core
+entities plus the idempotent `upsert`s (`person`/`org`/`deal upsert`).
+
+```bash
+pdcli mcp serve # curated, read-only (default)
+pdcli mcp serve --allow-writes # curated reads + core writes
+pdcli mcp serve --topics deal,person,org # everything under those topics (reads)
+pdcli mcp serve --all-tools --allow-writes # the whole CLI as tools
+```
+
+One command is deliberately kept out of the curated default even though it's a
+read: `lookup`. Its exit-3 "no match" result is a normal branch for a script, but
+an MCP host reads a non-zero exit as a **tool error** — noisy for an agent. It
+stays reachable via `--all-tools`; for match-or-branch logic inside an agent,
+prefer `search` or the `upsert` tools.
+
+## Custom-field names, not hash keys
+
+Every tool call forces `--resolve-fields`, so the host sees human-readable custom
+field **names** (and option labels) instead of Pipedrive's 40-character hash keys —
+on both input and output. An agent can ask for `"Renewal date"`, not
+`"a1b2c3…"`. Each call also forces `--output=json` (so the parent's stdio channel
+stays clean) and `--yes` (no interactive confirm can block a headless call).
+
+## Timeouts and limits
+
+Each tool call is a child process with guardrails:
+
+- `--tool-timeout ` (default **120**) bounds how long a call may run
+ before its child is terminated (SIGTERM, escalating to SIGKILL).
+- Output is capped at **16 MB** across stdout and stderr combined; a runaway
+ call is killed rather than flooding the host.
+
+Both a timeout and an overflow surface to the host as a tool error with a
+self-describing message, never as a silent success.
+
+## MCP vs. the CLI over bash
+
+If your agent already has a shell — a terminal agent like Claude Code or Codex —
+you may not need MCP at all. pdcli is built to be driven from bash directly:
+`--output json`, `--jq`, self-describing `--help`, and
+[deterministic exit codes](/pdcli/start/agents/) give a shell-capable agent
+everything it needs, with the full command set and no tool-registration step.
+
+Reach for `mcp serve` when the host **can't** run shell commands — Claude Desktop
+and other GUI/chat hosts that speak MCP but have no terminal. There, the MCP server
+turns pdcli into first-class typed tools with the same safety posture you'd get on
+the command line.
+
+:::tip
+Start read-only. Add `--allow-writes` only once you trust the workflow, and prefer
+scoping with `--topics` over `--all-tools` so the host sees a focused, relevant set
+of tools rather than the entire CLI.
+:::
diff --git a/website/src/content/docs/start/agents.mdx b/website/src/content/docs/start/agents.mdx
index 6cc3716..d524abe 100644
--- a/website/src/content/docs/start/agents.mdx
+++ b/website/src/content/docs/start/agents.mdx
@@ -7,6 +7,23 @@ pdcli is built to be driven by agents (Claude Code, Codex, CI bots). It is
self-describing, emits machine-readable JSON, and uses deterministic exit codes.
This page covers the conventions an agent needs.
+## Expose pdcli over MCP
+
+If your host speaks the [Model Context Protocol](https://modelcontextprotocol.io) —
+Claude Desktop, Claude Code, and other GUI or chat clients — pdcli can register as
+an MCP server and hand it a set of typed tools, read-only by default. Register it
+with Claude Code in one line:
+
+```bash
+claude mcp add pipedrive -- pdcli mcp serve
+```
+
+Everything below still applies: the MCP tools re-invoke this same CLI under your
+auth profile, with the same host-lock and exit-code semantics. If your agent has a
+shell, driving pdcli from bash (as this page describes) is often simpler than MCP.
+See the [MCP server guide](/pdcli/guides/mcp/) for the full tool model, the write
+gate, and scoping flags.
+
## Authenticate with environment variables
Avoid the interactive `auth login` flow. Set credentials in the environment so no
@@ -44,6 +61,8 @@ script can react to the failure class without parsing text:
| ---- | ------------------------------------------------------------------------------------------------- |
| 0 | Success |
| 1 | Generic error |
+| 3 | `lookup`: no record matched — not a failure; branch to create/upsert |
+| 8 | `watch`: new findings since the last run — the trigger for `pdcli watch \|\| notify` |
| 64 | Usage / bad flags or arguments (unknown flag, missing arg, invalid value, missing CSV column) |
| 65 | Bad input data (API 400 / 422) |
| 69 | Service unavailable (API 5xx, or unreachable) |
@@ -90,7 +109,8 @@ performance, `digest` for the whole Monday packet in one fetch (`--format md|htm
a cron → Slack/email artifact), `changes` for an incremental cross-entity change feed with a
self-advancing watermark (no receiver to host, unlike webhooks), `deal context ` for a
one-call denormalized, prompt-ready bundle (deal + person + org + activities + notes +
-products + flags), `backup diff` for a zero-API field-level diff of two snapshots, `watch` for
+products + flags; add `--mail` to fold in a mail summary — opt-in, since it needs the
+`mail:read` scope and email sync), `backup diff` for a zero-API field-level diff of two snapshots, `watch` for
an exit-code-gated anomaly poller that fires only on findings new since the last run
(`pdcli watch || notify`), `sync warehouse` for an incremental NDJSON export with per-entity
high-water marks, and `--updated-since` on the list commands for incremental polling.
@@ -102,6 +122,11 @@ writes the wrong one. `person import`/`org import --upsert --match-on ` a
match-or-create per CSV row, reporting created/updated/unchanged counts. Pair upsert with an
external key (a custom field carrying your system's ID) for clean, repeatable sync.
+When you'd rather branch on existence yourself before deciding to create or update, `lookup`
+is the lighter read-only probe: it finds a record by a field value and exits **0 when found**
+(printing it) or **3 when nothing matched**, so `pdcli lookup person --field email --value … ||
+pdcli person create …` is a clean create-if-missing pattern that never mutates on its own.
+
## The host-locked `api` escape hatch
When no dedicated command exists, call any endpoint directly. The request is
diff --git a/website/src/content/docs/start/distribution.mdx b/website/src/content/docs/start/distribution.mdx
index a3ce2eb..277cced 100644
--- a/website/src/content/docs/start/distribution.mdx
+++ b/website/src/content/docs/start/distribution.mdx
@@ -56,7 +56,7 @@ pdcli version
Pin a version in CI so a background release can't change behaviour mid-pipeline:
```bash
-npm install -g @wavyx/pdcli@0.20.0
+npm install -g @wavyx/pdcli@0.22.0
```
Auth: keychain by default (`pdcli auth login`), or env vars for scripts.
@@ -68,7 +68,7 @@ step where you'd rather not add an install line.
```bash
npx @wavyx/pdcli deal list
-npx @wavyx/pdcli@0.20.0 deal list # pinned
+npx @wavyx/pdcli@0.22.0 deal list # pinned
```
The global install is faster for daily use because npx resolves and caches the package
@@ -91,7 +91,7 @@ token never appears in the command or in shell history. Pin the tag in CI:
```bash
docker run --rm -e PDCLI_API_TOKEN -e PDCLI_COMPANY_DOMAIN \
- ghcr.io/wavyx/pdcli:0.20.0 deal list --output json
+ ghcr.io/wavyx/pdcli:0.22.0 deal list --output json
```
The entrypoint is `pdcli`, so everything after the image name is passed straight
@@ -141,23 +141,23 @@ Five targets are published per release:
| `win32-x64` | Windows, 64-bit |
Each tarball filename embeds the build's commit SHA (for example
-`pdcli-v0.20.0-1a2b3c4-linux-x64.tar.gz`), so there is no fixed, predictable
+`pdcli-v0.22.0-1a2b3c4-linux-x64.tar.gz`), so there is no fixed, predictable
download URL. Grab the exact asset from the
[Releases page](https://github.com/wavyx/pdcli/releases), or let the
[`gh` CLI](https://cli.github.com/) resolve it for you:
```bash
# See the tarballs attached to a release:
-gh release view v0.20.0 --json assets --jq '.assets[].name'
+gh release view v0.22.0 --json assets --jq '.assets[].name'
# Download the one for your target by pattern (no need to know the SHA):
-gh release download v0.20.0 --pattern '*linux-x64.tar.gz'
+gh release download v0.22.0 --pattern '*linux-x64.tar.gz'
```
Then unpack it and put the `bin` directory on your `PATH`:
```bash
-tar -xzf pdcli-v0.20.0-*-linux-x64.tar.gz
+tar -xzf pdcli-v0.22.0-*-linux-x64.tar.gz
./pdcli/bin/pdcli version
```