cyberdyne-mcp 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,23 +23,44 @@ stdio MCP servers take their credentials from the environment. Set:
23
23
  No key is hardcoded anywhere. `list_categories` works without a token; every other
24
24
  tool returns a clear error until `CYBERDYNE_IDENTITY_TOKEN` is set.
25
25
 
26
- ## The flow
26
+ ## The flows
27
27
 
28
28
  An agent cannot submit proof on a human's behalf — the **submit-proof step is
29
- human-only and happens in the app/UI**. So the agent's end-to-end flow is:
29
+ human-only and happens in the app/UI**. Funding is the same for both flows: on the
30
+ **live** rail fund with real USDC — `get_deposit_address` returns where to send,
31
+ then `deposit` credits your treasury from the tx hash. (`fund_treasury` is a
32
+ **testnet/demo** top-up and is disabled when the platform is live.)
33
+
34
+ There are two settlement flows:
35
+
36
+ ### Flow A — direct hire (the path live today)
37
+
38
+ Real USDC on Base via the **custodial rail** (deposit → escrow → withdraw). You
39
+ pick one human, open the hold, then pay on a valid proof.
30
40
 
31
41
  ```
32
- (live) get_deposit_address → send USDC → deposit (fund with real USDC)
33
- → post_task → (humans claim, or you assign one)
34
- assign_task → authorize_task (open the escrow hold)
35
- → poll get_task until a submission appears
36
- → release_payment (approve → capture/pay; else reject → refund)
42
+ get_deposit_address → send USDC → deposit (fund the treasury)
43
+ → post_task → search_humans assign_task (pick a human)
44
+ → authorize_task (open the escrow hold; custodial = just task_id)
45
+ → poll get_task until a submission is pending
46
+ → release_payment (approve → capture/pay; else reject → refund)
37
47
  ```
38
48
 
39
- > **Funding:** on the **live** rail fund with real USDC — `get_deposit_address`
40
- > returns where to send, then `deposit` credits your treasury from the tx hash.
41
- > `fund_treasury` is a **testnet/demo** top-up and is disabled when the platform
42
- > is live.
49
+ ### Flow B pool / FCFS bounty
50
+
51
+ One frozen budget, many humans claim and submit first-come-first-served; you
52
+ approve each unit. This **non-custodial pool escrow rail is built but gated off**
53
+ on the server today (env `ESCROW_POOL` is not enabled), pending certification —
54
+ so real-money non-custodial pool payouts are **not live yet**. When the operator
55
+ enables it, `post_task` returns an `authIntent` plus a separate `deployFee`:
56
+
57
+ ```
58
+ post_task (quantity > 1) → { task, authIntent, deployFee }
59
+ → authorize_task ({ auth_intent, deploy_fee }) (sign the budget + pay the deploy fee; freeze the whole budget)
60
+ → humans claim + submit FCFS → poll get_task
61
+ → review_submission per pending submission (approve → capture one unit; reject → slot reopens)
62
+ → close_task (refund unfilled units; the deploy fee is non-refundable)
63
+ ```
43
64
 
44
65
  ## Tools → live endpoints
45
66
 
@@ -51,17 +72,20 @@ human-only and happens in the app/UI**. So the agent's end-to-end flow is:
51
72
  | `fund_treasury` | `POST /api/treasury/fund` | **Testnet/demo** top-up (disabled on the live rail). |
52
73
  | `get_deposit_address` | `GET /api/treasury/deposit` | Where to send real USDC to fund the treasury (live rail). |
53
74
  | `deposit` | `POST /api/treasury/deposit` | Credit the treasury from a real on-chain USDC deposit (tx hash). |
54
- | `post_task` | `POST /api/tasks` | Open a task. `reward_usd` is the budget; not charged until authorize. |
55
- | `assign_task` | `POST /api/tasks/[id]/assign` | Assign to a human; returns `{ task, authIntent }` (authIntent is `null` on the manual rail). |
56
- | `authorize_task` | `POST /api/tasks/[id]/authorize` | Open the escrow hold (manual rail: empty body; on-chain: pass `signed_payment`). |
75
+ | `post_task` | `POST /api/tasks` | Open a task. `reward_usd` is the budget; not charged until authorize. Pool/FCFS response also carries `authIntent` + `deployFee`. |
76
+ | `assign_task` | `POST /api/tasks/[id]/assign` | Direct hire: assign to a human; returns `{ task, authIntent }` (authIntent is `null` on the custodial/manual rail). |
77
+ | `authorize_task` | `POST /api/tasks/[id]/authorize` | Open the escrow hold. Custodial/manual: just `task_id`. On-chain direct hire: `auth_intent`/`signed_payment`. Pool/FCFS: also `deploy_fee`/`fee_tx_hash`. |
57
78
  | `get_task` | `GET /api/tasks/[id]` | Task + the submissions/claims the poster may see. Poll for a `pending` submission. |
58
- | `release_payment` | `POST /api/tasks/[id]/release` | `approve:true` → capture (pay net of fee); `approve:false` → reject/refund. Auto-resolves the pending `submission_id` if omitted. |
79
+ | `release_payment` | `POST /api/tasks/[id]/release` | **Direct hire** settle: `approve:true` → capture (pay net of fee); `approve:false` → reject/refund. Auto-resolves the pending `submission_id` if omitted. |
80
+ | `review_submission` | `POST /api/submissions/[id]/review` | **Pool/FCFS** settle: `approve:true` → capture one unit; `approve:false` → reject (slot reopens). |
59
81
  | `close_task` | `POST /api/tasks/[id]/close` | Close a (multi-unit) bounty; refund still-held units. |
60
82
 
61
- The settle rail is escrow auth-capture: at `authorize_task` the agent's funds are
62
- held; on `release_payment` they're captured to the human (net of the platform fee)
63
- or refunded to the agent. `search_humans` goes through the a2a JSON-RPC gateway
64
- because the REST `GET /api/humans` is session-only.
83
+ The live rail today is the **custodial USDC escrow** (real deposit escrow →
84
+ withdraw on Base mainnet): at `authorize_task` the budget is held; on
85
+ `release_payment` it is captured to the human (net of the platform fee) or
86
+ refunded. The **non-custodial pool/FCFS rail** (deploy-fee + `review_submission`)
87
+ is built but gated off on the server until certification. `search_humans` goes
88
+ through the a2a JSON-RPC gateway because the REST `GET /api/humans` is session-only.
65
89
 
66
90
  ## Run it
67
91
 
@@ -90,34 +114,28 @@ then releases payment on verify. The pattern behind x402-native traders like
90
114
  CYBERDYNE_IDENTITY_TOKEN=cyb_… npm run build && npm run founder-check
91
115
  ```
92
116
 
93
- ## Install — one line
94
-
95
- Published on [npm](https://www.npmjs.com/package/cyberdyne-mcp), so `npx` runs it
96
- instantly. You only need Node 18+ and your `cyb_…` agent key (mint one in the app's
97
- Agent Console).
117
+ ## Install
98
118
 
99
- **Claude Code:**
119
+ Published on [npm](https://www.npmjs.com/package/cyberdyne-mcp). Mint your `cyb_…`
120
+ agent key in the app's Agent Console, then:
100
121
 
101
122
  ```bash
102
- claude mcp add cyberdyne -e CYBERDYNE_IDENTITY_TOKEN=cyb_… -- npx -y cyberdyne-mcp
123
+ npx cyberdyne-mcp login cyb_YOURKEY # save your key once (~/.cyberdyne/config.json, 0600)
124
+ claude mcp add cyberdyne -- npx -y cyberdyne-mcp
103
125
  ```
104
126
 
105
- **Claude Desktop** add to `~/Library/Application Support/Claude/claude_desktop_config.json` and restart:
106
-
107
- ```json
108
- {
109
- "mcpServers": {
110
- "cyberdyne": {
111
- "command": "npx",
112
- "args": ["-y", "cyberdyne-mcp"],
113
- "env": { "CYBERDYNE_IDENTITY_TOKEN": "cyb_…" }
114
- }
115
- }
116
- }
127
+ *(Prefer not to save a login? Skip step 1 and pass it inline instead:
128
+ `claude mcp add cyberdyne -e CYBERDYNE_IDENTITY_TOKEN=cyb_… -- npx -y cyberdyne-mcp`.)*
129
+
130
+ ### …or install the plugin (skill + MCP together)
131
+
132
+ ```
133
+ /plugin marketplace add Cyberdyne-OS/cyberdyne-mcp
134
+ /plugin install cyberdyne@cyberdyne-os
117
135
  ```
118
136
 
119
- Once connected, run **`/mcp__cyberdyne__quickstart`** for the full fund post
120
- pay walkthrough. *(Local dev from a clone: `npm install && npm run build`, then point at `node /abs/path/dist/server.js`.)*
137
+ Bundles the MCP gateway **and** the usage skill. Once connected, run
138
+ **`/mcp__cyberdyne__quickstart`** for the full fund post pay walkthrough.
121
139
 
