cyberdyne-mcp 0.5.4 → 0.6.1

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
@@ -11,6 +11,34 @@ stdio and the marketplace appears as tools. Each tool is a thin, typed wrapper
11
11
  over the **live CYBERDYNE platform API** — there is no in-memory demo state. The
12
12
  agent authenticates with its own API key.
13
13
 
14
+ ## Quickstart — zero-browser onboarding
15
+
16
+ An agent can go from **nothing** to **wallet + API key + ready to post/pay** with
17
+ one command — no web dashboard, no manual key copy/paste:
18
+
19
+ ```bash
20
+ npx -y cyberdyne-mcp onboard # generates a wallet + mints your cyb_ API key (no dashboard)
21
+ claude mcp add cyberdyne -- npx -y cyberdyne-mcp # the MCP now auto-uses the saved key
22
+ ```
23
+
24
+ `onboard` resolves a wallet (uses `CYBERDYNE_EVM_PRIVATE_KEY` if set, else a wallet
25
+ it generated on a prior run, else it **generates a fresh one**), signs in to
26
+ CYBERDYNE with it (SIWE — just a signature, no gas, no transaction), mints your
27
+ `cyb_` agent key, and saves **both** to `~/.cyberdyne/config.json` (mode `0600`).
28
+ It prints your wallet address and the `cyb_` key **once**. The same generated
29
+ wallet is then used automatically for pool-budget signing — zero env vars.
30
+
31
+ An agent already running inside an LLM with this MCP connected can **self-onboard
32
+ with zero web interaction** by calling the `onboard` tool — it's the one tool that
33
+ works without an existing key and bootstraps everything else. After that it can
34
+ fund (`get_deposit_address` → send USDC on Base → `deposit`) and `post_task` — and
35
+ `withdraw_treasury` to recover unspent treasury to your wallet.
36
+
37
+ > Mirrors the Bankr CLI UX (`bankr login` → wallet + API key in one shot). The
38
+ > human **submit-proof** step is intentionally still in the app (human-only); every
39
+ > agent-side action — onboard, fund, post, assign, authorize, review, close — is
40
+ > headless.
41
+
14
42
  ## Configuration (environment)
15
43
 
16
44
  stdio MCP servers take their credentials from the environment. Set:
@@ -20,8 +48,9 @@ stdio MCP servers take their credentials from the environment. Set:
20
48
  | `CYBERDYNE_IDENTITY_TOKEN` | yes (for any networked tool) | — | The agent's API key (`cyb_…`). |
21
49
  | `CYBERDYNE_API_URL` | no | `https://app.cyberdyne-os.xyz` | Base URL of the platform API. |
22
50
 
23
- No key is hardcoded anywhere. `list_categories` works without a token; every other
24
- tool returns a clear error until `CYBERDYNE_IDENTITY_TOKEN` is set.
51
+ No key is hardcoded anywhere. `list_categories` and `onboard` work without a token
52
+ (`onboard` mints one); every other tool returns a clear error until a key is set
53
+ via `CYBERDYNE_IDENTITY_TOKEN`, a saved `onboard`/`login`, or the `onboard` tool.
25
54
 
26
55
  ## The flows
27
56
 
@@ -66,12 +95,14 @@ post_task (quantity > 1) → { task, authIntent, depl
66
95
 
67
96
  | Tool | Endpoint | What it does |
68
97
  |---|---|---|
98
+ | `onboard` | `siwe/nonce → siwe/verify → agent/key` | **Bootstrap (no key needed).** Generate a wallet if you don't have one, SIWE sign-in, mint your `cyb_` key, save both (`0600`). Zero browser. |
69
99
  | `list_categories` | — (static) | The seven task categories. No network. |
70
100
  | `search_humans` | `POST /api/a2a` `{search_humans}` | Query the capability index by `skills[]`, `min_reputation`, `location`. Ranked by reputation; public columns only. |
71
101
  | `get_treasury` | `GET /api/treasury` | The agent's own treasury (null if none yet). |
72
102
  | `fund_treasury` | `POST /api/treasury/fund` | **Testnet/demo** top-up (disabled on the live rail). |
73
103
  | `get_deposit_address` | `GET /api/treasury/deposit` | Where to send real USDC to fund the treasury (live rail). |
74
104
  | `deposit` | `POST /api/treasury/deposit` | Credit the treasury from a real on-chain USDC deposit (tx hash). |
105
+ | `withdraw_treasury` | `POST /api/treasury/withdraw` | Recover unspent treasury to your wallet — pull USDC back to your own verified deposit wallet on Base (live rail; no destination param, so funds can only go to you). |
75
106
  | `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
107
  | `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
108
  | `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`. |
@@ -116,16 +147,25 @@ CYBERDYNE_IDENTITY_TOKEN=cyb_… npm run build && npm run founder-check
116
147
 
117
148
  ## Install
118
149
 
119
- Published on [npm](https://www.npmjs.com/package/cyberdyne-mcp). Mint your `cyb_…`
120
- agent key in the app's Agent Console, then:
150
+ Published on [npm](https://www.npmjs.com/package/cyberdyne-mcp).
151
+
152
+ **Fully autonomous (recommended) — no dashboard:**
153
+
154
+ ```bash
155
+ npx -y cyberdyne-mcp onboard # generate a wallet + mint your cyb_ key, save both (0600)
156
+ claude mcp add cyberdyne -- npx -y cyberdyne-mcp
157
+ ```
158
+
159
+ **Already minted a key in the app's Agent Console?** Save it instead:
121
160
 
122
161
  ```bash
123
162
  npx cyberdyne-mcp login cyb_YOURKEY # save your key once (~/.cyberdyne/config.json, 0600)
124
163
  claude mcp add cyberdyne -- npx -y cyberdyne-mcp
125
164
  ```
126
165
 
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`.)*
166
+ *(Prefer not to save a login? Pass it inline instead:
167
+ `claude mcp add cyberdyne -e CYBERDYNE_IDENTITY_TOKEN=cyb_… -- npx -y cyberdyne-mcp`.
168
+ Or skip the CLI entirely and call the `onboard` tool from inside the agent.)*
129
169
 
130
170
  ### …or install the plugin (skill + MCP together)
131
171
 
package/dist/client.js CHANGED
@@ -25,25 +25,38 @@ export const DEFAULT_API_URL = "https://app.cyberdyne-os.xyz";
25
25
  export function configPath() {
26
26
  return join(homedir(), ".cyberdyne", "config.json");
27
27
  }
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() {
28
+ /** Read + parse the whole config file (or {} if missing / unreadable). */
29
+ function readSavedConfig() {
32
30
  try {
33
31
  const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
34
- return typeof parsed?.identity_token === "string" ? parsed.identity_token.trim() : undefined;
32
+ return parsed && typeof parsed === "object" ? parsed : {};
35
33
  }
36
34
  catch {
37
- return undefined;
35
+ return {};
38
36
  }
39
37
  }
40
- /** Persist the agent key to ~/.cyberdyne/config.json. Returns the path. */
41
- export function saveToken(token) {
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.
38
+ // Only the key is persisted (plus the generated wallet key). The API endpoint is
39
+ // intentionally NOT read from this file — a tampered config must never be able to
40
+ // redirect the agent's key to a hostile host (credential exfiltration). The
41
+ // endpoint overrides via env only.
42
+ function readSavedToken() {
43
+ const v = readSavedConfig().identity_token;
44
+ return typeof v === "string" ? v.trim() : undefined;
45
+ }
46
+ /** The generated/saved wallet private key (0x…), if any. Used as the EVM signer fallback. */
47
+ export function readSavedWalletKey() {
48
+ const v = readSavedConfig().walletKey;
49
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
50
+ }
51
+ /**
52
+ * Atomically write the config file at ~/.cyberdyne/config.json, mode 0600.
53
+ * Owner-only dir + atomic 0600 create (no world-readable window / TOCTOU), and
54
+ * O_NOFOLLOW so a planted symlink at the path can't redirect the write.
55
+ */
56
+ function writeConfigFile(data) {
44
57
  mkdirSync(join(homedir(), ".cyberdyne"), { recursive: true, mode: 0o700 });
45
58
  const p = configPath();
46
- const contents = JSON.stringify({ identity_token: token.trim() }, null, 2);
59
+ const contents = JSON.stringify(data, null, 2);
47
60
  let flags = FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC;
48
61
  if (typeof FS.O_NOFOLLOW === "number")
49
62
  flags |= FS.O_NOFOLLOW;
@@ -64,6 +77,26 @@ export function saveToken(token) {
64
77
  }
65
78
  return p;
66
79
  }
80
+ /** Persist the agent key to ~/.cyberdyne/config.json (preserving any saved walletKey). Returns the path. */
81
+ export function saveToken(token) {
82
+ const existing = readSavedConfig();
83
+ const next = { identity_token: token.trim() };
84
+ if (typeof existing.walletKey === "string" && existing.walletKey.trim())
85
+ next.walletKey = existing.walletKey.trim();
86
+ return writeConfigFile(next);
87
+ }
88
+ /** Persist the generated wallet private key (preserving any saved token). Returns the path. */
89
+ export function saveWallet(walletKey) {
90
+ const existing = readSavedConfig();
91
+ const next = { walletKey: walletKey.trim() };
92
+ if (typeof existing.identity_token === "string" && existing.identity_token.trim())
93
+ next.identity_token = existing.identity_token.trim();
94
+ return writeConfigFile(next);
95
+ }
96
+ /** Persist BOTH the agent key and the wallet key in a single 0600 write. Returns the path. */
97
+ export function saveTokenAndWallet(token, walletKey) {
98
+ return writeConfigFile({ identity_token: token.trim(), walletKey: walletKey.trim() });
99
+ }
67
100
  /** Resolve config: token from env first, then the saved login. URL from env only. */
68
101
  export function readConfig(env = process.env) {
69
102
  const apiUrl = (env.CYBERDYNE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
@@ -16,10 +16,20 @@ import { privateKeyToAccount } from "viem/accounts";
16
16
  import { createPublicClient, createWalletClient, http, parseUnits } from "viem";
17
17
  import { base, baseSepolia } from "viem/chains";
18
18
  import { AuthCaptureEvmScheme, toClientEvmSigner } from "@x402/evm";
19
+ import { readSavedWalletKey } from "./client.js";
20
+ /**
21
+ * Resolve the signing private key: CYBERDYNE_EVM_PRIVATE_KEY first, else the
22
+ * walletKey generated at `onboard` and saved in ~/.cyberdyne/config.json. This is
23
+ * what makes onboard → post pool task → authorize all work with the SAME wallet
24
+ * and zero env vars.
25
+ */
26
+ function resolvePrivateKey() {
27
+ return process.env.CYBERDYNE_EVM_PRIVATE_KEY?.trim() || readSavedWalletKey();
28
+ }
19
29
  function account() {
20
- const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
30
+ const pk = resolvePrivateKey();
21
31
  if (!pk)
22
- throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set");
32
+ throw new Error("no signing key (set CYBERDYNE_EVM_PRIVATE_KEY or run `cyberdyne-mcp onboard`)");
23
33
  return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`));
24
34
  }
25
35
  function chain() {
@@ -61,12 +71,12 @@ export async function payDeployFee(params) {
61
71
  return hash;
62
72
  }
63
73
  export function hasEvmKey() {
64
- return !!process.env.CYBERDYNE_EVM_PRIVATE_KEY;
74
+ return !!resolvePrivateKey();
65
75
  }
66
76
  function scheme() {
67
- const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
77
+ const pk = resolvePrivateKey();
68
78
  if (!pk)
69
- throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set — cannot sign the escrow authorization");
79
+ throw new Error("no signing key (set CYBERDYNE_EVM_PRIVATE_KEY or run `cyberdyne-mcp onboard`) — cannot sign the escrow authorization");
70
80
  const chainId = Number(process.env.CYBERDYNE_CHAIN_ID ?? 8453);
71
81
  const chain = chainId === 8453 ? base : baseSepolia;
72
82
  const account = privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`));
@@ -76,7 +86,9 @@ function scheme() {
76
86
  }
77
87
  /** The agent's signing wallet address (so the platform can verify payer == this). */
78
88
  export function evmAddress() {
79
- const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
89
+ const pk = resolvePrivateKey();
90
+ if (!pk)
91
+ throw new Error("no signing key (set CYBERDYNE_EVM_PRIVATE_KEY or run `cyberdyne-mcp onboard`)");
80
92
  return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`)).address;
81
93
  }
82
94
  /**
@@ -0,0 +1,145 @@
1
+ /**
2
+ * FULLY AUTONOMOUS, zero-browser agent onboarding for CYBERDYNE.
3
+ *
4
+ * Goal: an agent goes from "nothing" to "has a wallet + cyb_ API key + can
5
+ * post/pay" using ONLY the CLI/MCP — never the web dashboard. This mirrors the
6
+ * Bankr CLI UX (`bankr login` generates a wallet + API key in one shot).
7
+ *
8
+ * The chain (all on the live platform API, no browser):
9
+ * 1. resolve a wallet — CYBERDYNE_EVM_PRIVATE_KEY → saved walletKey → generate fresh
10
+ * 2. GET /api/auth/siwe/nonce → { nonce } (+ sets a siwe-nonce cookie)
11
+ * 3. build the EXACT SIWE message and sign it with the wallet (EIP-191)
12
+ * 4. POST /api/auth/siwe/verify { message, signature, role:"agent" } → session cookie
13
+ * 5. POST /api/agent/key (session cookie) {} → { apiKey: "cyb_…" }
14
+ * 6. persist BOTH the cyb_ key and the wallet key to ~/.cyberdyne/config.json (0600)
15
+ *
16
+ * Nothing here moves funds or pays gas — onboarding is just a SIWE signature and
17
+ * a DB row. The wallet private key is persisted but NEVER logged to stdout.
18
+ */
19
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
20
+ import { readConfig, readSavedWalletKey, saveTokenAndWallet } from "./client.js";
21
+ /** Normalise a private key to the 0x-prefixed form viem expects. */
22
+ function normalizeKey(pk) {
23
+ return (pk.startsWith("0x") ? pk : `0x${pk}`);
24
+ }
25
+ /**
26
+ * Resolve the onboarding wallet, most-explicit first:
27
+ * 1. CYBERDYNE_EVM_PRIVATE_KEY (env) — operator-supplied
28
+ * 2. saved walletKey in config — generated on a prior onboard
29
+ * 3. generate a fresh key — first run, zero config
30
+ * Always returns a usable account + the private key. Persisting a freshly
31
+ * generated key is the caller's job (onboard() does it atomically with the token).
32
+ */
33
+ export function resolveWallet(env = process.env) {
34
+ const fromEnv = env.CYBERDYNE_EVM_PRIVATE_KEY?.trim();
35
+ if (fromEnv) {
36
+ const privateKey = normalizeKey(fromEnv);
37
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false };
38
+ }
39
+ const saved = readSavedWalletKey();
40
+ if (saved) {
41
+ const privateKey = normalizeKey(saved);
42
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false };
43
+ }
44
+ const privateKey = generatePrivateKey();
45
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: true };
46
+ }
47
+ /** Pull every Set-Cookie off a response into a `name=value; name=value` Cookie header. */
48
+ function collectCookies(res, jar) {
49
+ // getSetCookie() returns each Set-Cookie line separately (undici/Node 18.18+).
50
+ const lines = typeof res.headers.getSetCookie === "function"
51
+ ? res.headers.getSetCookie()
52
+ : (res.headers.get("set-cookie") ? [res.headers.get("set-cookie")] : []);
53
+ for (const line of lines) {
54
+ const pair = line.split(";")[0]?.trim();
55
+ if (!pair)
56
+ continue;
57
+ const eq = pair.indexOf("=");
58
+ if (eq <= 0)
59
+ continue;
60
+ jar.set(pair.slice(0, eq), pair.slice(eq + 1));
61
+ }
62
+ }
63
+ function cookieHeader(jar) {
64
+ return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
65
+ }
66
+ /**
67
+ * Run the full SIWE → mint chain and persist the credentials. Throws on any
68
+ * non-2xx step (with the endpoint + status) so the caller can surface a clear error.
69
+ */
70
+ export async function onboard(env = process.env) {
71
+ const { apiUrl } = readConfig(env);
72
+ const HOST = new URL(apiUrl).host;
73
+ const { account, privateKey, generated } = resolveWallet(env);
74
+ const address = account.address;
75
+ const jar = new Map();
76
+ // 1. nonce
77
+ const nonceRes = await fetch(`${apiUrl}/api/auth/siwe/nonce`, { headers: { accept: "application/json" } });
78
+ if (!nonceRes.ok)
79
+ throw new Error(`GET /api/auth/siwe/nonce → ${nonceRes.status}`);
80
+ collectCookies(nonceRes, jar); // carry siwe-nonce to verify
81
+ const { nonce } = (await nonceRes.json());
82
+ if (!nonce)
83
+ throw new Error("GET /api/auth/siwe/nonce → no nonce in response");
84
+ // 2. build the EXACT SIWE message (newline-joined) and sign it
85
+ const message = [
86
+ `${HOST} wants you to sign in with your Ethereum account:`,
87
+ address,
88
+ "",
89
+ "Sign in to CYBERDYNE — get paid by AI. This request will not trigger a blockchain transaction or cost any gas.",
90
+ "",
91
+ `URI: ${apiUrl}`,
92
+ "Version: 1",
93
+ "Chain ID: 8453",
94
+ `Nonce: ${nonce}`,
95
+ `Issued At: ${new Date().toISOString()}`,
96
+ ].join("\n");
97
+ const signature = await account.signMessage({ message });
98
+ // 3. verify → session cookie
99
+ const verifyRes = await fetch(`${apiUrl}/api/auth/siwe/verify`, {
100
+ method: "POST",
101
+ headers: {
102
+ "content-type": "application/json",
103
+ accept: "application/json",
104
+ ...(jar.size ? { cookie: cookieHeader(jar) } : {}),
105
+ },
106
+ body: JSON.stringify({ message, signature, role: "agent" }),
107
+ });
108
+ if (!verifyRes.ok) {
109
+ const detail = await verifyRes.text().catch(() => "");
110
+ throw new Error(`POST /api/auth/siwe/verify → ${verifyRes.status} ${detail.slice(0, 200)}`);
111
+ }
112
+ collectCookies(verifyRes, jar); // capture the session cookie(s)
113
+ // 4. mint the agent key with the session cookie
114
+ const keyRes = await fetch(`${apiUrl}/api/agent/key`, {
115
+ method: "POST",
116
+ headers: {
117
+ "content-type": "application/json",
118
+ accept: "application/json",
119
+ cookie: cookieHeader(jar),
120
+ },
121
+ body: "{}",
122
+ });
123
+ if (!keyRes.ok) {
124
+ const detail = await keyRes.text().catch(() => "");
125
+ throw new Error(`POST /api/agent/key → ${keyRes.status} ${detail.slice(0, 200)}`);
126
+ }
127
+ const keyJson = (await keyRes.json());
128
+ const apiKey = keyJson.apiKey?.trim();
129
+ if (!apiKey || !apiKey.startsWith("cyb_")) {
130
+ throw new Error("POST /api/agent/key → no cyb_ apiKey in response");
131
+ }
132
+ // 5. persist BOTH the token and the wallet key atomically (0600)
133
+ const configPath = saveTokenAndWallet(apiKey, privateKey);
134
+ return { address, apiKey, generated, configPath };
135
+ }
136
+ /** The multi-line "next steps" block shared by the CLI + the MCP tool. */
137
+ export function nextStepsText() {
138
+ return [
139
+ "Next steps (no dashboard needed):",
140
+ " 1. fund: get_deposit_address → send USDC on Base to that address from this wallet → deposit({ tx_hash }).",
141
+ " 2. post_task({ title, category, reward_usd, duration_min, difficulty }).",
142
+ " 3. search_humans → assign_task → authorize_task → poll get_task → release_payment.",
143
+ "The same generated wallet auto-signs pool budgets (no env vars needed).",
144
+ ].join("\n");
145
+ }
package/dist/server.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * fund_treasury — POST /api/treasury/fund → demo top-up (testnet only)
13
13
  * get_deposit_address — GET /api/treasury/deposit → where to send real USDC (live)
14
14
  * deposit — POST /api/treasury/deposit → credit treasury from a real USDC tx
15
+ * withdraw_treasury — POST /api/treasury/withdraw → pull unspent treasury back to your wallet (live)
15
16
  * post_task — POST /api/tasks → open a task
16
17
  * assign_task — POST /api/tasks/[id]/assign → pick a human (→ authIntent)
17
18
  * authorize_task — POST /api/tasks/[id]/authorize → open the escrow hold
@@ -57,6 +58,28 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
57
58
  import { z } from "zod";
58
59
  import { CATEGORIES, TASK_CATEGORIES } from "./registry.js";
59
60
  import { ApiError, CyberdyneClient, MissingTokenError, readConfig, saveToken } from "./client.js";
61
+ import { onboard, nextStepsText } from "./onboard.js";
62
+ // `cyberdyne-mcp onboard` — FULLY AUTONOMOUS, zero-browser bootstrap (Bankr-style).
63
+ // Generates a wallet (if none) + mints a cyb_ API key via SIWE, saves both to
64
+ // ~/.cyberdyne/config.json (0600), prints the summary, then exits. No web dashboard.
65
+ if (process.argv[2] === "onboard") {
66
+ try {
67
+ const r = await onboard();
68
+ console.error(`✓ CYBERDYNE agent onboarded — wallet ${r.generated ? "GENERATED" : "loaded"} + API key minted.\n` +
69
+ `\n wallet address : ${r.address}` +
70
+ `\n API key (cyb_) : ${r.apiKey} ← shown once; saved to ${r.configPath}` +
71
+ (r.generated
72
+ ? `\n (a fresh wallet private key was generated and saved to ${r.configPath}; keep that file safe)`
73
+ : "") +
74
+ `\n\n${nextStepsText()}` +
75
+ `\n\nThis MCP is already configured for this agent — networked tools will use the saved key.`);
76
+ process.exit(0);
77
+ }
78
+ catch (e) {
79
+ console.error(`✗ onboard failed: ${e instanceof Error ? e.message : String(e)}`);
80
+ process.exit(1);
81
+ }
82
+ }
60
83
  // `cyberdyne-mcp login` — persist the key so the MCP add line can omit it (short
61
84
  // one-time-login install). Runs before the server boots, then exits. The key is read
62
85
  // (most-private first) from: piped stdin → CYBERDYNE_LOGIN_TOKEN env → argv. argv
@@ -114,8 +137,19 @@ async function guard(fn) {
114
137
  }
115
138
  }
116
139
  // ---- Server ---------------------------------------------------------------
117
- const server = new McpServer({ name: "cyberdyne", version: "0.5.4" });
140
+ const server = new McpServer({ name: "cyberdyne", version: "0.6.1" });
118
141
  server.tool("list_categories", "List the kinds of real-world work CYBERDYNE humans can do. Static (no network). Use this to learn the valid `category` values before posting a task.", {}, async () => json(Object.entries(CATEGORIES).map(([id, blurb]) => ({ id, blurb }))));
