Grant tools on the MCP server — test the away-agent story from Claude (#12) - #118
Conversation
…laude
The human-not-present story had NO MCP surface: no Claude host could touch 'approve once,
the agent spends while you're away'. This exposes it:
- createStorefront({ grants }) additionally registers four tools wired to the REAL
credentagent.grants: create-spending-grant (returns the approveUrl for the human),
get-grant-status, spend-from-grant (typed refusal codes in the tool result:
not-authorized / not-allowed / per-spend-exceeded / budget-exceeded / step-up / revoked),
revoke-grant. Tool descriptions teach the agent the lifecycle (send the human the
approveUrl; age NEVER delegates — hand back on step-up). Omit grants ⇒ no tools (additive).
- examples/hnp-on-claude/serve.mjs — one runnable server with BOTH consent modes (checkout
gates + grant tools + the approve page), PUBLIC_URL-aware so a tunnel (ngrok/cloudflared)
makes it a claude.ai custom connector.
Verified end-to-end AS THE AGENT over the real MCP wire (JSON-RPC): tools listed → grant
created pending → spend before approval ✗ not-authorized → human approves at the page →
mouse ✓ $49 (remaining draws down) → whiskey ✗ step-up (age never delegates) → lamp ✗
not-allowed (outside category bounds) → revoke → ✗ revoked.
Tests: grants-tools.test.ts (4) drives the same lifecycle over InMemoryTransport, incl.
the pending-can't-spend and step-up assertions THROUGH the tool wire. Gate 359 +
storefront 90 green; lint clean.
Refs epic #12 · spec 009
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f8f127016f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const s = await g.spend({ | ||
| idempotencyKey: idempotencyKey ?? `mcp-${randomUUID().slice(0, 12)}`, | ||
| items: [{ sku: productId, ...(quantity ? { qty: quantity } : {}) }], | ||
| }); |
There was a problem hiding this comment.
Reprice delegated spends from the storefront catalog
When the storefront uses the supported dynamic CatalogSource (for example Firestore), this handler ignores the refreshed source.current() catalog and lets Grants price against the separate catalog captured by CredentAgent. A merchant price, category, or minimumAge update therefore does not affect delegated purchases; notably, an item newly marked age-restricted can still be bought unattended and an increased price can evade the approved per-spend cap. Validate and price the item against the storefront's current catalog, or provide a genuinely shared dynamic catalog to the grant engine.
Useful? React with 👍 / 👎.
| const s = await g.spend({ | ||
| idempotencyKey: idempotencyKey ?? `mcp-${randomUUID().slice(0, 12)}`, | ||
| items: [{ sku: productId, ...(quantity ? { qty: quantity } : {}) }], | ||
| }); |
There was a problem hiding this comment.
Return a typed refusal for unknown products
When Claude supplies a stale or hallucinated product ID, Grants.spend() reaches DelegatedGate.createOrder(), which deliberately throws for an unknown catalog item rather than returning a SpendDoor. Because this handler awaits it without validation or error mapping, the MCP call becomes a generic tool exception instead of the promised structured refusal code, leaving an unattended agent unable to branch or recover. Validate productId or map this case to a typed invalid-request result.
Useful? React with 👍 / 👎.
| }, | ||
| async ({ budget, perSpend, categories, description }): Promise<CallToolResult> => { | ||
| const g = await grants.create({ | ||
| merchant: "utopia", |
There was a problem hiding this comment.
Configure the merchant instead of sealing every grant as Utopia
For every non-Utopia consumer of this generic storefront package, the created intent is cryptographically scoped and audited as belonging to utopia, regardless of the actual merchant, and StorefrontOptions provides no way to override it. This makes the authorization record's merchant identity incorrect and prevents downstream merchant-scope checks from representing the host storefront; derive it from configured storefront identity or require a merchant option.
Useful? React with 👍 / 👎.
…Claude
A real Claude conversation against this example, over the MCP connector: create a
spending grant → approve it once at the link → the agent buys the in-scope mouse
($49 charged, $151 of $200 remaining, real delegation id) → and REFUSES the
age-restricted whiskey ('age verification can't be delegated, so that one waits for
your tap'), even though it's an allowed category and under budget.
- examples/hnp-on-claude/media/hnp-on-claude.gif (2.1MB, renders inline) + .mp4 (0.5MB)
- examples/hnp-on-claude/README.md walks the six beats + how to reproduce via tunnel
This is the tools from this PR, exercised end to end from the Claude app — the same
lifecycle the grants-tools.test.ts asserts over the wire.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
Demo media doesn't belong in the source repo — binaries bloat every clone forever and this is a Linux Foundation project. The runnable serve.mjs example + the README walkthrough stay (real docs); the .gif/.mp4 come out. The recording lives on the PR via GitHub's attachment CDN instead of in git history. Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…help The no-PUBLIC_URL branch said 'tunnel this port' without saying how. Now it prints the copy-pasteable ngrok/cloudflared commands + the PUBLIC_URL restart, and calls out that same-machine testing needs no tunnel at all (localhost approve links open locally). Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
…t, live re-pricing
Three findings on the new grant tools:
P1 — Re-price delegated spends from the storefront's LIVE catalog. The grant engine holds
its own catalog snapshot; a dynamic source (Firestore) can drift from it, letting a
newly-age-restricted item be bought unattended or a price bump evade the sealed per-spend
cap. spend-from-grant now re-reads source.current() and refuses fail-closed BEFORE
delegating: unknown → invalid-request, age-restricted → step-up, live price × qty > cap →
per-spend-exceeded. The engine stays the authority for allow-bounds + budget draw-down.
Residual (charged amount still uses the engine snapshot when prices agree under the cap)
tracked as a follow-up to fully unify the two catalogs.
P2 — Typed refusal for unknown products. A stale/hallucinated productId used to reach
DelegatedGate.createOrder(), which throws for an unknown item → a generic tool exception the
agent can't branch on. Now validated against the live catalog (and the engine call is
wrapped) → the promised { ok:false, code:'invalid-request' } door.
P2 — Configurable merchant. create-spending-grant hardcoded 'utopia', so every non-Utopia
consumer sealed grants as the wrong merchant. Added StorefrontOptions.merchant (default the
neutral 'storefront'); the example passes 'utopia'. Surfaced the SEALED merchant on the
Grant view so it's observable/testable.
Tests: +6 in grants-tools.test.ts — merchant config + default, unknown-product typed
refusal, and two red-proven BYPASS cases (live-catalog age + price drift). Each control
verified RED when removed. gate 359 + storefront 96 green; lint clean.
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
|
Addressed all three findings in P1 — reprice delegated spends against the storefront's live catalog. P2 — typed refusal for unknown products. A stale/hallucinated P2 — configurable merchant. Added gate 359 + storefront 96 green; lint clean. |
… main) main advanced 11 commits past this branch's fork point (the orders / webhooks / grants merges — #98, #102, #112, #118, #121, #123 — and the #120 browse-products fix). GitHub flagged PR #103 CONFLICTING. Reconcile MERGE (not rebase — the repo's precedent is reconcile merges, and rebasing would rewrite pushed, DCO-signed history). Two files conflicted (both-add — the two features touched adjacent lines, neither restructured the other's seam); resolved by keeping BOTH sides: - packages/credentagent-storefront/src/server.ts — the StorefrontOptions interface. BOTH won: kept the branch's `verifier?: DelegatedVerifier` (008 delegated ceremony) AND main's `grants?: Grants` + `merchant?: string` (spec 009 human-not-present). They are independent optional fields; a delegated storefront and a grants storefront compose. (Restored a `/**` the raw resolution dropped — caught by tsc, re-verified.) - packages/credentagent-storefront/src/server.test.ts — two separate describe blocks appended at end of file, sharing the trailing closers. BOTH won: kept the branch's "008 — delegated ceremony completes through the storefront (S5)" suite (the e2e delegated payment + the wrong-amount refusal) AND main's "browse-products is agent-legible (#120)" suite; closed each block explicitly. Auto-merged with both sides preserved (no hand resolution needed): client.ts (main's grants/webhooks wiring AND the branch's `this.delegated` verifier wiring), index.ts (main's grants exports AND the branch's DelegatedVerifier exports), the two READMEs. The #103 double-settle fix (completion.ts, ceremony/types.ts, keyed-mutex.ts) was branch-only — main never touched completion.ts, so it merged untouched. Verified after resolving: build (both packages typecheck), gate suite 380 + storefront 99 green, lint clean, and the security-critical files re-run explicitly — the delegated verify refusals + amount binding (delegated-payment 37, server delegated e2e), the new double-settle test (completion 33), and main's grants tests (grants 14 + grants-tools 10) all pass. Neither feature regressed the other. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5Y1aX9ffAF26hP4fgQFi7 Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
In plain terms
Until now there was no way to test the "away" story from Claude: you approve a spending limit once, then your agent buys for you while you're gone. The store's Claude connector only had the human-present flow (browse → cart → checkout). This adds the missing half — four grant tools on the MCP server — so a Claude conversation can now run the whole thing.
It runs live in the Claude app against this PR's code. A recorded walkthrough is attached in a comment below. The flow:
Every rule is enforced by the server, and every refusal comes back as a typed reason Claude acts on: not approved yet, outside the allowed categories, over the per-purchase cap, budget spent, revoked — and age-restricted items always refuse, no matter the budget: buying whiskey hands back to a human.
What you're approving
createStorefront({ grants })registers the tools only when wired; every existing surface is untouched (omit ⇒ byte-identical server).examples/hnp-on-claude/serve.mjs+ its README walkthrough are the only additions; the recording lives on this PR (GitHub CDN), not in git history.credentagent.grantsengine (merged + red-proven in Grants + the see-it-work hub: the agent buys while you're away — clickable (#104) #112); this PR is the wire to Claude.How to test
For the Claude app: tunnel port 3005 (
ngrok http 3005), restart withPUBLIC_URL=<tunnel URL>, add<tunnel URL>/mcpas a custom connector in claude.ai, then have the conversation in the recording. Full walk-through:examples/hnp-on-claude/README.md.For reviewers — the detail
Four tools registered in
buildServer()whenStorefrontOptions.grants(aGrantsinstance from@openmobilehub/credentagent-gate≥0.3.0) is present:create-spending-grant→{grantId, approveUrl, status:"pending"};get-grant-status;spend-from-grant→ the full typedSpendDoor(idempotencyKey passthrough, auto-generated when omitted);revoke-grant. Tool descriptions are written for the agent: send the human the approveUrl, treatstep-upas hand-back. The runnableexamples/hnp-on-claude/serve.mjswires both consent modes on one server (checkout gates viamount, grant tools + the approve page viagrants.serve), deriving the gate's dollar catalog fromSAMPLE_CATALOGso there's one price source,PUBLIC_URL-aware for tunneling. Tests drive the identical lifecycle overInMemoryTransportincluding pending-can't-spend and whiskey-step-up through the tool wire. Known scope cut: grants state is in-memory, so this targets single-process servers (local + tunnel) — the Redis-backed grant store for the serverless demo is the follow-up under epic #12.Refs epic #12 · spec 009
🤖 Generated with Claude Code
https://claude.ai/code/session_01Harfquzhqg5SCCURyYRbHD