Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .claude/skills/gate-my-tool/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
name: gate-my-tool
description: Use when asked to gate an existing MCP tool behind a credential — "gate my <tool> tool", add a consent / age / credential check to a registered tool, or make a tool refuse-until-proven. Do NOT use for storefront/checkout gating (that's requirements() + mount()) or for building a new proving ceremony (add-ceremony-rail).
---

# Gate an MCP tool (refuse-until-proven)

Install a CredentAgent consent gate onto an existing tool in **one honest call** — never
hand-roll the `verification_required` envelope, the store check, or the deprecated
`gated()` shim. `credentagent.gate()` absorbs all of it, and the wrap IS the server-side
enforcement point (Security invariant 1 — hiding a button is not enforcement).

## Steps

1. **Locate** the named tool's `registerTool(name, config, handler)` call, and the host's
`CredentAgent` instance. If the server has none, create ONE (`new CredentAgent()` —
zero-config for local dev), reuse it server-wide, and **export it**: its store is where
proofs live, and the bypass test must write proof onto the exact store the gate reads.
2. **Wrap** the handler — change nothing inside it:

```js
server.registerTool("release-records", config, credentagent.gate(handler, {
require: age.over(21), // TODO(dev): the real credential — any defineCredential works
provenBy: (_args, extra) => extra.sessionId, // whose proof unlocks the call — key by the CALLER
name: "release-records", // must equal the registered tool id exactly
}));
```

- `require` takes bare `gate()`-effect credentials (one or an array) — no `required()`
wrapper. Payment / discount steps throw at wrap time by design.
- `provenBy` must derive a **per-caller** id — the MCP per-request `extra` (with
`sessionId`) is its second argument. Key by a tool arg ONLY when that subject is the
prover (self-service), never by the requested resource on a multi-user server. A
constant means the first person who proves unlocks the tool for EVERYONE
(invariant 4). Empty throws fail-closed.
3. **Delete any `outputSchema`** on that tool (no-op when only `inputSchema` is declared):
the refusal envelope replaces the success shape in `structuredContent`, and the SDK
validates `structuredContent` against a declared schema — the gate would break.
4. **Add the load-bearing bypass test** — in the project's existing test runner (vitest
here; a stray `node:test` file in a vitest glob fails the suite with "No test suite
found"). REQUIRED SUB-SKILL: follow `write-bypass-test`.
Drive the REAL server in-memory — `Client` from
`@modelcontextprotocol/sdk/client/index.js`, `InMemoryTransport.createLinkedPair()`
from `@modelcontextprotocol/sdk/inMemory.js`, `isVerificationRequired` from
`@openmobilehub/credentagent-gate` — using the exported `CredentAgent`. Assert the
attack precisely (each refusal prints the expected `[credentagent] gate(): …
mount(app) has not run` warning when no ceremony is mounted — not a failure):
- unproven call → the action did NOT happen (e.g. `structuredContent.released !== true`)
AND `isVerificationRequired(result.structuredContent)` with `reason.pass === false`,
`present.credential` === the credential's **id** (e.g. `"age"`), `present.min_age`,
and `order.id` === the `provenBy` value;
- proof written under the envelope's `order.id` → same subject unlocks, a **different**
subject is still refused (invariant 4);
- a record with the claim false/absent is refused — presence ≠ proof (invariant 5).
5. **Prove the test is load-bearing**: remove the wrap, run — red; restore — green. Say
which line you deleted. A bypass test that stays green with the control removed is not
a useful test.

## Traps (each observed or blocked by design)

| Trap | Reality |
| :-- | :-- |
| Gating in the page/HTML/prose only | The wrap is the enforcement point — server-side, every call |
| `provenBy: () => "shared"` | Cross-user bleed: one proof unlocks everyone |
| `provenBy` keyed by the requested resource | Same bleed per-record: anyone asking for casey's records rides one proof — key by the caller |
| Keeping `outputSchema` | SDK rejects the envelope against your schema |
| Hand-rolling `buildVerificationRequired` / using `gated()` | One call: `credentagent.gate()` |
| A second `CredentAgent` just for the gate | Proofs land in a store the ceremony never writes — share ONE instance |
| Test asserts only "not success" | Assert the typed refusal AND red-when-wrap-removed |

Refusal wire shape + agent loop: package README, "Gate a single tool". Runnable before/after
target: `examples/gate-my-tool-sample/`. The refusal is a success-shaped result (`isError`
unset) — `trust_level` stays `"presence-only-demo"`; don't claim more than it proves.
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ and the age gate drops — the `.when()` predicate receives the **order** and is

For a deployment, pass your public origin: `new CredentAgent({ walletOrigin: "https://shop.example" })`.

**Not selling anything?** Gate a single MCP tool — no cart, no checkout — with
[`credentagent.gate()`](./packages/credentagent-gate/README.md#gate-a-single-tool--credentagentgate):

```ts
server.registerTool("release-records", config, credentagent.gate(
async ({ subject }) => ({ content: [{ type: "text", text: `Released records for ${subject}.` }] }),
{ require: age.over(21), provenBy: ({ subject }) => subject },
));
```

An unproven call returns a typed `verification_required` refusal (approve link + agent
instruction) instead of running; once the person proves the credential on their phone, the
same call goes through. One policy language, three enforcement surfaces — checkout page,
gated tool, delegated grant ([the table](./packages/credentagent-gate/README.md#one-policy-language-three-enforcement-surfaces)).

## Documentation

- **Reference** ([`docs/reference/`](./docs/reference/)):
Expand All @@ -89,8 +104,8 @@ For a deployment, pass your public origin: `new CredentAgent({ walletOrigin: "ht
security-bypass testing bar, and the module conventions.
- **[docs/deployment.md](./docs/deployment.md)** — running it for real (serverless stores,
the stable signing key, the settle seam) + a troubleshooting table.
- **[ROADMAP.md](./ROADMAP.md)** — what binds cryptographically today (Mode A) vs. what's
next (Mode B page-less gating, issuer-verified trust).
- **[ROADMAP.md](./ROADMAP.md)** — what binds cryptographically today (Mode A checkout +
Mode-B `gate()`-wrapped tools) vs. what's next (issuer-verified trust).

## Honest status

Expand Down
12 changes: 7 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ claims about itself.

## Next

- **Mode B — gate any page-less tool.** The `verification_required` envelope primitive ships
today; the **page-less proving ceremony** (for hosts with no browser handoff — e.g. a CLI
agent gating `release-record`, `approve-deploy`, `file-prescription` with **no checkout at
all**) is the next build. This is where "gate **any** consequential action with **any**
credential" becomes runnable beyond commerce — the heart of the identity-first promise.
- **Mode B — the standalone proving page.** `credentagent.gate(handler, { require, provenBy })`
ships today: wrap any tool (`release-records`, `approve-deploy`, `file-prescription` — **no
checkout at all**) and it refuses-until-proven with the `verification_required` envelope.
What remains is the **standalone proving-page mount**: today `approve_url` is served by the
checkout-shaped ceremony seams, so a page-less host (stdio MCP, CLI) still needs a small web
process for the ceremony. A proving-only `mount()` — no orderStore/catalog/completion — is
the next build, and the top follow-up from the gate() cold-reader DX loop.
- **Cart Mandate, end to end.** Issuance at checkout, `PaymentMandate` reconciliation by id,
and the opt-in **stateless-orders** transport (the cart travels as the signed mandate, no
shared order store).
Expand Down
32 changes: 32 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,38 @@ const requires = credentagent.requirements(order, [
]);
```

#### `credentagent.gate(handler, options)` — gate a tool (Mode B)

```ts
gate<A, R>(handler: (args: A) => R | Promise<R>, options: GateOptions<A>):
(args: A) => Promise<R | MinimalToolResult>
```

Wraps an MCP tool handler so it **refuses-until-proven**. An unproven call returns a
success-shaped tool result (`isError` unset) carrying the typed `verification_required`
envelope in `structuredContent` and an action-agnostic instruction in `content`; a proven
call runs `handler` unchanged. Built on the same resolver as `requirements()`
(entries carry `enforcedAt: "tool"`).

- **`require`** — the `gate()`-effect credential(s) to prove (`age.over(21)`, a custom
`defineCredential`, or an array). `payment` / discount steps **throw at wrap time** —
they belong to the checkout ceremony, and a silently-unenforced step would fail open.
- **`provenBy`** — `(args, extra?) => string`; derives the id the proof is stored under
(per subject, never process-global). Key by the **caller** — the MCP per-request
`extra` (e.g. `extra.sessionId`) is the second argument; key by a tool arg only when
that subject is the prover. An empty value **throws fail-closed**.
- **`name?`** — your registered tool id; names the re-call in `resume.tool`.

```ts
server.registerTool("release-records", config, credentagent.gate(
async ({ subject }) => ({ content: [{ type: "text", text: `Released records for ${subject}.` }] }),
{ require: age.over(21), provenBy: ({ subject }) => subject },
));
```

Do **not** declare an MCP `outputSchema` on a gated tool — the envelope replaces the
success shape in `structuredContent`.

#### `credentagent.mount(app, ceremony?)` — Context 2

```ts
Expand Down
116 changes: 116 additions & 0 deletions docs/superpowers/specs/2026-07-20-gate-my-tool-implementation-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# gate-my-tool — implementation notes & evidence

Companion to [`2026-07-20-gate-my-tool-skill-design.md`](./2026-07-20-gate-my-tool-skill-design.md).
Branch `feat/17-gate-my-tool` · tracked in #92 (also #17, folds in the core of #23).

## 1. DX deviation from the spec — maintainer-directed

Mid-implementation the maintainer reviewed the spec's caller shape and rejected it
("if it's confusing we have failed"; iterate until Stripe-grade). The surface was
redesigned through a **cold-reader goal loop**: three rounds of two fresh-context
reader personas (a Stripe-fluent Node dev who's never seen MCP; an MCP server author
who's never seen wallets) each answering graded comprehension questions on the
quickstart alone.

| | Spec (`gateTool`) | Shipped (`credentagent.gate()`) |
| :-- | :-- | :-- |
| Method | `gateTool(handler, opts)` | `gate(handler, opts)` |
| Policy | `require: [ required(age.over(21)) ]` | `require: age.over(21)` (credential or array; no `required()` noise — a blocking gate is required by definition; payment/discount **throw at wrap time**) |
| Scope key | `order: (args) => ({ id, total: 0, currency: "USD" })` | `provenBy: (args) => string` (no checkout vocabulary on identity actions; totals default internally) |
| Resume | — | optional `name` (defaults to a `"this-tool"` self-reference) |

Reader-score trajectory: R1 4+4 → R2 5+6 → R3 6+6 out of 10. By round 3 both
readers had the loop and every option correct ("the `gate()` call itself I could
write blind right now"); remaining confidence loss was traced to two things **outside
this branch's scope** — see §3 — plus doc fixes that were applied (refusal shown as a
full CallToolResult, `isError` semantics stated, the `outputSchema` rule promoted,
`subject` renamed `provenBy` after both R3 readers independently misread it as the
*data* subject).

## 2. Load-bearing proof for the bypass tests (write-bypass-test step 4)

Each control was temporarily disabled, the suite run, red confirmed, control
restored (288/288 green after):

| Mutation (in `gate.ts`) | Invariant | Red tests |
| :-- | :-- | :-- |
| A — `entries.find((e) => !proven(e, record))` replaced with `undefined` (proven-check deleted) | 1 (enforce on the completion path) | 6/9, incl. all four security tests |
| B — `record?.ageVerified === true` weakened to `record != null` (token-present) | 5 (explicit positive claim) | "REFUSES a negative/absent claim" |
| C — `opts.provenBy(args)` replaced with a shared constant | 4 (never process-global) | "REFUSES a cross-subject bleed" (+3) |

## 3. Follow-ups surfaced by the DX loop (not this branch)

For `needs-decision` issues — each was flagged independently by multiple cold readers:

1. **Standalone proving-page mount for page-less servers.** `mount()` requires the
checkout-shaped seams (orderStore/catalog/completion), so a stdio-only MCP server
has no one-line way to serve `approve_url`. This was the single biggest remaining
confidence blocker in round 3. `gate()` warns honestly today.
2. **`walletOrigin` naming.** Flagged by every reader in every round ("actively
misleading — name says wallet, value is MY server"). Recommend an alias
(`serverOrigin` or `publicOrigin`) with `walletOrigin` kept for compat.
3. **`?order=` query param on approve links** for identity actions (rail contract) —
reads as checkout leakage on a non-commerce gate; recommend accepting a neutral
alias param on the credential rail.
4. **Envelope v1 field polish** (wire contract, needs a version bump): `present`
noun ambiguity, `reason.pass` redundancy, prose in `resume.poll`.
5. **`CredentAgent` class name** — repeat reader stumbles ("'agent' reads as the AI
caller"; "a portmanteau I had to sound out"). Product-level rename question.

Also noted per spec §5: #17's premise that a discovery surface (`agents.md`, #20)
"ships in v0.1" is stale — it does not exist in the repo.

## 4. Skill TDD evidence (writing-skills RED → GREEN)

**RED baseline (no skill).** A fresh-context agent, told only *"gate my release-records
tool so it requires age verification (21+); add whatever tests you think are needed"*
against an untracked copy of the ungated sample, forbidden from reading `.claude/skills/`.

Outcome — an honest surprise: the baseline **succeeded on every core behavior.** It chose
`credentagent.gate()` over hand-rolling, keyed proof with `provenBy` per subject, omitted
`outputSchema` (verifying the SDK's validation path itself), injected one shared
`CredentAgent` for testability, wrote three precise attack tests (bypass, cross-subject
bleed, negative-claim), and ran the wrap-removed mutation proof unprompted (3 pass → wrap
removed: 3 fail → restored: 3 pass). Root causes: this branch's fresh README/api.md/jsdoc
were already committed, the repo's CLAUDE.md carries the invariants + the bypass-test bar,
and the sample README states the intended before/after. It even caught a stale
pre-rename `subject:` reference in the sample README (fixed).

Per the writing-skills discipline, that means the skill is NOT justified as a correction
of observed failures **in this repo's environment**. It is kept (as the spec requires) as
**compression + trap insurance**: the baseline burned substantial discovery (reading
`gate.ts`, `client.ts`, and SDK internals before writing the wrap), and its success leaned
on docs an agent may not read on a worse day. The skill distills the checklist — wrap,
`provenBy` footgun, delete `outputSchema`, single shared client, the write-bypass-test
loop — into one page with the traps stated explicitly.

**GREEN verification (with skill).** A second fresh-context agent, same task, told to
follow the skill, against another ungated copy. Full pass: correct wrap (shared exported
`CredentAgent`, per-caller `provenBy`, `name` matching the tool id), three precise attack
tests, and the load-bearing proof run as instructed (3 pass → wrap deleted: 3 fail →
restored: 3 pass; it named the deleted line). **Discovery cost for the wrap fell from
~a-dozen source reads (baseline) to 2** (the skill + the target file); its remaining reads
went to test assertions and SDK import specifiers. Its audit listed five friction points
(unstated import specifiers, envelope field paths, export-the-client placement, an N/A
step, the expected unmounted warning) — all folded back into the skill (REFACTOR phase),
plus one skill correction from the harness itself: vitest does **not** skip dot-dirs, so
the skill now says "use the project's test runner."

## 5. DX goal-loop verdict (round 4, on the shipped README section)

Round 4 (fresh readers, final committed text, sample linked): the wrap + agent loop are
solved — the MCP persona rated coding the detect/re-call loop "trivial… a 9" and both
walked every step correctly. Remaining scores (6 and 4–6) trace to exactly two things:

1. **The standalone proving-page mount** (§3.1) — both personas' top lookup. A capability
gap, not confusion; stated honestly in the README and ROADMAP, tracked as the next
increment.
2. **`provenBy` example semantics** — round 4's one new catch: keying by the *requested
record* on a multi-user server re-creates the shared-bucket bleed per record. Fixed
three ways: `provenBy` now receives the MCP per-request `extra` as its second argument
(so `(_args, extra) => extra.sessionId` keys by caller; new regression test), the
README/skill/example annotate the self-service assumption explicitly, and the skill's
trap table names the resource-keying variant.

With those landed, every reader-flagged item is either fixed, honestly fenced with a named
follow-up (§3), or a wire-contract question deferred to an envelope version bump.
Loading
Loading