Skip to content

Grant tools on the MCP server — test the away-agent story from Claude (#12) - #118

Merged
dzuluaga merged 5 commits into
mainfrom
feat/hnp-mcp-tools
Jul 26, 2026
Merged

Grant tools on the MCP server — test the away-agent story from Claude (#12)#118
dzuluaga merged 5 commits into
mainfrom
feat/hnp-mcp-tools

Conversation

@dzuluaga

@dzuluaga dzuluaga commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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:

Create a grant → approve it once at the link → the agent buys the in-scope mouse ($49 charged, $151 of $200 left) and refuses the whiskey: "age verification can't be delegated, so that one waits for your tap." — even though the whiskey is in an allowed category and under budget.

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

  • Additive: createStorefront({ grants }) registers the tools only when wired; every existing surface is untouched (omit ⇒ byte-identical server).
  • No binaries in the tree: the runnable 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.
  • Worst case if wrong: a grant tool misreports a refusal reason. The enforcement itself is the already-shipped credentagent.grants engine (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.
  • What stays safe: nothing spends without the human's one-time approval (proven in the recording and through the tool wire), and no real money moves — the approve page says so.

How to test

npm run build && npm test                 # gate 359 · storefront 90
node examples/hnp-on-claude/serve.mjs     # → http://localhost:3005/mcp

For the Claude app: tunnel port 3005 (ngrok http 3005), restart with PUBLIC_URL=<tunnel URL>, add <tunnel URL>/mcp as 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() when StorefrontOptions.grants (a Grants instance from @openmobilehub/credentagent-gate ≥0.3.0) is present: create-spending-grant{grantId, approveUrl, status:"pending"}; get-grant-status; spend-from-grant → the full typed SpendDoor (idempotencyKey passthrough, auto-generated when omitted); revoke-grant. Tool descriptions are written for the agent: send the human the approveUrl, treat step-up as hand-back. The runnable examples/hnp-on-claude/serve.mjs wires both consent modes on one server (checkout gates via mount, grant tools + the approve page via grants.serve), deriving the gate's dollar catalog from SAMPLE_CATALOG so there's one price source, PUBLIC_URL-aware for tunneling. Tests drive the identical lifecycle over InMemoryTransport including 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

…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>
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
credentagent-demo Ready Ready Preview, Comment Jul 26, 2026 5:56am

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +683 to +686
const s = await g.spend({
idempotencyKey: idempotencyKey ?? `mcp-${randomUUID().slice(0, 12)}`,
items: [{ sku: productId, ...(quantity ? { qty: quantity } : {}) }],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +683 to +686
const s = await g.spend({
idempotencyKey: idempotencyKey ?? `mcp-${randomUUID().slice(0, 12)}`,
items: [{ sku: productId, ...(quantity ? { qty: quantity } : {}) }],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@dzuluaga

Copy link
Copy Markdown
Contributor Author

Addressed all three findings in 358af6b — each with a red-proven test.

P1 — reprice delegated spends against the storefront's live catalog. spend-from-grant now re-reads source.current() and refuses fail-closed before delegating: an item newly marked age-restricted → step-up (age never delegates), and a live price × qty over the sealed cap → per-spend-exceeded (a price bump can't evade it). The engine stays the authority for allow-bounds + budget draw-down. Two BYPASS tests prove it (grant snapshot says cheap/unrestricted, live catalog says otherwise → still refuses); each fails RED when its pre-check is deleted. The residual — the charged amount still uses the engine's snapshot when prices agree under the cap — is tracked as #119 (fully unify the two catalogs), under epic #12.

P2 — typed refusal for unknown products. A stale/hallucinated productId no longer reaches DelegatedGate.createOrder()'s throw: validated against the live catalog (and the engine call is wrapped) → the promised { ok:false, code:"invalid-request" } door. Test asserts no tool exception.

P2 — configurable merchant. Added StorefrontOptions.merchant (default the neutral "storefront"; the example passes "utopia"). Surfaced the sealed merchant on the Grant view so it's observable — the test reads it back from the grant record, so a hardcoded value fails RED.

gate 359 + storefront 96 green; lint clean.

@dzuluaga
dzuluaga merged commit 16c1758 into main Jul 26, 2026
7 checks passed
dzuluaga added a commit that referenced this pull request Jul 26, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant