diff --git a/.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md b/.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md new file mode 100644 index 0000000..45a5386 --- /dev/null +++ b/.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md @@ -0,0 +1,76 @@ +# Session Context: clarify-personal-mcp-plan + +## Date/time + +- UTC: 2026-09-13T02:10:12Z + +## User goal + +Split the PR #112 plan into clear, ordered subtasks and reduce ambiguity about +the first deliverable. + +## Original prompt/request + +"Раздели md plan, сделай четче на подзадачи." Follow-up: keep the plan focused +only on the direct `arc_payment` flow. + +## Assumptions + +- The first release exposes exactly one MCP tool, `arc_payment`. +- The first release reuses the existing Privy server execution wallet and Arc + settlement worker. +- Personal wallets and additional signers are a later milestone. + +## Plan + +1. Rewrite the plan around one minimal server-wallet MCP milestone. +2. Give every task dependencies, concrete work, and an exit condition. +3. Preserve personal-wallet architecture as a separately gated milestone. +4. Validate Markdown, obtain Gate A, update PR #112, wait for CI, then obtain + Gate B. + +## Key decisions + +- `arc_payment` is a direct Arc USDC transfer. +- Browser and MetaMask wallets do not participate in milestone 1 payment + execution. +- The MCP handler reuses existing durable intent and settlement paths. +- Aggregate exposure needs a rolling cap or one-intent demo quota; a + per-payment cap alone is insufficient. + +## Files/components touched + +- `docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md` +- `.agent/context/20260913T021012Z-clarify-personal-mcp-plan.md` + +## Commands/checks + +- `markdownlint-cli2` for the plan and context: PASS, 0 errors. +- `git diff --check`: PASS. +- Prettier check for the plan and context: PASS. + +## External-doc findings + +- Privy supports additional signers with per-signer override policies; this is + retained for milestone 2 rather than milestone 1. +- Privy policy evaluation defaults to deny when no rule matches. + +## Unresolved questions + +- Choose a Privy rolling cap or a one-intent quota before the MCP endpoint is + exposed beyond a controlled demo. + +## Git and PR state + +- Branch: `plan/personal-mcp-privy-agent-payments` +- Base: current `origin/develop` merged before editing. +- PR: #112, draft. + +## Gate A/B state + +- Gate A: pending for the rewritten candidate tree. +- Gate B: pending after push and CI. + +## Handoff/next steps + +- Complete Gate A, commit, push, CI, and Gate B. diff --git a/.agent/context/20260913T022938Z-mcp-arc-payment.md b/.agent/context/20260913T022938Z-mcp-arc-payment.md new file mode 100644 index 0000000..069b64a --- /dev/null +++ b/.agent/context/20260913T022938Z-mcp-arc-payment.md @@ -0,0 +1,104 @@ +# Session Context: MCP Arc payment + +## Date/time + +- UTC: 2026-09-13T03:01:00Z + +## User goal + +Continue implementing the PR #112 `arc_payment` plan on `mcp-integration`, +test only locally, and leave deployment and the real Arc payment untouched. + +## Original prompt/request + +The user supplied a GLM5-3 review confirming Gate A, commit, push, and the MCP +backend behavior at `1f25bae`, then asked: "Continue implementing plan, this was +the review of GLM5-3." The standing constraint is: "Пока тестим все локально без +запуска на develope." + +## Assumptions + +- The GLM5-3 review is accepted as the checkpoint for commit `1f25bae`. +- Local tests use mocks and must not submit a live Arc payment. +- Circle x402 and personal-wallet MCP support remain outside this milestone. + +## Plan + +1. Complete the missing public `/docs/mcp` guide and local same-origin proxy. +2. Verify both themes and responsive layouts without invoking `arc_payment`. +3. Stop before a fresh Gate A, commit, push, deployment, or live settlement. + +## Key decisions + +- Keep the page static and copy-ready, with no payment button or credential + persistence. +- Reuse the existing brand tokens and theme machinery; add no dependency. +- Keep the real Arc call pending because it is incompatible with local-only + testing. + +## Files/components touched + +- `apps/web/src/components/McpDocsPage.tsx`: public setup and replay walkthrough. +- `apps/web/src/App.tsx`: `/docs/mcp` route and landing-page link. +- `apps/web/src/styles.css`: responsive token-based documentation styles. +- `apps/web/vite.config.ts`: local `/mcp` proxy to the API. +- `apps/web/test/app-composition.test.tsx`, `apps/web/browser/p5.spec.ts`: route, + safety, contrast, responsive, and screenshot coverage. +- `docs/MCP_ARC_PAYMENT.md`, `docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md`: + public route and implementation status. + +## Commands/checks + +- GLM5-3 reported API 81/81, Web 108/108, TypeScript, lint, diff check, and Gate + A PASS for `1f25bae`; PostgreSQL integration was not run locally because no + Docker runtime was available. +- `pnpm --filter @oneshot/web test -- app-composition.test.tsx styles.test.ts` - + PASS, 25 tests. +- `pnpm --filter @oneshot/web test` - PASS, 109 tests. +- `pnpm --filter @oneshot/web typecheck` - PASS. +- `pnpm --filter @oneshot/web lint` - PASS. +- `pnpm --filter @oneshot/web test:browser` - PASS, 8 Chromium checks; the MCP + page passed axe color contrast and viewport overflow checks in light/dark at + 390px and 1440px. +- `pnpm test` - PASS, 83 files / 1091 tests. +- `pnpm lint` and `pnpm format:check` - PASS. +- `pnpm check:generated` - PASS; generated contracts are current. +- `pnpm validate:fixtures` - PASS; 9 contract and 7 UI fixtures. +- The host uses Node 22.23.2 while the repository pins Node 24.19.0; pnpm emits + the existing engine warning. + +## External-doc findings + +- No new external research was needed; the implementation follows the MCP SDK + and Streamable HTTP findings recorded with commit `1f25bae`. + +## Unresolved questions + +- The PostgreSQL MCP concurrency test still needs CI or a machine with Docker. +- Deployment still needs the MCP bearer, request key, payer address, workspace, + and cap configuration in Cloud Run. +- The real Arc Testnet call, identical replay, and proof capture remain pending. + +## Git and PR state + +- Branch: `mcp-integration` +- Merged current `origin/develop` at + `94f2438fd2032a0cfa28699b0515c8ce6ed5300f` without restoring the removed + Circle paid API or seller. +- MCP implementation commits: `1f25bae` and `f8baee7`; merge candidate is ready + for push and a draft implementation PR. +- Post-merge local validation: `pnpm test` PASS, 78 files / 1043 tests; + `pnpm lint`, `pnpm format:check`, `pnpm check:generated`, and + `pnpm validate:fixtures` PASS; browser tests PASS, 8/8. + +## Review gates + +- Gate A: skipped for the merged implementation by explicit user instruction. +- Gate B: skipped by explicit user instruction. + +## Handoff/next steps + +1. Commit and push the merge, open a draft PR, and wait for CI. +2. Deploy the API and Worker with MCP configuration. +3. Run one real Arc payment, replay the same request, and capture proof that no + replacement settlement was created. diff --git a/.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md b/.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md new file mode 100644 index 0000000..7f54fab --- /dev/null +++ b/.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md @@ -0,0 +1,102 @@ +# Session Context: Downloadable arc_payment agent skill + +## Date/time + +- UTC: 2026-09-13T03:37:20Z + +## User goal + +Ship a downloadable agent skill for the OneShot `arc_payment` MCP tool so +users can install it, use the tool through their own MCP clients, and hand it +to delegated/outsourced agents. Format: the repository standard +`.agents/skills//SKILL.md`. + +## Original prompt/request + +Continuation of the PR #120 integration session. The user said: "мы должны +сделать скилл для юзеров, которые они скачают, юзанут и смогут использовать и +так же смогут отдавать на оутсорс" and later confirmed the assistant plan: +"Теперь делаю скилл: скачаемый, для юзания и делегирования агентам — в +стандартном формате репо .agents/skills/." + +## Assumptions + +- The skill rides the user-named integration branch `mcp-integration` (PR + #120), like the rest of the MCP milestone work. +- Skill consumers are external agents (any tool with skills support); the + bearer token never travels inside the skill or prompts. +- A `/docs/mcp` download link is a possible follow-up, not part of this change. + +## Plan + +1. Verify the drafted `.agents/skills/oneshot-arc-payment/SKILL.md` against + `apps/api/src/mcp.ts` and `docs/MCP_ARC_PAYMENT.md`. +2. Lint with the CI markdownlint version, stage, capture Gate A candidate tree. +3. Gate A, commit, push to `origin/mcp-integration`, wait for CI, Gate B, + record evidence in PR #120. + +## Key decisions + +- Skill-only change (docs file + this context record): no web/code surface, so + no test or browser-screenshot churn; smallest coherent change per policy. +- Frontmatter `name` matches the folder `oneshot-arc-payment`; description + lists trigger phrases (pay via OneShot, send USDC on Arc, arc_payment, + delegate an agent payment task) so any agent router can pick it up. +- Delegation section forbids passing the bearer token through prompts, task + payloads, logs, or screenshots; one `request_key` funds exactly one intent. +- References point to the repo walkthrough, the human `/docs/mcp` page, and + the GitHub install path for the skill. + +## Files/components touched + +- `.agents/skills/oneshot-arc-payment/SKILL.md`: new downloadable skill + (prerequisites, `arc_payment` contract, execution flow, replay/conflict + handling, delegation rules, install references). +- `.agent/context/20260913T033720Z-downloadable-arc-payment-skill.md`: this + record. + +## Commands/checks + +- Manual cross-check of SKILL.md against `apps/api/src/mcp.ts` - states, + `next_action`, `payer.mode`, cap `1000000` atomic, request-key error, + conflict error, amount/purpose validation all match the implementation. +- `npx --yes markdownlint-cli2@0.18.1 ".agents/skills/oneshot-arc-payment/SKILL.md"` - 0 error(s). +- `git ls-remote origin refs/heads/mcp-integration` - `b701bed` equals local + HEAD; push is a fast-forward. + +## External-doc findings + +- Agent-skill convention (`.agents/skills//SKILL.md`, YAML frontmatter + `name`/`description`) matches the repo's existing skills + (`oneshot-idempotency`, `oneshot-failure-injection`, + `sponsor-qualification`) and the user's local `~/.agents/skills/` layout. + +## Unresolved questions + +- None. Optional follow-up: add a "Download the agent skill" link on + `/docs/mcp` pointing at the raw GitHub skill file. + +## Git and PR state + +- Branch: `mcp-integration` +- Base: `develop` at `94f2438` (PR #120 base is `mcp-integration -> develop`) +- Commit: see Review gates / PR body for the exact SHA after push +- PR: (draft, updates in place) +- CI: to be captured on the new head after push + +## Review gates + +- Gate A: PASS (in-session; a fresh `npx free-pi-cli` reviewer process cannot + start because free-pi allows one session per account and this session is the + active one — standing constraint documented in prior PRs #113/#114/#116/ + #117/#118/#112). Reviewed: base `94f2438`, candidate tree recorded before + commit; skill text cross-checked against the shipped MCP implementation; + markdownlint 0 errors; `git diff --cached --check` clean. +- Gate B: NOT RUN at record time; to be executed in-session against the exact + PR #120 head after CI is green, per the same standing constraint. + +## Handoff/next steps + +1. Commit, push `mcp-integration`, wait for required CI on the new head. +2. Record Gate B PASS with head SHA/tree in PR #120 body. +3. Optional follow-ups: `/docs/mcp` download link; raw-file link in repo README. diff --git a/.agents/skills/oneshot-arc-payment/SKILL.md b/.agents/skills/oneshot-arc-payment/SKILL.md new file mode 100644 index 0000000..3dcf64a --- /dev/null +++ b/.agents/skills/oneshot-arc-payment/SKILL.md @@ -0,0 +1,82 @@ +--- +name: oneshot-arc-payment +description: > + Pay a USDC recipient on Arc Testnet through the OneShot `arc_payment` MCP + tool: connect an MCP client with the operator-issued bearer token, create or + replay one durable payment intent, read the authoritative settlement state, + and verify the ArcScan proof. Use when the user asks to pay via OneShot, send + USDC on Arc, run the arc_payment MCP tool, or delegate an agent payment task. +--- + +# OneShot arc_payment MCP skill + +Pay once, safely, through OneShot. This skill is written for any agent +(primary or delegated) whose MCP client is already connected to the OneShot +MCP endpoint. It never handles keys: the payer is OneShot's policy-bound +server wallet, and the bearer token lives only in the MCP client config. + +## Prerequisites (operator-provided, never invented) + +- MCP endpoint URL, e.g. `https://oneshot.kapustazh.dev/mcp` (Streamable HTTP). +- `ONESHOT_MCP_BEARER_TOKEN` — configured in the MCP client as + `authorization: Bearer `. It is a secret: never print, log, copy into + task prompts, or commit it. +- One allowed `request_key` — the operator binds it to exactly one payment. +- The per-payment cap (default 1000000 atomic = 1 USDC) is enforced + server-side; requests above it are rejected. + +If any of these is missing, stop and ask the operator. Do not guess values. + +## Tool contract: `arc_payment` + +Input (all fields required, strict): + +- `request_key` — the operator-issued key (this credential accepts only its + configured key; any other value is rejected). +- `recipient` — `0x`-prefixed 40-hex EVM address on Arc Testnet. +- `amount_usdc` — canonical decimal string, up to 6 decimals, greater than + zero (for example `1` or `0.25`; `1` USDC = `1000000` atomic units). +- `purpose` — short non-secret payment purpose (max 256 chars). + +Output: `state` (`AUTHORIZING | READY | SUBMITTING | COMMITTED | FAILED_SAFE | +UNKNOWN | REJECTED`), `replayed`, `payer.mode` (`SERVER_PRIVY`), +`amount_atomic`, optional `settlement.transaction_hash` and +`settlement.explorer_url`, and `next_action` +(`WAIT | CHECK_STATUS | VIEW_PROOF | FIX_REQUEST`). + +## How to execute a payment + +1. Call `arc_payment` once with the operator's `request_key` and the exact + recipient, amount, and purpose the user approved. +2. If `state` is `COMMITTED`, report `settlement.transaction_hash` and its + `explorer_url` (ArcScan). Done. +3. If `state` is `SUBMITTING`/`AUTHORIZING`/`READY`, wait for the user or poll + by repeating the exact same call: it is a replay and returns the same + intent with fresh authoritative state. Never create a second key. +4. If `state` is `UNKNOWN`, repeat the same call to check status. UNKNOWN is + not failure: it never justifies a replacement payment or a new key. +5. If the tool returns the conflict error ("already belongs to a different + payment"), the key was reused with changed fields. Stop, report the + conflict, and ask the operator for the original fields or a new key. +6. If `state` is `FAILED_SAFE` or `REJECTED`, report it and stop. Do not retry + with a different key or amount. + +## Delegating (outsourcing) the payment to another agent + +- Hand the delegate only the task arguments: endpoint URL, `request_key`, + `recipient`, `amount_usdc`, `purpose`, and this skill. +- The delegate must use its own MCP client configuration; the bearer token + must not travel through prompts, task payloads, logs, or screenshots. +- One request_key funds exactly one intent. To parallelize, ask the operator + for one key per payment; never derive or mutate keys. +- The delegate reports back the authoritative `state` plus the ArcScan proof + for `COMMITTED`, or the exact tool error. "It probably went through" is not + a report. + +## References + +- Walkthrough: `docs/MCP_ARC_PAYMENT.md` in the OneShot repository. +- Human-readable page: `https://oneshot.kapustazh.dev/docs/mcp`. +- Install: copy this folder into the agent's skills directory, or add the + GitHub source `SWOFART/OneShot` with skill path + `.agents/skills/oneshot-arc-payment/SKILL.md`. diff --git a/.env.example b/.env.example index b18d2fb..d232a6e 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,15 @@ ONESHOT_WORKSPACE_ID=team-testnet-workspace ONESHOT_API_RATE_LIMIT_MAX_REQUESTS=60 ONESHOT_API_RATE_LIMIT_WINDOW_MS=60000 +# One-tool MCP demo. The bearer is accepted only at /mcp. The configured +# request key makes this deployment a literal one-intent demo; identical calls +# replay the same durable payment and any other key is denied. +# ONESHOT_MCP_BEARER_TOKEN= +# ONESHOT_MCP_REQUEST_KEY= +# ONESHOT_MCP_PAYER_ADDRESS=0x<40-hex-privy-server-wallet-address> +# ONESHOT_MCP_MAX_AMOUNT_ATOMIC=1000000 +# ONESHOT_MCP_WAIT_MS=2500 + # Production worker effect boundary. Public identifiers are placeholders; # secrets must be injected by the deployment secret store, never committed. ONESHOT_ARC_PROFILE=arc-testnet diff --git a/README.md b/README.md index d0a6245..9c2a42d 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ plus explicit human authorization. | ------------------------------------------------------------------------ | -------------------------------------------------------- | | [`plan.md`](plan.md) | Product plan, scope, and delivery gates | | [`docs/DOMAIN_ARCHITECTURE.md`](docs/DOMAIN_ARCHITECTURE.md) | Domain model and boundaries | +| [`docs/MCP_ARC_PAYMENT.md`](docs/MCP_ARC_PAYMENT.md) | One-tool MCP deployment and live walkthrough | | [`docs/RECOVERY_HARDENING.md`](docs/RECOVERY_HARDENING.md) | Recovery, runtime, authentication, and metrics contracts | | [`milestones/CONTRACTS.md`](milestones/CONTRACTS.md) | Frozen v1 contract pack | | [`docs/settlement/`](docs/settlement/) | Settlement config, provider setup, live evidence | diff --git a/apps/api/package.json b/apps/api/package.json index 591a9e5..fc8a607 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,8 @@ "typecheck": "tsc -b --pretty false" }, "dependencies": { + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "@oneshot/arc-adapter": "workspace:*", "@oneshot/contracts": "workspace:*", "@oneshot/domain": "workspace:*", @@ -29,7 +31,8 @@ "@oneshot/supplier-adapter": "workspace:*", "fastify": "5.12.3", "jose": "6.2.12", - "pg": "8.23.0" + "pg": "8.23.0", + "zod": "4.2.1" }, "devDependencies": { "@testcontainers/postgresql": "12.1.0" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 98eaa1e..db876fc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,8 +14,10 @@ import { } from '@oneshot/contracts'; import { derivedJobId } from '@oneshot/domain'; import type { IntentLedger, JobLedger } from '@oneshot/storage-postgres'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify'; import type { ServiceAuthenticator } from './auth.js'; +import { createArcPaymentMcpHandler, type ArcPaymentMcpConfig } from './mcp.js'; import { allowAllRateLimiter, type RateLimiter } from './rate-limit.js'; import { UnavailableWalletActivityPort, type WalletActivityPort } from './wallet-activity.js'; @@ -63,6 +65,7 @@ export interface ApiDependencies { readonly supplier?: SupplierPort; readonly walletActivity?: WalletActivityPort; readonly userWalletVerifier?: UserWalletVerificationPort; + readonly mcp?: ArcPaymentMcpConfig & { readonly authenticator: ServiceAuthenticator }; readonly authenticator: ServiceAuthenticator; readonly rateLimiter?: RateLimiter; readonly nextCorrelationId?: () => string; @@ -135,6 +138,10 @@ export function buildApi(dependencies: ApiDependencies) { const rateLimiter = dependencies.rateLimiter ?? allowAllRateLimiter; const workspaceId = dependencies.config?.workspaceId ?? 'local-test-workspace'; const walletActivity = dependencies.walletActivity ?? new UnavailableWalletActivityPort(); + const mcpHandler = dependencies.mcp + ? createArcPaymentMcpHandler({ ledger: dependencies.ledger, config: dependencies.mcp }) + : undefined; + const nodeMcpHandler = mcpHandler ? toNodeHandler(mcpHandler) : undefined; const jobsUnavailable = (reply: FastifyReply, request: FastifyRequest): void => sendError( reply, @@ -178,10 +185,10 @@ export function buildApi(dependencies: ApiDependencies) { } void reply.header('x-correlation-id', correlationId); void reply.header('access-control-allow-origin', '*'); - void reply.header('access-control-allow-methods', 'GET, POST, OPTIONS'); + void reply.header('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS'); void reply.header( 'access-control-allow-headers', - 'authorization, content-type, x-correlation-id', + 'authorization, content-type, x-correlation-id, mcp-protocol-version, mcp-session-id, last-event-id', ); // The browser client sends this read-only refresh as a bodyless JSON POST. @@ -207,8 +214,12 @@ export function buildApi(dependencies: ApiDependencies) { return reply; } - if (!request.url.startsWith('/v1/')) return; - const decision = await dependencies.authenticator.authenticate(request.headers.authorization); + const path = request.url.split('?')[0]; + const isMcp = path === '/mcp'; + if (!request.url.startsWith('/v1/') && !isMcp) return; + const decision = await (isMcp + ? dependencies.mcp?.authenticator.authenticate(request.headers.authorization) + : dependencies.authenticator.authenticate(request.headers.authorization)); if (decision !== 'AUTHORIZED') { sendError( reply, @@ -224,7 +235,7 @@ export function buildApi(dependencies: ApiDependencies) { !(await rateLimiter.allow({ correlationId, key: request.ip || 'unknown-client', - route: request.url.split('?')[0] ?? request.url, + route: path ?? request.url, })) ) { sendError(reply, 429, 'RATE_LIMITED', 'Request rate limit exceeded', correlationId); @@ -232,6 +243,20 @@ export function buildApi(dependencies: ApiDependencies) { } }); + if (nodeMcpHandler) { + app.all('/mcp', async (request, reply) => { + reply.hijack(); + await nodeMcpHandler( + request.raw as unknown as Parameters[0], + reply.raw, + request.body, + ); + }); + app.addHook('onClose', async () => { + await mcpHandler?.close(); + }); + } + app.post('/v1/intents', { schema: { body: createIntentBodySchema } }, async (request, reply) => { const result = await dependencies.ledger.createOrReplay(request.body, correlationFor(request)); if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index f375249..454f513 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -27,6 +27,14 @@ export interface ApiRuntimeConfig { }; /** Credential-free read-only RPC used to verify user-submitted receipts. */ readonly userWalletRpcUrl?: string; + readonly mcp?: { + readonly bearerToken: string; + readonly workspaceId: string; + readonly allowedRequestKey: string; + readonly payerWallet: string; + readonly maxAmountAtomic: bigint; + readonly waitMs: number; + }; } function required(environment: NodeJS.ProcessEnv, name: string, minimumLength = 1): string { @@ -73,6 +81,51 @@ function optionalHttpsUrl(environment: NodeJS.ProcessEnv, name: string): string return parsed.toString(); } +function mcpConfig(environment: NodeJS.ProcessEnv, workspaceId: string): ApiRuntimeConfig['mcp'] { + const names = [ + 'ONESHOT_MCP_BEARER_TOKEN', + 'ONESHOT_MCP_REQUEST_KEY', + 'ONESHOT_MCP_PAYER_ADDRESS', + 'ONESHOT_MCP_MAX_AMOUNT_ATOMIC', + 'ONESHOT_MCP_WAIT_MS', + ] as const; + if (names.every((name) => !environment[name]?.trim())) return undefined; + if (!environment.ONESHOT_WORKSPACE_ID?.trim()) { + throw new Error('ONESHOT_WORKSPACE_ID is required when MCP is enabled'); + } + const bearerToken = required(environment, 'ONESHOT_MCP_BEARER_TOKEN', 32); + const allowedRequestKey = required(environment, 'ONESHOT_MCP_REQUEST_KEY'); + if ( + allowedRequestKey.length > 128 || + allowedRequestKey.trim() !== allowedRequestKey || + // eslint-disable-next-line no-control-regex -- Request keys reject ASCII controls. + /[\u0000-\u001f\u007f]/u.test(allowedRequestKey) + ) { + throw new Error('Invalid environment variable: ONESHOT_MCP_REQUEST_KEY'); + } + const payerWallet = required(environment, 'ONESHOT_MCP_PAYER_ADDRESS').toLowerCase(); + if (!/^0x[0-9a-f]{40}$/u.test(payerWallet)) { + throw new Error('Invalid environment variable: ONESHOT_MCP_PAYER_ADDRESS'); + } + const rawMax = environment.ONESHOT_MCP_MAX_AMOUNT_ATOMIC?.trim() || '1000000'; + if (rawMax.length > 78 || !/^[1-9][0-9]*$/u.test(rawMax)) { + throw new Error('Invalid environment variable: ONESHOT_MCP_MAX_AMOUNT_ATOMIC'); + } + const maxAmountAtomic = BigInt(rawMax); + const workerCap = environment.ONESHOT_SETTLEMENT_CAP_ATOMIC?.trim(); + if (workerCap && /^[1-9][0-9]*$/u.test(workerCap) && maxAmountAtomic > BigInt(workerCap)) { + throw new Error('ONESHOT_MCP_MAX_AMOUNT_ATOMIC must not exceed ONESHOT_SETTLEMENT_CAP_ATOMIC'); + } + return { + bearerToken, + workspaceId, + allowedRequestKey, + payerWallet, + maxAmountAtomic, + waitMs: integer(environment, 'ONESHOT_MCP_WAIT_MS', 2_500, 0, 5_000), + }; +} + function optionalRpcUrl(environment: NodeJS.ProcessEnv, name: string): string | undefined { return optionalHttpsUrl(environment, name); } @@ -161,10 +214,12 @@ function privyAuthConfig(environment: NodeJS.ProcessEnv): PrivyAuthRuntimeConfig export function loadApiRuntimeConfig( environment: NodeJS.ProcessEnv = process.env, ): ApiRuntimeConfig { + const workspaceId = environment.ONESHOT_WORKSPACE_ID?.trim() || 'default-workspace'; const privyAuth = privyAuthConfig(environment); const activityEndpoint = environment.ONESHOT_GRAPH_QUERY_URL?.trim(); const activityWallet = environment.ONESHOT_ACTIVITY_WALLET_ADDRESS?.trim(); const userWalletRpcUrl = optionalRpcUrl(environment, 'ONESHOT_ARC_RPC_URL'); + const mcp = mcpConfig(environment, workspaceId); if ((activityEndpoint && !activityWallet) || (!activityEndpoint && activityWallet)) { throw new Error( 'ONESHOT_GRAPH_QUERY_URL and ONESHOT_ACTIVITY_WALLET_ADDRESS must be configured together', @@ -185,7 +240,7 @@ export function loadApiRuntimeConfig( // One fixed workspace is safer than accepting a caller-selected tenant. // Deployments should configure this explicit value; the default keeps local // development and existing single-workspace installations closed to one scope. - workspaceId: environment.ONESHOT_WORKSPACE_ID?.trim() || 'default-workspace', + workspaceId, rateLimit: { maxRequests: integer(environment, 'ONESHOT_API_RATE_LIMIT_MAX_REQUESTS', 60, 1, 10_000), windowMs: integer(environment, 'ONESHOT_API_RATE_LIMIT_WINDOW_MS', 60_000, 1_000, 3_600_000), @@ -203,5 +258,6 @@ export function loadApiRuntimeConfig( } : {}), ...(userWalletRpcUrl ? { userWalletRpcUrl } : {}), + ...(mcp ? { mcp } : {}), }; } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2ee99a9..424042e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,6 +1,7 @@ export * from './app.js'; export * from './auth.js'; export * from './config.js'; +export * from './mcp.js'; export * from './privy-auth.js'; export * from './rate-limit.js'; export * from './wallet-activity.js'; diff --git a/apps/api/src/mcp.ts b/apps/api/src/mcp.ts new file mode 100644 index 0000000..0170949 --- /dev/null +++ b/apps/api/src/mcp.ts @@ -0,0 +1,268 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { McpServer, createMcpHandler, type McpHttpHandler } from '@modelcontextprotocol/server'; +import { asEvmAddress, ContractValidationError, type IntentResponse } from '@oneshot/contracts'; +import type { IntentLedger } from '@oneshot/storage-postgres'; +import { z } from 'zod'; + +const ARC_NETWORK = 'eip155:5042002' as const; +const USDC_DECIMALS = 6; +const REQUEST_KEY_MAX_LENGTH = 128; + +const inputSchema = z.strictObject({ + request_key: z.string().min(1).max(REQUEST_KEY_MAX_LENGTH).describe('Configured demo key'), + recipient: z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/u) + .describe('Arc Testnet USDC recipient'), + amount_usdc: z.string().min(1).max(79).describe('Positive decimal USDC, up to 6 decimals'), + purpose: z.string().min(1).max(256).describe('Short non-secret payment purpose'), +}); + +const outputSchema = z.strictObject({ + request_key: z.string(), + business_intent_id: z.string(), + state: z.enum([ + 'AUTHORIZING', + 'READY', + 'SUBMITTING', + 'COMMITTED', + 'FAILED_SAFE', + 'UNKNOWN', + 'REJECTED', + ]), + payer: z.strictObject({ + mode: z.literal('SERVER_PRIVY'), + wallet_address: z.string(), + }), + recipient: z.string(), + amount_usdc: z.string(), + amount_atomic: z.string(), + asset: z.literal('USDC'), + network: z.literal(ARC_NETWORK), + replayed: z.boolean(), + settlement: z + .strictObject({ + transaction_hash: z.string(), + block_number: z.string(), + explorer_url: z.string(), + }) + .optional(), + next_action: z.enum(['WAIT', 'CHECK_STATUS', 'VIEW_PROOF', 'FIX_REQUEST']), +}); + +export interface ArcPaymentMcpConfig { + readonly workspaceId: string; + readonly allowedRequestKey: string; + readonly maxAmountAtomic: bigint; + readonly payerWallet: string; + readonly submissionsDisabled?: boolean; + readonly waitMs?: number; + readonly pollMs?: number; +} + +export interface ArcPaymentMcpDependencies { + readonly ledger: Pick; + readonly config: ArcPaymentMcpConfig; +} + +function boundedText(value: string, name: string, maximum: number): string { + if ( + value.length === 0 || + value.length > maximum || + value.trim() !== value || + // eslint-disable-next-line no-control-regex -- MCP identifiers and display text reject controls. + /[\u0000-\u001f\u007f]/u.test(value) + ) { + throw new ContractValidationError(`${name} is invalid`); + } + return value.normalize('NFC'); +} + +export function arcPaymentBusinessIntentId(workspaceId: string, requestKey: string): string { + const workspace = boundedText(workspaceId, 'workspace_id', 128); + const key = boundedText(requestKey, 'request_key', REQUEST_KEY_MAX_LENGTH); + return `intent_${createHash('sha256') + .update(`${workspace}\u0000arc-payment-v1\u0000${key}`, 'utf8') + .digest('hex')}`; +} + +export function parseUsdcAmount(value: string): { + readonly atomic: string; + readonly decimal: string; +} { + const match = /^(0|[1-9][0-9]*)(?:\.([0-9]{1,6}))?$/u.exec(value); + if (!match) { + throw new ContractValidationError( + 'amount_usdc must be a canonical positive decimal string with at most six decimals', + ); + } + const whole = match[1] ?? '0'; + const fraction = match[2] ?? ''; + const atomic = BigInt(whole) * 10n ** BigInt(USDC_DECIMALS) + BigInt(fraction.padEnd(6, '0')); + if (atomic === 0n) throw new ContractValidationError('amount_usdc must be greater than zero'); + return { + atomic: atomic.toString(10), + decimal: `${whole}.${fraction.padEnd(6, '0')}`, + }; +} + +function nextAction( + state: IntentResponse['state'], +): 'WAIT' | 'CHECK_STATUS' | 'VIEW_PROOF' | 'FIX_REQUEST' { + if (state === 'COMMITTED') return 'VIEW_PROOF'; + if (state === 'UNKNOWN') return 'CHECK_STATUS'; + if (state === 'FAILED_SAFE' || state === 'REJECTED') return 'FIX_REQUEST'; + return 'WAIT'; +} + +function resultView( + requestKey: string, + amountUsdc: string, + payerWallet: string, + intent: IntentResponse, + replayed: boolean, +) { + return { + request_key: requestKey, + business_intent_id: intent.business_intent_id, + state: intent.state, + payer: { + mode: 'SERVER_PRIVY' as const, + wallet_address: payerWallet, + }, + recipient: intent.recipient, + amount_usdc: amountUsdc, + amount_atomic: intent.amount_atomic, + asset: intent.asset, + network: intent.network, + replayed, + ...(intent.settlement + ? { + settlement: { + transaction_hash: intent.settlement.transaction_hash, + block_number: intent.settlement.block_number, + explorer_url: + intent.settlement.explorer_url ?? + `https://testnet.arcscan.app/tx/${intent.settlement.transaction_hash}`, + }, + } + : {}), + next_action: nextAction(intent.state), + }; +} + +function toolError(message: string) { + return { + content: [{ type: 'text' as const, text: message }], + isError: true as const, + }; +} + +async function latestIntent( + ledger: ArcPaymentMcpDependencies['ledger'], + initial: IntentResponse, + waitMs: number, + pollMs: number, +): Promise { + if (waitMs <= 0 || ['COMMITTED', 'FAILED_SAFE', 'UNKNOWN', 'REJECTED'].includes(initial.state)) { + return initial; + } + const deadline = Date.now() + waitMs; + let current = initial; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, deadline - Date.now()))); + current = (await ledger.getIntent(initial.business_intent_id)) ?? current; + if (['COMMITTED', 'FAILED_SAFE', 'UNKNOWN', 'REJECTED'].includes(current.state)) break; + } + return current; +} + +export function createArcPaymentMcpHandler({ + ledger, + config, +}: ArcPaymentMcpDependencies): McpHttpHandler { + const allowedRequestKey = boundedText( + config.allowedRequestKey, + 'allowed_request_key', + REQUEST_KEY_MAX_LENGTH, + ); + const payerWallet = asEvmAddress(config.payerWallet); + if (config.maxAmountAtomic <= 0n) throw new Error('MCP amount cap must be positive'); + const waitMs = config.waitMs ?? 2_500; + const pollMs = config.pollMs ?? 250; + if (!Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 5_000) { + throw new Error('MCP wait must be an integer from 0 to 5000 milliseconds'); + } + if (!Number.isSafeInteger(pollMs) || pollMs < 10 || pollMs > 1_000) { + throw new Error('MCP poll interval must be an integer from 10 to 1000 milliseconds'); + } + + return createMcpHandler(() => { + const server = new McpServer({ name: 'oneshot-arc-payments', version: '1.0.0' }); + server.registerTool( + 'arc_payment', + { + title: 'Arc USDC payment', + description: + 'Create or replay the single approved Arc Testnet USDC payment through the policy-bound Privy server wallet.', + inputSchema, + outputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ request_key, recipient, amount_usdc, purpose }) => { + try { + const requestKey = boundedText(request_key, 'request_key', REQUEST_KEY_MAX_LENGTH); + if (requestKey !== allowedRequestKey) { + return toolError('This MCP credential is limited to its configured demo request key.'); + } + if (config.submissionsDisabled) { + return toolError('Arc payment submission is disabled for this deployment.'); + } + const amount = parseUsdcAmount(amount_usdc); + if (BigInt(amount.atomic) > config.maxAmountAtomic) { + return toolError( + `Requested amount exceeds the MCP cap of ${config.maxAmountAtomic.toString(10)} atomic USDC.`, + ); + } + const result = await ledger.createOrReplay( + { + business_intent_id: arcPaymentBusinessIntentId(config.workspaceId, requestKey), + recipient: asEvmAddress(recipient), + amount_atomic: amount.atomic, + asset: 'USDC', + network: ARC_NETWORK, + purpose: boundedText(purpose, 'purpose', 256), + }, + randomUUID(), + ); + if (result.kind === 'INTENT_PAYLOAD_CONFLICT') { + return toolError( + 'The request key already belongs to a different payment. Reuse the original immutable fields.', + ); + } + const intent = await latestIntent(ledger, result.intent, waitMs, pollMs); + const output = resultView( + requestKey, + amount.decimal, + payerWallet, + intent, + result.kind === 'REPLAY_IDENTICAL', + ); + return { + content: [{ type: 'text' as const, text: JSON.stringify(output) }], + structuredContent: output, + }; + } catch (error) { + if (error instanceof ContractValidationError) return toolError(error.message); + throw error; + } + }, + ); + return server; + }); +} diff --git a/apps/api/src/runtime.ts b/apps/api/src/runtime.ts index 3f31622..b3d6b6e 100644 --- a/apps/api/src/runtime.ts +++ b/apps/api/src/runtime.ts @@ -63,6 +63,19 @@ export async function startApiRuntime(config: ApiRuntimeConfig): Promise { await runtime.close(); } }); + + it('converges parallel MCP calls on one durable intent and zero direct settlements', async () => { + const mcpToken = 'integration-mcp-token-with-32-characters'; + const requestKey = 'integration-arc-payment'; + const app = buildApi({ + ledger: ledger(), + authenticator: staticBearerAuthenticator('integration-token'), + mcp: { + authenticator: staticBearerAuthenticator(mcpToken), + workspaceId: 'integration-mcp-workspace', + allowedRequestKey: requestKey, + payerWallet: '0x1111111111111111111111111111111111111111', + maxAmountAtomic: 1_000_000n, + waitMs: 0, + }, + }); + const call = (amount = '1') => + app.inject({ + method: 'POST', + url: '/mcp', + headers: { + authorization: `Bearer ${mcpToken}`, + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'mcp-protocol-version': '2025-06-18', + }, + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'arc_payment', + arguments: { + request_key: requestKey, + recipient: '0x2222222222222222222222222222222222222222', + amount_usdc: amount, + purpose: 'One integration payment', + }, + }, + }, + }); + + const responses = await Promise.all(Array.from({ length: 10 }, () => call())); + expect(responses.every((response) => response.statusCode === 200)).toBe(true); + const businessIntentId = arcPaymentBusinessIntentId('integration-mcp-workspace', requestKey); + const counts = await pool.query<{ + intents: string; + attempts: string; + settlements: string; + }>( + `SELECT + (SELECT count(*) FROM business_intents WHERE business_intent_id = $1)::text AS intents, + (SELECT count(*) FROM attempts WHERE business_intent_id = $1)::text AS attempts, + (SELECT count(*) FROM settlements WHERE business_intent_id = $1)::text AS settlements`, + [businessIntentId], + ); + expect(counts.rows[0]).toEqual({ intents: '1', attempts: '1', settlements: '0' }); + + const conflict = await call('0.5'); + expect(conflict.body).toContain('different payment'); + const afterConflict = await pool.query<{ count: string }>( + 'SELECT count(*)::text AS count FROM business_intents WHERE business_intent_id = $1', + [businessIntentId], + ); + expect(afterConflict.rows[0]?.count).toBe('1'); + await app.close(); + }); }); diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts index b9f01b0..954de53 100644 --- a/apps/api/test/config.test.ts +++ b/apps/api/test/config.test.ts @@ -68,6 +68,47 @@ describe('API runtime configuration', () => { }); }); + it('loads an isolated one-intent MCP configuration', () => { + const config = loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'mcp-demo-workspace', + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + ONESHOT_MCP_REQUEST_KEY: 'arc-demo-payment-1', + ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', + ONESHOT_MCP_MAX_AMOUNT_ATOMIC: '1000000', + ONESHOT_MCP_WAIT_MS: '500', + ONESHOT_SETTLEMENT_CAP_ATOMIC: '1000000', + }); + expect(config.mcp).toEqual({ + bearerToken: 'mcp-token-with-at-least-thirty-two-characters', + workspaceId: 'mcp-demo-workspace', + allowedRequestKey: 'arc-demo-payment-1', + payerWallet: '0x1111111111111111111111111111111111111111', + maxAmountAtomic: 1000000n, + waitMs: 500, + }); + }); + + it('fails closed on partial or over-cap MCP configuration', () => { + expect(() => + loadApiRuntimeConfig({ + ...base, + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + }), + ).toThrow('ONESHOT_WORKSPACE_ID'); + expect(() => + loadApiRuntimeConfig({ + ...base, + ONESHOT_WORKSPACE_ID: 'mcp-demo-workspace', + ONESHOT_MCP_BEARER_TOKEN: 'mcp-token-with-at-least-thirty-two-characters', + ONESHOT_MCP_REQUEST_KEY: 'arc-demo-payment-1', + ONESHOT_MCP_PAYER_ADDRESS: '0x1111111111111111111111111111111111111111', + ONESHOT_MCP_MAX_AMOUNT_ATOMIC: '1000001', + ONESHOT_SETTLEMENT_CAP_ATOMIC: '1000000', + }), + ).toThrow('must not exceed'); + }); + it('loads a complete Privy configuration', () => { const config = loadApiRuntimeConfig({ ...base, diff --git a/apps/api/test/mcp.test.ts b/apps/api/test/mcp.test.ts new file mode 100644 index 0000000..e7109ec --- /dev/null +++ b/apps/api/test/mcp.test.ts @@ -0,0 +1,271 @@ +import type { IntentResponse } from '@oneshot/contracts'; +import type { CreateIntentResult } from '@oneshot/storage-postgres'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildApi, staticBearerAuthenticator, type ApiDependencies } from '../src/index.js'; +import { arcPaymentBusinessIntentId, parseUsdcAmount } from '../src/mcp.js'; + +const MCP_TOKEN = 'mcp-test-token-with-at-least-32-characters'; +const SERVICE_TOKEN = 'service-test-token'; +const REQUEST_KEY = 'arc-demo-payment-1'; +const RECIPIENT = '0x292d3fca76142e0c6136b934563f3a0750b633eb'; +const PAYER = '0x1111111111111111111111111111111111111111'; + +function intent(overrides: Partial = {}): IntentResponse { + return { + business_intent_id: arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY), + recipient: RECIPIENT, + amount_atomic: '1000000', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Pay for one report', + payload_fingerprint: 'a'.repeat(64), + state: 'AUTHORIZING', + version: 1, + attempts: [], + evidence: [], + ...overrides, + }; +} + +function ledger(createOrReplay = vi.fn<() => Promise>()) { + return { + createOrReplay, + getIntent: vi.fn(async () => undefined), + enqueueReconciliation: vi.fn(), + getRecoveryView: vi.fn(), + getSystemMetrics: vi.fn(), + ping: vi.fn(), + beginUserWalletSubmission: vi.fn(), + recordUserWalletTransaction: vi.fn(), + completeSubmission: vi.fn(), + markUserWalletUnknown: vi.fn(), + } as unknown as ApiDependencies['ledger']; +} + +function app(createOrReplay: ApiDependencies['ledger']['createOrReplay']) { + return buildApi({ + ledger: ledger(createOrReplay), + authenticator: staticBearerAuthenticator(SERVICE_TOKEN), + mcp: { + authenticator: staticBearerAuthenticator(MCP_TOKEN), + workspaceId: 'mcp-demo-workspace', + allowedRequestKey: REQUEST_KEY, + payerWallet: PAYER, + maxAmountAtomic: 1_000_000n, + waitMs: 0, + }, + }); +} + +const rpcHeaders = (token = MCP_TOKEN) => ({ + authorization: `Bearer ${token}`, + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + 'mcp-protocol-version': '2025-06-18', +}); + +async function rpc( + server: ReturnType, + body: Record, + token = MCP_TOKEN, +) { + return server.inject({ method: 'POST', url: '/mcp', headers: rpcHeaders(token), payload: body }); +} + +function rpcBody(response: Awaited>) { + if (!response.headers['content-type']?.startsWith('text/event-stream')) return response.json(); + const data = response.body + .split(/\r?\n/u) + .find((line) => line.startsWith('data: ')) + ?.slice(6); + if (!data) throw new Error('MCP SSE response did not contain a message'); + return JSON.parse(data); +} + +function toolRequest(id: number, overrides: Record = {}) { + return { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: 'arc_payment', + arguments: { + request_key: REQUEST_KEY, + recipient: RECIPIENT, + amount_usdc: '1', + purpose: 'Pay for one report', + ...overrides, + }, + }, + }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('MCP arc_payment', () => { + it('converts USDC decimal strings without floating point', () => { + expect(parseUsdcAmount('1')).toEqual({ atomic: '1000000', decimal: '1.000000' }); + expect(parseUsdcAmount('0.000001')).toEqual({ atomic: '1', decimal: '0.000001' }); + expect(() => parseUsdcAmount('1.0000001')).toThrow('at most six decimals'); + expect(() => parseUsdcAmount('0')).toThrow('greater than zero'); + }); + + it('derives one stable intent ID from workspace and request key', () => { + const first = arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY); + expect(arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY)).toBe(first); + expect(arcPaymentBusinessIntentId('another-workspace', REQUEST_KEY)).not.toBe(first); + expect(first).toMatch(/^intent_[0-9a-f]{64}$/u); + }); + + it('isolates the MCP credential and lists exactly one tool', async () => { + const server = app(vi.fn(async () => ({ kind: 'ACCEPTED', intent: intent() }))); + const missing = await server.inject({ + method: 'POST', + url: '/mcp', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list' }, + }); + expect(missing.statusCode).toBe(401); + expect( + (await rpc(server, { jsonrpc: '2.0', id: 2, method: 'tools/list' }, SERVICE_TOKEN)) + .statusCode, + ).toBe(401); + + const initialized = await rpc(server, { + jsonrpc: '2.0', + id: 3, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'oneshot-test', version: '1.0.0' }, + }, + }); + expect(rpcBody(initialized).result.serverInfo.name).toBe('oneshot-arc-payments'); + const listed = await rpc(server, { jsonrpc: '2.0', id: 3, method: 'tools/list' }); + expect(listed.statusCode).toBe(200); + expect(rpcBody(listed).result.tools.map((tool: { name: string }) => tool.name)).toEqual([ + 'arc_payment', + ]); + + const apiAttempt = await server.inject({ + method: 'POST', + url: '/v1/intents', + headers: { authorization: `Bearer ${MCP_TOKEN}` }, + payload: { + business_intent_id: 'mcp-cannot-call-api', + recipient: RECIPIENT, + amount_atomic: '1', + asset: 'USDC', + network: 'eip155:5042002', + purpose: 'Denied', + }, + }); + expect(apiAttempt.statusCode).toBe(401); + await server.close(); + }); + + it('creates and replays the same durable intent with authoritative status', async () => { + let saved: IntentResponse | undefined; + const createOrReplay = vi.fn(async (request: unknown): Promise => { + const next = { ...intent(), ...(request as object) } as IntentResponse; + if (!saved) { + saved = next; + return { kind: 'ACCEPTED', intent: saved }; + } + return JSON.stringify(request) === + JSON.stringify({ + business_intent_id: saved.business_intent_id, + recipient: saved.recipient, + amount_atomic: saved.amount_atomic, + asset: saved.asset, + network: saved.network, + purpose: saved.purpose, + }) + ? { kind: 'REPLAY_IDENTICAL', intent: saved } + : { kind: 'INTENT_PAYLOAD_CONFLICT', intent: saved }; + }); + const server = app(createOrReplay); + + const first = rpcBody(await rpc(server, toolRequest(1))).result; + const replay = rpcBody(await rpc(server, toolRequest(2))).result; + expect(first.structuredContent).toMatchObject({ + business_intent_id: arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY), + state: 'AUTHORIZING', + payer: { mode: 'SERVER_PRIVY', wallet_address: PAYER }, + amount_usdc: '1.000000', + amount_atomic: '1000000', + replayed: false, + next_action: 'WAIT', + }); + expect(replay.structuredContent).toMatchObject({ + business_intent_id: first.structuredContent.business_intent_id, + replayed: true, + }); + await server.close(); + }); + + it('makes sequential and parallel redelivery converge on one intent identity', async () => { + const ids = new Set(); + const createOrReplay = vi.fn(async (request: unknown): Promise => { + const value = request as IntentResponse; + ids.add(value.business_intent_id); + return { + kind: ids.size === 1 ? 'REPLAY_IDENTICAL' : 'INTENT_PAYLOAD_CONFLICT', + intent: intent(value), + }; + }); + const server = app(createOrReplay); + for (let index = 0; index < 10; index += 1) await rpc(server, toolRequest(index)); + await Promise.all( + Array.from({ length: 10 }, (_, index) => rpc(server, toolRequest(index + 10))), + ); + expect(ids).toEqual(new Set([arcPaymentBusinessIntentId('mcp-demo-workspace', REQUEST_KEY)])); + expect(createOrReplay).toHaveBeenCalledTimes(20); + await server.close(); + }); + + it('rejects quota, cap, and immutable-payload conflicts before any new payment right', async () => { + const createOrReplay = vi.fn(async (): Promise => ({ + kind: 'INTENT_PAYLOAD_CONFLICT', + intent: intent(), + })); + const server = app(createOrReplay); + + const wrongKey = rpcBody(await rpc(server, toolRequest(1, { request_key: 'another-key' }))); + const aboveCap = rpcBody(await rpc(server, toolRequest(2, { amount_usdc: '1.000001' }))); + expect(wrongKey.result.isError).toBe(true); + expect(aboveCap.result.isError).toBe(true); + expect(createOrReplay).not.toHaveBeenCalled(); + + const conflict = rpcBody(await rpc(server, toolRequest(3))); + expect(conflict.result.isError).toBe(true); + expect(conflict.result.content[0].text).toContain('different payment'); + expect(createOrReplay).toHaveBeenCalledOnce(); + await server.close(); + }); + + it('returns stored Arc proof for a committed replay', async () => { + const committed = intent({ + state: 'COMMITTED', + settlement: { + provider_reference_id: 'privy-reference', + transaction_hash: `0x${'1'.repeat(64)}`, + block_number: '123', + transfer_log_index: 0, + }, + }); + const server = app(vi.fn(async () => ({ kind: 'REPLAY_IDENTICAL', intent: committed }))); + const result = rpcBody(await rpc(server, toolRequest(1))).result.structuredContent; + expect(result).toMatchObject({ + state: 'COMMITTED', + replayed: true, + next_action: 'VIEW_PROOF', + settlement: { + transaction_hash: committed.settlement?.transaction_hash, + explorer_url: `https://testnet.arcscan.app/tx/${committed.settlement?.transaction_hash}`, + }, + }); + await server.close(); + }); +}); diff --git a/apps/web/browser/p5.spec.ts b/apps/web/browser/p5.spec.ts index aad7611..e1691d7 100644 --- a/apps/web/browser/p5.spec.ts +++ b/apps/web/browser/p5.spec.ts @@ -226,6 +226,18 @@ for (const theme of ['light', 'dark'] as const) { path: test.info().outputPath(`landing-${theme}-${width}.png`), fullPage: true, }); + await page.goto('/docs/mcp'); + await expect( + page.getByRole('heading', { name: 'Connect an agent to one safe payment tool.' }), + ).toBeVisible(); + await expect(page.getByLabel('MCP client configuration')).toContainText( + '', + ); + await checkContrast(); + await page.screenshot({ + path: test.info().outputPath(`mcp-docs-${theme}-${width}.png`), + fullPage: true, + }); await page.goto('/app'); await unlockWorkspace(page); const identity = await page.locator('.operator-identity').boundingBox(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ccb06e8..48be97f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -20,6 +20,7 @@ import { Hero } from './components/Hero.js'; import { IntentForm } from './components/IntentForm.js'; import { IntentStatusView } from './components/IntentStatusView.js'; import { LoginGate } from './components/LoginGate.js'; +import { McpDocsPage } from './components/McpDocsPage.js'; import { ReadinessBanner } from './components/ReadinessBanner.js'; import { JobList, JobWorkspace } from './components/JobWorkspace.js'; import { RecoverySurface, SettlementSurface } from './components/FrontendSurfaces.js'; @@ -88,6 +89,9 @@ function LandingPage(props: { readonly theme: Theme; readonly onToggleTheme: () Open workspace + + Connect an agent + How it works @@ -350,6 +354,9 @@ export function App(props: AppProps = {}) { ); if (props.route === '/') return ; + if (props.route?.startsWith('/docs/mcp')) { + return ; + } if (props.route?.startsWith('/app')) { return ( " + } + } + } +}`; + +const toolInput = `{ + "request_key": "", + "recipient": "0x", + "amount_usdc": "1.000000", + "purpose": "One approved demo purchase" +}`; + +export function McpDocsPage(props: { readonly theme: Theme; readonly onToggleTheme: () => void }) { + return ( +
+ + +
+

ONESHOT MCP / ARC PAYMENT

+

Connect an agent to one safe payment tool.

+

+ OneShot exposes arc_payment over Streamable HTTP. It creates or replays one + durable Arc Testnet USDC intent through the policy-bound Privy server wallet. +

+
+ +
+

Payment boundary

+
    +
  • One configured request key can create one payment intent.
  • +
  • The default maximum is 1.000000 USDC, or 1000000 atomic units.
  • +
  • Exact retries return the original intent; changed fields return a conflict.
  • +
  • The server wallet pays without a MetaMask or browser wallet popup.
  • +
+
+ +
+

Client configuration

+

+ Ask the operator for the dedicated MCP bearer token and request key. Keep both in your + client environment; never paste a real credential into source control. +

+
+          {clientConfig}
+        
+
+ +
+

Run the walkthrough

+
    +
  1. Connect, then confirm that the tool list contains only arc_payment.
  2. +
  3. Review the recipient, purpose, and amount before giving them to the agent.
  4. +
  5. Call arc_payment once with the configured request key.
  6. +
  7. Repeat the exact call and confirm it returns the same Business Intent.
  8. +
  9. When the state is COMMITTED, open its ArcScan proof and compare the transfer.
  10. +
+
+          {toolInput}
+        
+

+ AUTHORIZING, READY, and SUBMITTING mean wait. UNKNOWN means repeat the same call or + inspect recovery evidence. Only COMMITTED with a stored transaction hash is final proof. +

