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.