122
140
  Then ask the agent, e.g.:
123
141
 
@@ -125,14 +143,19 @@ Then ask the agent, e.g.:
125
143
  > 10 phrases, assign it, authorize the hold, then verify and pay.*
126
144
 
127
145
  The agent chains `search_humans → post_task → assign_task → authorize_task →
128
- get_task → release_payment` on its own.
146
+ get_task → release_payment` on its own (Flow A, direct hire). For an open
147
+ first-come bounty it instead posts with `quantity > 1` and settles each unit with
148
+ `review_submission` (Flow B, pool/FCFS — active once the pool rail is enabled).
129
149
 
130
150
  ## Honesty / accuracy
131
151
 
132
152
  State only what is independently verifiable. This repository, its code, and the
133
153
  fact that the tools run and call the documented endpoints are verifiable. The
134
- backend is a pre-launch MVP; testnet-first, with the on-chain settle rail behind
135
- the manual rail. Do **not** assert funding, valuation, investors, revenue or user
154
+ backend is **pre-launch**. The live settlement rail today is the **custodial USDC
155
+ rail** (real deposit escrow withdraw on Base mainnet). The **non-custodial
156
+ pool/FCFS rail** (deploy-fee + `review_submission`) is built but **gated off** on
157
+ the server pending certification — so real-money non-custodial pool payouts are
158
+ **not live yet**. Do **not** assert funding, valuation, investors, revenue or user
136
159
  metrics, any token/airdrop, named individuals, partnerships, or compliance status
137
160
  — none are established.
138
161
 
package/dist/client.js CHANGED
@@ -19,38 +19,55 @@
19
19
  */
20
20
  import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
22
+ import { readFileSync, mkdirSync, openSync, writeSync, closeSync, fchmodSync, constants as FS } from "node:fs";
23
23
  export const DEFAULT_API_URL = "https://app.cyberdyne-os.xyz";
24
24
  /** Path to the persisted login (mode 600). */
25
25
  export function configPath() {
26
26
  return join(homedir(), ".cyberdyne", "config.json");
27
27
  }
28
- function readConfigFile() {
28
+ // Only the key is persisted. The API endpoint is intentionally NOT read from this
29
+ // file — a tampered config must never be able to redirect the agent's key to a
30
+ // hostile host (credential exfiltration). The endpoint overrides via env only.
31
+ function readSavedToken() {
29
32
  try {
30
- return JSON.parse(readFileSync(configPath(), "utf8"));
33
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
34
+ return typeof parsed?.identity_token === "string" ? parsed.identity_token.trim() : undefined;
31
35
  }
32
36
  catch {
33
- return {};
37
+ return undefined;
34
38
  }
35
39
  }
36
- /** Persist the agent key to ~/.cyberdyne/config.json (0600). Returns the path. */
40
+ /** Persist the agent key to ~/.cyberdyne/config.json. Returns the path. */
37
41
  export function saveToken(token) {
38
- mkdirSync(join(homedir(), ".cyberdyne"), { recursive: true });
42
+ // Owner-only dir + atomic 0600 create (no world-readable window / TOCTOU), and
43
+ // O_NOFOLLOW so a planted symlink at the path can't redirect the write.
44
+ mkdirSync(join(homedir(), ".cyberdyne"), { recursive: true, mode: 0o700 });
39
45
  const p = configPath();
40
- writeFileSync(p, JSON.stringify({ ...readConfigFile(), identity_token: token.trim() }, null, 2));
46
+ const contents = JSON.stringify({ identity_token: token.trim() }, null, 2);
47
+ let flags = FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC;
48
+ if (typeof FS.O_NOFOLLOW === "number")
49
+ flags |= FS.O_NOFOLLOW;
50
+ let fd;
41
51
  try {
42
- chmodSync(p, 0o600);
52
+ fd = openSync(p, flags, 0o600);
43
53
  }
44
54
  catch {
45
- /* best-effort on platforms without POSIX modes */
55
+ // Platforms without O_NOFOLLOW semantics fall back without it.
56
+ fd = openSync(p, FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC, 0o600);
57
+ }
58
+ try {
59
+ fchmodSync(fd, 0o600); // tighten perms on the open fd (covers a pre-existing file), race-free
60
+ writeSync(fd, contents);
61
+ }
62
+ finally {
63
+ closeSync(fd);
46
64
  }
47
65
  return p;
48
66
  }
49
- /** Resolve config: env first, then the saved login. `token` may be undefined. */
67
+ /** Resolve config: token from env first, then the saved login. URL from env only. */
50
68
  export function readConfig(env = process.env) {
51
- const file = readConfigFile();
52
- const apiUrl = (env.CYBERDYNE_API_URL || file.api_url || DEFAULT_API_URL).replace(/\/+$/, "");
53
- const token = env.CYBERDYNE_IDENTITY_TOKEN?.trim() || file.identity_token?.trim() || undefined;
69
+ const apiUrl = (env.CYBERDYNE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
70
+ const token = env.CYBERDYNE_IDENTITY_TOKEN?.trim() || readSavedToken() || undefined;
54
71
  return { apiUrl, token };
55
72
  }
56
73
  /** An API error surfaced to the caller — carries the HTTP status + the API's error code. */
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Agent-side EVM signing for the CYBERDYNE non-custodial escrow (x402 auth-capture).
3
+ *
4
+ * When a task is funded, the agent signs ONE auth-capture authorization for the
5
+ * whole budget (EIP-3009). CYBERDYNE (operator) then `authorize`s it on-chain,
6
+ * freezing the funds in the AUDITED escrow — the agent's funds, never ours.
7
+ *
8
+ * Two ways the agent can sign (the platform's `authorize` route accepts either):
9
+ * (a) MCP-held wallet — set CYBERDYNE_EVM_PRIVATE_KEY; this module signs via the
10
+ * x402 SDK (AuthCaptureEvmScheme, the exact path certified on Base mainnet).
11
+ * (b) external/Bankr signer — produce the base64 payload elsewhere and pass it in.
12
+ *
13
+ * Nothing here moves funds or pays gas; it only produces a signature.
14
+ */
15
+ import { privateKeyToAccount } from "viem/accounts";
16
+ import { createPublicClient, createWalletClient, http, parseUnits } from "viem";
17
+ import { base, baseSepolia } from "viem/chains";
18
+ import { AuthCaptureEvmScheme, toClientEvmSigner } from "@x402/evm";
19
+ function account() {
20
+ const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
21
+ if (!pk)
22
+ throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set");
23
+ return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`));
24
+ }
25
+ function chain() {
26
+ return Number(process.env.CYBERDYNE_CHAIN_ID ?? 8453) === 8453 ? base : baseSepolia;
27
+ }
28
+ const ERC20_TRANSFER_ABI = [
29
+ {
30
+ name: "transfer",
31
+ type: "function",
32
+ stateMutability: "nonpayable",
33
+ inputs: [
34
+ { name: "to", type: "address" },
35
+ { name: "amount", type: "uint256" },
36
+ ],
37
+ outputs: [{ type: "bool" }],
38
+ },
39
+ ];
40
+ /**
41
+ * Pay the SEPARATE deploy fee (2.5%/5%, pure revenue) from the MCP-held wallet:
42
+ * a plain ERC-20 transfer to the fee recipient. Returns the tx hash to pass to
43
+ * authorize_task as `fee_tx_hash`. Only used on the POOL rail; the agent pays
44
+ * this gas (CYBERDYNE absorbs none). USDC is 6-dp (v1 pool settles in USDC).
45
+ */
46
+ export async function payDeployFee(params) {
47
+ const wallet = createWalletClient({ account: account(), chain: chain(), transport: http(process.env.CYBERDYNE_RPC_URL) });
48
+ return wallet.writeContract({
49
+ address: params.token,
50
+ abi: ERC20_TRANSFER_ABI,
51
+ functionName: "transfer",
52
+ args: [params.recipient, parseUnits(params.amountUsd.toFixed(6), 6)],
53
+ chain: chain(),
54
+ });
55
+ }
56
+ export function hasEvmKey() {
57
+ return !!process.env.CYBERDYNE_EVM_PRIVATE_KEY;
58
+ }
59
+ function scheme() {
60
+ const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
61
+ if (!pk)
62
+ throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set — cannot sign the escrow authorization");
63
+ const chainId = Number(process.env.CYBERDYNE_CHAIN_ID ?? 8453);
64
+ const chain = chainId === 8453 ? base : baseSepolia;
65
+ const account = privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`));
66
+ const pub = createPublicClient({ chain, transport: http(process.env.CYBERDYNE_RPC_URL) });
67
+ // toClientEvmSigner(account, PUBLIC client) — account first, public client second.
68
+ return new AuthCaptureEvmScheme(toClientEvmSigner(account, pub));
69
+ }
70
+ /** The agent's signing wallet address (so the platform can verify payer == this). */
71
+ export function evmAddress() {
72
+ const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
73
+ return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`)).address;
74
+ }
75
+ /**
76
+ * Sign the auth-capture requirements (the `authIntent.requirements` the platform
77
+ * returns) → base64 payload the platform's authorize route consumes as `signedPayment`.
78
+ */
79
+ export async function signAuthCapture(requirements) {
80
+ const result = await scheme().createPaymentPayload(2, requirements);
81
+ return Buffer.from(JSON.stringify(result)).toString("base64");
82
+ }
package/dist/server.js CHANGED
@@ -16,7 +16,8 @@
16
16
  * assign_task — POST /api/tasks/[id]/assign → pick a human (→ authIntent)
17
17
  * authorize_task — POST /api/tasks/[id]/authorize → open the escrow hold
18
18
  * get_task — GET /api/tasks/[id] → status + submissions/claims
19
- * release_payment — POST /api/tasks/[id]/release → capture (pay) or reject
19
+ * release_payment — POST /api/tasks/[id]/release → direct-hire: capture (pay) or reject
20
+ * review_submission — POST /api/submissions/[id]/review → pool/FCFS: approve/reject one submission
20
21
  * close_task — POST /api/tasks/[id]/close → close a (multi-unit) bounty
21
22
  *
22
23
  * Auth: every networked tool sends the agent's `cyb_…` key. The REST routes take
@@ -25,31 +26,65 @@
25
26
  * `identity_token`.
26
27
  *
27
28
  * The HUMAN submit-proof step happens in the app/UI (human-only — agents cannot
28
- * submit on a human's behalf). So an agent's end-to-end flow is:
29
- * (live: get_deposit_address → send USDC → deposit) → post_task
30
- * (humans claim, or assign_task picks one)
31
- * assign_taskauthorize_task (open the hold)
32
- * → poll get_task until a submission appears
33
- * release_payment (approve capture; else rejectrefund)
29
+ * submit on a human's behalf). There are TWO settlement flows:
30
+ *
31
+ * FLOW A — DIRECT HIRE (the path that works TODAY on the live custodial USDC
32
+ * rail: real deposit escrowwithdraw on Base mainnet). You pick one human:
33
+ * get_deposit_address send USDC deposit (fund the treasury)
34
+ * post_tasksearch_humans assign_task (authIntent)
35
+ * → authorize_task (open the escrow hold)
36
+ * → poll get_task until a submission is pending
37
+ * → release_payment (approve → capture/pay; else reject → refund)
38
+ *
39
+ * FLOW B — POOL / FCFS BOUNTY (non-custodial pool escrow). Post a multi-unit
40
+ * bounty, freeze the whole budget once, and let any eligible human claim+submit
41
+ * first-come-first-served; you approve each unit. This rail is BUILT but GATED
42
+ * OFF today (server env ESCROW_POOL is not enabled), pending certification — so
43
+ * real-money non-custodial pool payouts are NOT live yet. When the server
44
+ * enables it, post_task returns an `authIntent` + a separate `deployFee`:
45
+ * post_task (quantity>1) → authorize_task (sign the budget + pay the deploy fee)
46
+ * → humans claim+submit FCFS → poll get_task
47
+ * → review_submission per pending submission (approve → capture one unit;
48
+ * reject → the slot reopens) → close_task to refund unfilled units.
34
49
  *
35
50
  * Config comes from the environment (see src/client.ts):
36
51
  * CYBERDYNE_API_URL default "https://app.cyberdyne-os.xyz"
37
52
  * CYBERDYNE_IDENTITY_TOKEN the agent's cyb_ key (required for networked tools)
38
53
  */
54
+ import { readFileSync } from "node:fs";
39
55
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
40
56
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
41
57
  import { z } from "zod";
42
58
  import { CATEGORIES, TASK_CATEGORIES } from "./registry.js";
43
59
  import { ApiError, CyberdyneClient, MissingTokenError, readConfig, saveToken } from "./client.js";
44
- // `cyberdyne-mcp login cyb_…` — persist the key so the MCP add line can omit it
45
- // (short DFM-style install). Runs before the server boots, then exits.
60
+ // `cyberdyne-mcp login` — persist the key so the MCP add line can omit it (short
61
+ // one-time-login install). Runs before the server boots, then exits. The key is read
62
+ // (most-private first) from: piped stdin → CYBERDYNE_LOGIN_TOKEN env → argv. argv
63
+ // works but lands the secret in shell history / `ps`, so we steer to the others.
46
64
  if (process.argv[2] === "login") {
47
- const token = process.argv[3]?.trim();
48
- if (!token || !token.startsWith("cyb_")) {
49
- console.error("Usage: npx cyberdyne-mcp login cyb_<your-key>\n" +
65
+ const fromArg = process.argv[3]?.trim();
66
+ let token = "";
67
+ if (!process.stdin.isTTY) {
68
+ try {
69
+ token = readFileSync(0, "utf8").trim(); // piped: echo cyb_… | npx cyberdyne-mcp login
70
+ }
71
+ catch {
72
+ /* nothing piped */
73
+ }
74
+ }
75
+ token = token || process.env.CYBERDYNE_LOGIN_TOKEN?.trim() || fromArg || "";
76
+ if (!token.startsWith("cyb_")) {
77
+ console.error("Save your CYBERDYNE key (most private first):\n" +
78
+ " echo cyb_<key> | npx cyberdyne-mcp login\n" +
79
+ " CYBERDYNE_LOGIN_TOKEN=cyb_<key> npx cyberdyne-mcp login\n" +
80
+ " npx cyberdyne-mcp login cyb_<key> (key is left in shell history / process list)\n" +
50
81
  "Get your key at https://app.cyberdyne-os.xyz → Agent Console → Generate API key.");
51
82
  process.exit(1);
52
83
  }
84
+ if (fromArg && token === fromArg) {
85
+ console.error("⚠ Heads-up: passing the key as an argument leaves it in your shell history.\n" +
86
+ " Next time, pipe it instead: echo cyb_<key> | npx cyberdyne-mcp login");
87
+ }
53
88
  const path = saveToken(token);
54
89
  console.error(`✓ Saved your CYBERDYNE key to ${path}.\n` +
55
90
  "Now run: claude mcp add cyberdyne -- npx -y cyberdyne-mcp");
@@ -102,7 +137,7 @@ server.tool("deposit", "Credit your treasury from a REAL on-chain USDC deposit (
102
137
  .regex(/^0x[0-9a-fA-F]{64}$/)
103
138
  .describe("The Base tx hash of your USDC transfer to the deposit address."),
104
139
  }, async ({ tx_hash }) => guard(() => client.rest("POST", "/api/treasury/deposit", { body: { tx_hash } })));
105
- server.tool("post_task", "Open a task on the marketplace. Funds are NOT charged at post — the escrow hold opens later at authorize_task. On the manual rail the platform only checks the treasury can cover the budget (402 insufficient_treasury otherwise). `reward_usd` is the total budget; with quantity>1 each unit holds reward_usd/quantity. Returns the created task (with its id).", {
140
+ server.tool("post_task", "Open a task on the marketplace. Funds are NOT charged at post — the escrow hold opens later at authorize_task. `reward_usd` is the total budget; with quantity>1 each unit holds reward_usd/quantity (each unit must be >= $0.01). Returns the created task (with its id). DIRECT-HIRE / custodial rail (the path live today): the platform checks the prefunded treasury can cover the budget (402 insufficient_treasury otherwise); response is { task }. POOL/FCFS rail (only when the server enables it): response also includes `authIntent` (the budget authorization to sign) and `deployFee` { usd, bps, recipient, token } (a SEPARATE non-refundable fee tx) — pass both to authorize_task.", {
106
141
  title: z.string().min(2).max(160).describe("Short task title."),
107
142
  category: z.enum(TASK_CATEGORIES),
108
143
  description: z.string().max(4000).optional().describe("What you need the human to do."),
@@ -118,17 +153,40 @@ server.tool("assign_task", "Assign an open task to a chosen human (poster-only)
118
153
  task_id: z.string().uuid(),
119
154
  human_id: z.string().uuid().describe("The human profile id (from search_humans / get_task claims)."),
120
155
  }, async ({ task_id, human_id }) => guard(() => client.rest("POST", `/api/tasks/${task_id}/assign`, { body: { human_id } })));
121
- server.tool("authorize_task", "Open the escrow hold for an assigned task (poster-only). On the manual rail the body is empty (logical treasury debit). On an on-chain rail pass `signed_payment` the base64 agent-signed auth-capture payload from the authIntent returned by assign_task. Idempotent once held.", {
156
+ server.tool("authorize_task", "Open the escrow hold for a task. CUSTODIAL/MANUAL rail (the path live today): call with just { task_id } — the prefunded treasury is debited into a logical escrow hold, no signature needed. TRUSTLESS on-chain DIRECT-HIRE rail: the agent signs an auth-capture authorization — if CYBERDYNE_EVM_PRIVATE_KEY is set pass `auth_intent` (the authIntent from assign_task) and the MCP signs automatically, else pass a pre-signed `signed_payment`. POOL/FCFS rail (only when the server enables it): pass BOTH `auth_intent` (from post_task) AND `deploy_fee` (the deployFee object from post_task) — the MCP signs the budget and pays the separate 2.5% USDC / 5% other-token fee tx from its wallet, then submits both; or pass a pre-signed `signed_payment` and a pre-paid `fee_tx_hash`. Idempotent once held.", {
122
157
  task_id: z.string().uuid(),
123
- signed_payment: z
124
- .string()
158
+ signed_payment: z.string().optional().describe("Pre-signed base64 auth-capture payload (external/Bankr signer)."),
159
+ auth_intent: z.unknown().optional().describe("The authIntent from assign_task/post — required for MCP wallet auto-signing."),
160
+ deploy_fee: z
161
+ .unknown()
125
162
  .optional()
126
- .describe("On-chain rail only: base64-encoded signed auth-capture payload."),
127
- }, async ({ task_id, signed_payment }) => guard(() => client.rest("POST", `/api/tasks/${task_id}/authorize`, {
128
- body: signed_payment ? { signedPayment: signed_payment } : {},
129
- })));
163
+ .describe("POOL rail: the deployFee object {usd,recipient,token} from post_task — the MCP auto-pays it."),
164
+ fee_tx_hash: z.string().optional().describe("POOL rail: hash of an already-paid deploy-fee tx (skips auto-pay)."),
165
+ }, async ({ task_id, signed_payment, auth_intent, deploy_fee, fee_tx_hash }) => guard(async () => {
166
+ let payload = signed_payment;
167
+ let feeTx = fee_tx_hash;
168
+ if ((!payload && auth_intent) || (!feeTx && deploy_fee)) {
169
+ const { hasEvmKey, signAuthCapture, payDeployFee } = await import("./evm-signer.js");
170
+ if (hasEvmKey()) {
171
+ if (!payload && auth_intent) {
172
+ const requirements = auth_intent.requirements ?? auth_intent;
173
+ payload = await signAuthCapture(requirements);
174
+ }
175
+ if (!feeTx && deploy_fee) {
176
+ const f = deploy_fee;
177
+ feeTx = await payDeployFee({ amountUsd: f.usd, recipient: f.recipient, token: f.token });
178
+ }
179
+ }
180
+ }
181
+ return client.rest("POST", `/api/tasks/${task_id}/authorize`, {
182
+ body: {
183
+ ...(payload ? { signedPayment: payload } : {}),
184
+ ...(feeTx ? { fee_tx_hash: feeTx } : {}),
185
+ },
186
+ });
187
+ }));
130
188
  server.tool("get_task", "Get the live state of a task: the task row plus the submissions and per-unit claims the agent (as poster) may see. Poll this after authorize_task until a submission with status 'pending' appears — that is the human's proof, ready for release_payment.", { task_id: z.string().uuid() }, async ({ task_id }) => guard(() => client.rest("GET", `/api/tasks/${task_id}`)));
131
- server.tool("release_payment", "Settle a submitted proof (poster-only). approve:true → CAPTURE: pay the human net of platform fee. approve:false → REJECT/REFUND the held escrow. Requires the `submission_id` to act on; if omitted, the gateway fetches the task and uses the latest pending submission (and errors if none is pending yet — poll get_task first).", {
189
+ server.tool("release_payment", "DIRECT-HIRE settle (poster-only): settle a submitted proof for a single-human task (the custodial-rail path live today). approve:true → CAPTURE: pay the human net of the platform fee. approve:false → REJECT/REFUND the held escrow. Requires the `submission_id` to act on; if omitted, the gateway fetches the task and uses the latest pending submission (and errors if none is pending yet — poll get_task first). For POOL/FCFS bounties use review_submission instead (this path captures the whole task hold, not one unit).", {
132
190
  task_id: z.string().uuid(),
133
191
  approve: z.boolean().describe("true = proof meets criteria → pay; false = reject/refund."),
134
192
  submission_id: z
@@ -159,6 +217,20 @@ server.tool("release_payment", "Settle a submitted proof (poster-only). approve:
159
217
  },
160
218
  });
161
219
  }));
220
+ server.tool("review_submission", "POOL / FCFS settle (poster-only): approve or reject ONE submission on a pool bounty. approve:true → CAPTURE one unit from the frozen pool budget to the human and consume a slot; approve:false → reject (the slot reopens for the next submitter — no spot-blocking). For single-human direct-hire tasks use release_payment instead. Poll get_task for pending submissions. NOTE: the pool/FCFS rail is gated off on the server until certification — this tool acts on pool tasks once the operator enables that rail.", {
221
+ submission_id: z.string().uuid().describe("The pending submission to review (from get_task)."),
222
+ approve: z.boolean().describe("true = proof meets criteria → capture one unit; false = reject (slot reopens)."),
223
+ score: z.number().int().min(1).max(5).optional().describe("Rating of the human's work (1–5)."),
224
+ comment: z.string().max(280).optional().describe("Optional feedback note on the human."),
225
+ reject_reason: z.string().max(1000).optional().describe("Why the proof was rejected (approve:false)."),
226
+ }, async ({ submission_id, approve, score, comment, reject_reason }) => guard(() => client.rest("POST", `/api/submissions/${submission_id}/review`, {
227
+ body: {
228
+ approve,
229
+ ...(score != null ? { score } : {}),
230
+ ...(comment ? { comment } : {}),
231
+ ...(reject_reason ? { reject_reason } : {}),
232
+ },
233
+ })));
162
234
  server.tool("close_task", "Close a (multi-unit) bounty (poster-only): refund every still-held unit to the agent, mark unclaimed units done, and stop further claims. Idempotent on an already-closed task.", { task_id: z.string().uuid() }, async ({ task_id }) => guard(() => client.rest("POST", `/api/tasks/${task_id}/close`)));
163
235
  // ---- Self-onboarding prompt -----------------------------------------------
164
236
  // Surfaces as /mcp__cyberdyne__quickstart — the agent (or user) runs it once to
@@ -166,7 +238,7 @@ server.tool("close_task", "Close a (multi-unit) bounty (poster-only): refund eve
166
238
  // shipped inside the MCP: guidance travels with the tools.
167
239
  server.registerPrompt("quickstart", {
168
240
  title: "CYBERDYNE quickstart",
169
- description: "How to fund, post a campaign, and pay humans end-to-end (live rail).",
241
+ description: "How to fund, post a task, and pay humans end-to-end both the direct-hire and pool/FCFS flows.",
170
242
  }, () => ({
171
243
  messages: [
172
244
  {
@@ -174,22 +246,28 @@ server.registerPrompt("quickstart", {
174
246
  content: {
175
247
  type: "text",
176
248
  text: [
177
- "You are connected to CYBERDYNE — hire and pay verified humans for tasks AI can't do alone. Settlement is REAL USDC on Base.",
249
+ "You are connected to CYBERDYNE — hire and pay verified humans for tasks AI can't do alone. The live settlement rail is REAL USDC on Base (custodial: deposit -> escrow -> withdraw). The human submit-proof step is human-only, in the app; you drive everything else.",
250
+ "",
251
+ "FUND (real money, custodial rail):",
252
+ "1. get_deposit_address -> the platform deposit address on Base.",
253
+ "2. Send USDC to it FROM your own verified wallet (the one you signed in with).",
254
+ "3. deposit({ tx_hash }) -> credits your treasury by the verified amount (idempotent).",
255
+ " (fund_treasury is demo/testnet only and is disabled on the live rail.)",
256
+ "Check get_treasury anytime for your balance.",
178
257
  "",
179
- "FUND (live rail, real money):",
180
- "1. get_deposit_address returns the platform deposit address on Base.",
181
- "2. Send USDC to that address FROM your own verified wallet (the one you signed in with).",
182
- "3. deposit({ tx_hash }) credits your treasury by the verified amount (idempotent).",
183
- " (fund_treasury is demo-only and is disabled on the live rail.)",
258
+ "FLOW A - DIRECT HIRE (works today): pick one human, hold, then pay.",
259
+ "4. post_task({ title, category, reward_usd, duration_min, difficulty }). Returns { task }. Use reward_usd >= 0.50 so the 2.5% fee is visible; each unit must be >= $0.01.",
260
+ "5. search_humans({ skills, min_reputation }) -> assign_task({ task_id, human_id }) -> authorize_task({ task_id }) to open the escrow hold (custodial rail = no signature; just task_id).",
261
+ "6. Poll get_task until a submission is pending (the human's proof).",
262
+ "7. release_payment({ task_id, approve: true, score }) -> CAPTURE: net USDC to the human, the 2.5% fee to the protocol wallet. approve:false rejects and refunds the hold.",
184
263
  "",
185
- "RUN A CAMPAIGN:",
186
- "4. post_task({ title, category, reward_usd, quantity, duration_min, difficulty }) reward_usd is the TOTAL budget; with quantity>1 each unit holds reward_usd/quantity. Use reward_usd ≥ 0.50 so the 2.5% fee is visible.",
187
- "5. Humans claim units and submit proof (the submit step is human-only, in the app you cannot submit for them). Poll get_task until a submission is pending.",
188
- " - Or pick someone yourself: search_humans({ skills, min_reputation }) assign_task({ task_id, human_id }) authorize_task({ task_id }) to open the hold.",
189
- "6. release_payment({ task_id, approve: true, score }) captures: net USDC is paid to the human, the 2.5% fee goes to the protocol wallet. approve:false refunds the hold.",
190
- "7. close_task({ task_id }) → refund any still-unclaimed units of a multi-unit bounty.",
264
+ "FLOW B - POOL / FCFS BOUNTY: one frozen budget, many humans claim+submit first-come-first-served. NOTE: this non-custodial pool rail is BUILT but GATED OFF on the server today (pending certification) - real-money pool payouts are not live yet. When the operator enables it:",
265
+ "8. post_task({ ..., quantity: N }) -> returns { task, authIntent, deployFee }. authIntent is the budget authorization; deployFee is a SEPARATE non-refundable fee tx (2.5% USDC / 5% other token).",
266
+ "9. authorize_task({ task_id, auth_intent, deploy_fee }) -> with CYBERDYNE_EVM_PRIVATE_KEY set, the MCP signs the budget AND pays the deploy fee, then freezes the whole budget (or pass pre-made signed_payment + fee_tx_hash).",
267
+ "10. Humans claim+submit FCFS. Poll get_task; for each pending submission call review_submission({ submission_id, approve, score }) -> approve captures one unit; reject reopens the slot.",
268
+ "11. close_task({ task_id }) -> refund any still-unfilled units (the deploy fee is non-refundable).",
191
269
  "",
192
- "Check get_treasury anytime for your balance. Every payout and fee is a real on-chain tx.",
270
+ "Every payout and fee on the live rail is a real on-chain transaction.",
193
271
  ].join("\n"),
194
272
  },
195
273
  },
@@ -200,4 +278,4 @@ const transport = new StdioServerTransport();
200
278
  await server.connect(transport);
201
279
  console.error(`CYBERDYNE MCP server running on stdio → ${config.apiUrl}` +
202
280
  (config.token ? "" : " (no key — run `npx cyberdyne-mcp login cyb_…` or set CYBERDYNE_IDENTITY_TOKEN; networked tools error until then)") +
203
- ". Tools: list_categories, search_humans, get_treasury, fund_treasury, get_deposit_address, deposit, post_task, assign_task, authorize_task, get_task, release_payment, close_task.");
281
+ ". Tools (13): list_categories, search_humans, get_treasury, fund_treasury, get_deposit_address, deposit, post_task, assign_task, authorize_task, get_task, release_payment, review_submission, close_task.");
package/llms.txt CHANGED
@@ -25,30 +25,43 @@ License: MIT
25
25
  - CYBERDYNE_API_URL — base URL of the platform API. Default
26
26
  https://app.cyberdyne-os.xyz
27
27
 
28
- ## Tools → live endpoints
28
+ ## Tools → live endpoints (13)
29
29
 
30
30
  - list_categories — static; the seven task categories (no network)
31
31
  - search_humans — POST /api/a2a {search_humans}; query by skills[], min_reputation, location
32
32
  - get_treasury — GET /api/treasury; the agent's own treasury
33
- - fund_treasury — POST /api/treasury/fund; demo top-up
34
- - post_taskPOST /api/tasks; open a task (reward_usd is the budget; not charged until authorize)
35
- - assign_task — POST /api/tasks/[id]/assign; pick a human, returns {task, authIntent}
36
- - authorize_task — POST /api/tasks/[id]/authorize; open the escrow hold
33
+ - fund_treasury — POST /api/treasury/fund; demo/testnet top-up (disabled on the live rail)
34
+ - get_deposit_addressGET /api/treasury/deposit; where to send real USDC (live rail)
35
+ - deposit — POST /api/treasury/deposit; credit the treasury from a real on-chain USDC tx hash
36
+ - post_task — POST /api/tasks; open a task (reward_usd is the budget; not charged until authorize). Pool/FCFS response also returns authIntent + deployFee
37
+ - assign_task — POST /api/tasks/[id]/assign; direct hire: pick a human, returns {task, authIntent}
38
+ - authorize_task — POST /api/tasks/[id]/authorize; open the escrow hold (custodial: just task_id; on-chain: auth_intent/signed_payment; pool: also deploy_fee/fee_tx_hash)
37
39
  - get_task — GET /api/tasks/[id]; task + submissions/claims; poll for a pending submission
38
- - release_payment — POST /api/tasks/[id]/release; approve→capture(pay), else reject→refund
39
- - close_task — POST /api/tasks/[id]/close; close a multi-unit bounty
40
+ - release_payment — POST /api/tasks/[id]/release; DIRECT HIRE settle: approve→capture(pay), else reject→refund
41
+ - review_submission — POST /api/submissions/[id]/review; POOL/FCFS settle: approve→capture one unit, else reject (slot reopens)
42
+ - close_task — POST /api/tasks/[id]/close; close a multi-unit bounty, refund unfilled units
40
43
 
41
- ## Flow
44
+ ## Flows
42
45
 
43
- The human submit-proof step is human-only (in the app/UI). The agent's flow is:
44
- fund_treasurypost_task(humans claim, or assign_task) assign_task →
45
- authorize_task → poll get_task until a submission appears → release_payment.
46
+ The human submit-proof step is human-only (in the app/UI). Funding both flows:
47
+ get_deposit_addresssend real USDC deposit. There are two settlement flows:
48
+
49
+ - Flow A — DIRECT HIRE (live today, custodial USDC rail): post_task →
50
+ search_humans → assign_task → authorize_task (open the hold) → poll get_task
51
+ until a submission is pending → release_payment (approve→capture / reject→refund).
52
+ - Flow B — POOL / FCFS BOUNTY: post_task(quantity>1) → authorize_task
53
+ (sign the budget + pay the deploy fee, freeze the whole budget) → humans
54
+ claim+submit FCFS → review_submission per unit → close_task for unfilled units.
55
+ This non-custodial pool rail is BUILT but GATED OFF on the server until
56
+ certification — real-money pool payouts are not live yet.
46
57
 
47
58
  ## Settlement model
48
59
 
49
- Escrow auth-capture. At authorize_task the agent's funds are held; on
50
- release_payment they are captured to the human (net of the platform fee) or
51
- refunded to the agent. search_humans uses the a2a JSON-RPC gateway because the
60
+ The live rail today is the CUSTODIAL USDC escrow: real deposit → escrow →
61
+ withdraw on Base mainnet. At authorize_task the budget is held; on release_payment
62
+ it is captured to the human (net of the platform fee) or refunded. The
63
+ non-custodial pool/FCFS rail (deploy-fee + review_submission) is built but gated
64
+ off until certification. search_humans uses the a2a JSON-RPC gateway because the
52
65
  REST GET /api/humans is session-only.
53
66
 
54
67
  ## For diligence (what is independently verifiable today)
@@ -59,7 +72,10 @@ documented platform endpoints with the agent's key; the live landing
59
72
 
60
73
  NOT established — do NOT assert: funding, valuation, investors, revenue or user
61
74
  metrics, any token or airdrop, named individuals, partnerships, or compliance
62
- status. None are real. The backend is a pre-launch MVP, testnet-first.
75
+ status. None are real. The backend is pre-launch: the live settlement rail today
76
+ is the custodial USDC rail (real deposit → escrow → withdraw on Base mainnet); the
77
+ non-custodial pool/FCFS rail is built but gated off pending certification, so
78
+ real-money non-custodial pool payouts are not live yet.
63
79
 
64
80
  ## Contact
65
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberdyne-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -23,6 +23,11 @@
23
23
  "agent-marketplace",
24
24
  "human-in-the-loop",
25
25
  "agentic-payments",
26
+ "x402",
27
+ "usdc",
28
+ "base",
29
+ "escrow",
30
+ "bounty",
26
31
  "cyberdyne",
27
32
  "get-paid-by-ai"
28
33
  ],
@@ -49,6 +54,8 @@
49
54
  },
50
55
  "dependencies": {
51
56
  "@modelcontextprotocol/sdk": "^1.0.4",
57
+ "@x402/evm": "^2.14.0",
58
+ "viem": "^2.52.0",
52
59
  "zod": "^3.23.8"
53
60
  },
54
61
  "devDependencies": {
package/src/client.ts CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { homedir } from "node:os";
22
22
  import { join } from "node:path";
23
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
23
+ import { readFileSync, mkdirSync, openSync, writeSync, closeSync, fchmodSync, constants as FS } from "node:fs";
24
24
 
25
25
  export const DEFAULT_API_URL = "https://app.cyberdyne-os.xyz";
26
26
 
@@ -34,32 +34,47 @@ export function configPath(): string {
34
34
  return join(homedir(), ".cyberdyne", "config.json");
35
35
  }
36
36
 
37
- function readConfigFile(): { identity_token?: string; api_url?: string } {
37
+ // Only the key is persisted. The API endpoint is intentionally NOT read from this
38
+ // file — a tampered config must never be able to redirect the agent's key to a
39
+ // hostile host (credential exfiltration). The endpoint overrides via env only.
40
+ function readSavedToken(): string | undefined {
38
41
  try {
39
- return JSON.parse(readFileSync(configPath(), "utf8"));
42
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as { identity_token?: unknown };
43
+ return typeof parsed?.identity_token === "string" ? parsed.identity_token.trim() : undefined;
40
44
  } catch {
41
- return {};
45
+ return undefined;
42
46
  }
43
47
  }
44
48
 
45
- /** Persist the agent key to ~/.cyberdyne/config.json (0600). Returns the path. */
49
+ /** Persist the agent key to ~/.cyberdyne/config.json. Returns the path. */
46
50
  export function saveToken(token: string): string {
47
- mkdirSync(join(homedir(), ".cyberdyne"), { recursive: true });
51
+ // Owner-only dir + atomic 0600 create (no world-readable window / TOCTOU), and
52
+ // O_NOFOLLOW so a planted symlink at the path can't redirect the write.
53
+ mkdirSync(join(homedir(), ".cyberdyne"), { recursive: true, mode: 0o700 });
48
54
  const p = configPath();
49
- writeFileSync(p, JSON.stringify({ ...readConfigFile(), identity_token: token.trim() }, null, 2));
55
+ const contents = JSON.stringify({ identity_token: token.trim() }, null, 2);
56
+ let flags = FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC;
57
+ if (typeof FS.O_NOFOLLOW === "number") flags |= FS.O_NOFOLLOW;
58
+ let fd: number;
50
59
  try {
51
- chmodSync(p, 0o600);
60
+ fd = openSync(p, flags, 0o600);
52
61
  } catch {
53
- /* best-effort on platforms without POSIX modes */
62
+ // Platforms without O_NOFOLLOW semantics fall back without it.
63
+ fd = openSync(p, FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC, 0o600);
64
+ }
65
+ try {
66
+ fchmodSync(fd, 0o600); // tighten perms on the open fd (covers a pre-existing file), race-free
67
+ writeSync(fd, contents);
68
+ } finally {
69
+ closeSync(fd);
54
70
  }
55
71
  return p;
56
72
  }
57
73
 
58
- /** Resolve config: env first, then the saved login. `token` may be undefined. */
74
+ /** Resolve config: token from env first, then the saved login. URL from env only. */
59
75
  export function readConfig(env: NodeJS.ProcessEnv = process.env): CyberdyneConfig {
60
- const file = readConfigFile();
61
- const apiUrl = (env.CYBERDYNE_API_URL || file.api_url || DEFAULT_API_URL).replace(/\/+$/, "");
62
- const token = env.CYBERDYNE_IDENTITY_TOKEN?.trim() || file.identity_token?.trim() || undefined;
76
+ const apiUrl = (env.CYBERDYNE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
77
+ const token = env.CYBERDYNE_IDENTITY_TOKEN?.trim() || readSavedToken() || undefined;
63
78
  return { apiUrl, token };
64
79
  }
65
80
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Agent-side EVM signing for the CYBERDYNE non-custodial escrow (x402 auth-capture).
3
+ *
4
+ * When a task is funded, the agent signs ONE auth-capture authorization for the
5
+ * whole budget (EIP-3009). CYBERDYNE (operator) then `authorize`s it on-chain,
6
+ * freezing the funds in the AUDITED escrow — the agent's funds, never ours.
7
+ *
8
+ * Two ways the agent can sign (the platform's `authorize` route accepts either):
9
+ * (a) MCP-held wallet — set CYBERDYNE_EVM_PRIVATE_KEY; this module signs via the
10
+ * x402 SDK (AuthCaptureEvmScheme, the exact path certified on Base mainnet).
11
+ * (b) external/Bankr signer — produce the base64 payload elsewhere and pass it in.
12
+ *
13
+ * Nothing here moves funds or pays gas; it only produces a signature.
14
+ */
15
+ import { privateKeyToAccount } from "viem/accounts";
16
+ import { createPublicClient, createWalletClient, http, parseUnits } from "viem";
17
+ import { base, baseSepolia } from "viem/chains";
18
+ import { AuthCaptureEvmScheme, toClientEvmSigner } from "@x402/evm";
19
+
20
+ function account() {
21
+ const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
22
+ if (!pk) throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set");
23
+ return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`);
24
+ }
25
+ function chain() {
26
+ return Number(process.env.CYBERDYNE_CHAIN_ID ?? 8453) === 8453 ? base : baseSepolia;
27
+ }
28
+
29
+ const ERC20_TRANSFER_ABI = [
30
+ {
31
+ name: "transfer",
32
+ type: "function",
33
+ stateMutability: "nonpayable",
34
+ inputs: [
35
+ { name: "to", type: "address" },
36
+ { name: "amount", type: "uint256" },
37
+ ],
38
+ outputs: [{ type: "bool" }],
39
+ },
40
+ ] as const;
41
+
42
+ /**
43
+ * Pay the SEPARATE deploy fee (2.5%/5%, pure revenue) from the MCP-held wallet:
44
+ * a plain ERC-20 transfer to the fee recipient. Returns the tx hash to pass to
45
+ * authorize_task as `fee_tx_hash`. Only used on the POOL rail; the agent pays
46
+ * this gas (CYBERDYNE absorbs none). USDC is 6-dp (v1 pool settles in USDC).
47
+ */
48
+ export async function payDeployFee(params: {
49
+ amountUsd: number;
50
+ recipient: string;
51
+ token: string;
52
+ }): Promise<string> {
53
+ const wallet = createWalletClient({ account: account(), chain: chain(), transport: http(process.env.CYBERDYNE_RPC_URL) });
54
+ return wallet.writeContract({
55
+ address: params.token as `0x${string}`,
56
+ abi: ERC20_TRANSFER_ABI,
57
+ functionName: "transfer",
58
+ args: [params.recipient as `0x${string}`, parseUnits(params.amountUsd.toFixed(6), 6)],
59
+ chain: chain(),
60
+ });
61
+ }
62
+
63
+ export function hasEvmKey(): boolean {
64
+ return !!process.env.CYBERDYNE_EVM_PRIVATE_KEY;
65
+ }
66
+
67
+ function scheme() {
68
+ const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
69
+ if (!pk) throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set — cannot sign the escrow authorization");
70
+ const chainId = Number(process.env.CYBERDYNE_CHAIN_ID ?? 8453);
71
+ const chain = chainId === 8453 ? base : baseSepolia;
72
+ const account = privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`);
73
+ const pub = createPublicClient({ chain, transport: http(process.env.CYBERDYNE_RPC_URL) });
74
+ // toClientEvmSigner(account, PUBLIC client) — account first, public client second.
75
+ return new AuthCaptureEvmScheme(toClientEvmSigner(account, pub));
76
+ }
77
+
78
+ /** The agent's signing wallet address (so the platform can verify payer == this). */
79
+ export function evmAddress(): string {
80
+ const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY!;
81
+ return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`).address;
82
+ }
83
+
84
+ /**
85
+ * Sign the auth-capture requirements (the `authIntent.requirements` the platform
86
+ * returns) → base64 payload the platform's authorize route consumes as `signedPayment`.
87
+ */
88
+ export async function signAuthCapture(requirements: unknown): Promise<string> {
89
+ const result = await scheme().createPaymentPayload(
90
+ 2,
91
+ requirements as Parameters<AuthCaptureEvmScheme["createPaymentPayload"]>[1],
92
+ );
93
+ return Buffer.from(JSON.stringify(result)).toString("base64");
94
+ }
package/src/server.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * assign_task — POST /api/tasks/[id]/assign → pick a human (→ authIntent)
17
17
  * authorize_task — POST /api/tasks/[id]/authorize → open the escrow hold
18
18
  * get_task — GET /api/tasks/[id] → status + submissions/claims
19
- * release_payment — POST /api/tasks/[id]/release → capture (pay) or reject
19
+ * release_payment — POST /api/tasks/[id]/release → direct-hire: capture (pay) or reject
20
+ * review_submission — POST /api/submissions/[id]/review → pool/FCFS: approve/reject one submission
20
21
  * close_task — POST /api/tasks/[id]/close → close a (multi-unit) bounty
21
22
  *
22
23
  * Auth: every networked tool sends the agent's `cyb_…` key. The REST routes take
@@ -25,34 +26,69 @@
25
26
  * `identity_token`.
26
27
  *
27
28
  * The HUMAN submit-proof step happens in the app/UI (human-only — agents cannot
28
- * submit on a human's behalf). So an agent's end-to-end flow is:
29
- * (live: get_deposit_address → send USDC → deposit) → post_task
30
- * (humans claim, or assign_task picks one)
31
- * assign_taskauthorize_task (open the hold)
32
- * → poll get_task until a submission appears
33
- * release_payment (approve capture; else rejectrefund)
29
+ * submit on a human's behalf). There are TWO settlement flows:
30
+ *
31
+ * FLOW A — DIRECT HIRE (the path that works TODAY on the live custodial USDC
32
+ * rail: real deposit escrowwithdraw on Base mainnet). You pick one human:
33
+ * get_deposit_address send USDC deposit (fund the treasury)
34
+ * post_tasksearch_humans assign_task (authIntent)
35
+ * → authorize_task (open the escrow hold)
36
+ * → poll get_task until a submission is pending
37
+ * → release_payment (approve → capture/pay; else reject → refund)
38
+ *
39
+ * FLOW B — POOL / FCFS BOUNTY (non-custodial pool escrow). Post a multi-unit
40
+ * bounty, freeze the whole budget once, and let any eligible human claim+submit
41
+ * first-come-first-served; you approve each unit. This rail is BUILT but GATED
42
+ * OFF today (server env ESCROW_POOL is not enabled), pending certification — so
43
+ * real-money non-custodial pool payouts are NOT live yet. When the server
44
+ * enables it, post_task returns an `authIntent` + a separate `deployFee`:
45
+ * post_task (quantity>1) → authorize_task (sign the budget + pay the deploy fee)
46
+ * → humans claim+submit FCFS → poll get_task
47
+ * → review_submission per pending submission (approve → capture one unit;
48
+ * reject → the slot reopens) → close_task to refund unfilled units.
34
49
  *
35
50
  * Config comes from the environment (see src/client.ts):
36
51
  * CYBERDYNE_API_URL default "https://app.cyberdyne-os.xyz"
37
52
  * CYBERDYNE_IDENTITY_TOKEN the agent's cyb_ key (required for networked tools)
38
53
  */
54
+ import { readFileSync } from "node:fs";
39
55
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
40
56
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
41
57
  import { z } from "zod";
42
58
  import { CATEGORIES, TASK_CATEGORIES } from "./registry.js";
43
59
  import { ApiError, CyberdyneClient, MissingTokenError, readConfig, saveToken } from "./client.js";
44
60
 
45
- // `cyberdyne-mcp login cyb_…` — persist the key so the MCP add line can omit it
46
- // (short DFM-style install). Runs before the server boots, then exits.
61
+ // `cyberdyne-mcp login` — persist the key so the MCP add line can omit it (short
62
+ // one-time-login install). Runs before the server boots, then exits. The key is read
63
+ // (most-private first) from: piped stdin → CYBERDYNE_LOGIN_TOKEN env → argv. argv
64
+ // works but lands the secret in shell history / `ps`, so we steer to the others.
47
65
  if (process.argv[2] === "login") {
48
- const token = process.argv[3]?.trim();
49
- if (!token || !token.startsWith("cyb_")) {
66
+ const fromArg = process.argv[3]?.trim();
67
+ let token = "";
68
+ if (!process.stdin.isTTY) {
69
+ try {
70
+ token = readFileSync(0, "utf8").trim(); // piped: echo cyb_… | npx cyberdyne-mcp login
71
+ } catch {
72
+ /* nothing piped */
73
+ }
74
+ }
75
+ token = token || process.env.CYBERDYNE_LOGIN_TOKEN?.trim() || fromArg || "";
76
+ if (!token.startsWith("cyb_")) {
50
77
  console.error(
51
- "Usage: npx cyberdyne-mcp login cyb_<your-key>\n" +
78
+ "Save your CYBERDYNE key (most private first):\n" +
79
+ " echo cyb_<key> | npx cyberdyne-mcp login\n" +
80
+ " CYBERDYNE_LOGIN_TOKEN=cyb_<key> npx cyberdyne-mcp login\n" +
81
+ " npx cyberdyne-mcp login cyb_<key> (key is left in shell history / process list)\n" +
52
82
  "Get your key at https://app.cyberdyne-os.xyz → Agent Console → Generate API key.",
53
83
  );
54
84
  process.exit(1);
55
85
  }
86
+ if (fromArg && token === fromArg) {
87
+ console.error(
88
+ "⚠ Heads-up: passing the key as an argument leaves it in your shell history.\n" +
89
+ " Next time, pipe it instead: echo cyb_<key> | npx cyberdyne-mcp login",
90
+ );
91
+ }
56
92
  const path = saveToken(token);
57
93
  console.error(
58
94
  `✓ Saved your CYBERDYNE key to ${path}.\n` +
@@ -154,7 +190,7 @@ server.tool(
154
190
 
155
191
  server.tool(
156
192
  "post_task",
157
- "Open a task on the marketplace. Funds are NOT charged at post — the escrow hold opens later at authorize_task. On the manual rail the platform only checks the treasury can cover the budget (402 insufficient_treasury otherwise). `reward_usd` is the total budget; with quantity>1 each unit holds reward_usd/quantity. Returns the created task (with its id).",
193
+ "Open a task on the marketplace. Funds are NOT charged at post — the escrow hold opens later at authorize_task. `reward_usd` is the total budget; with quantity>1 each unit holds reward_usd/quantity (each unit must be >= $0.01). Returns the created task (with its id). DIRECT-HIRE / custodial rail (the path live today): the platform checks the prefunded treasury can cover the budget (402 insufficient_treasury otherwise); response is { task }. POOL/FCFS rail (only when the server enables it): response also includes `authIntent` (the budget authorization to sign) and `deployFee` { usd, bps, recipient, token } (a SEPARATE non-refundable fee tx) — pass both to authorize_task.",
158
194
  {
159
195
  title: z.string().min(2).max(160).describe("Short task title."),
160
196
  category: z.enum(TASK_CATEGORIES),
@@ -183,20 +219,41 @@ server.tool(
183
219
 
184
220
  server.tool(
185
221
  "authorize_task",
186
- "Open the escrow hold for an assigned task (poster-only). On the manual rail the body is empty (logical treasury debit). On an on-chain rail pass `signed_payment` the base64 agent-signed auth-capture payload from the authIntent returned by assign_task. Idempotent once held.",
222
+ "Open the escrow hold for a task. CUSTODIAL/MANUAL rail (the path live today): call with just { task_id } — the prefunded treasury is debited into a logical escrow hold, no signature needed. TRUSTLESS on-chain DIRECT-HIRE rail: the agent signs an auth-capture authorization — if CYBERDYNE_EVM_PRIVATE_KEY is set pass `auth_intent` (the authIntent from assign_task) and the MCP signs automatically, else pass a pre-signed `signed_payment`. POOL/FCFS rail (only when the server enables it): pass BOTH `auth_intent` (from post_task) AND `deploy_fee` (the deployFee object from post_task) — the MCP signs the budget and pays the separate 2.5% USDC / 5% other-token fee tx from its wallet, then submits both; or pass a pre-signed `signed_payment` and a pre-paid `fee_tx_hash`. Idempotent once held.",
187
223
  {
188
224
  task_id: z.string().uuid(),
189
- signed_payment: z
190
- .string()
225
+ signed_payment: z.string().optional().describe("Pre-signed base64 auth-capture payload (external/Bankr signer)."),
226
+ auth_intent: z.unknown().optional().describe("The authIntent from assign_task/post — required for MCP wallet auto-signing."),
227
+ deploy_fee: z
228
+ .unknown()
191
229
  .optional()
192
- .describe("On-chain rail only: base64-encoded signed auth-capture payload."),
230
+ .describe("POOL rail: the deployFee object {usd,recipient,token} from post_task — the MCP auto-pays it."),
231
+ fee_tx_hash: z.string().optional().describe("POOL rail: hash of an already-paid deploy-fee tx (skips auto-pay)."),
193
232
  },
194
- async ({ task_id, signed_payment }) =>
195
- guard(() =>
196
- client.rest("POST", `/api/tasks/${task_id}/authorize`, {
197
- body: signed_payment ? { signedPayment: signed_payment } : {},
198
- }),
199
- ),
233
+ async ({ task_id, signed_payment, auth_intent, deploy_fee, fee_tx_hash }) =>
234
+ guard(async () => {
235
+ let payload = signed_payment;
236
+ let feeTx = fee_tx_hash;
237
+ if ((!payload && auth_intent) || (!feeTx && deploy_fee)) {
238
+ const { hasEvmKey, signAuthCapture, payDeployFee } = await import("./evm-signer.js");
239
+ if (hasEvmKey()) {
240
+ if (!payload && auth_intent) {
241
+ const requirements = (auth_intent as { requirements?: unknown }).requirements ?? auth_intent;
242
+ payload = await signAuthCapture(requirements);
243
+ }
244
+ if (!feeTx && deploy_fee) {
245
+ const f = deploy_fee as { usd: number; recipient: string; token: string };
246
+ feeTx = await payDeployFee({ amountUsd: f.usd, recipient: f.recipient, token: f.token });
247
+ }
248
+ }
249
+ }
250
+ return client.rest("POST", `/api/tasks/${task_id}/authorize`, {
251
+ body: {
252
+ ...(payload ? { signedPayment: payload } : {}),
253
+ ...(feeTx ? { fee_tx_hash: feeTx } : {}),
254
+ },
255
+ });
256
+ }),
200
257
  );
201
258
 
202
259
  server.tool(
@@ -208,7 +265,7 @@ server.tool(
208
265
 
209
266
  server.tool(
210
267
  "release_payment",
211
- "Settle a submitted proof (poster-only). approve:true → CAPTURE: pay the human net of platform fee. approve:false → REJECT/REFUND the held escrow. Requires the `submission_id` to act on; if omitted, the gateway fetches the task and uses the latest pending submission (and errors if none is pending yet — poll get_task first).",
268
+ "DIRECT-HIRE settle (poster-only): settle a submitted proof for a single-human task (the custodial-rail path live today). approve:true → CAPTURE: pay the human net of the platform fee. approve:false → REJECT/REFUND the held escrow. Requires the `submission_id` to act on; if omitted, the gateway fetches the task and uses the latest pending submission (and errors if none is pending yet — poll get_task first). For POOL/FCFS bounties use review_submission instead (this path captures the whole task hold, not one unit).",
212
269
  {
213
270
  task_id: z.string().uuid(),
214
271
  approve: z.boolean().describe("true = proof meets criteria → pay; false = reject/refund."),
@@ -251,6 +308,29 @@ server.tool(
251
308
  }),
252
309
  );
253
310
 
311
+ server.tool(
312
+ "review_submission",
313
+ "POOL / FCFS settle (poster-only): approve or reject ONE submission on a pool bounty. approve:true → CAPTURE one unit from the frozen pool budget to the human and consume a slot; approve:false → reject (the slot reopens for the next submitter — no spot-blocking). For single-human direct-hire tasks use release_payment instead. Poll get_task for pending submissions. NOTE: the pool/FCFS rail is gated off on the server until certification — this tool acts on pool tasks once the operator enables that rail.",
314
+ {
315
+ submission_id: z.string().uuid().describe("The pending submission to review (from get_task)."),
316
+ approve: z.boolean().describe("true = proof meets criteria → capture one unit; false = reject (slot reopens)."),
317
+ score: z.number().int().min(1).max(5).optional().describe("Rating of the human's work (1–5)."),
318
+ comment: z.string().max(280).optional().describe("Optional feedback note on the human."),
319
+ reject_reason: z.string().max(1000).optional().describe("Why the proof was rejected (approve:false)."),
320
+ },
321
+ async ({ submission_id, approve, score, comment, reject_reason }) =>
322
+ guard(() =>
323
+ client.rest("POST", `/api/submissions/${submission_id}/review`, {
324
+ body: {
325
+ approve,
326
+ ...(score != null ? { score } : {}),
327
+ ...(comment ? { comment } : {}),
328
+ ...(reject_reason ? { reject_reason } : {}),
329
+ },
330
+ }),
331
+ ),
332
+ );
333
+
254
334
  server.tool(
255
335
  "close_task",
256
336
  "Close a (multi-unit) bounty (poster-only): refund every still-held unit to the agent, mark unclaimed units done, and stop further claims. Idempotent on an already-closed task.",
@@ -266,7 +346,7 @@ server.registerPrompt(
266
346
  "quickstart",
267
347
  {
268
348
  title: "CYBERDYNE quickstart",
269
- description: "How to fund, post a campaign, and pay humans end-to-end (live rail).",
349
+ description: "How to fund, post a task, and pay humans end-to-end both the direct-hire and pool/FCFS flows.",
270
350
  },
271
351
  () => ({
272
352
  messages: [
@@ -275,22 +355,28 @@ server.registerPrompt(
275
355
  content: {
276
356
  type: "text",
277
357
  text: [
278
- "You are connected to CYBERDYNE — hire and pay verified humans for tasks AI can't do alone. Settlement is REAL USDC on Base.",
358
+ "You are connected to CYBERDYNE — hire and pay verified humans for tasks AI can't do alone. The live settlement rail is REAL USDC on Base (custodial: deposit -> escrow -> withdraw). The human submit-proof step is human-only, in the app; you drive everything else.",
359
+ "",
360
+ "FUND (real money, custodial rail):",
361
+ "1. get_deposit_address -> the platform deposit address on Base.",
362
+ "2. Send USDC to it FROM your own verified wallet (the one you signed in with).",
363
+ "3. deposit({ tx_hash }) -> credits your treasury by the verified amount (idempotent).",
364
+ " (fund_treasury is demo/testnet only and is disabled on the live rail.)",
365
+ "Check get_treasury anytime for your balance.",
279
366
  "",
280
- "FUND (live rail, real money):",
281
- "1. get_deposit_address returns the platform deposit address on Base.",
282
- "2. Send USDC to that address FROM your own verified wallet (the one you signed in with).",
283
- "3. deposit({ tx_hash }) credits your treasury by the verified amount (idempotent).",
284
- " (fund_treasury is demo-only and is disabled on the live rail.)",
367
+ "FLOW A - DIRECT HIRE (works today): pick one human, hold, then pay.",
368
+ "4. post_task({ title, category, reward_usd, duration_min, difficulty }). Returns { task }. Use reward_usd >= 0.50 so the 2.5% fee is visible; each unit must be >= $0.01.",
369
+ "5. search_humans({ skills, min_reputation }) -> assign_task({ task_id, human_id }) -> authorize_task({ task_id }) to open the escrow hold (custodial rail = no signature; just task_id).",
370
+ "6. Poll get_task until a submission is pending (the human's proof).",
371
+ "7. release_payment({ task_id, approve: true, score }) -> CAPTURE: net USDC to the human, the 2.5% fee to the protocol wallet. approve:false rejects and refunds the hold.",
285
372
  "",
286
- "RUN A CAMPAIGN:",
287
- "4. post_task({ title, category, reward_usd, quantity, duration_min, difficulty }) reward_usd is the TOTAL budget; with quantity>1 each unit holds reward_usd/quantity. Use reward_usd ≥ 0.50 so the 2.5% fee is visible.",
288
- "5. Humans claim units and submit proof (the submit step is human-only, in the app you cannot submit for them). Poll get_task until a submission is pending.",
289
- " - Or pick someone yourself: search_humans({ skills, min_reputation }) assign_task({ task_id, human_id }) authorize_task({ task_id }) to open the hold.",
290
- "6. release_payment({ task_id, approve: true, score }) captures: net USDC is paid to the human, the 2.5% fee goes to the protocol wallet. approve:false refunds the hold.",
291
- "7. close_task({ task_id }) → refund any still-unclaimed units of a multi-unit bounty.",
373
+ "FLOW B - POOL / FCFS BOUNTY: one frozen budget, many humans claim+submit first-come-first-served. NOTE: this non-custodial pool rail is BUILT but GATED OFF on the server today (pending certification) - real-money pool payouts are not live yet. When the operator enables it:",
374
+ "8. post_task({ ..., quantity: N }) -> returns { task, authIntent, deployFee }. authIntent is the budget authorization; deployFee is a SEPARATE non-refundable fee tx (2.5% USDC / 5% other token).",
375
+ "9. authorize_task({ task_id, auth_intent, deploy_fee }) -> with CYBERDYNE_EVM_PRIVATE_KEY set, the MCP signs the budget AND pays the deploy fee, then freezes the whole budget (or pass pre-made signed_payment + fee_tx_hash).",
376
+ "10. Humans claim+submit FCFS. Poll get_task; for each pending submission call review_submission({ submission_id, approve, score }) -> approve captures one unit; reject reopens the slot.",
377
+ "11. close_task({ task_id }) -> refund any still-unfilled units (the deploy fee is non-refundable).",
292
378
  "",
293
- "Check get_treasury anytime for your balance. Every payout and fee is a real on-chain tx.",
379
+ "Every payout and fee on the live rail is a real on-chain transaction.",
294
380
  ].join("\n"),
295
381
  },
296
382
  },
@@ -305,5 +391,5 @@ await server.connect(transport);
305
391
  console.error(
306
392
  `CYBERDYNE MCP server running on stdio → ${config.apiUrl}` +
307
393
  (config.token ? "" : " (no key — run `npx cyberdyne-mcp login cyb_…` or set CYBERDYNE_IDENTITY_TOKEN; networked tools error until then)") +
308
- ". Tools: list_categories, search_humans, get_treasury, fund_treasury, get_deposit_address, deposit, post_task, assign_task, authorize_task, get_task, release_payment, close_task.",
394
+ ". Tools (13): list_categories, search_humans, get_treasury, fund_treasury, get_deposit_address, deposit, post_task, assign_task, authorize_task, get_task, release_payment, review_submission, close_task.",
309
395
  );