Service MCP implementation
Services such as shops, subscriptions and finance build and host their own service MCP. A service quotes and builds intents only.
The user’s wallet MCP (pay_intent) moves the money, and later charges happen only inside a mandate the user approved.
Service template
Section titled “Service template”The generator is on GitHub Packages. Before running npm init, obtain package access from business@pouchi.app, configure @shard-lab:registry=https://npm.pkg.github.com and //npm.pkg.github.com/:_authToken=${NC_WAAS_PACKAGES_TOKEN} in your user ~/.npmrc, and set that environment variable to an authorized read:packages token. Keep the token out of files and chat. The project .npmrc is created only after the generator runs.
npm init @shard-lab/agentpay-service acme-news -- --name "Acme News"cd acme-newsnpm installnpm test # runs against the test gateway: no chain, no networkDirectoryacme-news/
Directorysrc/
- service.mjs tools, intents, merchant charges
Directorytest/
- service.test.mjs
- server.mjs reads the environment and starts the server
- Dockerfile
- .env.example
- .npmrc GitHub Packages (token from the environment)
The template has four tools: plans (prices), buy (one-time purchase intent), subscribe (monthly subscription intent) and cancel.
A one-time purchase intent
Section titled “A one-time purchase intent”One run, exact item and price. The mandate pins the product and price with eq predicates.
const it = kit.intent({ action: "purchase", summary: "Single report 3,000 KRW", spend_okrw: 3000, calls: [approve(3000), pay(product)], // calls to run now (approve + your pay function) mandate: { key: "acme-news:buy:1", label: "Single report", total_okrw: 3000, per_okrw: 3000, approve_cap_okrw: 3000, calls: 1, days: 1, permissions: [{ target: MERCHANT, signature: "pay(bytes32,uint256,uint256)", predicates: [{ arg: 1, op: "eq", uint: "1" }, { arg: 2, op: "eq", okrw: 3000 }] }] }});return { intent_url: it.intent_url }; // the agent passes this URL to pay_intentSubscriptions: merchant-initiated charges
Section titled “Subscriptions: merchant-initiated charges”The first payment runs now through the intent; the service charges the next ones on its own schedule. Add merchant_charges: true and a period limit.
mandate: { key: "acme-news:plan", label: "Acme News monthly", total_okrw: 9900 * 12, per_okrw: 9900, approve_cap_okrw: 9900, calls: 12, days: 360, merchant_charges: true, period: { seconds: 30 * 86400, max_runs: 1 }, // at most once every 30 days permissions: [{ target: MERCHANT, signature: "pay(bytes32,uint256,uint256)", predicates: [{ arg: 1, op: "eq", uint: "2" }, { arg: 2, op: "lte", okrw: 9900 }] }]}-
When the user approves the mandate, the gateway sends a credential bound to that mandate to the service’s
/agentpay/mandates. The agent never sees it. -
The kit confirms the credential with a signed read from the gateway, then calls
onMandate. Store it encrypted, like a card token.async onMandate({ credential, intentId }) {await db.subscriptions.attachCredential(intentId, encrypt(credential));} -
When a charge is due, call
kit.merchant.charge. Every request is HMAC-signed, and acharge_idruns at most once, even on retry.const r = await kit.merchant.charge(credential, {charge_id: `${sub.id}-w${window}`, // one per period windowsummary: "Monthly plan, month 2", spend_okrw: 9900,calls: [approve(9900), pay(product)]});// r.status === "paid", or r.error: PERIOD_LIMIT, OVER_LIMIT, OUTSIDE_MANDATE, MANDATE_REVOKED ... -
When the user cancels, give the mandate back with
kit.merchant.release(credential). The user turns it off on chain with the wallet’srevoke_mandate.
Testing
Section titled “Testing”The test gateway in @shard-lab/agentpay-service-kit/testing speaks the same HTTP contract as the real gateway: it approves intents, delivers credentials and accepts merchant charges. It uses no chain and no Pouchi.
const gw = await startTestGateway({ service: { id: "acme-news", origin, contracts: [MERCHANT], merchantCharges: true }, asset: TOKEN });const out = await mcp.call("subscribe", { months: 3 });const r = await gw.approve(out.intent_url); // as if the user approvedgw.advance(PERIOD); // next windowconst [charged] = await svc.chargeDue(); // merchant chargeassert.equal(charged.status, "paid");Registration and deployment
Section titled “Registration and deployment”- Register the service with the tenant:
id,name,origin, whether it may charge later, and its contract addresses. - Set
PUBLIC_URLto exactly the registered origin. The wallet fetches intents only from there. - Run the Docker image.
Run this in the terminal where NC_WAAS_PACKAGES_TOKEN is exported. BuildKit passes .npmrc and the token as separate temporary secrets. Do not place the token in an image or build argument.
docker build --secret id=npmrc,src=$HOME/.npmrc \ --secret id=packages_token,env=NC_WAAS_PACKAGES_TOKEN -t acme-news-mcp .docker run -d -p 8080:8080 --env-file .env acme-news-mcpEnvironment variables and registration are in Distribution.
Rules at a glance
Section titled “Rules at a glance”| Rule | Why |
|---|---|
Mandate key starts with <service id>: |
Mandates of different services never mix |
permissions target only your own registered contracts |
A finance service cannot get a shop contract’s permission |
calls to run now must fit the mandate |
What runs is what was approved |
Payment-token approve only to mandate targets, at most approve_cap_okrw |
No leftover allowance to pull later |
| Kit-generated intents expire 15 minutes after creation and are pinned at first fetch | No swapping after approval |
Selected API declarations are in the service kit reference.