# The CLI in Practice

Turning a museum collection into a Unix-composable data source

`rijks-mcp` is a thin command-line client over the rijksmuseum-mcp+ server. It speaks the same tools an AI assistant uses — so a shell command returns identical results to a model — but emits clean JSON to `stdout`, which means the whole 834,000-object collection drops straight into pipes, scripts, cron jobs, and bash-driven agents. No API key, no glue code, no second copy of the query logic.

11
tools at the prompt
1 file · 0 deps
reuses the existing server
~1 s
cold vocab query
98%
smaller output with `--fields`
The niche

## Why a CLI when there's already an MCP server?

For an AI assistant wired directly to the server, MCP is the better interface — typed schemas, native discovery, no shell-quoting. The CLI earns its place precisely where MCP can't reach: the shell.

### Bash-only agents

A generic coding agent (or a Claude Code session) with shell access but no MCP wiring to this server can still query the collection — one command, structured JSON back.

### Pipelines & data work

When `… | jq | sort | uniq -c` beats a sequence of discrete tool calls, JSONL on `stdout` composes with every tool you already use.

### CI, cron & scripts

Reproducible, replayable invocations with documented exit codes — the building block of scheduled exports, smoke tests, and incremental sync.

A free side benefit: because the CLI drives the real tool handlers, every command is also a faithful regression harness — if the CLI returns it, the model gets the same thing.
Architecture

## How it connects

The CLI is a client, not a second implementation. It carries no domain logic: it resolves a short verb to a tool, forwards typed arguments, and prints the result. One `invoke()` seam, two transports.

You

### shell / script / agent

`rijks-mcp search …`

→
Client

### rijks-mcp

verb→tool, arg coercion from the live schema, JSON out

→
Transport

### HTTP or stdio

`--http` to a warm server, or spawn `dist/index.js`

→
Server

### MCP handlers

the exact same code paths the LLM hits

→
Data

### SQLite

vocab + embeddings, locally

Every example on this page calls `rijks-mcp` — the bin declared in `package.json`. Link it onto your `PATH` once and it runs from any directory; `npm run cli -- …` and `node scripts/cli.mjs …` drive the exact same client if you'd rather not install anything.

# one-time: put rijks-mcp on your PATH (or: npm install -g .) $ npm link # now callable from anywhere — identical to npm run cli / node scripts/cli.mjs $ rijks-mcp search --query "tulip" --max 5 --fields objectNumber,title

### `--http` — warm server (the default for repeated use)

- Points at a running `npm run serve` or the Railway deployment.
- Already warm → every call is instant.
- Ideal for agents and tight loops.

### stdio — zero-config fallback

- No server needed; spawns the build as a subprocess.
- Skips the ~13 s eager warm-up (caches build lazily): vocab queries land in ~1 s.
- Perfect for a one-off on a fresh checkout.

Pipelines

## Composable with the Unix toolbox

Lists print as JSONL — one JSON object per line — on `stdout`. Counts, pagination hints and warnings go to `stderr`. That split is deliberate: the data channel stays clean for `jq`, while progress never corrupts a pipe.

Produce

### rijks-mcp

JSONL rows on `stdout`

|
Reshape

### jq / sed

pluck & transform fields

|
Aggregate

### sort / uniq

group & count

→
Result

### a table, a CSV, a count

no bespoke code

A real one-liner — how Rembrandt's dated works in the collection distribute by year (his authority `vocabId` is `2103429`):

$ rijks-mcp search --creator 2103429 --max 50 --fields date \ | jq -r '.date' | sed 's/[^0-9].*//' | sort | uniq -c 1 1628 2 1633 1 1639 1 1642 3 1656 1 1658 …

Swap `uniq -c` for `jq -s`, pipe into `gnuplot`, or redirect to a file — the producer never changes.

Efficiency

## The token lever: `--fields` projection

For a human, output size is cosmetic. For an LLM agent reading the result back, every byte is a token. `--fields a,b,c` keeps only the keys you asked for — on each row of a list, so the saving compounds across hundreds of results.

One `get_artwork_details` record (The Night Watch, `SK-C-5`)
full payload
12,224 B
`--fields` ×4
177 B
−98.5%
One `search_artwork` result row
full row
173 B
`objectNumber,title`
48 B
−72%

Measured against the live database. A 50-row search projected to two fields is roughly `50 × 48 B` instead of `50 × 173 B` — before any `jq` even runs. Need everything? `--json` prints the full payload verbatim.

Automation

## Batch workflows

Because each invocation is a plain command with a clean exit code, ordinary shell constructs — variables, loops, redirects — turn the collection into a scriptable resource.

### Resolve a name, then fetch the works

chain

Look up an artist's authority ID, then query by it — spelling- and language-proof.

# name → vocabId → works, saved as JSONL $ VID=$(rijks-mcp persons "Vermeer" --max 1 --fields vocabId | jq -r .vocabId) $ rijks-mcp search --creator "$VID" --max 50 --fields objectNumber,title > vermeer.jsonl

### Fan out: detail every search hit

loop

Stream object numbers into a loop that pulls a projected detail record for each.

$ rijks-mcp search --query "self-portrait" --max 20 --fields objectNumber \ | jq -r .objectNumber \ | while read on; do rijks-mcp details "$on" --fields objectNumber,title,date; done > portraits.jsonl

### Export an aggregate as CSV

report

`stats` emits JSONL entries; `jq` turns them into a spreadsheet column.

$ rijks-mcp stats type --topN 20 --fields label,count \ | jq -r '[.label, .count] | @csv' > object-types.csv

### Incremental sync with token pagination

cron

Walk a change feed page by page. `--json` surfaces the `resumptionToken` alongside the records, so the loop is self-contained.

$ TOKEN="" $ while :; do PAGE=$(rijks-mcp changes --from 2024-01-01 --identifiersOnly --max 1000 \ ${TOKEN:+--resumption-token "$TOKEN"} --json) echo "$PAGE" | jq -c '.records[]' >> changes.jsonl TOKEN=$(echo "$PAGE" | jq -r '.resumptionToken // empty') [ -z "$TOKEN" ] && break done

### Guard a CI step with an exit code

CI

Exit `0` ok · `1` tool/connection error · `2` usage error — branchable like any Unix tool.

$ rijks-mcp details SK-C-5 --fields objectNumber >/dev/null \ || { echo "collection API unreachable" >&2; exit 1; }
Why it pays off

## Benefits at a glance

### Composable by default

JSONL out, diagnostics on `stderr`, exit codes in — a well-behaved Unix citizen that drops into any pipeline.

### Token-frugal

`--fields` projection and JSONL keep payloads small — up to 98% smaller — which is real money for an LLM consumer.

### Agent-ready

`tools --json` bootstraps capabilities; `--show-call` dry-runs the argument mapping; help is generated from the live schema, so it never drifts.

### Zero second source of truth

No reimplemented query logic: one server, two front doors. The CLI can't disagree with the model, and it doubles as a regression harness.

### Fast where it counts

Point it at a warm server for instant calls, or run cold in ~1 s for a vocab one-off — no 13 s warm-up tax on a throwaway query.

### Nothing new to install

A single script reusing the dependencies the server already ships. `npm run cli`, the `rijks-mcp` bin, or `node scripts/cli.mjs`.