cyberdyne-mcp 0.4.0

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/src/server.ts ADDED
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CYBERDYNE MCP server — the agent gateway (LIVE).
4
+ *
5
+ * Exposes the CYBERDYNE marketplace to any MCP-capable agent (Claude, etc.) as
6
+ * tools that call the REAL platform API. There is NO in-memory state any more —
7
+ * every tool is a thin, typed wrapper over an HTTP endpoint on the live backend.
8
+ *
9
+ * list_categories — the static task taxonomy (no network)
10
+ * search_humans — POST /api/a2a {search_humans} → capability index
11
+ * get_treasury — GET /api/treasury → the agent's balance
12
+ * fund_treasury — POST /api/treasury/fund → demo top-up (testnet only)
13
+ * get_deposit_address — GET /api/treasury/deposit → where to send real USDC (live)
14
+ * deposit — POST /api/treasury/deposit → credit treasury from a real USDC tx
15
+ * post_task — POST /api/tasks → open a task
16
+ * assign_task — POST /api/tasks/[id]/assign → pick a human (→ authIntent)
17
+ * authorize_task — POST /api/tasks/[id]/authorize → open the escrow hold
18
+ * get_task — GET /api/tasks/[id] → status + submissions/claims
19
+ * release_payment — POST /api/tasks/[id]/release → capture (pay) or reject
20
+ * close_task — POST /api/tasks/[id]/close → close a (multi-unit) bounty
21
+ *
22
+ * Auth: every networked tool sends the agent's `cyb_…` key. The REST routes take
23
+ * it as `Authorization: Bearer …`; search_humans goes through the a2a JSON-RPC
24
+ * gateway (the REST GET /api/humans is session-only), which carries the key as
25
+ * `identity_token`.
26
+ *
27
+ * 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_task → authorize_task (open the hold)
32
+ * → poll get_task until a submission appears
33
+ * → release_payment (approve → capture; else reject → refund)
34
+ *
35
+ * Config comes from the environment (see src/client.ts):
36
+ * CYBERDYNE_API_URL default "https://app.cyberdyne-os.xyz"
37
+ * CYBERDYNE_IDENTITY_TOKEN the agent's cyb_ key (required for networked tools)
38
+ */
39
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
40
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
41
+ import { z } from "zod";
42
+ import { CATEGORIES, TASK_CATEGORIES } from "./registry.js";
43
+ import { ApiError, CyberdyneClient, MissingTokenError, readConfig } from "./client.js";
44
+
45
+ const config = readConfig();
46
+ const client = new CyberdyneClient(config);
47
+
48
+ // ---- Result helpers -------------------------------------------------------
49
+
50
+ const json = (data: unknown) => ({
51
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
52
+ });
53
+ const err = (message: string) => ({
54
+ content: [{ type: "text" as const, text: JSON.stringify({ error: message }, null, 2) }],
55
+ isError: true,
56
+ });
57
+
58
+ /** Run a tool body, mapping client errors to a clean MCP error result. */
59
+ async function guard<T>(fn: () => Promise<T>) {
60
+ try {
61
+ return json(await fn());
62
+ } catch (e) {
63
+ if (e instanceof MissingTokenError) return err(e.message);
64
+ if (e instanceof ApiError) return err(e.message);
65
+ return err(e instanceof Error ? e.message : String(e));
66
+ }
67
+ }
68
+
69
+ // ---- Server ---------------------------------------------------------------
70
+
71
+ const server = new McpServer({ name: "cyberdyne", version: "0.2.0" });
72
+
73
+ server.tool(
74
+ "list_categories",
75
+ "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.",
76
+ {},
77
+ async () => json(Object.entries(CATEGORIES).map(([id, blurb]) => ({ id, blurb }))),
78
+ );
79
+
80
+ server.tool(
81
+ "search_humans",
82
+ "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.",
83
+ {
84
+ skills: z
85
+ .array(z.enum(TASK_CATEGORIES))
86
+ .optional()
87
+ .describe("Task categories the human must be able to do (all must match)."),
88
+ min_reputation: z.number().min(0).max(5).optional().describe("Minimum reputation (0–5)."),
89
+ location: z.string().optional().describe("Substring match on location, e.g. 'ES', 'Tokyo'."),
90
+ },
91
+ async ({ skills, min_reputation, location }) =>
92
+ guard(() =>
93
+ client.a2a<{ humans: unknown[] }>("search_humans", {
94
+ ...(skills ? { skills } : {}),
95
+ ...(min_reputation != null ? { min_reputation } : {}),
96
+ ...(location ? { location } : {}),
97
+ }),
98
+ ),
99
+ );
100
+
101
+ server.tool(
102
+ "get_treasury",
103
+ "Get the agent's own treasury (the source of task rewards on the manual rail). Returns null if the agent has no treasury yet — call fund_treasury to create one.",
104
+ {},
105
+ async () => guard(() => client.rest("GET", "/api/treasury")),
106
+ );
107
+
108
+ server.tool(
109
+ "fund_treasury",
110
+ "Demo top-up (TESTNET/DEMO ONLY): add USD to the treasury balance. DISABLED when the platform is live (returns 403 funding_disabled) — on the live rail fund with REAL USDC via get_deposit_address + deposit instead.",
111
+ { amount_usd: z.number().positive().describe("USD to add to the treasury balance.") },
112
+ async ({ amount_usd }) =>
113
+ guard(() => client.rest("POST", "/api/treasury/fund", { body: { amount_usd } })),
114
+ );
115
+
116
+ server.tool(
117
+ "get_deposit_address",
118
+ "Get the on-chain address to fund your treasury with REAL USDC (live rail). Returns { deposit_address, chain_id, usdc_address, decimals }. Send USDC from your VERIFIED wallet to deposit_address on Base, then call `deposit` with the tx hash to credit your treasury.",
119
+ {},
120
+ async () => guard(() => client.rest("GET", "/api/treasury/deposit")),
121
+ );
122
+
123
+ server.tool(
124
+ "deposit",
125
+ "Credit your treasury from a REAL on-chain USDC deposit (live rail; the real-money replacement for fund_treasury). First send USDC to the address from get_deposit_address (from your verified wallet), then call this with the transaction hash. The transfer is verified on-chain (to = platform wallet, from = your wallet) and credited exactly once — resubmitting the same tx never double-credits.",
126
+ {
127
+ tx_hash: z
128
+ .string()
129
+ .regex(/^0x[0-9a-fA-F]{64}$/)
130
+ .describe("The Base tx hash of your USDC transfer to the deposit address."),
131
+ },
132
+ async ({ tx_hash }) =>
133
+ guard(() => client.rest("POST", "/api/treasury/deposit", { body: { tx_hash } })),
134
+ );
135
+
136
+ server.tool(
137
+ "post_task",
138
+ "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).",
139
+ {
140
+ title: z.string().min(2).max(160).describe("Short task title."),
141
+ category: z.enum(TASK_CATEGORIES),
142
+ description: z.string().max(4000).optional().describe("What you need the human to do."),
143
+ steps: z.array(z.string()).optional().describe("Ordered steps / acceptance criteria."),
144
+ reward_usd: z.number().positive().describe("Total reward budget in USD."),
145
+ quantity: z.number().int().positive().optional().describe("Number of identical units (default 1)."),
146
+ duration_min: z.number().int().positive().describe("Estimated minutes to complete."),
147
+ difficulty: z.enum(["easy", "medium", "hard"]),
148
+ pay_token: z.enum(["USDC", "BNKR", "CYOS"]).optional().describe("Settlement token (default USDC)."),
149
+ deadline_hours: z.number().int().positive().optional(),
150
+ },
151
+ async (args) => guard(() => client.rest("POST", "/api/tasks", { body: args })),
152
+ );
153
+
154
+ server.tool(
155
+ "assign_task",
156
+ "Assign an open task to a chosen human (poster-only) and open the escrow intent. Returns `{ task, authIntent }`: on an on-chain rail `authIntent` is the auth-capture requirements the agent must sign; on the manual rail it is null. Next call authorize_task to actually open the hold.",
157
+ {
158
+ task_id: z.string().uuid(),
159
+ human_id: z.string().uuid().describe("The human profile id (from search_humans / get_task claims)."),
160
+ },
161
+ async ({ task_id, human_id }) =>
162
+ guard(() => client.rest("POST", `/api/tasks/${task_id}/assign`, { body: { human_id } })),
163
+ );
164
+
165
+ server.tool(
166
+ "authorize_task",
167
+ "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.",
168
+ {
169
+ task_id: z.string().uuid(),
170
+ signed_payment: z
171
+ .string()
172
+ .optional()
173
+ .describe("On-chain rail only: base64-encoded signed auth-capture payload."),
174
+ },
175
+ async ({ task_id, signed_payment }) =>
176
+ guard(() =>
177
+ client.rest("POST", `/api/tasks/${task_id}/authorize`, {
178
+ body: signed_payment ? { signedPayment: signed_payment } : {},
179
+ }),
180
+ ),
181
+ );
182
+
183
+ server.tool(
184
+ "get_task",
185
+ "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.",
186
+ { task_id: z.string().uuid() },
187
+ async ({ task_id }) => guard(() => client.rest("GET", `/api/tasks/${task_id}`)),
188
+ );
189
+
190
+ server.tool(
191
+ "release_payment",
192
+ "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).",
193
+ {
194
+ task_id: z.string().uuid(),
195
+ approve: z.boolean().describe("true = proof meets criteria → pay; false = reject/refund."),
196
+ submission_id: z
197
+ .string()
198
+ .uuid()
199
+ .optional()
200
+ .describe("The submission to settle. Auto-resolved to the latest pending one if omitted."),
201
+ score: z.number().int().min(1).max(5).optional().describe("Rating of the human's work (1–5)."),
202
+ reject_reason: z.string().max(1000).optional().describe("Why the proof was rejected (approve:false)."),
203
+ },
204
+ async ({ task_id, approve, submission_id, score, reject_reason }) =>
205
+ guard(async () => {
206
+ // The release endpoint settles a specific submission. If the caller didn't
207
+ // pass one, resolve the latest PENDING submission from the live task.
208
+ let sid = submission_id;
209
+ if (!sid) {
210
+ const detail = await client.rest<{ submissions?: Array<{ id: string; status: string }> }>(
211
+ "GET",
212
+ `/api/tasks/${task_id}`,
213
+ );
214
+ const pending = (detail.submissions ?? []).find((s) => s.status === "pending");
215
+ if (!pending) {
216
+ throw new ApiError(
217
+ 409,
218
+ "no_pending_submission (poll get_task until the human submits proof)",
219
+ `GET /api/tasks/${task_id}`,
220
+ );
221
+ }
222
+ sid = pending.id;
223
+ }
224
+ return client.rest("POST", `/api/tasks/${task_id}/release`, {
225
+ body: {
226
+ submission_id: sid,
227
+ approve,
228
+ ...(score != null ? { score } : {}),
229
+ ...(reject_reason ? { reject_reason } : {}),
230
+ },
231
+ });
232
+ }),
233
+ );
234
+
235
+ server.tool(
236
+ "close_task",
237
+ "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.",
238
+ { task_id: z.string().uuid() },
239
+ async ({ task_id }) => guard(() => client.rest("POST", `/api/tasks/${task_id}/close`)),
240
+ );
241
+
242
+ // ---- Self-onboarding prompt -----------------------------------------------
243
+ // Surfaces as /mcp__cyberdyne__quickstart — the agent (or user) runs it once to
244
+ // learn the end-to-end campaign flow without reading docs. This is the "skill"
245
+ // shipped inside the MCP: guidance travels with the tools.
246
+ server.registerPrompt(
247
+ "quickstart",
248
+ {
249
+ title: "CYBERDYNE quickstart",
250
+ description: "How to fund, post a campaign, and pay humans end-to-end (live rail).",
251
+ },
252
+ () => ({
253
+ messages: [
254
+ {
255
+ role: "user",
256
+ content: {
257
+ type: "text",
258
+ text: [
259
+ "You are connected to CYBERDYNE — hire and pay verified humans for tasks AI can't do alone. Settlement is REAL USDC on Base.",
260
+ "",
261
+ "FUND (live rail, real money):",
262
+ "1. get_deposit_address → returns the platform deposit address on Base.",
263
+ "2. Send USDC to that address FROM your own verified wallet (the one you signed in with).",
264
+ "3. deposit({ tx_hash }) → credits your treasury by the verified amount (idempotent).",
265
+ " (fund_treasury is demo-only and is disabled on the live rail.)",
266
+ "",
267
+ "RUN A CAMPAIGN:",
268
+ "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.",
269
+ "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.",
270
+ " - Or pick someone yourself: search_humans({ skills, min_reputation }) → assign_task({ task_id, human_id }) → authorize_task({ task_id }) to open the hold.",
271
+ "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.",
272
+ "7. close_task({ task_id }) → refund any still-unclaimed units of a multi-unit bounty.",
273
+ "",
274
+ "Check get_treasury anytime for your balance. Every payout and fee is a real on-chain tx.",
275
+ ].join("\n"),
276
+ },
277
+ },
278
+ ],
279
+ }),
280
+ );
281
+
282
+ // ---- Boot -----------------------------------------------------------------
283
+
284
+ const transport = new StdioServerTransport();
285
+ await server.connect(transport);
286
+ console.error(
287
+ `CYBERDYNE MCP server running on stdio → ${config.apiUrl}` +
288
+ (config.token ? "" : " (no CYBERDYNE_IDENTITY_TOKEN set; networked tools will error until you set it)") +
289
+ ". 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.",
290
+ );
package/src/smoke.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Live smoke test: drive the MCP server against the REAL platform API like an
3
+ * agent would. Not shipped.
4
+ *
5
+ * Requires both env vars to run for real:
6
+ * CYBERDYNE_API_URL e.g. http://localhost:3000 or https://app.cyberdyne-os.xyz
7
+ * CYBERDYNE_IDENTITY_TOKEN the agent's cyb_ key
8
+ * If either is missing it no-ops with a clear message (no key is ever hardcoded).
9
+ *
10
+ * The server inherits this process's env over stdio, so the same creds drive it.
11
+ */
12
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
14
+
15
+ const apiUrl = process.env.CYBERDYNE_API_URL;
16
+ const token = process.env.CYBERDYNE_IDENTITY_TOKEN;
17
+
18
+ if (!token) {
19
+ console.log(
20
+ "smoke: no-op. Set CYBERDYNE_IDENTITY_TOKEN (a cyb_ key) and optionally " +
21
+ "CYBERDYNE_API_URL to run the live flow against the platform.\n" +
22
+ " e.g. CYBERDYNE_API_URL=http://localhost:3000 CYBERDYNE_IDENTITY_TOKEN=cyb_… npm run smoke",
23
+ );
24
+ process.exit(0);
25
+ }
26
+
27
+ const transport = new StdioClientTransport({
28
+ command: "node",
29
+ args: ["dist/server.js"],
30
+ env: { ...process.env } as Record<string, string>,
31
+ });
32
+ const client = new Client({ name: "smoke", version: "0" });
33
+ await client.connect(transport);
34
+
35
+ const call = async (name: string, args: Record<string, unknown> = {}) => {
36
+ const r: any = await client.callTool({ name, arguments: args });
37
+ const data = JSON.parse(r.content[0].text);
38
+ if (r.isError) throw new Error(`${name} → ${data.error}`);
39
+ return data;
40
+ };
41
+
42
+ console.log(`smoke → ${apiUrl ?? "https://app.cyberdyne-os.xyz"}`);
43
+ console.log("tools:", (await client.listTools()).tools.map((t) => t.name).join(", "));
44
+
45
+ // 1. Categories (static, no network).
46
+ const cats = await call("list_categories");
47
+ console.log(`list_categories → ${cats.length} categories`);
48
+
49
+ // 2. Treasury — ensure it can cover a small task; top up if needed.
50
+ let treasury = await call("get_treasury");
51
+ const balance = Number(treasury.treasury?.balance_usd ?? 0);
52
+ console.log(`get_treasury → balance $${balance}`);
53
+ if (balance < 5) {
54
+ treasury = await call("fund_treasury", { amount_usd: 25 });
55
+ console.log(`fund_treasury → balance $${treasury.treasury?.balance_usd}`);
56
+ }
57
+
58
+ // 3. Discover a human via the live capability index.
59
+ const found = await call("search_humans", { skills: ["capture"] });
60
+ console.log(`search_humans(capture) → ${found.humans.length} match`);
61
+ const human = found.humans[0];
62
+
63
+ // 4. Post a task. reward_usd is the budget; not charged until authorize.
64
+ const posted = await call("post_task", {
65
+ title: "Read 10 phrases (smoke)",
66
+ category: "capture",
67
+ description: "Record 10 short phrases clearly in a quiet room.",
68
+ reward_usd: 3.5,
69
+ duration_min: 10,
70
+ difficulty: "easy",
71
+ });
72
+ const taskId = posted.task.id;
73
+ console.log(`post_task → ${taskId} (status ${posted.task.status})`);
74
+
75
+ // 5. If we found a human, assign + authorize (open the escrow hold).
76
+ if (human?.id) {
77
+ const assigned = await call("assign_task", { task_id: taskId, human_id: human.id });
78
+ console.log(`assign_task → status ${assigned.task.status}, authIntent ${assigned.authIntent ? "present" : "null (manual rail)"}`);
79
+ const authd = await call("authorize_task", { task_id: taskId });
80
+ console.log(`authorize_task → escrow_status ${authd.task?.escrow_status}`);
81
+ }
82
+
83
+ // 6. Poll the live task. The human submits proof in the app (human-only), so on a
84
+ // fresh task there is typically no submission yet — that is expected here.
85
+ const got = await call("get_task", { task_id: taskId });
86
+ console.log(
87
+ `get_task → status ${got.task.status}, submissions ${got.submissions.length}, claims ${got.claims.length}`,
88
+ );
89
+
90
+ const pending = (got.submissions ?? []).find((s: any) => s.status === "pending");
91
+ if (pending) {
92
+ const settled = await call("release_payment", { task_id: taskId, approve: true, score: 5 });
93
+ console.log(`release_payment → settled, task status ${settled.task?.status}`);
94
+ } else {
95
+ console.log(
96
+ "release_payment → skipped: no pending submission yet (a human submits proof in the app). " +
97
+ "Closing the task to release the hold.",
98
+ );
99
+ const closed = await call("close_task", { task_id: taskId });
100
+ console.log(`close_task → status ${closed.task?.status}`);
101
+ }
102
+
103
+ await client.close();
104
+ console.log("OK");