From 87a13d9006f5667c64544f50b023389ebd524c5b Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:36:43 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20agent=20storefront=20deployment=20?= =?UTF-8?q?=E2=80=94=20admission=20validity=20backbone=20+=20typed=20MCP?= =?UTF-8?q?=20front=20door?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on proposal/multistore-storefront-routing. Lets a permissioned obol-stack agent deploy/manage storefront offers with structured design + metadata control, plan/apply semantics, and — the load-bearing part — validity enforced at ADMISSION for every writer. Split after adversarial review (3 reviewers vs be0a237, all cites re-verified): (1) a ValidatingWebhookConfiguration is the validity backbone — it runs price-required / cross-offer path first-claimant / grant-bound / logo checks synchronously at write time for the CLI, the existing raw-agent apiserver path (monetize.py POSTs offers today with zero validation), and the MCP alike; a webhook (unlike the CEL-only VAP already in-tree, which cannot list other objects) can do the cross-object lookups. (2) storefront-mcp is the ergonomic layer on top: 9 typed tools (whoami/list/get/plan/apply/status/preview_landing/set_profile/retire), grant-scoped via a StorefrontGrant CRD + minted bearer token, whose unique value over 'just kubectl --dry-run=server' is rendered buyer-byte preview, charter discovery, and convergence probes. Design control is structured tokens (template enum + accent + bounded hero copy + icon), never agent-supplied HTML — with a copy-safe reject-not-mangle validator (the existing sanitizeDisplayToken allowlist is identifier-scoped and would strip spaces). Includes machine-readable storefront-mcp-tools.json and phasing where Phase 0 (webhook + in-cluster offer record, no orphaning on stack up) delivers the robustness win with no MCP at all. Claude-Session: https://claude.ai/code/session_01VquWN9UMaSHH7MHGcG8bw1 --- docs/proposals/storefront-mcp-surface.md | 382 +++++++++++++++++++++++ docs/proposals/storefront-mcp-tools.json | 264 ++++++++++++++++ 2 files changed, 646 insertions(+) create mode 100644 docs/proposals/storefront-mcp-surface.md create mode 100644 docs/proposals/storefront-mcp-tools.json diff --git a/docs/proposals/storefront-mcp-surface.md b/docs/proposals/storefront-mcp-surface.md new file mode 100644 index 00000000..7b75c95b --- /dev/null +++ b/docs/proposals/storefront-mcp-surface.md @@ -0,0 +1,382 @@ +# Proposal: agent-driven storefront deployment — an admission validity backbone + a typed MCP front door + +**Status:** proposal — stacks on [multistore-storefront-routing](./multistore-storefront-routing.md) +(P1 per-offer origins, P2 route table, P3 CR-owned agent config) +**Baseline:** main `be0a237`; every code claim below is file:line-cited against it. +**Note on rigor:** an earlier draft framed this as an MCP broker alone. Adversarial +review (3 reviewers vs `be0a237`) showed the broker was overselling itself as *the* +validation path when native Kubernetes gives a stronger one. This version splits the +design accordingly and corrects the citations that pass had flagged. + +--- + +## TL;DR — two parts, one goal ("robust and predictable to be valid") + +Making an agent's storefront deployment **valid** and making it **ergonomic** are +two different jobs, best solved by two different mechanisms: + +1. **Validity backbone — a `ValidatingWebhookConfiguration`** that runs the full + check battery (price-required, cross-offer path first-claimant, grant bounds, + reserved paths, logo/contact, design tokens) **synchronously at admission, for + every writer** — the CLI's `kubectl apply`, an agent's raw + `POST /apis/obol.org/…` (which is how agents create offers *today*, `monetize.py:472`), + and the MCP server alike. This is the part that makes deployment *predictable to + be valid*: an invalid ServiceOffer is **rejected at write time**, not discovered + later by a buyer hitting a $0-gated offer. A webhook — unlike the CEL-only + ValidatingAdmissionPolicy already in the tree — *can* list other objects, so it + can do the path-conflict and grant lookups the VAP structurally cannot + ("a ValidatingAdmissionPolicy cannot do this — VAPs cannot list other cluster + objects", `pathconflict.go:17`). + +2. **Ergonomic front door — a `storefront-mcp` server** giving permissioned agents + *typed* tools with plan / preview / apply / status semantics, so an LLM deploys + by calling `storefront_plan_offer` and reading a structured diff instead of + hand-writing CR YAML and decoding admission errors. Its unique value over "just + let the agent `kubectl apply --dry-run=server`" is exactly what a webhook can't + give: **rendered-bytes preview** of the buyer-visible landing/openapi/402 + (`storefront_preview_landing`), grant-scoped **discovery of one's own charter** + (`storefront_whoami`), and **convergence probes** (applied ≠ serving). + +The backbone enforces validity for everyone; the front door makes it pleasant for +agents. Neither invents a new privilege — agents already have ServiceOffer write +power (mother agent: `obol-agent-monetize-rbac.yaml:52`; the raw path: +`monetize.py:472`); what's missing is *validation on that path*, which today is +**zero** (the CLI's preflights are `package main`, unexported, and never run for a +non-CLI writer). + +``` + ┌─────────────────────────────── validity backbone ───────────────────────────────┐ +CLI apply ─┐ │ +raw agent ─┼─► kube-apiserver ─► ValidatingWebhookConfiguration (storefront-validate) ──reject──┤ +MCP apply ─┘ price-required · pathconflict(list) · grant · reserved │ + └──────────────────────────────────────────────────────┬──────────────────────────┘ + ▼ admitted + serviceoffer-controller (unchanged privileged fan-out) + +agent ─► storefront-mcp (typed tools) ─► same apiserver ─► same webhook (ergonomic front door) + whoami · plan · preview · apply · status · retire +``` + +--- + +## 1. Grounding — what main actually does (verified) + +* **Agents already create offers, unvalidated.** The `sell` skill POSTs CRs straight + at the apiserver with the pod's mounted SA token (`monetize.py:472` via + `kube.py:32` "No kubectl required"). The mother agent SA holds ServiceOffer + + PurchaseRequest CRUD (`obol-agent-monetize-rbac.yaml:52`). **Generated child agents + have a mounted token and zero RBAC** (`agent_render.go:76` renders no Role/Binding; + repo-wide grep confirms) — so "let the agent do it" means either the over-powered + mother or a powerless child. +* **No validation reaches that path.** The path-collision and price checks are + `preflightOfferPathCollision` / `resolvePriceTable` — both `package main`, + unexported, kubectl-shelling (`sell.go:4651`, `sell.go:3687`). `resolvePriceTable` + is the *only* place "price required" is enforced (`sell.go:3719`); the schema layer + returns `"0", nil` for an empty table (`payment.go:141`), and the CRD requires the + `price` *object* but leaves all four sub-fields optional + (`serviceoffer-crd.yaml:192-221`, `required: [network, payTo, price]`), so + `price: {}` is admitted and silently gates at $0. +* **Admission today is shape-only and doesn't cover offers.** The single VAP + `openclaw-resource-guard` matches only `openclaw-`/`hermes-` SA prefixes (child + `agent-*` SAs are *outside* it) and guards middlewares/HTTPRoutes/namespaces/ + secrets/Agents — **ServiceOffers are not in its resourceRules** + (`obol-agent-admission-policy.yaml:20-36,39`). And CEL VAPs can't list other + objects (`pathconflict.go:17`), so first-claimant path conflict is unreachable + there by construction. +* **The MCP plumbing is vendored and unused.** `modelcontextprotocol/go-sdk v1.3.0` + (go.mod:17), streamable-HTTP handler with a per-request server getter + (`x402mcp/server.go:149`, matching `NewStreamableHTTPHandler`), bearer-auth + middleware `auth.RequireBearerToken` (`go-sdk auth/auth.go:69`) + + `TokenInfoFromContext` (`auth/auth.go:55`) — zero in-tree imports. Reference + streamable client: `client.Connect(ctx, &StreamableClientTransport{Endpoint}, nil)` + (`flows/clients/mcp-paid-client.go:72`). +* **The validation building blocks are already exported and CLI-free** in + `internal/storefront`: `PreflightLogoURL` (`logocheck.go:46`), `ValidateLogoURL` / + `ValidateContactEmail` / `MergeProfile` / `ConfigMapManifest` (`profile.go`). + `schemas.PriceTable` + `EffectiveRequestPriceE` are exported (`payment.go:128`). + `pickPathConflict` is a **pure** function (`pathconflict.go:42`, + `func(offer, others []*ServiceOffer) string`) — but its caller feeds `others` from + the controller's shared informer, so a separate process must supply its own + cluster-wide ServiceOffer list. +* **Network + apply mechanics check out for colocation in `x402`.** The traefik + gateway `web` listener sets `namespacePolicy.from: All` (`helmfile.yaml:81`), so a + Service+HTTPRoute in `x402` needs no ReferenceGrant, mirroring the x402-verifier + (`render.go:581`). The controller writes ServiceOffer `.status` via the status + subresource only (`controller.go:1371`), never `.spec` via SSA — so a webhook/MCP + and the controller never fight over `.spec`. Agent-pod egress reaches `traefik` + (any port) and same-namespace, but `x402` is **ingress-only** from agents + (`agent_render.go:546-591`) — so the MCP is *reached through the gateway route*, + not by direct pod-to-`x402` dialing. + +--- + +## 2. Why not just native primitives? (the maintainer's first question) + +A fair challenge: most of this is reducible to Kubernetes-native pieces. The honest +accounting, and why the answer is "use them, plus a thin MCP": + +| Job | Native primitive | Verdict | +|---|---|---| +| Authn of the caller | Mounted SA token (apiserver-verified) | **Use it** for the raw path. The MCP adds a *grant* token only to scope agents more tightly than their SA (see §3). | +| Race-free apply | `resourceVersion` optimistic concurrency (409 Conflict) | **Use it** — drop the hand-rolled planId-drift check for same-object edits; keep a plan only as an *ergonomic* preview (§5). | +| Reject invalid specs | `ValidatingWebhookConfiguration` (+ `kubectl apply --dry-run=server` for a preflight) | **Use it — this is the backbone.** A webhook runs the cross-object checks a VAP can't, for all writers. | +| Cross-namespace offer RBAC | ClusterRole or per-namespace Role/RoleBinding | See §3 — the honest version is a ClusterRole; tenant isolation is application/webhook logic, not RBAC. | +| Rendered preview of buyer bytes | — none — | **Only the MCP/controller can do this.** A webhook validates; it doesn't render a landing page. | +| LLM-friendly typed tool calls | — none — | **Only MCP.** Agents are far better at typed tool calls than at raw apiserver JSON + admission-error round-trips. | + +So the design is **not** "a broker instead of native validation." It's: put validity +in a webhook (native, universal), and put *ergonomics + preview + grant-scoping* in +the MCP. The MCP's `plan`/`apply` become thin conveniences over +`--dry-run=server` + a normal apply; the webhook is what actually guarantees +validity, including when an agent bypasses the MCP and POSTs raw (which it can do +today and will keep being able to do). + +--- + +## 3. Permission model — corrected, with honest RBAC + +Four mechanisms, but **not** four independently-enforced layers over every property — +the earlier draft overclaimed that. What each actually enforces: + +| Mechanism | Enforces | Honest limit | +|---|---|---| +| **Grant token** (per-agent bearer, §4) | *Which agent* is calling the MCP | Only gates the MCP door; the agent's SA can still POST raw (webhook catches that path) | +| **Webhook** (`storefront-validate`) | *Validity + grant bounds* (price/quota/namespace/hostname) and path first-claimant — **for all writers**, via live cross-object lists | Async-free but adds an apiserver dependency; must fail-open-or-closed deliberately (we fail **closed** for storefront-mcp SA writes, **open** with a warning event for CLI to avoid bricking operators) | +| **Service RBAC** | The MCP process's *reach* | **Necessarily a ClusterRole** over ServiceOffers cluster-wide — cross-namespace offer CRUD has no namespaced form here without minting per-grant RoleBindings. **Tenant isolation between agents is therefore application + webhook logic, not RBAC.** Stated plainly, not dressed up. | +| **Controller backstop** | Everything else | Unchanged: `RoutePublished=False/PathConflict`, drain/finalizer, catalog render | + +Two ways to make layer-3 honest rather than hand-wavy — pick one at implementation: +(a) accept a cluster-scoped MCP ClusterRole and rely on the webhook to enforce +"this grant may only write in these namespaces" (simplest; the webhook already has to +do grant lookups); or (b) have the serviceoffer-controller mint a per-grant +`Role`+`RoleBinding` in each `offerNamespace` when a `StorefrontGrant` is created, so +the MCP SA is genuinely scoped. (b) is more work and needs the controller to hold +`rolebindings.create`; (a) is the lazy correct default given the webhook exists. + +Deliberate non-goals unchanged: the MCP gets **no** factory powers (agents/namespaces +stay with the factory RBAC+VAP pair) and **holds no funds** (§7). + +Writes use SSA with field-manager `storefront-mcp`, **no `Force`** (parent P5), an +`obol.org/created-by: agent..` label, and a Kubernetes Event on the offer. + +--- + +## 4. `StorefrontGrant` — the permission object + +Namespaced in `x402`, operator-created, reconciled by the serviceoffer-controller: + +```yaml +apiVersion: obol.org/v1alpha1 +kind: StorefrontGrant +metadata: {name: hyperliquid-analyst, namespace: x402} +spec: + subject: {kind: Agent, namespace: agent-hyperliquid-analyst, name: hyperliquid-analyst} + offerNamespaces: [agent-hyperliquid-analyst] + maxOffers: 3 + types: [http, agent] + pricePerRequest: {min: "0.0001", max: "1.00"} # USDC bounds + hostnamePatterns: ["*.agents.v1337.org"] # P1 spec.hostname allowlist + allowProfile: false # store-wide branding stays operator-only + registration: {allowed: true, requiresApproval: true} +status: + tokenSecretRef: {name: storefront-mcp-token, namespace: agent-hyperliquid-analyst} + offersInUse: 1 + conditions: [...] +``` + +The controller mints a random bearer token into a Secret in the agent's namespace +(the per-agent `hermes-api-server` pattern, `agent.go:535`). **The webhook reads the +grant** to enforce bounds regardless of writer; the MCP reads it for `whoami` and +pre-checks. Once P3's `spec.mcpServers[]` exists, the controller injects the +storefront-mcp entry into the agent's config with the token *sourced from the Secret*, +never inline (inline secrets leak and bounce pods on the checksum annotation — +the LiteLLM-key lesson, `agent_render.go:124/255`). + +**Token mechanics (corrected):** `auth.RequireBearerToken` rejects a token whose +verifier returns no non-zero, non-expired `Expiration` (`auth/auth.go:121`), so the +verifier must synthesize a rolling expiry for an otherwise-static Secret. To bound +revocation latency, the verifier watches the token Secret via an informer (not a TTL +poll): grant deletion → controller deletes the Secret → the watch invalidates the +cached token within propagation latency, not "instantly on next call" as the earlier +draft claimed. Short-lived TokenRequest-API tokens are a later hardening (§7). + +--- + +## 5. Tool surface — 9 tools (validate folded into plan) + +Full machine-readable schemas: [`storefront-mcp-tools.json`](./storefront-mcp-tools.json). +Offer-spec input schemas are derived mechanically from the ServiceOffer CRD structural +schema (itself generated from `internal/monetizeapi` by `just generate`, +controller-gen v0.16.5), so the tool contract and the validation contract can't drift. + +**Read / discover** (no side effects): + +| Tool | Purpose | +|---|---| +| `storefront_whoami` | The grant: namespaces, remaining quota, allowed types, price bounds, hostname patterns, profile/registration permissions. Plan within the charter instead of learning limits by rejection. | +| `storefront_list_offers` / `storefront_get_offer` | Offers under the grant: spec + conditions (`RoutePublished`, `PaymentGateReady`, `Draining`) + public URLs + `resumeCovered` | +| `storefront_offer_status` | Conditions **plus convergence probes**: is the offer's entry in the rendered `services.json` (the `waitForPublishedCatalog` pattern, `sell_info.go:538`), and a live 402/200 probe of the public URL. Applied ≠ serving. | +| `storefront_preview_landing` | Render the exact buyer-visible bytes — landing HTML, offer-scoped `openapi.json`, `/.well-known/x402` — from a plan or live offer, no apply. **The MCP's headline capability a webhook can't provide.** (Meaningful once parent P1 lands the per-offer origin; before that it previews the openapi/well-known only — see phasing.) | + +**Write** (plan → apply; the webhook is the real gate): + +| Tool | Purpose | +|---|---| +| `storefront_plan_offer` | `--dry-run=server`-backed: submit the spec to the apiserver dry-run so the **webhook** returns the authoritative findings, then normalize + diff live state → `{planId, action, diff, findings, approvalRequired}`. `dryRun:true` skips diff/planId for a pure validity check (this replaces the separate validate tool). No mutation. | +| `storefront_apply_plan` | Apply by `planId`. Uses **`resourceVersion` optimistic concurrency** — a 409 from the apiserver (someone changed the object) forces a re-plan; the webhook re-runs path-conflict against *live* state at admission, so a sibling grabbing the path between plan and apply is caught **by the webhook**, not missed (the earlier planId-only check couldn't see sibling objects). Emits the audit Event; returns conditions + a poll hint. | +| `storefront_set_profile` | Store-wide branding (`StorefrontProfile`), grant-gated by `allowProfile`, reusing `MergeProfile`/`ValidateLogoURL`/`ValidateContactEmail`/`ConfigMapManifest` verbatim. Plan/apply like offers. | +| `storefront_retire_offer` | `mode: drain` patches `spec.drainAt`(+grace) — route/gate stay up for in-flight buyers, discovery flips `available=false`, controller tears routes at expiry; `mode: delete` requires Drained (finalizer serializes route teardown + ERC-8004 handoff, `controller.go:529-663`). `confirm` must echo the offer name. | + +Plan TTL is **30 minutes** (raised from 10 after review), and a plan carrying +`approvalRequired` or a recorded `preview_landing` never hard-expires — `apply_plan` +re-validates live state in place rather than forcing a blind re-plan, so an +operator's review window doesn't invalidate the agent's plan. + +These are **management** tools; auth is the grant token, not x402. The per-tool +`PaymentWrapper` from `x402mcp` stays available for *seller-facing* paid tools later +(parent P2.4) — paid and free tools already coexist on one server +(`x402mcp/server.go:116-146`). + +--- + +## 6. Structured design control — tokens with a copy-safe sanitizer + +A `design` block on the ServiceOffer, beside `spec.listing` (already framed "Cosmetic +only — it does not affect routing, pricing, or payment", `serviceoffer-crd.yaml:100`): + +```yaml +spec: + design: + template: terminal # enum: minimal | terminal | data — fixed set (cf. runtime enum) + accent: "#2fe4ab" # ^#[0-9a-fA-F]{6}$ + heroTitle: "Hyperliquid Intelligence" # free text, ≤80 + heroTagline: "90 trading-intelligence endpoints, $0.001/call" # free text, ≤200 + icon: "data:image/png;base64,…" # ValidateLogoURL rules; ≤256 KiB +``` + +**Sanitizer — corrected.** The existing `sanitizeDisplayToken` allowlist is +`^[A-Za-z0-9._:/-]+$` (`paymentrequired.go:24`) — no space, comma, or `$`. It exists +to keep k8s-name/model-id tokens safe inside *copy-pasteable shell commands*, and is +applied today only to `d.Model` at two sites. Reusing it for hero copy would mangle +every realistic title ("Hyperliquid Intelligence" → placeholder). Design copy is free +text that lands in **HTML, not a shell command**, so it needs its own rule: +HTML-escape on render (the landing/402 are `html/template`, which auto-escapes) plus a +**reject-don't-mangle** validator that bounds length and forbids control chars and +raw `<`/`>`/`&` in the *source*, surfacing a `design-copy-invalid` finding rather than +silently collapsing to a placeholder. The `template.HTML` Lede rule +(`paymentrequired.go:337`) — "no PaymentDisplay strings interpolated raw" — is the +XSS precedent to preserve: hero copy is a normal escaped field, never `template.HTML`. + +**Render sites** (all existing machinery, but they don't exist until Phase 2 — see +phasing; a plan with `design` on a pre-Phase-2 cluster returns a +`design-not-yet-rendered` **warning** so an agent isn't silently no-op'd): + +1. **Per-offer landing** — parent P1's origin `/` document: one more ConfigMap key + + Exact route, the `catalog.go` content-hash pattern; `scalar_html.go` is the + HTML-in-Go + CSS-custom-property precedent the `accent` token maps onto. +2. **402 page** — today hardcodes Obol branding and never reads StorefrontProfile + (`paymentrequired.go:278-283`); its palette is a hand-copied mirror of the + storefront tokens (template line 25). Thread `accent/icon/heroTitle` through + `RouteRule → PaymentDisplay`, the same plumbing that already carries + `OfferDescription`/`AgentSkills` (`serviceoffer_source.go:175` → `verifier.go:507`). + This turns the mirrored palette into data — *reducing* the documented drift hazard. +3. **Storefront cards** — `design.accent`/`icon` on `ServiceCatalogEntry` + (`service_catalog.go:21`) for per-offer tinting on the shared origin. + +Tokens-not-HTML is a deliberate predictability/injection call: the agent picks a +template enum and a few bounded fields; it never supplies markup. + +--- + +## 7. Approvals — on-chain and store-wide stay human-gated + +* **On-chain registration** (ERC-8004) costs agent-wallet gas and its cleanup is a + finalizer-serialized multi-step (`controller.go:628-663`). With + `registration.requiresApproval: true` a plan containing registration returns + `approvalRequired: true`; apply parks the CR with registration disabled and the + controller sets `PendingApproval`. **Neither an `obol sell approve` command nor an + automated gas-balance preflight exists today** — the register command only prints a + human-readable "make sure it has a small balance" note (`sell.go:3091`). Phase 3 + would add both: an `obol sell approve ` and a programmatic pre-broadcast + balance check. Until then approval is an operator `kubectl` edit of a documented + condition/annotation. +* **Store-wide profile** (`allowProfile: false` default): rebranding the whole mall + is not one shop's call. + +--- + +## 8. Robustness gaps surfaced honestly + +* **Resume-ledger orphaning — moved into Phase 0, not deferred.** The host ledger + (`/sell-http/…`, replayed by `resumePersistedServiceOffers`, + `sell.go:4859`) is written *only* by the CLI's `persistServiceOffer` and is + unreachable from a pod. An MCP-created offer would therefore **silently vanish on + the next `stack down`/`stack up`** — a routine workflow, not an edge case. That is + unacceptable for a "robust" claim, so Phase 0 ships an **in-cluster offer record**: + the controller writes each reconciled offer's minimal manifest into a per-offer + ConfigMap (label `obol.org/managed-by: serviceoffer-controller`), and `stack up`'s + replay reads *both* the host ledger and these ConfigMaps. This also fixes the CLI's + own dual-write fragility (`record.go:21` "the host file is the source of truth") and + the "MCP mutates a ledgered offer → stale replay clobbers it" hazard, because there + is then one in-cluster source of truth. (`--adopt-agent-offers` does **not** exist + today; this replaces the need for it.) +* **`price: {}` $0 hole** (`payment.go:141`, `serviceoffer-crd.yaml:192-221`): closed + by the webhook for every writer, and by the parent proposal's CRD `minProperties` + fix at the schema layer. +* **Token lifetime**: static Secrets with informer-bounded revocation now (§4); + TokenRequest short-lived tokens later. +* **Webhook availability**: a down webhook must not brick the cluster — scope its + `ValidatingWebhookConfiguration` to `obol.org/serviceoffers` only, `failurePolicy: + Fail` for the storefront-mcp SA (agents must be validated) but with a short + `timeoutSeconds` and an operator break-glass label, matching how the tree already + reasons about fail-closed vs fail-open (`verifier.go` readyz gating). + +--- + +## 9. Phasing (revised so each phase is safe to run) + +| Phase | Ships | Depends on | +|---|---|---| +| **0** | The **`ValidatingWebhookConfiguration` + validation binary** (reuses `PreflightLogoURL`/`ValidateContactEmail`/`schemas.PriceTable` + an exported `pickPathConflict` + its own cluster-wide ServiceOffer list) — this alone closes the validity gap for the *existing raw-agent path*. **In-cluster offer record** so nothing orphans on `stack up`. `StorefrontGrant` CRD + controller (token mint, quota). | nothing upstream | +| **1** | `storefront-mcp` Deployment in `x402` (the ~40-line `x402mcp` server pattern + `auth.RequireBearerToken` + informer-backed token verify); tools `whoami/list/get/plan/apply/status/retire`; traefik route; a `storefront` skill (SKILL.md + streamable-HTTP script, the `buy-x402` shape) so agents use it before P3 renders `mcpServers`. | Phase 0 | +| **2** | `spec.design` block + per-offer landing render + 402/card consumption; `preview_landing` becomes fully meaningful; `set_profile`. | parent P1 (origins); Phase 1 | +| **3** | Registration approvals (`obol sell approve` + gas preflight, both net-new); TokenRequest rotation. | Phases 1–2, parent P5 | + +The ordering matters: **Phase 0 delivers the robustness win (validity for all writers ++ no orphaning) with no MCP at all.** The MCP is the ergonomic layer that follows. +A maintainer skeptical of the broker can adopt Phase 0 alone and already be strictly +better off. + +--- + +## Appendix — worked flows (phase-labeled) + +**Phase 1 flow (path-routed, no design/preview yet):** +``` +agent → storefront_whoami + ← {namespaces:[agent-analyst], quota:2/3, price:0.0001–1.00, hostnames:*.agents.v1337.org} +agent → storefront_plan_offer {name:funding-feed, type:http, upstream:{…}, + payment:{network:base, payTo:0x…, price:{perRequest:"0.002"}}, + registration:{description:"Predicted funding-rate feed…"}} + ← {planId:"sha256:…", action:create, approvalRequired:false, findings:[{severity:warning,code:logo-cors}]} + # findings came from the webhook via --dry-run=server — authoritative, same gate a raw apply hits +agent → storefront_apply_plan {planId} + ← {applied:true, resumeCovered:true, conditions:[RoutePublished=Unknown]} # in-cluster record → survives stack up +agent → storefront_offer_status {name:funding-feed} + ← {RoutePublished:True, PaymentGateReady:True, catalogPublished:true, + probe:{url:"https://…/services/funding-feed", status:402, ok:true}} # gated & discoverable — done +``` + +**Phase 2 adds** (per-offer origin + design + preview): +``` +agent → storefront_plan_offer {…, hostname:funding.agents.v1337.org, + design:{template:data, accent:"#2fe4ab", heroTitle:"Funding Feed"}} + ← {planId:"…", findings:[]} +agent → storefront_preview_landing {planId} + ← {landingHtml:"…", openapiJson:{…}, wellKnownX402:{…}} # exact buyer-visible bytes, pre-apply +agent → storefront_apply_plan {planId} + ← {applied:true, …} +``` + +Every step is typed, webhook-validated, diffed, auditable, and (Phase 2) previewable — +and none of it grants a privilege the stack didn't already hand out more dangerously. diff --git a/docs/proposals/storefront-mcp-tools.json b/docs/proposals/storefront-mcp-tools.json new file mode 100644 index 00000000..dcdff1c7 --- /dev/null +++ b/docs/proposals/storefront-mcp-tools.json @@ -0,0 +1,264 @@ +{ + "$comment": "storefront-mcp tool catalog (proposal). Offer-spec sub-schemas are a hand-written representative subset here; the implementation derives them mechanically from the ServiceOffer CRD structural schema (internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml, generated from internal/monetizeapi types by `just generate`). Shared shapes are under $defs. Validity is ultimately enforced by the ValidatingWebhookConfiguration (see the proposal doc §1-§2); plan_offer surfaces the webhook's findings via kubectl apply --dry-run=server, so a tool call and a raw apply hit the same gate.", + "server": { + "name": "storefront", + "transport": "streamable-http", + "endpoint": "/mcp", + "auth": "bearer (per-agent token minted from StorefrontGrant; go-sdk auth.RequireBearerToken; verifier watches the token Secret via informer for bounded revocation)" + }, + "$defs": { + "finding": { + "type": "object", + "required": ["severity", "code", "message"], + "properties": { + "severity": { "type": "string", "enum": ["error", "warning"] }, + "code": { "type": "string", "description": "stable machine code, e.g. price-required, path-conflict, path-reserved, hostname-not-granted, logo-cors, logo-unreachable, design-copy-invalid, design-not-yet-rendered, quota-exceeded, price-out-of-bounds, ledger-provenance" }, + "field": { "type": "string", "description": "JSON pointer into the offer spec, e.g. /payment/price" }, + "message": { "type": "string" } + } + }, + "priceTable": { + "type": "object", + "description": "USDC human-decimal strings; at least one field REQUIRED by the webhook (the CRD alone admits price:{} which gates at $0, payment.go:141). perEpoch is fine-tuning-only.", + "minProperties": 1, + "properties": { + "perRequest": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$" }, + "perMTok": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$" }, + "perHour": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$" }, + "perEpoch": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?$" } + } + }, + "design": { + "type": "object", + "description": "Structured visual identity. Tokens only — no raw HTML/CSS. Rendered server-side (Phase 2) into the per-offer landing (P1), the 402 paywall, and storefront cards. On a pre-Phase-2 cluster a non-empty design block yields a design-not-yet-rendered warning finding (syntactically valid, no-op).", + "properties": { + "template": { "type": "string", "enum": ["minimal", "terminal", "data"], "default": "minimal" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "heroTitle": { "type": "string", "maxLength": 80, "description": "free text; HTML-escaped on render, reject-not-mangle validation (control chars and raw <>& forbidden in source → design-copy-invalid). NOT sanitizeDisplayToken, whose identifier allowlist would strip spaces." }, + "heroTagline": { "type": "string", "maxLength": 200, "description": "free text; same validation as heroTitle" }, + "icon": { "type": "string", "description": "https URL, /-relative, or data:image/*;base64 (decoded max 256 KiB — ConfigMap budget)" } + } + }, + "offerSpec": { + "type": "object", + "required": ["name", "type", "upstream", "payment"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", "maxLength": 63 }, + "namespace": { "type": "string", "description": "must be within grant.offerNamespaces; defaults to the grant's first namespace" }, + "type": { "type": "string", "enum": ["inference", "fine-tuning", "http", "agent"], "description": "further restricted by grant.types" }, + "upstream": { + "type": "object", + "required": ["service", "namespace", "port"], + "properties": { + "service": { "type": "string" }, + "namespace": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "healthPath": { "type": "string", "default": "/health" } + } + }, + "path": { "type": "string", "pattern": "^/[a-zA-Z0-9/_.-]*$", "description": "default /services/; checked against reserved-path denylist and cross-offer first-claimant conflict" }, + "hostname": { "type": "string", "description": "P1 per-offer origin; must match a grant.hostnamePatterns entry" }, + "payment": { + "type": "object", + "required": ["network", "payTo", "price"], + "properties": { + "network": { "type": "string", "enum": ["base", "base-sepolia"] }, + "payTo": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$" }, + "price": { "$ref": "#/$defs/priceTable" } + } + }, + "listing": { + "type": "object", + "properties": { + "category": { "type": "string", "maxLength": 40 }, + "weight": { "type": "integer", "minimum": 0, "maximum": 1000 } + } + }, + "registration": { + "type": "object", + "description": "ERC-8004 metadata; on-chain publication gated by grant.registration (may require operator approval)", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "name": { "type": "string", "maxLength": 80 }, + "description": { "type": "string", "maxLength": 800 }, + "image": { "type": "string" }, + "skills": { "type": "array", "items": { "type": "string" }, "maxItems": 32 }, + "domains": { "type": "array", "items": { "type": "string" }, "maxItems": 16 }, + "metadata": { "type": "object", "additionalProperties": { "type": "string" } } + } + }, + "design": { "$ref": "#/$defs/design" }, + "drainGracePeriod": { "type": "string", "description": "Go duration, default 1h; used by retire mode=drain" } + } + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "RoutePublished | PaymentGateReady | Draining | PendingApproval | ..." }, + "status": { "type": "string", "enum": ["True", "False", "Unknown"] }, + "reason": { "type": "string" }, + "message": { "type": "string" } + } + } + } + }, + "tools": [ + { + "name": "storefront_whoami", + "description": "Return the caller's StorefrontGrant: allowed offer namespaces, remaining quota, allowed types, price bounds, hostname patterns, profile/registration permissions. Call first; plan within the charter.", + "inputSchema": { "type": "object", "properties": {} }, + "output": { + "type": "object", + "properties": { + "subject": { "type": "string" }, + "offerNamespaces": { "type": "array", "items": { "type": "string" } }, + "quota": { "type": "object", "properties": { "used": { "type": "integer" }, "max": { "type": "integer" } } }, + "types": { "type": "array", "items": { "type": "string" } }, + "pricePerRequest": { "type": "object", "properties": { "min": { "type": "string" }, "max": { "type": "string" } } }, + "hostnamePatterns": { "type": "array", "items": { "type": "string" } }, + "allowProfile": { "type": "boolean" }, + "registration": { "type": "object", "properties": { "allowed": { "type": "boolean" }, "requiresApproval": { "type": "boolean" } } } + } + } + }, + { + "name": "storefront_list_offers", + "description": "List offers visible under the grant with name, namespace, type, path/hostname, price, condition summary, resumeCovered.", + "inputSchema": { + "type": "object", + "properties": { "namespace": { "type": "string", "description": "optional filter; must be within grant" } } + } + }, + { + "name": "storefront_get_offer", + "description": "Full offer view: normalized spec, conditions, public URLs (paid surface, openapi, landing), provenance labels, resumeCovered flag.", + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" }, "namespace": { "type": "string" } } + } + }, + { + "name": "storefront_plan_offer", + "description": "Submit the draft spec to the apiserver via kubectl apply --dry-run=server so the ValidatingWebhookConfiguration returns AUTHORITATIVE findings (price-required, grant bounds, quota, path first-claimant conflict, reserved paths, hostname grant, logo/contact, design). Then normalize + diff live state. Returns {planId, action, diff, findings, approvalRequired}. dryRun:true returns findings only (no planId/diff) — this subsumes a separate validate tool. NO mutation.", + "inputSchema": { + "type": "object", + "required": ["spec"], + "properties": { + "spec": { "$ref": "#/$defs/offerSpec" }, + "dryRun": { "type": "boolean", "default": false, "description": "true = validity check only, skip diff/planId" } + } + }, + "output": { + "type": "object", + "properties": { + "planId": { "type": "string", "description": "absent when dryRun:true" }, + "action": { "type": "string", "enum": ["create", "update", "noop"] }, + "diff": { "type": "string" }, + "findings": { "type": "array", "items": { "$ref": "#/$defs/finding" } }, + "approvalRequired": { "type": "boolean" }, + "expiresAt": { "type": "string", "format": "date-time", "description": "30-min TTL; plans with approvalRequired or a recorded preview never hard-expire (apply re-validates in place)" } + } + } + }, + { + "name": "storefront_apply_plan", + "description": "Apply a plan by id. Uses resourceVersion optimistic concurrency — a 409 (object changed) forces a re-plan; the WEBHOOK re-runs path-conflict against live state at admission, so a sibling offer claiming the path between plan and apply is rejected there (not missed). Server-side apply, field-manager storefront-mcp (no Force); labels obol.org/created-by; emits an audit Event. The controller records the offer in-cluster so it survives stack down/up. Returns initial conditions; poll storefront_offer_status for convergence.", + "inputSchema": { + "type": "object", + "required": ["planId"], + "properties": { "planId": { "type": "string" } } + }, + "output": { + "type": "object", + "properties": { + "applied": { "type": "boolean" }, + "name": { "type": "string" }, + "namespace": { "type": "string" }, + "resumeCovered": { "type": "boolean", "description": "true once the controller has written the in-cluster offer record (Phase 0); the offer survives stack down/up" }, + "conditions": { "$ref": "#/$defs/conditions" } + } + } + }, + { + "name": "storefront_offer_status", + "description": "Conditions plus convergence probes: (a) catalog publish — the offer's entry visible in the rendered services.json; (b) live probe of the public URL (expect 402 with accepts[] on the paid surface, 200 on free discovery docs). Distinguishes applied from serving.", + "inputSchema": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" }, "namespace": { "type": "string" } } + }, + "output": { + "type": "object", + "properties": { + "conditions": { "$ref": "#/$defs/conditions" }, + "catalogPublished": { "type": "boolean" }, + "probe": { + "type": "object", + "properties": { + "url": { "type": "string" }, + "status": { "type": "integer" }, + "ok": { "type": "boolean", "description": "status matches expectation for the surface (402 paid / 200 free)" } + } + } + } + } + }, + { + "name": "storefront_preview_landing", + "description": "Render the buyer-visible artifacts for a plan or live offer WITHOUT applying: landing HTML (from design tokens + fixed template), offer-scoped openapi.json, /.well-known/x402. Pure function of the input.", + "inputSchema": { + "type": "object", + "properties": { + "planId": { "type": "string" }, + "name": { "type": "string", "description": "live offer alternative to planId" }, + "namespace": { "type": "string" } + } + }, + "output": { + "type": "object", + "properties": { + "landingHtml": { "type": "string" }, + "openapiJson": { "type": "object" }, + "wellKnownX402": { "type": "object" } + } + } + }, + { + "name": "storefront_set_profile", + "description": "Plan/apply a patch to the store-wide StorefrontProfile (displayName, tagline, logoUrl, contactEmail). Requires grant.allowProfile. Non-empty fields overlay (MergeProfile semantics); logo and email validated. Two-phase like offers: omit planId to plan, pass it to apply.", + "inputSchema": { + "type": "object", + "properties": { + "patch": { + "type": "object", + "properties": { + "displayName": { "type": "string", "maxLength": 80 }, + "tagline": { "type": "string", "maxLength": 200 }, + "logoUrl": { "type": "string" }, + "contactEmail": { "type": "string", "format": "email" } + } + }, + "planId": { "type": "string" } + } + } + }, + { + "name": "storefront_retire_offer", + "description": "Safe retire. mode=drain: patch spec.drainAt (+grace) — route and payment gate stay up for in-flight buyers, discovery flips available=false, controller tears down routes at drain expiry. mode=delete: requires Drained conditions (finalizer then serializes route teardown and ERC-8004 handoff/cleanup). confirm must echo the offer name exactly.", + "inputSchema": { + "type": "object", + "required": ["name", "mode", "confirm"], + "properties": { + "name": { "type": "string" }, + "namespace": { "type": "string" }, + "mode": { "type": "string", "enum": ["drain", "delete"] }, + "gracePeriod": { "type": "string", "description": "Go duration for drain, default 1h" }, + "confirm": { "type": "string", "description": "must equal name" } + } + } + } + ] +}