Skip to main content
Glama
PederHP

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.