142
+ server.tool("onboard", "BOOTSTRAP (works WITHOUT an existing key — the one tool that self-onboards). Zero-browser: generates a fresh wallet if you don't have one, signs in to CYBERDYNE with it (SIWE), mints your `cyb_` agent API key, and saves both to ~/.cyberdyne/config.json (0600) so every other tool here authenticates automatically. No web dashboard, no env vars. Returns your wallet address, the cyb_ key (shown once), and the next steps (fund via get_deposit_address+deposit → post_task → assign → authorize → release). The same generated wallet auto-signs pool budgets. Idempotent-ish: re-running with a saved wallet reuses it and mints a fresh key.", {}, async () => guard(async () => {
143
+ const r = await onboard();
144
+ return {
145
+ address: r.address,
146
+ apiKey: r.apiKey,
147
+ generated: r.generated,
148
+ savedTo: r.configPath,
149
+ next_steps: nextStepsText(),
150
+ note: "API key + wallet saved (0600). This MCP now authenticates automatically; networked tools are ready.",
151
+ };
152
+ }));
119
153
  server.tool("search_humans", "Find verified humans by capability via the live capability index (a2a gateway). Filters are optional and combine (AND). Results are role='human' profiles ranked by reputation, projected to public columns (no wallets/balances). Note: `skills` is an array.", {
120
154
  skills: z
121
155
  .array(z.enum(TASK_CATEGORIES))
@@ -137,6 +171,7 @@ server.tool("deposit", "Credit your treasury from a REAL on-chain USDC deposit (
137
171
  .regex(/^0x[0-9a-fA-F]{64}$/)
138
172
  .describe("The Base tx hash of your USDC transfer to the deposit address."),
139
173
  }, async ({ tx_hash }) => guard(() => client.rest("POST", "/api/treasury/deposit", { body: { tx_hash } })));
174
+ server.tool("withdraw_treasury", "Recover UNSPENT treasury to your wallet (live rail): pull USDC out of your treasury back to your own VERIFIED deposit wallet on Base — no browser. Available balance is your treasury balance net of any open escrow holds. Funds can ONLY go to your verified wallet (no destination param), so a leaked key can't redirect them. Returns { ok, tx_hash, amount_usd, to }. 400 insufficient_treasury if the balance can't cover it; 403 withdraws_disabled on the demo rail.", { amount_usd: z.number().positive().describe("USD to withdraw from your treasury to your verified wallet.") }, async ({ amount_usd }) => guard(() => client.rest("POST", "/api/treasury/withdraw", { body: { amount_usd } })));
140
175
  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.", {
141
176
  title: z.string().min(2).max(160).describe("Short task title."),
142
177
  category: z.enum(TASK_CATEGORIES),
@@ -277,5 +312,5 @@ server.registerPrompt("quickstart", {
277
312
  const transport = new StdioServerTransport();
278
313
  await server.connect(transport);
279
314
  console.error(`CYBERDYNE MCP server running on stdio → ${config.apiUrl}` +
280
- (config.token ? "" : " (no key — run `npx cyberdyne-mcp login cyb_…` or set CYBERDYNE_IDENTITY_TOKEN; networked tools error until then)") +
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.");
315
+ (config.token ? "" : " (no key — run `npx cyberdyne-mcp onboard` to self-generate a wallet + key, or `login cyb_…`, or set CYBERDYNE_IDENTITY_TOKEN; networked tools error until then)") +
316
+ ". Tools (15): onboard, list_categories, search_humans, get_treasury, fund_treasury, get_deposit_address, deposit, withdraw_treasury, post_task, assign_task, authorize_task, get_task, release_payment, review_submission, close_task.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyberdyne-mcp",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/client.ts CHANGED
@@ -34,25 +34,46 @@ export function configPath(): string {
34
34
  return join(homedir(), ".cyberdyne", "config.json");
35
35
  }
36
36
 
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 {
37
+ /** Shape of the on-disk config. Both fields are optional for back-compat. */
38
+ interface SavedConfig {
39
+ identity_token?: unknown;
40
+ walletKey?: unknown;
41
+ }
42
+
43
+ /** Read + parse the whole config file (or {} if missing / unreadable). */
44
+ function readSavedConfig(): SavedConfig {
41
45
  try {
42
- const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as { identity_token?: unknown };
43
- return typeof parsed?.identity_token === "string" ? parsed.identity_token.trim() : undefined;
46
+ const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as SavedConfig;
47
+ return parsed && typeof parsed === "object" ? parsed : {};
44
48
  } catch {
45
- return undefined;
49
+ return {};
46
50
  }
47
51
  }
48
52
 
49
- /** Persist the agent key to ~/.cyberdyne/config.json. Returns the path. */
50
- export function saveToken(token: string): string {
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
+ // Only the key is persisted (plus the generated wallet key). The API endpoint is
54
+ // intentionally NOT read from this file — a tampered config must never be able to
55
+ // redirect the agent's key to a hostile host (credential exfiltration). The
56
+ // endpoint overrides via env only.
57
+ function readSavedToken(): string | undefined {
58
+ const v = readSavedConfig().identity_token;
59
+ return typeof v === "string" ? v.trim() : undefined;
60
+ }
61
+
62
+ /** The generated/saved wallet private key (0x…), if any. Used as the EVM signer fallback. */
63
+ export function readSavedWalletKey(): string | undefined {
64
+ const v = readSavedConfig().walletKey;
65
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
66
+ }
67
+
68
+ /**
69
+ * Atomically write the config file at ~/.cyberdyne/config.json, mode 0600.
70
+ * Owner-only dir + atomic 0600 create (no world-readable window / TOCTOU), and
71
+ * O_NOFOLLOW so a planted symlink at the path can't redirect the write.
72
+ */
73
+ function writeConfigFile(data: SavedConfig): string {
53
74
  mkdirSync(join(homedir(), ".cyberdyne"), { recursive: true, mode: 0o700 });
54
75
  const p = configPath();
55
- const contents = JSON.stringify({ identity_token: token.trim() }, null, 2);
76
+ const contents = JSON.stringify(data, null, 2);
56
77
  let flags = FS.O_WRONLY | FS.O_CREAT | FS.O_TRUNC;
57
78
  if (typeof FS.O_NOFOLLOW === "number") flags |= FS.O_NOFOLLOW;
58
79
  let fd: number;
@@ -71,6 +92,28 @@ export function saveToken(token: string): string {
71
92
  return p;
72
93
  }
73
94
 
95
+ /** Persist the agent key to ~/.cyberdyne/config.json (preserving any saved walletKey). Returns the path. */
96
+ export function saveToken(token: string): string {
97
+ const existing = readSavedConfig();
98
+ const next: SavedConfig = { identity_token: token.trim() };
99
+ if (typeof existing.walletKey === "string" && existing.walletKey.trim()) next.walletKey = existing.walletKey.trim();
100
+ return writeConfigFile(next);
101
+ }
102
+
103
+ /** Persist the generated wallet private key (preserving any saved token). Returns the path. */
104
+ export function saveWallet(walletKey: string): string {
105
+ const existing = readSavedConfig();
106
+ const next: SavedConfig = { walletKey: walletKey.trim() };
107
+ if (typeof existing.identity_token === "string" && existing.identity_token.trim())
108
+ next.identity_token = existing.identity_token.trim();
109
+ return writeConfigFile(next);
110
+ }
111
+
112
+ /** Persist BOTH the agent key and the wallet key in a single 0600 write. Returns the path. */
113
+ export function saveTokenAndWallet(token: string, walletKey: string): string {
114
+ return writeConfigFile({ identity_token: token.trim(), walletKey: walletKey.trim() });
115
+ }
116
+
74
117
  /** Resolve config: token from env first, then the saved login. URL from env only. */
75
118
  export function readConfig(env: NodeJS.ProcessEnv = process.env): CyberdyneConfig {
76
119
  const apiUrl = (env.CYBERDYNE_API_URL || DEFAULT_API_URL).replace(/\/+$/, "");
package/src/evm-signer.ts CHANGED
@@ -16,10 +16,21 @@ import { privateKeyToAccount } from "viem/accounts";
16
16
  import { createPublicClient, createWalletClient, http, parseUnits } from "viem";
17
17
  import { base, baseSepolia } from "viem/chains";
18
18
  import { AuthCaptureEvmScheme, toClientEvmSigner } from "@x402/evm";
19
+ import { readSavedWalletKey } from "./client.js";
20
+
21
+ /**
22
+ * Resolve the signing private key: CYBERDYNE_EVM_PRIVATE_KEY first, else the
23
+ * walletKey generated at `onboard` and saved in ~/.cyberdyne/config.json. This is
24
+ * what makes onboard → post pool task → authorize all work with the SAME wallet
25
+ * and zero env vars.
26
+ */
27
+ function resolvePrivateKey(): string | undefined {
28
+ return process.env.CYBERDYNE_EVM_PRIVATE_KEY?.trim() || readSavedWalletKey();
29
+ }
19
30
 
20
31
  function account() {
21
- const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
22
- if (!pk) throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set");
32
+ const pk = resolvePrivateKey();
33
+ if (!pk) throw new Error("no signing key (set CYBERDYNE_EVM_PRIVATE_KEY or run `cyberdyne-mcp onboard`)");
23
34
  return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`);
24
35
  }
25
36
  function chain() {
@@ -68,12 +79,12 @@ export async function payDeployFee(params: {
68
79
  }
69
80
 
70
81
  export function hasEvmKey(): boolean {
71
- return !!process.env.CYBERDYNE_EVM_PRIVATE_KEY;
82
+ return !!resolvePrivateKey();
72
83
  }
73
84
 
74
85
  function scheme() {
75
- const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY;
76
- if (!pk) throw new Error("CYBERDYNE_EVM_PRIVATE_KEY not set — cannot sign the escrow authorization");
86
+ const pk = resolvePrivateKey();
87
+ if (!pk) throw new Error("no signing key (set CYBERDYNE_EVM_PRIVATE_KEY or run `cyberdyne-mcp onboard`) — cannot sign the escrow authorization");
77
88
  const chainId = Number(process.env.CYBERDYNE_CHAIN_ID ?? 8453);
78
89
  const chain = chainId === 8453 ? base : baseSepolia;
79
90
  const account = privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`);
@@ -84,7 +95,8 @@ function scheme() {
84
95
 
85
96
  /** The agent's signing wallet address (so the platform can verify payer == this). */
86
97
  export function evmAddress(): string {
87
- const pk = process.env.CYBERDYNE_EVM_PRIVATE_KEY!;
98
+ const pk = resolvePrivateKey();
99
+ if (!pk) throw new Error("no signing key (set CYBERDYNE_EVM_PRIVATE_KEY or run `cyberdyne-mcp onboard`)");
88
100
  return privateKeyToAccount((pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`).address;
89
101
  }
90
102
 
package/src/onboard.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * FULLY AUTONOMOUS, zero-browser agent onboarding for CYBERDYNE.
3
+ *
4
+ * Goal: an agent goes from "nothing" to "has a wallet + cyb_ API key + can
5
+ * post/pay" using ONLY the CLI/MCP — never the web dashboard. This mirrors the
6
+ * Bankr CLI UX (`bankr login` generates a wallet + API key in one shot).
7
+ *
8
+ * The chain (all on the live platform API, no browser):
9
+ * 1. resolve a wallet — CYBERDYNE_EVM_PRIVATE_KEY → saved walletKey → generate fresh
10
+ * 2. GET /api/auth/siwe/nonce → { nonce } (+ sets a siwe-nonce cookie)
11
+ * 3. build the EXACT SIWE message and sign it with the wallet (EIP-191)
12
+ * 4. POST /api/auth/siwe/verify { message, signature, role:"agent" } → session cookie
13
+ * 5. POST /api/agent/key (session cookie) {} → { apiKey: "cyb_…" }
14
+ * 6. persist BOTH the cyb_ key and the wallet key to ~/.cyberdyne/config.json (0600)
15
+ *
16
+ * Nothing here moves funds or pays gas — onboarding is just a SIWE signature and
17
+ * a DB row. The wallet private key is persisted but NEVER logged to stdout.
18
+ */
19
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
20
+ import type { PrivateKeyAccount } from "viem/accounts";
21
+ import { readConfig, readSavedWalletKey, saveTokenAndWallet } from "./client.js";
22
+
23
+ /** Normalise a private key to the 0x-prefixed form viem expects. */
24
+ function normalizeKey(pk: string): `0x${string}` {
25
+ return (pk.startsWith("0x") ? pk : `0x${pk}`) as `0x${string}`;
26
+ }
27
+
28
+ export interface ResolvedWallet {
29
+ account: PrivateKeyAccount;
30
+ privateKey: `0x${string}`;
31
+ /** true when a fresh key was generated this call (caller persists it). */
32
+ generated: boolean;
33
+ }
34
+
35
+ /**
36
+ * Resolve the onboarding wallet, most-explicit first:
37
+ * 1. CYBERDYNE_EVM_PRIVATE_KEY (env) — operator-supplied
38
+ * 2. saved walletKey in config — generated on a prior onboard
39
+ * 3. generate a fresh key — first run, zero config
40
+ * Always returns a usable account + the private key. Persisting a freshly
41
+ * generated key is the caller's job (onboard() does it atomically with the token).
42
+ */
43
+ export function resolveWallet(env: NodeJS.ProcessEnv = process.env): ResolvedWallet {
44
+ const fromEnv = env.CYBERDYNE_EVM_PRIVATE_KEY?.trim();
45
+ if (fromEnv) {
46
+ const privateKey = normalizeKey(fromEnv);
47
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false };
48
+ }
49
+ const saved = readSavedWalletKey();
50
+ if (saved) {
51
+ const privateKey = normalizeKey(saved);
52
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: false };
53
+ }
54
+ const privateKey = generatePrivateKey();
55
+ return { account: privateKeyToAccount(privateKey), privateKey, generated: true };
56
+ }
57
+
58
+ /** Pull every Set-Cookie off a response into a `name=value; name=value` Cookie header. */
59
+ function collectCookies(res: Response, jar: Map<string, string>): void {
60
+ // getSetCookie() returns each Set-Cookie line separately (undici/Node 18.18+).
61
+ const lines =
62
+ typeof res.headers.getSetCookie === "function"
63
+ ? res.headers.getSetCookie()
64
+ : (res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []);
65
+ for (const line of lines) {
66
+ const pair = line.split(";")[0]?.trim();
67
+ if (!pair) continue;
68
+ const eq = pair.indexOf("=");
69
+ if (eq <= 0) continue;
70
+ jar.set(pair.slice(0, eq), pair.slice(eq + 1));
71
+ }
72
+ }
73
+
74
+ function cookieHeader(jar: Map<string, string>): string {
75
+ return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
76
+ }
77
+
78
+ export interface OnboardResult {
79
+ address: string;
80
+ apiKey: string;
81
+ generated: boolean;
82
+ configPath: string;
83
+ }
84
+
85
+ /**
86
+ * Run the full SIWE → mint chain and persist the credentials. Throws on any
87
+ * non-2xx step (with the endpoint + status) so the caller can surface a clear error.
88
+ */
89
+ export async function onboard(env: NodeJS.ProcessEnv = process.env): Promise<OnboardResult> {
90
+ const { apiUrl } = readConfig(env);
91
+ const HOST = new URL(apiUrl).host;
92
+ const { account, privateKey, generated } = resolveWallet(env);
93
+ const address = account.address;
94
+
95
+ const jar = new Map<string, string>();
96
+
97
+ // 1. nonce
98
+ const nonceRes = await fetch(`${apiUrl}/api/auth/siwe/nonce`, { headers: { accept: "application/json" } });
99
+ if (!nonceRes.ok) throw new Error(`GET /api/auth/siwe/nonce → ${nonceRes.status}`);
100
+ collectCookies(nonceRes, jar); // carry siwe-nonce to verify
101
+ const { nonce } = (await nonceRes.json()) as { nonce?: string };
102
+ if (!nonce) throw new Error("GET /api/auth/siwe/nonce → no nonce in response");
103
+
104
+ // 2. build the EXACT SIWE message (newline-joined) and sign it
105
+ const message = [
106
+ `${HOST} wants you to sign in with your Ethereum account:`,
107
+ address,
108
+ "",
109
+ "Sign in to CYBERDYNE — get paid by AI. This request will not trigger a blockchain transaction or cost any gas.",
110
+ "",
111
+ `URI: ${apiUrl}`,
112
+ "Version: 1",
113
+ "Chain ID: 8453",
114
+ `Nonce: ${nonce}`,
115
+ `Issued At: ${new Date().toISOString()}`,
116
+ ].join("\n");
117
+ const signature = await account.signMessage({ message });
118
+
119
+ // 3. verify → session cookie
120
+ const verifyRes = await fetch(`${apiUrl}/api/auth/siwe/verify`, {
121
+ method: "POST",
122
+ headers: {
123
+ "content-type": "application/json",
124
+ accept: "application/json",
125
+ ...(jar.size ? { cookie: cookieHeader(jar) } : {}),
126
+ },
127
+ body: JSON.stringify({ message, signature, role: "agent" }),
128
+ });
129
+ if (!verifyRes.ok) {
130
+ const detail = await verifyRes.text().catch(() => "");
131
+ throw new Error(`POST /api/auth/siwe/verify → ${verifyRes.status} ${detail.slice(0, 200)}`);
132
+ }
133
+ collectCookies(verifyRes, jar); // capture the session cookie(s)
134
+
135
+ // 4. mint the agent key with the session cookie
136
+ const keyRes = await fetch(`${apiUrl}/api/agent/key`, {
137
+ method: "POST",
138
+ headers: {
139
+ "content-type": "application/json",
140
+ accept: "application/json",
141
+ cookie: cookieHeader(jar),
142
+ },
143
+ body: "{}",
144
+ });
145
+ if (!keyRes.ok) {
146
+ const detail = await keyRes.text().catch(() => "");
147
+ throw new Error(`POST /api/agent/key → ${keyRes.status} ${detail.slice(0, 200)}`);
148
+ }
149
+ const keyJson = (await keyRes.json()) as { apiKey?: string };
150
+ const apiKey = keyJson.apiKey?.trim();
151
+ if (!apiKey || !apiKey.startsWith("cyb_")) {
152
+ throw new Error("POST /api/agent/key → no cyb_ apiKey in response");
153
+ }
154
+
155
+ // 5. persist BOTH the token and the wallet key atomically (0600)
156
+ const configPath = saveTokenAndWallet(apiKey, privateKey);
157
+
158
+ return { address, apiKey, generated, configPath };
159
+ }
160
+
161
+ /** The multi-line "next steps" block shared by the CLI + the MCP tool. */
162
+ export function nextStepsText(): string {
163
+ return [
164
+ "Next steps (no dashboard needed):",
165
+ " 1. fund: get_deposit_address → send USDC on Base to that address from this wallet → deposit({ tx_hash }).",
166
+ " 2. post_task({ title, category, reward_usd, duration_min, difficulty }).",
167
+ " 3. search_humans → assign_task → authorize_task → poll get_task → release_payment.",
168
+ "The same generated wallet auto-signs pool budgets (no env vars needed).",
169
+ ].join("\n");
170
+ }
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * fund_treasury — POST /api/treasury/fund → demo top-up (testnet only)
13
13
  * get_deposit_address — GET /api/treasury/deposit → where to send real USDC (live)
14
14
  * deposit — POST /api/treasury/deposit → credit treasury from a real USDC tx
15
+ * withdraw_treasury — POST /api/treasury/withdraw → pull unspent treasury back to your wallet (live)
15
16
  * post_task — POST /api/tasks → open a task
16
17
  * assign_task — POST /api/tasks/[id]/assign → pick a human (→ authIntent)
17
18
  * authorize_task — POST /api/tasks/[id]/authorize → open the escrow hold
@@ -57,6 +58,30 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
57
58
  import { z } from "zod";
58
59
  import { CATEGORIES, TASK_CATEGORIES } from "./registry.js";
59
60
  import { ApiError, CyberdyneClient, MissingTokenError, readConfig, saveToken } from "./client.js";
61
+ import { onboard, nextStepsText } from "./onboard.js";
62
+
63
+ // `cyberdyne-mcp onboard` — FULLY AUTONOMOUS, zero-browser bootstrap (Bankr-style).
64
+ // Generates a wallet (if none) + mints a cyb_ API key via SIWE, saves both to
65
+ // ~/.cyberdyne/config.json (0600), prints the summary, then exits. No web dashboard.
66
+ if (process.argv[2] === "onboard") {
67
+ try {
68
+ const r = await onboard();
69
+ console.error(
70
+ `✓ CYBERDYNE agent onboarded — wallet ${r.generated ? "GENERATED" : "loaded"} + API key minted.\n` +
71
+ `\n wallet address : ${r.address}` +
72
+ `\n API key (cyb_) : ${r.apiKey} ← shown once; saved to ${r.configPath}` +
73
+ (r.generated
74
+ ? `\n (a fresh wallet private key was generated and saved to ${r.configPath}; keep that file safe)`
75
+ : "") +
76
+ `\n\n${nextStepsText()}` +
77
+ `\n\nThis MCP is already configured for this agent — networked tools will use the saved key.`,
78
+ );
79
+ process.exit(0);
80
+ } catch (e) {
81
+ console.error(`✗ onboard failed: ${e instanceof Error ? e.message : String(e)}`);
82
+ process.exit(1);
83
+ }
84
+ }
60
85
 
61
86
  // `cyberdyne-mcp login` — persist the key so the MCP add line can omit it (short
62
87
  // one-time-login install). Runs before the server boots, then exits. The key is read
@@ -123,7 +148,7 @@ async function guard<T>(fn: () => Promise<T>) {
123
148
 
124
149
  // ---- Server ---------------------------------------------------------------
125
150
 
126
- const server = new McpServer({ name: "cyberdyne", version: "0.5.4" });
151
+ const server = new McpServer({ name: "cyberdyne", version: "0.6.1" });
127
152
 
128
153
  server.tool(
129
154
  "list_categories",
@@ -132,6 +157,24 @@ server.tool(
132
157
  async () => json(Object.entries(CATEGORIES).map(([id, blurb]) => ({ id, blurb }))),
133
158
  );
134
159
 
160
+ server.tool(
161
+ "onboard",
162
+ "BOOTSTRAP (works WITHOUT an existing key — the one tool that self-onboards). Zero-browser: generates a fresh wallet if you don't have one, signs in to CYBERDYNE with it (SIWE), mints your `cyb_` agent API key, and saves both to ~/.cyberdyne/config.json (0600) so every other tool here authenticates automatically. No web dashboard, no env vars. Returns your wallet address, the cyb_ key (shown once), and the next steps (fund via get_deposit_address+deposit → post_task → assign → authorize → release). The same generated wallet auto-signs pool budgets. Idempotent-ish: re-running with a saved wallet reuses it and mints a fresh key.",
163
+ {},
164
+ async () =>
165
+ guard(async () => {
166
+ const r = await onboard();
167
+ return {
168
+ address: r.address,
169
+ apiKey: r.apiKey,
170
+ generated: r.generated,
171
+ savedTo: r.configPath,
172
+ next_steps: nextStepsText(),
173
+ note: "API key + wallet saved (0600). This MCP now authenticates automatically; networked tools are ready.",
174
+ };
175
+ }),
176
+ );
177
+
135
178
  server.tool(
136
179
  "search_humans",
137
180
  "Find verified humans by capability via the live capability index (a2a gateway). Filters are optional and combine (AND). Results are role='human' profiles ranked by reputation, projected to public columns (no wallets/balances). Note: `skills` is an array.",
@@ -188,6 +231,14 @@ server.tool(
188
231
  guard(() => client.rest("POST", "/api/treasury/deposit", { body: { tx_hash } })),
189
232
  );
190
233
 
234
+ server.tool(
235
+ "withdraw_treasury",
236
+ "Recover UNSPENT treasury to your wallet (live rail): pull USDC out of your treasury back to your own VERIFIED deposit wallet on Base — no browser. Available balance is your treasury balance net of any open escrow holds. Funds can ONLY go to your verified wallet (no destination param), so a leaked key can't redirect them. Returns { ok, tx_hash, amount_usd, to }. 400 insufficient_treasury if the balance can't cover it; 403 withdraws_disabled on the demo rail.",
237
+ { amount_usd: z.number().positive().describe("USD to withdraw from your treasury to your verified wallet.") },
238
+ async ({ amount_usd }) =>
239
+ guard(() => client.rest("POST", "/api/treasury/withdraw", { body: { amount_usd } })),
240
+ );
241
+
191
242
  server.tool(
192
243
  "post_task",
193
244
  "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.",
@@ -390,6 +441,6 @@ const transport = new StdioServerTransport();
390
441
  await server.connect(transport);
391
442
  console.error(
392
443
  `CYBERDYNE MCP server running on stdio → ${config.apiUrl}` +
393
- (config.token ? "" : " (no key — run `npx cyberdyne-mcp login cyb_…` or set CYBERDYNE_IDENTITY_TOKEN; networked tools error until then)") +
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.",
444
+ (config.token ? "" : " (no key — run `npx cyberdyne-mcp onboard` to self-generate a wallet + key, or `login cyb_…`, or set CYBERDYNE_IDENTITY_TOKEN; networked tools error until then)") +
445
+ ". Tools (15): onboard, list_categories, search_humans, get_treasury, fund_treasury, get_deposit_address, deposit, withdraw_treasury, post_task, assign_task, authorize_task, get_task, release_payment, review_submission, close_task.",
395
446
  );