prestidigitation
by PederHP
README.md
# prestidigitation π
Two small MCP servers that perform **stage magic on a language model**, built to
demonstrate the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview)
(`io.modelcontextprotocol/ui`) β specifically **tool visibility scoping** and
**`ui/update-model-context`** β at the London MCP release party, 28 July 2026.
> Every magic trick is a secret channel the spectator can't see. MCP Apps gives
> your UI its own API on the MCP plumbing you already have β same server, same
> session, same auth. You didn't build a second backend for your frontend. You
> scoped the one you have. Tonight, the spectator is the model.
## The two acts
**Act 1 β Everywhere and Nowhere.** The model is the spectator. It shuffles and
pulls cards with honest tools. Then the human clicks **Sleight of Hand** β an
**app-only tool** (`_meta.ui.visibility: ["app"]`) that replaces every card in
the deck with the model's chosen favorite. The tool isn't hidden from the model.
It isn't denied. It's *absent from the model's world*. Every subsequent pull
returns the favorite; the model's tool results stay perfectly honest reports
about a deck that had another writer.
In production, `sleight_of_hand` is `approve_transfer`: an app-only tool means
the human click is the **only possible call path** β not "the model is
instructed not to," not "the frontend promises a dialog." The card trick is your
payment-approval architecture.
**Act 2 β The Mentalist.** Roles inverted, fresh server, fresh session. The
volunteer silently *clicks* a card in the widget β no tool call, no message. The
widget calls **`ui/update-model-context`**, and the payload lands in the model's
context for future turns. The model (holding `sleight_of_hand` in the open this
time) waits for its moment, rigs the deck to your card, and takes a bow. Then it
explains its own method, because there's nothing to hide: there is **no
select_card tool anywhere in the server** β the selection traveled on the
communication channel, not the execution channel.
**The difference between a magic trick and mind reading is one visibility flag.**
## The three channels
An MCP App has three channels to the model:
1. **Tools β the execution channel.** Things happen. Visibility-scoped per tool:
`["model"]`, `["app"]`, or `["model","app"]` in `_meta.ui.visibility`.
2. **`ui/update-model-context` β the async communication channel.** The UI tells
the model what's going on. No tool call, no user turn.
3. **`ui/message` β the user-mediated channel.** The app drafts a message for
the conversation. Not needed for either act.
## The honesty invariant
No tool lies. `pull_card` pops the real top of the real deck β there is no
`if (rigged)` branch anywhere ([see for yourself](packages/core/src/act1.ts)).
`sleight_of_hand` genuinely rewrites server state. Skip the sleight and the
trick genuinely fails β that's the on-camera control run, and the answer to
"was it hardcoded?".
One caveat, stated honestly: per the spec, **visibility is host-enforced**. The
server publishes the metadata; the host keeps app-only tools out of the model's
list and rejects cross-server calls. (Our [rehearsal harness](rehearsal/scripted-client)
depends on this: a raw SDK client can drive both lanes.)
## Try it yourself
```bash
git clone https://github.com/pederhp/prestidigitation && cd prestidigitation
npm install
npm run build # builds the three single-file widgets
npm test # state-machine invariants
npm run show # Act 1 on :3001 and Act 2 on :3002
npm run probe # host probe on :3003 (see docs/host-probe-procedure.md)
npm run rehearse # headless run-of-show against live servers
```
To connect a real host (Claude, ChatGPT, β¦) the server must be reachable over
HTTPS. In separate terminals:
```bash
npx cloudflared tunnel --url http://localhost:3001 # Act 1
npx cloudflared tunnel --url http://localhost:3002 # Act 2
```
Add each `https://<random>.trycloudflare.com/mcp` URL as a custom connector in
your host. Start tunnels once and leave them up β the URL changes every
invocation. Then:
1. **Start a fresh chat β incognito/temporary if your host has it.** This matters
more than it sounds: if the model remembers you discussing how the trick
works, Act 1 is over before it starts. The spectator has to be a real
spectator.
2. **Load the persona.** Each server ships its persona as an **MCP prompt**
(`spectator` on Act 1, `magician` on Act 2), so you can insert it from the
host's prompt menu in a chat that has no custom-instructions field and no
memory. The same text lives in [`personas/`](personas/) if you'd rather paste
it, or use it as a project/system prompt.
3. **Say "open the table"** (or "set the stage") β that's the one tool that
renders the widget.
4. Go fool a model. Reveal the method afterwards β it's only polite.
Which hosts support what: see the empirical
[compatibility matrix](docs/compatibility-matrix.md) (and run the
[probe](docs/host-probe-procedure.md) against your own host β it takes a minute).
## Repo layout
| Path | What |
| --- | --- |
| `packages/core` | Deck, two-lane event log, both trick state machines β pure TS, fully tested |
| `servers/everywhere-and-nowhere` | Act 1 server (port 3001) β the tool manifest *is* the demo |
| `servers/the-mentalist` | Act 2 server (port 3002) β note the absence of any selection tool |
| `servers/host-probe` | Phase 0 probe server (port 3003) |
| `widgets/*` | Single-file HTML widgets (vite + singlefile; the official `App` bridge inlined) |
| `personas/` | System prompts for the spectator (Act 1) and the magician (Act 2) |
| `rehearsal/scripted-client` | Headless soak-test of both state machines |
| `docs/` | Run-of-show, probe procedure, compatibility matrix |
## Q&A ammunition
- *"Was pull_card hardcoded?"* β No; the control run is on camera, and the test
suite proves pulls follow the seeded shuffle exactly.
- *"Why is this an extension and not an iframe with a REST API?"* β The UI's API
rides the same MCP session: same auth, same server state, same transport, same
security boundary. One backend, scoped twice.
- *"Couldn't the model social-engineer the human into clicking?"* β Yes, which
is exactly why HITL approval must be an app-only tool plus unspoofable UI, not
a model-visible tool with a confirmation prompt.
- *"You did the sleight after the shuffle."* β Deliberately: the rig is real
server state and a shuffle would destroy it⦠except 51 identical cards shuffle
to themselves. Same reason real card stacks need false shuffles.
## One gotcha worth stealing: don't put `resourceUri` on every tool
Per the spec, *"if `ui.resourceUri` is present β¦ host renders tool results using
the specified UI resource."* So **every model-called tool that carries a
`resourceUri` makes the host render a new view.** Our first version put it on all
seven tools, and the widget visibly re-mounted on every single `pull_card` β
fatal for a demo whose whole point is a running event log.
The fix: only the **flow-starting** tool (`open_the_table`) carries a
`resourceUri`. Every other model-callable tool keeps its `_meta.ui.visibility`
but drops the `resourceUri`, so the table rendered at the start stays on stage
for the whole performance and updates itself. App-only tools keep theirs (they're
never model-called, so they never trigger a render).
Consequence to design for: a view only receives `ui/notifications/tool-result`
for the tool call that rendered *it*, so the other tools' effects arrive by
polling an app-only `get_state` (every 500 ms here). There is no "pin this
widget" or side-panel feature in the extension β `ui/request-display-mode`
(`inline` / `fullscreen` / `pip`) is the only placement control, and hosts
advertise what they support in `hostContext.availableDisplayModes`. Both widgets
show a fullscreen toggle when the host offers one.
MIT licensed. Built for a talk; kept small enough to read on your phone from a
QR code.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues