Inspiration
Museum labels sound finished. Behind a lot of them there is a hole: a stretch of years where nobody wrote down who owned the thing, a place name copied off a dealer's invoice instead of asked from the people who made it, a phrase like "ceremonial use" standing in for knowledge that somebody still has.
The people who could fill those holes usually have nowhere to put what they know. When they do send something in, it either sits in an inbox forever or gets quietly folded into the official text with no name on it.
We wanted to see what that process looks like with agents in it. The problem showed up right away. An agent that reads accession records and drafts label language is genuinely useful. An agent that can also publish is an agent deciding whose version of history becomes official, and that is not a thing we wanted to build.
So the rule became: research is free, the record needs a curator.
What it does
RE:TURN is a museum collection where the record is treated as unfinished. Eight fictional objects, each with a public label, a provenance timeline, and the gaps in that timeline marked rather than smoothed over.
A visitor can flip any label to see the questions behind it. They can submit a photograph, a document, or an oral history, and they choose at that moment whether it may be published, published without their name, or kept private for internal research only.
On the other side, a curator works the inbox. Community material arrives marked submitted, not verified, and it sits beside the official record instead of inside it. A curator agent can compare a submission against the accession record, build a timeline, and draft new label wording. Nothing it drafts goes public on its own. The draft lands in a human's queue with the evidence attached, and a person approves it, edits it, or turns it down.
Why this is a good fit for WebMCP
The interesting question in this domain is not "can an agent do the work." It is "who is allowed to decide." Those two things need to come apart, and WebMCP is the first browser API we have used where they come apart cleanly.
Tools are declared by the page, scoped to a role, and executed by the page. The site decides what an action means. The model does not get to reinterpret it. That let us put a policy gateway behind every single tool call and make the answer an agent gets back into a real part of the interface rather than an HTTP status.
There are five answers. applied means it was within authority and it is done. pending_approval means a person now holds it, and here is an id to poll. denied means the policy refused, here is the code and here is what would change the outcome. invalid means the caller made a mistake, and here is the field. Error means nothing was written.
Thirteen policy codes are reachable through the tool surface. submitted_sole_authority fires when an agent tries to change an official label citing only community material. consent_not_public blocks publishing something a contributor did not agree to publish. approval_snapshot_mismatch catches a stale draft trying to overwrite a label that changed while the approval was waiting. A fourteenth code, outside_agent_surface, exists so that it can never fire: deletion and physical return are classified CRITICAL and no tool maps to them at all.
What people and agents can do together that was hard before
Someone's knowledge can reach the record without being swallowed by it. A contributor sends a photograph with a consent choice. A curator agent compares it against the verified 1968 invoice, finds the contradiction, and drafts language for it. All of that happens without publishing anything, and the contribution keeps its own voice on the page instead of dissolving into the museum's.
Before, those were two disconnected things. A contact form on one side that swallowed submissions, and a collections system on the other that only staff could see.
An agent can also be refused in a way it can act on. Ask for a label change backed only by community material and you do not get a 400 with a sentence in it. You get submitted_sole_authority, a recovery line telling you what would work, and a referral id, because the gateway handed that refusal to a curator. The agent polls the id and carries on with something else. Being told no becomes a step in the workflow.
Two agents can also work the same object from opposite sides without reaching each other's tools. A community page registers 10 tools. The curator console registers 15. The server re-checks the role on every call, so registration is a convenience rather than the security boundary.
One more thing we are happy with: files stay files. Binaries never cross the tool surface. A photograph goes up through an ordinary multipart POST, and the agent then reads its id with list_my_uploads and binds it with attach_assets. The whole contribution completes through tools without a single byte passing through a tool call.
How it creates a better experience
For a contributor, the main thing is that consent is asked where the choice is made, and the record keeps the promise. If you pick "public, with my name," the form asks for the name right there. We had to learn this one the hard way, which is in the challenges section below. Length limits are enforced as you type instead of at the end of a five-step form, and every submission gets a receipt page showing what happened to it, including any question the curator sent back.
For a curator, the console and the agent surface never disagree. When the gateway hands something to a person, it shows up in the console, in list_pending_approvals, in check_approval, and in the summary count. All four, or an agent is working from a different picture of the queue than the human sitting next to it. Approvals are snapshot-checked, so a draft that has gone stale cannot quietly publish over a newer label.
For a reader, community material is visible and labelled as submitted. Only public_attributed carries a name. Label revisions show a word-level diff of what changed and when.
How we implemented WebMCP
Three files do the work.
lib/webmcp/tools.ts is the catalogue. Twenty-three tools, each with a name, a description, JSON-Schema properties, a role, and readOnly / untrusted annotations. One declaration, read by three consumers: the browser registration, the server's argument validation, and the tool panel in the console.
lib/webmcp/register.ts hands them to the browser.
context.registerTool({
name: tool.name,
description: tool.description,
inputSchema: {
type: 'object',
properties: tool.properties ?? {},
required: tool.required ?? [],
additionalProperties: false,
},
annotations: { readOnlyHint: tool.readOnly, untrustedContentHint: tool.untrusted ?? false },
signal,
execute: async (args) => (await fetch(`/api/tools/${tool.name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(args ?? {}),
})).json(),
});
context is document.modelContext, falling back to navigator.modelContext on browsers that still only expose the older one. Registration follows the session role. additionalProperties: false makes the published schema say exactly what the server enforces, so an undeclared argument comes back refused with a list of what the tool actually takes, rather than being dropped in silence.
app/api/tools/[name]/route.ts is what runs. Because execute posts to the same route any HTTP client would use, the tool surface and the HTTP surface cannot drift apart. Every consequential call goes through evaluatePolicy, a pure function with no I/O, before anything is written. Submitted content comes back to a curator agent flagged untrusted_content, and no tool takes authority from text sitting inside a document.
Next.js App Router on vinext, React 19, deployed as a Cloudflare Worker with D1 and R2.
Challenges we ran into
The consent bug we are least proud of. The form offered "Public, with my name" and had nowhere to type a name. Left blank, the public record printed "Contributor chose not to be named," which is the exact opposite of what the person chose. The byline was reading a field from step one labelled "Where did this come from?", a question about the object rather than the person. We now ask for the name on the consent step and refuse the combination server-side. It was a small fix for something that mattered a lot more than its size.
Our tests went stale without failing loudly. We ran a long browser pass over the live deployment, filled in hundreds of verification cells, and wrote "no unverified areas." Then we ran our own smoke suite against that same deployment and eight checks failed, and had been failing since the previous deploy. None of them was a product bug. Every one was a check pinned to a shape we had deliberately improved: next had become a real cursor instead of a sentence, undeclared arguments had become refusals instead of silent drops. The checks were asserting last week's contract.
Telling a flaky test from a leaking rate limiter. A write past the ceiling came back applied, which looked like the limiter failing. Measuring it by hand showed 64 of 130 concurrent uploads refused and the next one refused too. The limiter was fine. The check was firing a separate request after the burst, and the burst took long enough to cross a minute boundary and reset the counter. If we had "fixed" the code first we would have broken something that worked.
An agent could not finish a contribution that had a photo in it. attach_assets wanted an asset id, and the only place that id existed was React state inside the form. A tool whose required argument is unobtainable is not a tool. We added a small read tool, list_my_uploads, rather than pushing bytes through the tool surface.
What we learned
Verify as the caller, not as the author. We always had the ids in hand when we tested, so we never noticed that an agent starting from nothing could not reach attach_assets at all. That only becomes visible when you start from nothing yourself.
A check that pins a shape instead of a rule will fail on the day you improve the thing it guards. We rewrote all eight of them to ask the same question against the current contract.
And consent is a promise the interface has to be able to keep. A dropdown offering attribution without collecting a name is not a small UI gap.
Verification
161 unit tests, and 418 end-to-end checks that run against the live deployment, 421 with the rate-limit flood included. They cover every page route, all 23 tools, the asset pipeline, the role boundary, all five outcomes, approve-with-edit, and the concurrency guards. An escalation resolved six times at once answers 200, 409×5. A fuzz sweep of 308 malformed calls across the whole surface produced zero 500s.
What's next
Authentication, which is the honest blocker. With no accounts, every visitor's writes land in their own copy of the workspace. That is the right shape for an open beta and it means nobody can vandalise anyone else's museum, but contributions cannot accumulate into one shared record until real accounts exist.
After that, the model-run half of our tool-selection eval, and then the part that is a partnership question rather than an engineering one: real collections, with a real consent process behind them.
Honest limitations
- Every museum, object, person, community, image, and historical record here is fictional demo material.
- The role switch is a demo affordance and not authentication. There is no login, and anyone can open the curator console. The role is still re-checked on the server for every call.
- 'verified' means source, consent, and institutional review status. It is not a judgment about historical truth.
- RE:TURN does not determine whether an object was removed unlawfully, and it performs no repatriation, no transfer of ownership, and no legal decision of any kind.
- The policy gateway does not scan document bodies for injection keywords. It constrains what tools are able to do, and it marks submitted content as untrusted.
Built With
- cloudflare
- drizzle-orm
- model-context-protocol
- next.js
- react
- server-sent-events
- sqlite
- tailwindcss
- typescript
- vinext
- vite
- webmcp
- wrangler
Log in or sign up for Devpost to join the conversation.