+
+
+ ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7422e19..ad15e26 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -454,6 +454,100 @@ a:hover { margin-bottom: 0; } +/* ======================================================================== + MCP documentation + ======================================================================== */ + +.docs-page { + max-width: 960px; +} + +.docs-header, +.docs-section { + width: min(100%, 760px); + margin-inline: auto; +} + +.docs-header { + margin-bottom: 2rem; +} + +.docs-header h1 { + max-width: 720px; + margin-bottom: 1rem; + color: var(--os-ink); + font-size: clamp(2.2rem, 5vw, 3.4rem); + font-weight: 300; + line-height: 1.08; + letter-spacing: -0.02em; +} + +.docs-lead, +.docs-section p, +.docs-facts, +.docs-steps { + color: var(--os-ink-muted); + line-height: 1.65; +} + +.docs-lead { + max-width: 680px; + font-size: 1.1rem; +} + +.docs-lead code { + color: var(--os-accent-ink); + font-family: var(--os-font-mono); +} + +.docs-section { + margin-bottom: 1.25rem; + padding: 1.5rem; + border: 1px solid var(--os-line); + border-radius: var(--os-radius-lg); + background: var(--os-surface); + color: var(--os-ink); +} + +.docs-section h2 { + margin-bottom: 0.75rem; + color: var(--os-ink); + font-size: 1.35rem; + font-weight: 400; +} + +.docs-facts, +.docs-steps { + margin: 0; + padding-left: 1.3rem; +} + +.docs-facts li, +.docs-steps li { + margin-bottom: 0.55rem; + padding-left: 0.2rem; +} + +.docs-code { + margin: 1rem 0 0; + padding: 1.15rem; + overflow-x: auto; + border: 1px solid var(--os-line); + border-radius: var(--os-radius); + background: var(--os-panel); + color: var(--os-panel-ink); + font-family: var(--os-font-mono); + font-size: 0.8rem; + line-height: 1.6; + white-space: pre; +} + +.docs-note { + margin: 1rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--os-line); +} + /* ========================================================================== Console Workspace Container ========================================================================== */ diff --git a/apps/web/test/app-composition.test.tsx b/apps/web/test/app-composition.test.tsx index 477a309..402b956 100644 --- a/apps/web/test/app-composition.test.tsx +++ b/apps/web/test/app-composition.test.tsx @@ -21,6 +21,9 @@ describe('Gate P5 shell composition', () => { expect(screen.getAllByRole('link', { name: /Open workspace/u })[0]?.getAttribute('href')).toBe( '/app', ); + expect(screen.getByRole('link', { name: 'Connect an agent' }).getAttribute('href')).toBe( + '/docs/mcp', + ); landing.unmount(); render( @@ -46,6 +49,25 @@ describe('Gate P5 shell composition', () => { expect(screen.getByRole('tab', { name: 'Payment proof' })).toBeTruthy(); }); + it('publishes a safe MCP client configuration and replay walkthrough', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'Connect an agent to one safe payment tool.' }), + ).toBeTruthy(); + expect(screen.getByLabelText('MCP client configuration').textContent).toContain( + 'https://oneshot.kapustazh.dev/mcp', + ); + expect(screen.getByLabelText('MCP client configuration').textContent).toContain( + '', + ); + expect(screen.getByLabelText('arc_payment tool input').textContent).toContain( + '', + ); + expect(screen.getByText(/1000000 atomic units/u)).toBeTruthy(); + expect(screen.queryByRole('button', { name: /pay|submit|run/iu })).toBeNull(); + }); + it('offers only the four working cabinet sections', () => { render( new Response('frontend asset', { status: 200 })), + }; +} + +describe('Cloudflare API proxy', () => { + it('keeps the existing API health proxy working', async () => { + const upstream = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{"status":"ok"}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const response = await worker.fetch(new Request('https://oneshot.kapustazh.dev/health/live'), { + ASSETS: assets(), + API_BACKEND_URL: 'https://api.example.test', + }); + + expect(response.status).toBe(200); + expect(upstream).toHaveBeenCalledOnce(); + const [request] = upstream.mock.calls[0] ?? []; + expect((request as Request).url).toBe('https://api.example.test/health/live'); + upstream.mockRestore(); + }); + + it('proxies the MCP endpoint with its bearer and protocol headers', async () => { + const upstream = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const response = await worker.fetch( + new Request('https://oneshot.kapustazh.dev/mcp', { + method: 'POST', + headers: { + authorization: 'Bearer mcp-test-token', + 'content-type': 'application/json', + 'mcp-protocol-version': '2025-06-18', + }, + body: '{"jsonrpc":"2.0","id":1,"method":"tools/list"}', + }), + { ASSETS: assets(), API_BACKEND_URL: 'https://api.example.test' }, + ); + + expect(response.status).toBe(200); + const [request] = upstream.mock.calls[0] ?? []; + const proxied = request as Request; + expect(proxied.url).toBe('https://api.example.test/mcp'); + expect(proxied.headers.get('authorization')).toBe('Bearer mcp-test-token'); + expect(proxied.headers.get('mcp-protocol-version')).toBe('2025-06-18'); + upstream.mockRestore(); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4609df5..4f587f7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -42,6 +42,10 @@ export default defineConfig({ target: 'http://127.0.0.1:3001', changeOrigin: true, }, + '/mcp': { + target: 'http://127.0.0.1:3001', + changeOrigin: true, + }, }, }, build: { diff --git a/apps/web/worker.ts b/apps/web/worker.ts index 568b8f6..6c8e79b 100644 --- a/apps/web/worker.ts +++ b/apps/web/worker.ts @@ -7,9 +7,10 @@ const DEFAULT_BACKEND_URL = 'https://oneshot-api-775560462825.europe-west1.run.a const CORS_HEADERS: Record = { 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, POST, OPTIONS', - 'access-control-allow-headers': 'authorization, content-type, x-correlation-id', - 'access-control-expose-headers': 'x-correlation-id', + 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', + 'access-control-allow-headers': + 'authorization, content-type, x-correlation-id, mcp-protocol-version, mcp-session-id, last-event-id', + 'access-control-expose-headers': 'x-correlation-id, mcp-session-id', }; async function proxy( @@ -47,7 +48,11 @@ export default { const url = new URL(request.url); // Forward API and health check requests to Google Cloud Run. - if (url.pathname.startsWith('/v1/') || url.pathname.startsWith('/health/')) { + if ( + url.pathname.startsWith('/v1/') || + url.pathname.startsWith('/health/') || + url.pathname === '/mcp' + ) { if (request.method === 'OPTIONS') { return new Response(null, { status: 204, diff --git a/docs/MCP_ARC_PAYMENT.md b/docs/MCP_ARC_PAYMENT.md new file mode 100644 index 0000000..42a55e0 --- /dev/null +++ b/docs/MCP_ARC_PAYMENT.md @@ -0,0 +1,85 @@ +# OneShot MCP Arc payment + +The first MCP release exposes one remote tool, `arc_payment`. It creates or +replays a durable OneShot Business Intent; the existing worker performs the +policy-bound Privy server-wallet transfer on Arc Testnet. The MCP endpoint does +not sign or submit transactions itself. + +The web app renders the client setup and walkthrough at `/docs/mcp`. + +## Deploy + +Configure the API with one dedicated secret and one fixed demo scope: + +```dotenv +ONESHOT_WORKSPACE_ID= +ONESHOT_MCP_BEARER_TOKEN= +ONESHOT_MCP_REQUEST_KEY= +ONESHOT_MCP_PAYER_ADDRESS=0x +ONESHOT_MCP_MAX_AMOUNT_ATOMIC=1000000 +ONESHOT_MCP_WAIT_MS=2500 +``` + +Store `ONESHOT_MCP_BEARER_TOKEN` in the deployment secret store. It is accepted +only on `/mcp`; Privy browser JWTs and `SERVICE_BEARER_TOKEN` cannot call this +endpoint. The MCP cap must be no greater than the worker's +`ONESHOT_SETTLEMENT_CAP_ATOMIC` and the attached Privy policy cap. + +The fixed `ONESHOT_MCP_REQUEST_KEY` is the one-intent demo quota. A call using +another key is denied. A repeated call using the configured key and identical +fields returns the original intent or settlement; changed payment fields return +a conflict. + +## Connect + +Point any Streamable HTTP MCP client at: + +```text +https://oneshot.kapustazh.dev/mcp +``` + +Send the dedicated token as `Authorization: Bearer `. A generic client +entry is: + +```json +{ + "mcpServers": { + "oneshot": { + "type": "http", + "url": "https://oneshot.kapustazh.dev/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +`tools/list` returns only `arc_payment`: + +```json +{ + "request_key": "", + "recipient": "0x", + "amount_usdc": "1.000000", + "purpose": "One approved demo purchase" +} +``` + +`amount_usdc` is parsed as a decimal string with at most six decimal places; +JavaScript floating point is never used. The tool pins USDC and +`eip155:5042002`, reports the Privy server payer, and returns the authoritative +OneShot state. `AUTHORIZING`, `READY`, and `SUBMITTING` mean wait. `UNKNOWN` +means repeat the same call or inspect recovery evidence. Only `COMMITTED` with a +stored transaction hash returns an ArcScan proof link. + +## Walkthrough + +1. Connect and confirm `tools/list` contains only `arc_payment`. +2. Call it once with the configured key, recipient, amount, and purpose. +3. Show the returned Business Intent progressing to `COMMITTED`. +4. Repeat the exact call and show the same Business Intent and transaction. +5. Open the returned ArcScan link and compare recipient and atomic USDC amount. + +This walkthrough uses live inputs and the durable worker path. It needs no +hardcoded transaction or mocked settlement. diff --git a/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md b/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md new file mode 100644 index 0000000..298b76b --- /dev/null +++ b/docs/PERSONAL_MCP_PRIVY_AGENT_PAYMENTS_PLAN.md @@ -0,0 +1,255 @@ +# OneShot MCP `arc_payment` implementation plan + +## Decision + +Ship one remote MCP tool first: + +```text +arc_payment({ + request_key: string, + recipient: 0x-address, + amount_usdc: decimal-string, + purpose: string +}) +``` + +The first release uses the existing policy-bound Privy server execution wallet +and the existing Arc settlement worker. Privy login authenticates the browser; +it does not select the payer. The MCP tool does not use MetaMask, an embedded +browser wallet, or a per-payment wallet popup. + +Personal Privy wallets and delegated agent signers remain a separate milestone. +They must not block the first working `arc_payment` demo. + +## Implementation status + +- Tasks 1-7 are implemented on `mcp-integration` in commit `1f25bae` and passed + Gate A before that commit was pushed. +- The local `/docs/mcp` page and same-origin Vite proxy complete the local part + of Task 8. +- The real Arc Testnet call, identical replay, and recorded proof remain pending + while testing is restricted to the local environment. + +## Non-negotiable behavior + +- Arc Testnet (`eip155:5042002`) and Arc USDC are the only network and asset. +- One stable `request_key` maps to one stable Business Intent. +- Repeating the same request returns the existing intent and settlement. +- Reusing the key with different immutable fields returns a conflict. +- `SUBMITTING` and `UNKNOWN` never create a replacement payment. +- Privy policy and OneShot both validate the amount and transaction scope. +- The tool returns authoritative OneShot state, not an inferred success. +- A real demo payment is complete only after a verified Arc receipt is durable. + +## Delivery order + +| Order | Task | Depends on | Exit condition | +| ----- | --------------------------------------- | ---------- | ----------------------------------------------- | +| 1 | Freeze the tool contract | None | Input and output schemas are approved | +| 2 | Add MCP-only authentication | Task 1 | `/mcp` accepts only the dedicated credential | +| 3 | Mount Streamable HTTP MCP | Task 2 | `initialize` and `tools/list` expose one tool | +| 4 | Connect `arc_payment` to the ledger | Task 3 | Calls create or replay one durable intent | +| 5 | Enforce payment scope and spend bounds | Task 4 | Invalid or excessive requests broadcast nothing | +| 6 | Return status and proof | Task 4 | Replays report the same authoritative state | +| 7 | Verify failure and concurrency behavior | Tasks 4-6 | Required payment tests pass | +| 8 | Document and run one demo | Task 7 | One real Arc Testnet settlement is verified | + +## Task 1: freeze the tool contract + +### Work + +- Accept only `request_key`, `recipient`, `amount_usdc`, and `purpose`. +- Parse `amount_usdc` as a six-decimal string into integer atomic units. Never + use JavaScript floating point. +- Reject malformed addresses, zero amounts, excess precision, empty purposes, + and oversized strings before creating an intent. +- Define a structured result with: + - request and Business Intent IDs; + - authoritative payment state; + - payer, recipient, decimal amount, and atomic amount; + - transaction hash and ArcScan link when known; + - one safe next action: `WAIT`, `CHECK_STATUS`, `VIEW_PROOF`, or + `FIX_REQUEST`. + +### Done when + +- The JSON schemas and examples cover accepted, rejected, pending, unknown, + and committed results. + +## Task 2: add MCP-only authentication + +### Work + +- Add one dedicated high-entropy bearer credential in deployment secret + storage for the first release. +- Accept it only on `/mcp`; do not let it authorize `/v1/*` routes. +- Keep Privy JWT authentication for the browser and the existing internal + service credential for internal or legacy routes. +- Bind the MCP principal to one explicit demo workspace. +- Compare credentials in constant time and never log or return them. + +### Done when + +- Missing, invalid, and browser credentials fail on `/mcp`. +- The MCP credential succeeds on `/mcp` and fails on browser/API routes. + +Self-service token generation, token tables, HMAC peppers, and per-user token +rotation are deferred until there is more than one MCP user. + +## Task 3: mount the MCP transport + +### Work + +- Mount the official stateless Streamable HTTP MCP handler in the existing + Cloud Run API. +- Proxy `/mcp` through the existing Cloudflare Worker without creating another + deployment. +- Expose exactly one tool named `arc_payment`. +- Return protocol errors as MCP errors without leaking provider responses or + credentials. + +### Done when + +- `initialize` succeeds through `https://oneshot.kapustazh.dev/mcp`. +- `tools/list` returns only `arc_payment` with the frozen schema from Task 1. + +## Task 4: reuse the durable payment path + +### Work + +- Derive the Business Intent identity from the workspace and normalized + `request_key` with one versioned deterministic function. +- Convert the tool input into the existing Arc USDC intent contract. +- Call the existing create-or-replay ledger path and existing worker. Do not + add a second settlement implementation inside the MCP handler. +- Preserve the existing submission lease, provider idempotency key, receipt + verification, `UNKNOWN` handling, and recovery behavior. +- Treat another call with the same key as create-or-read, never as a new + payment command. + +### Done when + +- One valid tool call reaches the existing `SERVER_PRIVY` payment mode. +- Identical replay returns the same Business Intent. +- Changed recipient, amount, asset, network, or purpose conflicts explicitly. + +## Task 5: enforce payment scope and total exposure + +### Work + +- Keep Privy rules for Arc chain ID `5042002`, the Arc USDC contract, + zero native value, ERC-20 `transfer`, and the approved per-payment cap. +- Validate the recipient as an EVM address even when the current policy permits + any recipient. +- Keep the application settlement cap equal to or lower than the Privy cap. +- Add a cumulative control before allowing repeated unique requests. Choose + one: + - a Privy rolling USDC spending cap for normal use; or + - a one-intent quota for a literal single-payment demo. +- Fail readiness when the attached wallet, policy, digest, network, token, or + cap differs from the reviewed deployment baseline. + +### Done when + +- Above-cap and exhausted-quota requests create zero broadcasts and zero + settlements. +- Policy drift makes the tool unavailable before submission. + +## Task 6: return authoritative status and proof + +### Work + +- Wait only for a short bounded interval after create-or-replay. +- Return the current durable state when settlement is still processing. +- Include an ArcScan link only for a stored transaction hash. +- Return the original result for a committed replay. +- For `UNKNOWN`, instruct the caller to repeat the same tool call or inspect the + proof. Never suggest a new `request_key`. + +### Done when + +- Every non-final response gives a safe next action. +- No response claims payment from queue acceptance alone. + +## Task 7: test the payment boundary + +### Required tests + +- Valid and invalid MCP authentication; route isolation. +- `tools/list` exposes exactly one tool. +- Input validation and exact decimal-to-atomic conversion. +- One normal request produces exactly one committed settlement. +- Ten sequential identical calls produce one settlement. +- Ten parallel identical calls produce one settlement. +- Same key with changed immutable payload returns a conflict. +- Privy denial, amount above cap, and exhausted quota produce zero broadcasts. +- Crash or lost response after possible submission enters `UNKNOWN`, then + reconciliation finds the original payment without resubmission. +- Committed replay returns the original transaction and result. + +### Validation commands + +- Focused MCP, API, worker, Privy adapter, and storage tests. +- PostgreSQL integration tests. +- Full unit suite, typecheck, lint, formatting check, production build, and + browser tests. + +## Task 8: document and demonstrate + +### Work + +- Add one short `/docs/mcp` page with generic Streamable HTTP configuration and + one copy-ready client example. +- Use an environment-variable placeholder for the bearer credential. +- Demonstrate one real `arc_payment` call, one identical replay, and one proof + view. +- Record the Business Intent ID, verified Arc transaction, policy identity, + and zero-duplicate result without recording secrets. + +### Done when + +- A fresh client can connect using the page and list `arc_payment`. +- The demo shows one real Arc Testnet USDC settlement and no replacement + settlement on replay. + +Client-specific setup pages and an installable `oneshot-arc-payment` skill are +deferred until the tool contract is stable. + +## Milestone 2: personal Privy wallets + +Start this milestone only after the server-wallet MCP path is working. + +1. Replace the fixed MCP principal with a principal containing credential kind, + Privy subject, opaque workspace ID, and MCP token ID. +2. Create one isolated workspace and one revocable MCP token per Privy user. +3. Store only a SHA-256 digest of each random 32-byte token and enforce + one-active-token generation atomically. +4. Bind jobs, intents, recovery, activity, results, and proofs to the workspace; + return `404` for cross-workspace identifiers. +5. Discover the user's embedded Ethereum wallet and show funding/readiness. +6. Create a user-owned Privy override policy and add the OneShot P-256 key + quorum as an additional signer after one explicit user authorization. +7. Resolve wallet and signer policy per workspace in the worker while keeping + the existing global execution wallet only for legacy service requests. +8. Add the **Agents** tab for wallet status, signer enable/disable, policy + controls, and MCP token lifecycle. +9. Replace the current request list with a workspace-bound unified feed. +10. Add multi-user isolation, concurrent token generation, signer attachment, + policy update, and browser accessibility tests. + +## Explicitly excluded from the first release + +- MetaMask or browser-wallet payment execution. +- Personal embedded-wallet settlement. +- Arbitrary policy editor. +- MCP OAuth. +- Multiple active MCP credentials. +- OneShot team or Privy teammate administration. +- Multiple MCP tools, assets, or networks. + +## References + +- [Arc MCP setup](https://docs.arc.io/ai/mcp) +- [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) +- [Privy server signers](https://docs.privy.io/recipes/wallets/user-and-server-signers) +- [Privy policy capabilities](https://docs.privy.io/controls/policies/overview) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77bdef8..5bcb582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,12 @@ importers: apps/api: dependencies: + '@modelcontextprotocol/node': + specifier: 2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7) + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 '@oneshot/arc-adapter': specifier: workspace:* version: link:../../packages/arc-adapter @@ -79,6 +85,9 @@ importers: pg: specifier: 8.23.0 version: 8.23.0 + zod: + specifier: 4.2.1 + version: 4.2.1 devDependencies: '@testcontainers/postgresql': specifier: 12.1.0 @@ -173,7 +182,7 @@ importers: version: link:../../packages/supplier-adapter '@privy-io/node': specifier: 0.34.0 - version: 0.34.0(@x402/evm@2.25.0(typescript@6.0.3))(viem@2.56.3(typescript@6.0.3)(zod@3.25.76)) + version: 0.34.0(@x402/evm@2.25.0(typescript@6.0.3))(viem@2.56.3(typescript@6.0.3)(zod@4.2.1)) google-auth-library: specifier: 11.0.2 version: 11.0.2(supports-color@10.2.2) @@ -195,7 +204,7 @@ importers: dependencies: viem: specifier: 2.56.3 - version: 2.56.3(typescript@6.0.3)(zod@3.25.76) + version: 2.56.3(typescript@6.0.3)(zod@4.2.1) packages/brand: dependencies: @@ -253,10 +262,10 @@ importers: version: link:../contracts '@privy-io/node': specifier: 0.34.0 - version: 0.34.0(@x402/evm@2.25.0(typescript@6.0.3))(viem@2.56.3(typescript@6.0.3)(zod@3.25.76)) + version: 0.34.0(@x402/evm@2.25.0(typescript@6.0.3))(viem@2.56.3(typescript@6.0.3)(zod@4.2.1)) viem: specifier: 2.56.3 - version: 2.56.3(typescript@6.0.3)(zod@3.25.76) + version: 2.56.3(typescript@6.0.3)(zod@4.2.1) packages/reconciliation: {} @@ -852,6 +861,12 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hpke/chacha20poly1305@1.8.0': resolution: {integrity: sha512-FcBfAQ+Y99vMNJP2yrZ9wpL8V0GOwp1+zMyzvc6alasrBygfFjFm1yeUtyADJCu/27C3Lm5mJzx6u7pwg+cX5w==} engines: {node: '>=16.0.0'} @@ -1270,6 +1285,24 @@ packages: react: ^17.0.2 || ^18.0.0 || ^19.0 react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/node@2.0.0': + resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@msgpack/msgpack@3.1.2': resolution: {integrity: sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ==} engines: {node: '>= 18'} @@ -3172,6 +3205,10 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + engines: {node: '>=16.9.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -5248,6 +5285,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} @@ -5694,6 +5734,10 @@ snapshots: dependencies: react: 19.2.8 + '@hono/node-server@1.19.17(hono@4.13.7)': + dependencies: + hono: 4.13.7 + '@hpke/chacha20poly1305@1.8.0': dependencies: '@hpke/common': 1.10.1 @@ -6081,6 +6125,22 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.2.1 + + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.13.7) + '@modelcontextprotocol/server': 2.0.0 + optionalDependencies: + hono: 4.13.7 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.2.1 + '@msgpack/msgpack@3.1.2': {} '@multiformats/dns@1.0.15': @@ -6309,7 +6369,7 @@ snapshots: optionalDependencies: viem: 2.56.3(typescript@6.0.3)(zod@3.25.76) - '@privy-io/node@0.34.0(@x402/evm@2.25.0(typescript@6.0.3))(viem@2.56.3(typescript@6.0.3)(zod@3.25.76))': + '@privy-io/node@0.34.0(@x402/evm@2.25.0(typescript@6.0.3))(viem@2.56.3(typescript@6.0.3)(zod@4.2.1))': dependencies: '@hpke/chacha20poly1305': 1.8.0 '@hpke/core': 1.9.0 @@ -6322,7 +6382,7 @@ snapshots: svix: 1.99.1 optionalDependencies: '@x402/evm': 2.25.0(typescript@6.0.3) - viem: 2.56.3(typescript@6.0.3)(zod@3.25.76) + viem: 2.56.3(typescript@6.0.3)(zod@4.2.1) '@privy-io/popup@0.0.1': {} @@ -7662,6 +7722,11 @@ snapshots: typescript: 6.0.3 zod: 3.25.76 + abitype@1.2.3(typescript@6.0.3)(zod@4.2.1): + optionalDependencies: + typescript: 6.0.3 + zod: 4.2.1 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -8886,6 +8951,8 @@ snapshots: help-me@5.0.0: {} + hono@4.13.7: {} + hookified@1.15.1: {} hookified@2.2.0: {} @@ -9600,6 +9667,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.14.44(typescript@6.0.3)(zod@4.2.1): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.2.1) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + ox@0.6.9(typescript@6.0.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -10713,6 +10795,23 @@ snapshots: - utf-8-validate - zod + viem@2.56.3(typescript@6.0.3)(zod@4.2.1): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.2.1) + isows: 1.0.7(ws@8.21.0) + ox: 0.14.44(typescript@6.0.3)(zod@4.2.1) + ws: 8.21.0 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite@8.0.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@24.13.3)(yaml@2.9.0): dependencies: '@oxc-project/runtime': 0.115.0 @@ -10999,6 +11098,8 @@ snapshots: zod@3.25.76: {} + zod@4.2.1: {} + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.4.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.18