Skip to content
PricingDocsKO

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.

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.

Terminal window
npm init @shard-lab/agentpay-service acme-news -- --name "Acme News"
cd acme-news
npm install
npm test # runs against the test gateway: no chain, no network
  • Directoryacme-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.

One run, exact item and price. The mandate pins the product and price with eq predicates.

src/service.mjs
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_intent

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 }] }]
}
  1. 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.

  2. 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));
    }
  3. When a charge is due, call kit.merchant.charge. Every request is HMAC-signed, and a charge_id runs at most once, even on retry.

    const r = await kit.merchant.charge(credential, {
    charge_id: `${sub.id}-w${window}`, // one per period window
    summary: "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 ...
  4. When the user cancels, give the mandate back with kit.merchant.release(credential). The user turns it off on chain with the wallet’s revoke_mandate.

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.

test/service.test.mjs
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 approved
gw.advance(PERIOD); // next window
const [charged] = await svc.chargeDue(); // merchant charge
assert.equal(charged.status, "paid");
  1. Register the service with the tenant: id, name, origin, whether it may charge later, and its contract addresses.
  2. Set PUBLIC_URL to exactly the registered origin. The wallet fetches intents only from there.
  3. 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.

Terminal window
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-mcp

Environment variables and registration are in Distribution.

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.