openplate
Provides access to Wikidata's open knowledge base as one of the server's live data sources, supplying artwork, creator, and term-of-protection data used by the rights assessment engine.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@openplatecan I print the Met's photo of a Monet water lilies in my newsletter?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
OpenPlate
Know what you are allowed to put in print.
Live app · GitHub · Health · Agent · Issues
A painting can be out of copyright and the photograph of it can still be protected.
That sentence is the whole problem, and almost every tool gets it wrong in the same direction. Museums publish two different things about one artwork:
What the institution publishes | Example, from a real record | |
The artwork | whether the work is in copyright | Monet, 1867, creator died 1926 → life + 70 expired in 1997 |
The reproduction | whether their picture of it is in copyright | The Met's record for it is flagged in copyright |
Neither answer is wrong. They are different rights, and only the first one is usually what the person using the image checked. OpenPlate reads both, does the arithmetic on the published term, and shows the sentence behind every point of the score.
It exists because the job is real and annoying. Somebody has to put a picture in a newsletter, a deck, a print run or a game, has to be able to say why they are allowed to, and has to hand a colleague something checkable instead of a guess.
✨ Features
Live search across two open-access collections. The Metropolitan Museum of Art and The Cleveland Museum of Art, no API key, with the institution's own rights claim on every record.
The artwork/reproduction distinction, made explicit. Term arithmetic and reproduction restrictions are reported separately, because averaging them produces a number that means nothing.
A versioned, explainable engine. Six weighted factors, each with its raw value, its points and a sentence you could check. Identical arithmetic in the browser, the REST API and the agent tools.
Restriction text parsed out of the real statement. The Met's Picasso records read "Ac 2026 Estate of Pablo Picasso / Artists Rights Society (ARS), New York". OpenPlate finds that estate, that collecting society and that holder, and reports the offsets so the interface can underline the exact words.
A step wedge you drag to watch the verdict move as the intended use escalates from personal to broadcast — a real hypothetical, not an animation.
A SHA-384 hash chain per plate, replayable from a fixed genesis value, with a known vector in the tests and a public verify endpoint.
Six MCP tools over JSON-RPC 2.0, including two mutations that go through the same service layer the buttons use.
A downloadable docket in Markdown, CSV and JSON, carrying the credit line, the arithmetic, the restrictions, the decision and the seals.
Honest about failure. If an institution is unreachable the sealed sample stands in and says so, with the date it was taken. If a verdict is computed from a field nobody published, confidence drops and the band becomes Needs review.
Related MCP server: webfetch
🚀 Quickstart
git clone https://github.com/aniruddhaadak80/openplate.git
cd openplate
npm install
npm run devOpen http://localhost:3000. No environment variables and no API key are
required. Without DATABASE_URL the app runs on an embedded PGlite build of
Postgres in ./.pglite, so the schema, constraints, indexes and transactions you
exercise locally are the ones that run in production. All three upstream
institutions are keyless public endpoints.
Scripts
Command | What it does |
| Development server |
|
|
| ESLint |
| Vitest: engine, hash chain, validation, full CRUD over a real database |
| Production bundle |
| Playwright: the primary journey through visible controls |
| Boots the production bundle and runs the full live verifier against it |
| Runs the same verifier against a deployment ( |
| Checks that every absolute URL in this file still resolves |
Production variables
Only one is required, and it is documented without a value in .env.example.
Variable | Required | Purpose |
| Yes, in production | Hosted Postgres. Neon via the Vercel Marketplace supplies it automatically. Without it a production build refuses to open the repository rather than writing into volatile storage. |
| No | Schema namespace, to share one database. Defaults to |
| No | Canonical origin for metadata, the sitemap and the MCP manifest. Defaults to the alias Vercel injects. |
| No | Force the sealed sample with no network. Used by tests. |
OpenPlate deliberately has no third-party API keys: the Met, Cleveland and Wikidata endpoints require none, and adding one would make the demo depend on something a visitor does not have.
🔌 API
Typed REST over the same service layer as the interface and the agent tools.
Errors always arrive as {"error":{"code","message","fields"?}}.
# 1. Search both collections, live.
curl -s 'https://openplate-sigma.vercel.app/api/works?q=jaguar&limit=4' | jq '.works[] | {id, title, institutionCleared}'
# 2. Pin a real record to an intended use. Returns 201 with the plate and its seal.
curl -s -X POST https://openplate-sigma.vercel.app/api/plates \
-H 'content-type: application/json' \
-b cookies.txt -c cookies.txt \
-d '{"workId":"met:11298","use":"editorial","jurisdiction":"us","circulation":10000,"note":"Issue 14"}' \
| jq '{id: .plate.id, shareUrl, band: .assessment.band, score: .assessment.score, seal: .plate.seal}'
# 3. Read it back through the UI-facing API.
curl -s -b cookies.txt https://openplate-sigma.vercel.app/api/plates | jq '.plates[0] | {title, use, chain}'
# 4. Change the inputs. The verdict is recomputed and the change is sealed.
curl -s -X PATCH https://openplate-sigma.vercel.app/api/plates/<PLATE_ID> \
-H 'content-type: application/json' -b cookies.txt \
-d '{"use":"merchandise","circulation":250000}' | jq '.assessment.band'
# 5. Run the engine for a hypothetical, saving nothing.
curl -s -X POST https://openplate-sigma.vercel.app/api/plates/<PLATE_ID>/assess \
-H 'content-type: application/json' -b cookies.txt \
-d '{"use":"broadcast"}' | jq '{band: .assessed.band, differs: .differsFromSaved}'
# 6. Record a sealed decision.
curl -s -X POST https://openplate-sigma.vercel.app/api/plates/<PLATE_ID>/decision \
-H 'content-type: application/json' -b cookies.txt \
-d '{"decision":"approved","note":"Term satisfied, credit line complete."}' | jq '.seal'
# 7. Replay the chain.
curl -s -b cookies.txt https://openplate-sigma.vercel.app/api/plates/<PLATE_ID>/verify \
| jq '.verification | {ok, eventCount, brokenAtSeq}'
# 8. Download the docket.
curl -s 'https://openplate-sigma.vercel.app/api/docket/<SHARE_CODE>?format=md'
# 9. Retire it. It is a tombstone: gone from reads, retained for verification.
curl -s -X DELETE -b cookies.txt https://openplate-sigma.vercel.app/api/plates/<PLATE_ID> | jq '{retired, note}'Route | Methods | Purpose |
|
| Real probe of the configured store. Reports |
|
| Live search, per-provider provenance, honest |
|
| The docket, and filing a plate. |
|
| Read, change inputs, retire as a tombstone. |
|
| Committed verdict, or a hypothetical that saves nothing. |
|
| Record a decision, sealed with the verdict that was read. |
|
| Replay the chain, first broken link reported. |
|
| Download as |
|
| Persisted defaults and the seeded term rules. |
|
| JSON-RPC 2.0: |
|
| Manifest with the live endpoint already filled in. |
🤖 Agent tools
The MCP endpoint is at https://openplate-sigma.vercel.app/api/mcp, and
/mcp.json is generated at request time
with that host already filled in.
{
"mcpServers": {
"openplate": {
"type": "http",
"url": "https://openplate-sigma.vercel.app/api/mcp",
"transport": "streamable-http"
}
}
}Tool | Kind | What it does |
| read | Both collections, normalised, with each institution's rights claim. |
| analysis | The engine on a record, saving nothing. |
| mutating | Re-reads the work live, stores a snapshot, seals the genesis event. Idempotent on |
| mutating | Writes the decision with the band, score and seal of the verdict read. |
| read | Replays the chain, reports the first broken link. |
| mutating | Retires a plate, retaining the tombstone and its chain. |
curl -s https://openplate-sigma.vercel.app/api/mcp -H 'content-type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"assess_work","arguments":{
"workId":"met:19275","use":"merchandise","jurisdiction":"us","circulation":50000}}}'📁 Project map
User routes
Route | User goal | States |
| Search both collections and pin a work | Live/degraded banner, per-provider latency, empty state, pin form per card |
| The workspace: every plate, filtered | Empty state, filters and paging in the URL, retired tombstones |
| Inspect, re-run the engine, decide, retire | Full factor table, hypothetical vs committed, blockers, cautions, chain, retired banner |
| The read-only docket you send someone |
|
| Jurisdiction, term rules and persisted defaults | Seeded rules read from the database, save/unsaved states |
| Drive the MCP tools and watch the wire | Preset calls, raw request and response, tool error states, link to whatever it wrote |
| The downloadable artifact | Empty state, index with per-plate downloads in three formats |
API routes
/api/health, /api/works, /api/plates, /api/plates/<id>,
/api/plates/<id>/assess, /api/plates/<id>/decision, /api/plates/<id>/verify,
/api/docket/<shareCode>, /api/terms, /api/mcp, /mcp.json.
Library
Path | Responsibility |
| The engine. Pure, versioned, no I/O. |
| Canonical JSON, seals, replay. |
| The domain layer every entry point calls. |
| Met, Cleveland and Wikidata adapters, plus the sealed sample. |
| Repository interface, schema, and the Postgres and PGlite adapters. |
| Published term lengths, use bands, circulation bands, disclaimer. |
| Docket rendering: one view, three formats. |
| Every bounded input. |
| Best-effort anonymous abuse control. |
🏗 Architecture
flowchart TB
classDef data fill:#22d3ee,stroke:#0e7490,color:#083344
classDef engine fill:#a78bfa,stroke:#6d28d9,color:#2e1065
classDef agent fill:#34d399,stroke:#047857,color:#022c22
classDef infra fill:#94a3b8,stroke:#475569,color:#0f172a
classDef risk fill:#fb7185,stroke:#be123c,color:#4c0519
B[Browser]:::infra --> P[proxy.ts<br/>anonymous owner cookie]:::infra
P --> R[Server components]:::infra
P --> API[REST routes]:::infra
P --> MCP[JSON-RPC 2.0<br/>/api/mcp]:::agent
R --> S[service.ts<br/>the domain layer]:::infra
API --> S
MCP --> S
S --> E[engine<br/>clearance v1.1.0]:::engine
S --> DB[(Hosted Postgres)]:::data
S --> SRC[Met + Cleveland + Wikidata]:::data
E --> SEAL[SHA-384 chain]:::engine
S --> SEAL
SEAL --> REP[Replay: first broken link]:::riskSix routes and two entry points, one service layer, one engine, one SQL implementation behind two adapters. The mutating agent tools and the buttons call the same functions, which is why an agent and a person produce identical rows.
📊 Data pipeline and the honest fallback
flowchart LR
classDef live fill:#22d3ee,stroke:#0e7490,color:#083344
classDef warn fill:#fbbf24,stroke:#b45309,color:#451a03
classDef infra fill:#94a3b8,stroke:#475569,color:#0f172a
Q[Search terms]:::infra --> M[Met v1.1 search]:::live
Q --> C[Cleveland Open Access]:::live
Q --> W[Wikidata<br/>creator dates]:::live
M --> MD[(Object records<br/>rights, life dates)]:::live
C --> CD[(CC0 licence field<br/>machine-readable years)]:::live
MD --> N[Normalise into Work]:::infra
CD --> N
N --> J[Interleave, image-first]:::infra
W -.only when the institution<br/>published no death year.-> N
M -.timeout or 5xx.-> F[Sealed sample]:::warn
C -.timeout or 5xx.-> F
J --> R{Any live record?}:::infra
R -->|yes| OK[live, with per-provider provenance]:::live
R -->|no| FB[fallback, labelled with<br/>the date the sample was taken]:::warnThree things this pipeline will not do: present a fallback sample as current, hide a provider that failed, or replace a plate you created with upstream data. User records are never touched by a refresh.
Endpoint note.
/public/collection/v1/searchwas retired on 2026-10-01. OpenPlate uses/public/collection/v1.1/search, which is paginated, and reads each record from/public/collection/v1/objects/{id}. The search endpoint returns identifiers only — every rights field is in the per-record response, so the detail pass is unavoidable rather than decorative.
⚖️ The deterministic engine
Six factors, weights summing to exactly 1. The score is
Σ (weight × normalised) × 100; the band comes from the score, from the
institution's own claim, and from the confidence.
flowchart TB
classDef engine fill:#a78bfa,stroke:#6d28d9,color:#2e1065
classDef data fill:#22d3ee,stroke:#0e7490,color:#083344
classDef risk fill:#fb7185,stroke:#be123c,color:#4c0519
classDef ok fill:#34d399,stroke:#047857,color:#022c22
IN[Work + use + jurisdiction<br/>+ circulation + asOfYear]:::data --> F1
F1[Institution claim 0.20]:::engine --> SUM
IN --> F2[Creator term 0.26]:::engine --> SUM
IN --> F3[Publication-age rule 0.14]:::engine --> SUM
IN --> F4[Reproduction restrictions 0.20]:::engine --> SUM
IN --> F5[Use exposure 0.12]:::engine --> SUM
IN --> F6[Attribution completeness 0.08]:::engine --> SUM
SUM[Weighted score 0-100]:::engine --> BAND
F1 --> OVER{Institution says<br/>in copyright?}:::risk
OVER -->|yes| BL[BLOCKED]:::risk
OVER -->|no| BAND
CONF[Unresolved factors<br/>lower confidence]:::engine --> BAND
BAND --> C1[Clear]:::ok
BAND --> C2[Clear with credit]:::ok
BAND --> C3[Needs review]:::risk
BAND --> BL
SUM --> CL[Credit line assembled<br/>from the record]:::engineThree decisions worth arguing with, deliberately:
The institution's negative claim is never averaged away. If a museum publishes a record as in copyright, the answer is
blocked, whatever the term arithmetic says. The other five factors exist to explain how close it got.An unresolved factor costs confidence, and low confidence is never a clearance. A verdict computed from a field nobody published is not a verdict.
The artwork's term and the reproduction's rights are separate factors, so a record can be
satisfiedon one andfailingon the other without being averaged into nonsense.
Term lengths come from TERM_RULES and are seeded into the database, so the
/terms page and the arithmetic cannot drift apart.
🔗 Integrity and replay
flowchart TB
classDef agent fill:#34d399,stroke:#047857,color:#022c22
classDef engine fill:#a78bfa,stroke:#6d28d9,color:#2e1065
classDef risk fill:#fb7185,stroke:#be123c,color:#4c0519
classDef infra fill:#94a3b8,stroke:#475569,color:#0f172a
G[GENESIS<br/>SHA-384 of a fixed string]:::infra --> E1
E1[filed]:::agent --> E2[updated]:::agent --> E3[decision_recorded]:::agent --> E4[retired]:::agent
E1 -.seal_n = SHA-384 UTF-8 prevSeal + canonicalJson event_n.-> E2
E2 --> E3
E3 --> E4
E4 --> REP{Replay from genesis}:::engine
REP -->|every seal matches| OK[Intact: N events]:::engine
REP -->|mismatch| BR[Broken at sequence K<br/>with the reason]:::riskcanonicalJson sorts object keys recursively and drops undefined, so two
identical events always produce an identical digest regardless of the order the
fields were written in. Arrays keep their order, because in a payload an array is
meaningful rather than incidental. tests/chain.test.ts pins the algorithm with a
vector recomputed using raw node:crypto, so a refactor cannot quietly invalidate
seals already written to somebody's database.
Retiring a plate appends a retired event and tombstones the row, so a docket that
was already shared keeps verifying while the record leaves the docket list.
🔁 The user journey
flowchart LR
classDef live fill:#22d3ee,stroke:#0e7490,color:#083344
classDef agent fill:#34d399,stroke:#047857,color:#022c22
classDef infra fill:#94a3b8,stroke:#475569,color:#0f172a
S[Search two collections]:::live --> P[Pin a work to an intended use]:::live
P --> I[Inspect six factors<br/>and the term arithmetic]:::live
I --> W[Drag the step wedge:<br/>a real hypothetical]:::infra
W --> D[Record a sealed decision]:::agent
D --> V[Replay the chain]:::agent
V --> X[Export the docket<br/>md, csv, json]:::infra
X --> R[Retire as a replayable tombstone]:::infraThe whole loop is available through visible controls, and npm run smoke drives
it in a real browser while asserting the console and the network stay clean.
🔒 Security model
No accounts. An unguessable v4 UUID in an HTTP-only, SameSite=Lax cookie is the ownership boundary, minted in
src/proxy.ts; every query is scoped by it, so one visitor cannot read or mutate another's plates.Share codes are capabilities.
/d/<shareCode>is the one deliberate public read: 12 characters from a 31-letter alphabet, resolving to exactly one docket, never to a list. Not derived from any id.Rate limiting is best-effort, and says so. 30 writes and 120 reads per minute in process. On serverless that stops a runaway client and not a distributed one; a hard limit needs a shared store, which this project does not assume exists.
Everything is validated and parameterised. Closed enum sets, bounded strings, control characters stripped, pagination clamped. The only dynamic SQL is a filter clause built from values that passed an enum check.
Retirement is not deletion, so shared dockets stay verifiable.
No secrets anywhere: no keys, no
.env, nothing in the bundle, the manifest or an error response. Upstream failures name the provider and the status, and nothing about our own infrastructure.
Full detail, including what the chain does not prove, is in SECURITY.md.
🧪 Verification
npm run typecheck && npm run lint && npm run test && npm run build && npm run smoke70 tests. The engine's normal, boundary, empty, malformed and deterministic-repeat cases; the chain's known vector plus tamper, splice and orphan detection; validation's empty-versus-malformed cases; and a full CRUD run against a real database through the real service layer, including idempotent replay, ownership isolation, constraint enforcement and the tombstone.
The browser journey files, inspects, re-runs the engine, decides, verifies, uses an agent tool, exports and retires — then asserts zero console errors and zero failed same-origin requests, checks focus visibility, and checks that neither a 390 px nor a 1440 px viewport overflows horizontally.
npm run verify:liveproves the deployed instance end to end: health really reached a durable store, live data is non-empty with provenance, create → read → update → assess → MCP mutation → replay → retire, the docket downloads as three valid formats, and the rendered navigation and footer both carry the real repository URL.
flowchart LR
classDef ok fill:#34d399,stroke:#047857,color:#022c22
classDef infra fill:#94a3b8,stroke:#475569,color:#0f172a
classDef agent fill:#22d3ee,stroke:#0e7490,color:#083344
V[typecheck]:::ok --> L[lint]:::ok --> T[70 tests]:::ok --> B[build]:::ok --> S[smoke]:::ok
B --> VL[verify:live<br/>22 checks against production]:::agent
CI[Node 22, npm ci,<br/>no secrets required]:::infra --> VCI runs Node 22 with npm ci, typecheck, lint, test and build, then the browser
journey. No job needs a secret or an API key.
🚢 Deploying
flowchart TB
classDef infra fill:#94a3b8,stroke:#475569,color:#0f172a
classDef live fill:#34d399,stroke:#047857,color:#022c22
classDef warn fill:#fbbf24,stroke:#b45309,color:#451a03
P[Push to main]:::infra --> G[GitHub Actions<br/>typecheck, lint, test, build, smoke]:::infra
G --> V[Vercel production build]:::infra
D[(Neon Postgres)]:::live --> V
V --> A[Production alias]:::live
V -.no DATABASE_URL.-> X[Boot fails loudly<br/>rather than writing<br/>to volatile storage]:::warn
A --> M[Metadata, sitemap<br/>and /mcp.json<br/>name the real alias]:::infraThe production build refuses to open the repository without DATABASE_URL.
That is deliberate: a deployment that has quietly lost its database fails its
health check instead of accepting writes into storage a cold start would erase.
🗺️ Roadmap
Now — shipped in this repository
Search both open-access collections live, with per-provider provenance and an honest sealed fallback → a visitor never mistakes a sample for a current answer
Six-factor versioned engine, shared by the page, the REST API and the agent tools → one verdict, computed one way
Artwork term and reproduction rights reported separately → the reason a Monet can be public domain while its photograph is not
Step wedge for a real hypothetical, saving nothing → the reader can ask "what if it were merchandise" without changing the record
SHA-384 chain per plate, replayable, with a known vector → a later reader can find the first edited event
Six MCP tools with idempotent mutations, in an inspectable console → agent and person produce identical rows
Docket in Markdown, CSV and JSON, with source attribution and timestamps → something a publisher can hand to a client
flowchart LR
classDef ok fill:#34d399,stroke:#047857,color:#022c22
S[Search]:::ok --> E[Engine]:::ok --> C[Chain]:::ok --> M[MCP]:::ok --> X[Export]:::okNext — the obvious next problems
Bulk clearance. Import a folder of candidate images and get one docket for all of them, instead of filing plates one at a time → a picture editor clears an issue in one pass
Restoration lookups. Persist the visual crop, colour profile and embedded licence metadata, and warn when an export drops them → the "no derivatives" clause stops being invisible
A watch list. Re-check a docket against the institution's record on demand and append a
verifiedevent when the rights claim changes → a publisher learns their credit line went stale before a client doesComparable-terms view. For a restricted record, show every jurisdiction and term length whose arithmetic would clear the artwork, with the reasoning → "where can I actually use this?" gets an answer
flowchart TB
classDef n fill:#fbbf24,stroke:#b45309,color:#451a03
B[Bulk import]:::n --> R[Restoration lookups]:::n --> W[Watch list]:::n --> J[Comparable terms]:::n
B --> R
R --> W
W --> JLater — open questions, not commitments
Permit-reporting for jurisdictions where a collective society must be identified before publication → the credit line starts as a filing
A CLI that verifies a docket offline from its exported JSON, with no network at all → an auditor can check a file, not a website
Model-to-scan records (photographs of works) alongside institution records → the reproduction question gets asked of the right record
flowchart LR
classDef l fill:#94a3b8,stroke:#475569,color:#0f172a
P[Society reporting]:::l --> O[Offline verifier CLI]:::l --> S[Photograph records]:::l
P --> O
O --> S⚠️ Not legal advice
OpenPlate reports what a museum's own records say and does arithmetic on published copyright term lengths. It is a research desk, not legal advice, and it does not replace a rights clearance for a paying client. A term running out is a necessary condition for reuse, not a sufficient one, and an institution's claim about its own photograph is a separate right. When the verdict is Needs review or Blocked, write to the institution or to a rights adviser before you print.
📚 Attribution and data provenance
The Metropolitan Museum of Art Collection API (Open Access) — record metadata, rights statements and images. Terms
The Cleveland Museum of Art Open Access API — record metadata, licence fields and images. Open Access
Wikidata (CC0) — creator life dates, used only where an institution published none.
Records are cited with the institution, the fetched timestamp and the upstream identifier, and every plate keeps the snapshot it was created from. OpenPlate is not affiliated with or endorsed by either museum.
🤝 Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md for the setup, the five commands that must be green, and how to add a jurisdiction or a factor without breaking the engine. Security reports go through the private advisory route described in SECURITY.md.
📄 License
MIT © 2026 Aniruddha Adak. Museum metadata and images remain under their institutions' own terms; this project claims none of them.
This server cannot be deployed
Maintenance
Related MCP Connectors
Art MCP — Metropolitan Museum of Art Collection API (free, no auth)
Unlock a world of art with the Met Museum MCP! Use the 'search_artworks' API to find stunning
FBI Art Crimes MCP — FBI National Stolen Art File (free, no auth).
Openverse MCP — Creative-Commons-licensed image + audio search.
Related MCP Servers
- AlicenseAqualityAmaintenanceFederated, license-verified search across open-access museum collections — currently The Met, Cleveland, AIC, Wikimedia Commons, and Europeana, with more being added. Strict-default-deny rights gate accepts only CC0 / Public Domain Mark, returning reuse-safe artwork with citations in three styles.5140 npm13MIT
- AlicenseAqualityBmaintenanceLicense-first federated image search for AI agents and humans. Exposes MCP tools for concise, attribution-aware image discovery, license probing, and guarded downloads across open, platform, and editorial sources.736 npm3MIT
- AlicenseAqualityAmaintenanceEnables AI-native access to Swiss cultural heritage data from SIK-ISEA (artists), Nationalmuseum (collections), and Nationalbibliothek (bibliography) via MCP tools, without authentication.1138 PyPIMIT
- AlicenseAqualityDmaintenanceAn MCP server for browsing and downloading open-licensed imagery from major museum and archive APIs including Wellcome, Met, Library of Congress, Smithsonian, and Europeana.1215 npmMIT