Skip to main content
Glama
rephol

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`).

![plate-cost running in the ext-apps reference host](docs/screenshot.jpg)

*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