plate-cost
by rephol
README.md
# plate-cost
An **MCP App** that costs recipes and prices menu items — an interactive calculator
the model opens instead of doing the arithmetic itself.
Ask Claude *"help me price my nasi goreng"* and you get a working spreadsheet:
ingredient rows, purchase prices, waste percentages, portion yield, a margin slider.
Change a number, everything recalculates. Press **Use these numbers** and the model
continues the conversation with figures you entered rather than figures it invented.
Built on [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) (`ext-apps`).

*Running in the `basic-host` reference host from `ext-apps` — not Claude. The model
proposed the recipe, the widget loaded it, and "Use these numbers" sent the corrected
figures back into the conversation.*
## Why this is an app and not a tool
Costing is exploration, not a question. You do not know the answer you want until
you have moved the portion size and watched the margin move. Chat is a bad interface
for that, and a model doing the arithmetic in its head is worse — LLMs are unreliable
at multi-step numeric work and completely reliable at *sounding* certain about it.
So the split is: the widget captures inputs, the server does the maths, the model
does the talking.
## The interesting part: knowing where the numbers came from
MCP Apps has an open gap — [ext-apps#746](https://github.com/modelcontextprotocol/ext-apps/issues/746):
a `tools/call` from a widget and one the model decided to make are indistinguishable
at the server. For most tools that does not matter. For a costing tool it decides
whether you are doing arithmetic or laundering a guess.
[`mcp-app-attest`](https://github.com/rephol/mcp-app-attest) closes it — extracted
from this repo, which is its first consumer. The server mints a short-lived token each time it
serves the app resource and substitutes it into the document; the widget echoes it
back in `_meta` on every call. The model never sees the resource body, so it cannot
produce the token. Calls are answered either way — the response says which it got:
```json
{ "perPortion": 3300, "inputsFrom": "model" }
```
and when the numbers came from the model, the text reply says so:
> Note: these figures came from the model, not from the calculator. Confirm the
> purchase prices with the user before relying on them.
Verified end to end against the reference host, not just in unit tests:
```
[provenance] inputsFrom=model reason=missing meta=null
[provenance] inputsFrom=app meta={..."io.platecost/app-attestation":"XfqGVAiP…"}
```
**What this is not.** Not authorisation, and not proof a human typed anything. It
distinguishes *code paths*, not identities. If a host copies the resource text into
model context, the model can read the token — nothing here prevents that, it is the
host's contract to keep. A compromised widget can send whatever it likes. Treat it
as the difference between "typed into my form" and "produced by a language model",
which is exactly what #746 asks for.
It has since been extracted into [`mcp-app-attest`](https://github.com/rephol/mcp-app-attest),
which switched to signed tokens rather than stored ones — see the "what didn't work"
note below for why.
## Two things the domain gets wrong
Both are in `src/costing.ts`, both have tests.
**Waste means buying more, not using more.** Cost is `used / (1 - wastePct)`, not
`used * (1 + wastePct)`. At 50% waste you buy double, not one and a half times. The
naive version understates every trimmed ingredient you own.
**Margin and markup are different numbers.** Margin is `(price - cost) / price`;
markup is `(price - cost) / cost`. A 60% markup is a 37.5% margin, and a 60% margin
needs a 150% markup. `priceForMargin` takes margin — that is what "we want 65%"
means in a kitchen — and returns both, so the gap is visible rather than assumed.
Also handled: unit conversion within mass and volume families, with a refusal across
them (`kg` → `l` needs a density you have not given), per-ingredient cost share, and
overhead applied per portion rather than per batch.
## Try it
```bash
npm install # pulls mcp-app-attest from npm
npm run build # bundles the widget to a single inlined HTML
npm test # 21 tests
npm run dev # widget standalone in a browser, no host
```
### In Claude Desktop
Build first, then add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"plate-cost": {
"command": "node",
"args": ["--experimental-strip-types", "/absolute/path/to/plate-cost/bin/stdio.ts"]
}
}
}
```
`npm start` runs the same thing directly. Requires Node 22+ for type stripping.
### Against an MCP Apps host over HTTP
```bash
npm run serve # http://localhost:3001/mcp
```
Used with the `basic-host` example from `ext-apps` to produce the screenshot above.
Outside any host the attestation placeholder is never substituted, so calls are
labelled `model` — which is the honest answer.
## What didn't work
- **A stateless HTTP transport.** The first version built a fresh server per request,
which is fine for stateless tools and silently breaks this one: the attestation was
minted by a server instance that no longer existed when the widget called back, so
every widget call was rejected as `unknown`. The in-memory tests could not see it —
they share one instance. Only running against a real host exposed it.
- **Blaming the SDK.** Before finding the above, the obvious suspect was `_meta` being
stripped in transit. It is not: the reference host forwards it intact through the
sandbox proxy. Worth knowing, because the whole mechanism depends on hosts doing so
and the spec does not require it.
## Status
v0. Two tools (`cost-recipe`, `price-portion`), one widget, 21 tests.
Not built: persistence between conversations, multi-recipe comparison, and anything
touching the namespacing problem in
[#753](https://github.com/modelcontextprotocol/ext-apps/issues/753) /
[#745](https://github.com/modelcontextprotocol/ext-apps/issues/745) — `callServerTool`
uses bare tool names, so this will break behind an aggregator. That is the next thing
worth solving and it is not solved here.
MIT.
TDQS
A3.5/5.0
Scored across 2 tools
Disambiguation4/5
The two tools have distinct purposes: costing a recipe versus converting a cost into a selling price. There is minor thematic overlap around food-cost math, but each description clarifies the boundary.
Naming Consistency3/5
Both names use a noun_noun or verb_noun style ('cost-recipe', 'price-portion') with hyphenation, but the verb-first pattern is only loosely applied and not fully predictable.
Tool Count3/5
Two tools is thin for a costing/pricing domain; it may be intentionally narrow, but it is borderline for useful coverage.
Completeness3/5
The surface covers recipe costing and portion pricing, but lacks operations for managing ingredients, recipes, or price lists, creating notable gaps for a full workflow.
Maintenance
ActivityMaintained
ResponsivenessNo issues