@humanops/mcp-server 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +55 -8
  2. package/dist/index.js +219 -46
  3. package/package.json +12 -3
package/README.md CHANGED
@@ -26,7 +26,46 @@ HumanOps bridges the gap between AI capabilities and physical-world actions. Whe
26
26
  |----------|----------|-------------|
27
27
  | `HUMANOPS_API_KEY` | Yes | Your HumanOps API key |
28
28
  | `HUMANOPS_API_URL` | No | API base URL (default: `https://api.humanops.io`) |
29
- | `HUMANOPS_SANDBOX` | No | Set to `true` for sandbox mode |
29
+ | `HUMANOPS_DEV_ALLOW_LOCALHOST` | No | Set to `true` to allow `HUMANOPS_API_URL` to be `http://localhost:8787` for local development |
30
+ | `HUMANOPS_ALLOW_ANY_API_HOST` | No | Set to `true` to allow non-`*.humanops.io` API hosts (still blocks private/internal hosts) |
31
+ | `HUMANOPS_SANDBOX` | No | Set to `true` to force sandbox mode for non-SANDBOX tier agents (useful for integration testing with VERIFIED/STANDARD accounts) |
32
+
33
+ ## Prerequisites
34
+
35
+ Before using the MCP server, register your agent via the REST API:
36
+
37
+ ```bash
38
+ curl -X POST https://api.humanops.io/api/v1/agents/register \
39
+ -H "Content-Type: application/json" \
40
+ -d '{"name": "my-agent", "email": "agent@example.com"}'
41
+ ```
42
+
43
+ This returns your API key and starts you at **SANDBOX** tier (tasks auto-complete with synthetic proof). To create real tasks:
44
+
45
+ 1. **Verify your email** — click the link in the registration email to upgrade to VERIFIED tier
46
+ 2. **Deposit USDC** — send $50+ USDC on Base L2 to upgrade to STANDARD tier
47
+
48
+ ### Agent Tiers
49
+
50
+ | Tier | Daily Tasks | Max Task Value | Daily Spend | Real Tasks? |
51
+ |------|------------|---------------|-------------|-------------|
52
+ | SANDBOX | 50 | $10 | $10 | No (auto-complete) |
53
+ | VERIFIED | 10 | $100 | $200 | Yes |
54
+ | STANDARD | 100 | $10,000 | $50,000 | Yes |
55
+
56
+ ### Sandbox Mode
57
+
58
+ SANDBOX tier agents operate in sandbox mode. Every task auto-completes through a simulated lifecycle:
59
+
60
+ 1. Task is created (PENDING) — no funds are escrowed
61
+ 2. A simulated operator auto-accepts the task within seconds
62
+ 3. Synthetic proof is generated and submitted automatically
63
+ 4. A synthetic Guardian verification auto-approves
64
+ 5. Task completes — no real money moves, no real human is involved
65
+
66
+ All API responses for sandbox tasks include `sandbox: true` and a `sandbox_notice` field explaining the simulation. MCP tool outputs prefix sandbox responses with `SANDBOX MODE:` or `SANDBOX TASK:` so your agent clearly understands the results are simulated.
67
+
68
+ **To exit sandbox mode:** verify your email (upgrades to VERIFIED) and deposit USDC (upgrades to STANDARD).
30
69
 
31
70
  ## Available Tools
32
71
 
@@ -61,19 +100,25 @@ Credential tasks use end-to-end encryption (P-256 ECDH + AES-256-GCM). The serve
61
100
 
62
101
  | Tool | Description |
63
102
  |------|-------------|
103
+ | `approve_estimate` | Approve an operator's time estimate for an ESTIMATE_PENDING task |
104
+ | `reject_estimate` | Reject an estimate, returning the task to the available pool |
64
105
  | `get_task_result` | Get task status, proof, and verification result |
65
106
  | `check_verification_status` | Check AI Guardian verification status |
66
- | `cancel_task` | Cancel a pending/accepted task (refunds escrowed funds) |
107
+ | `cancel_task` | Cancel a pending/estimate-pending/accepted task (refunds escrowed funds) |
67
108
  | `list_tasks` | List your tasks with optional status filter |
68
109
 
69
110
  ### Payments (USDC)
70
111
 
112
+ Production safety note: HumanOps uses a shared USDC deposit address on Base L2. To safely
113
+ confirm deposits in production, the API may require you to bind (verify) the wallet you
114
+ deposit from via `POST /api/v1/agents/wallet/challenge` + `PUT /api/v1/agents/wallet`.
115
+
71
116
  | Tool | Description |
72
117
  |------|-------------|
73
118
  | `get_deposit_address` | Get your USDC deposit address |
74
- | `fund_account` | Verify a USDC deposit (Base, Polygon, Ethereum, Arbitrum) |
119
+ | `fund_account` | Verify a USDC deposit (Base L2) |
75
120
  | `get_balance` | Check deposit and escrow balances |
76
- | `request_payout` | Request operator payout |
121
+ | `request_payout` | Request operator USDC payout (gas fee deducted from amount) |
77
122
 
78
123
  ## Example Usage
79
124
 
@@ -127,10 +172,11 @@ retrieve_credential({ task_id: "...", private_key: "..." })
127
172
  ## Task Lifecycle
128
173
 
129
174
  1. **PENDING** — Task created, funds escrowed
130
- 2. **ACCEPTED** — Operator picked up the task
131
- 3. **SUBMITTED** — Operator submitted proof
132
- 4. **VERIFIED** / **REJECTED** AI Guardian reviewed the proof
133
- 5. **COMPLETED** — Task finished, operator paid
175
+ 2. **ESTIMATE_PENDING** — Operator submitted a time/cost estimate, awaiting agent approval (24h deadline)
176
+ 3. **ACCEPTED** — Operator picked up the task (or estimate approved)
177
+ 4. **SUBMITTED** — Operator submitted proof
178
+ 5. **VERIFIED** / **REJECTED** AI Guardian reviewed the proof
179
+ 6. **COMPLETED** — Task finished, operator paid
134
180
 
135
181
  ## Security
136
182
 
@@ -140,6 +186,7 @@ retrieve_credential({ task_id: "...", private_key: "..." })
140
186
  - Redirects blocked to prevent key exfiltration
141
187
  - Credential tasks use client-side E2EE (server never sees plaintext)
142
188
  - Single-read credential retrieval (cleared after first read)
189
+ - AI Guardian pre-screens task content at creation time (VERIFIED/STANDARD tiers) — blocks illegal, fraudulent, or abusive tasks
143
190
 
144
191
  ## License
145
192
 
package/dist/index.js CHANGED
@@ -123,7 +123,12 @@ async function generateKeyPair() {
123
123
  return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
124
124
  }
125
125
  async function importPublicKey(base64) {
126
- return crypto.subtle.importKey("raw", fromBase64(base64), { name: "ECDH", namedCurve: "P-256" }, false, []);
126
+ const raw = fromBase64(base64);
127
+ // P-256 uncompressed public key is exactly 65 bytes (0x04 || x || y)
128
+ if (raw.byteLength !== 65) {
129
+ throw new Error("Invalid public key length (expected 65 bytes for P-256 uncompressed)");
130
+ }
131
+ return crypto.subtle.importKey("raw", raw, { name: "ECDH", namedCurve: "P-256" }, false, []);
127
132
  }
128
133
  async function importPrivateKey(base64) {
129
134
  return crypto.subtle.importKey("pkcs8", fromBase64(base64), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
@@ -145,6 +150,16 @@ async function decryptCredential(encrypted, privateKeyBase64) {
145
150
  // ---------------------------------------------------------------------------
146
151
  // SSRF protection
147
152
  // ---------------------------------------------------------------------------
153
+ // DNS rebinding services that resolve to arbitrary IPs (including private ranges)
154
+ const DNS_REBINDING_BLOCKLIST = [
155
+ "nip.io", "sslip.io", "xip.io", "nip.test",
156
+ "localtest.me", "lvh.me", "vcap.me",
157
+ "lacolhost.com", "yoogle.com",
158
+ "beweb.com", "servebeer.com", "servecounterstrike.com",
159
+ "servehalflife.com", "servehttp.com", "serveirc.com",
160
+ "serveminecraft.net", "servemp3.com", "servepics.com",
161
+ "servequake.com", "sytes.net",
162
+ ];
148
163
  function isSSRFSafeUrl(urlStr, requireHttps = false) {
149
164
  let parsed;
150
165
  try {
@@ -173,6 +188,11 @@ function isSSRFSafeUrl(urlStr, requireHttps = false) {
173
188
  return false;
174
189
  if (hostname.endsWith(".internal"))
175
190
  return false;
191
+ // Block DNS rebinding services (resolve to attacker-controlled IPs including private ranges)
192
+ for (const domain of DNS_REBINDING_BLOCKLIST) {
193
+ if (hostname === domain || hostname.endsWith(`.${domain}`))
194
+ return false;
195
+ }
176
196
  const ipv4 = parseIpv4(hostname);
177
197
  if (ipv4 && isPrivateIpv4(ipv4))
178
198
  return false;
@@ -331,6 +351,9 @@ function isPrivateIpv6(groups) {
331
351
  * authz/rate-limits/audit logging, it talks to HumanOps via HTTP only.
332
352
  */
333
353
  const DEFAULT_API_URL = "https://api.humanops.io";
354
+ function isAllowedLocalhost(hostname) {
355
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
356
+ }
334
357
  function getApiUrl() {
335
358
  const raw = process.env.HUMANOPS_API_URL?.trim();
336
359
  if (!raw)
@@ -344,11 +367,15 @@ function getApiUrl() {
344
367
  throw new Error("HUMANOPS_API_URL must be a valid URL.");
345
368
  }
346
369
  const allowAnyHost = process.env.HUMANOPS_ALLOW_ANY_API_HOST === "true";
370
+ const allowLocalhost = process.env.HUMANOPS_DEV_ALLOW_LOCALHOST === "true";
347
371
  const hostname = parsed.hostname.trim().toLowerCase().replace(/\.+$/, "");
348
- if (!allowAnyHost) {
372
+ const isHumanopsHost = hostname === "api.humanops.io" || hostname.endsWith(".humanops.io");
373
+ const isLocalhost = isAllowedLocalhost(hostname);
374
+ if (!allowAnyHost && !isHumanopsHost) {
349
375
  // Prevent accidental exfiltration of HUMANOPS_API_KEY to an unrelated domain.
350
- if (hostname !== "api.humanops.io" && !hostname.endsWith(".humanops.io")) {
351
- throw new Error("HUMANOPS_API_URL must be api.humanops.io (or *.humanops.io). To override, set HUMANOPS_ALLOW_ANY_API_HOST=true.");
376
+ // For local development, allow explicit localhost-only override.
377
+ if (!(allowLocalhost && isLocalhost)) {
378
+ throw new Error("HUMANOPS_API_URL must be api.humanops.io (or *.humanops.io). To override, set HUMANOPS_ALLOW_ANY_API_HOST=true. For local dev, set HUMANOPS_DEV_ALLOW_LOCALHOST=true and use http://localhost:8787.");
352
379
  }
353
380
  }
354
381
  // Prevent subtle base URL bugs like including paths, querystrings, or fragments.
@@ -359,18 +386,26 @@ function getApiUrl() {
359
386
  throw new Error("HUMANOPS_API_URL must not include query params or a fragment.");
360
387
  }
361
388
  const normalized = parsed.origin;
389
+ if (allowLocalhost && isLocalhost) {
390
+ // Explicit opt-in to allow local testing (still blocks redirects, and only applies to localhost).
391
+ return normalized;
392
+ }
362
393
  if (!isSSRFSafeUrl(normalized, true)) {
363
394
  throw new Error("HUMANOPS_API_URL must be a public HTTPS URL (private/internal addresses are blocked).");
364
395
  }
365
396
  return normalized;
366
397
  }
367
398
  function getApiKey() {
368
- const apiKey = process.env.HUMANOPS_API_KEY;
399
+ const apiKey = process.env.HUMANOPS_API_KEY?.trim();
369
400
  if (!apiKey) {
370
401
  throw new Error("HUMANOPS_API_KEY environment variable is required.");
371
402
  }
403
+ if (!/^ho_(live|test)_[a-zA-Z0-9]{32,}$/.test(apiKey)) {
404
+ console.error("Warning: HUMANOPS_API_KEY does not match expected format (ho_live_... or ho_test_...)");
405
+ }
372
406
  return apiKey;
373
407
  }
408
+ const MCP_REQUEST_TIMEOUT = 30_000;
374
409
  async function apiRequest(path, init = {}) {
375
410
  const baseUrl = getApiUrl();
376
411
  const apiKey = getApiKey();
@@ -381,7 +416,7 @@ async function apiRequest(path, init = {}) {
381
416
  headers.set("Content-Type", "application/json");
382
417
  }
383
418
  // Never follow redirects when sending X-API-Key (prevents key exfiltration).
384
- const res = await fetch(url, { ...init, headers, redirect: "error" });
419
+ const res = await fetch(url, { ...init, headers, redirect: "error", signal: AbortSignal.timeout(MCP_REQUEST_TIMEOUT) });
385
420
  const contentType = res.headers.get("content-type") ?? "";
386
421
  const raw = await res.text();
387
422
  const json = contentType.includes("application/json") && raw ? JSON.parse(raw) : null;
@@ -431,7 +466,7 @@ const DispatchDigitalTaskInputSchema = z.object({
431
466
  digital_category: DigitalCategoryEnum,
432
467
  reward_usd: z.number().min(MIN_TASK_VALUE_USD).max(MAX_TASK_VALUE_USD),
433
468
  deadline: z.string().datetime(),
434
- proof_requirements: z.array(z.string()).min(1).max(10),
469
+ proof_requirements: z.array(z.string().min(1).max(500)).min(1).max(10),
435
470
  digital_instructions: z.string().max(5000).optional(),
436
471
  callback_url: z
437
472
  .string()
@@ -449,7 +484,7 @@ const DispatchCredentialTaskInputSchema = z.object({
449
484
  digital_category: CredentialCategoryEnum,
450
485
  reward_usd: z.number().min(MIN_TASK_VALUE_USD).max(MAX_TASK_VALUE_USD),
451
486
  deadline: z.string().datetime(),
452
- proof_requirements: z.array(z.string()).min(1).max(10),
487
+ proof_requirements: z.array(z.string().min(1).max(500)).min(1).max(10),
453
488
  digital_instructions: z.string().max(5000).optional(),
454
489
  callback_url: z
455
490
  .string()
@@ -466,8 +501,8 @@ const RetrieveCredentialInputSchema = z.object({
466
501
  private_key: z.string().min(1),
467
502
  });
468
503
  const SearchOperatorsInputSchema = z.object({
469
- lat: z.number(),
470
- lng: z.number(),
504
+ lat: z.number().min(-90).max(90),
505
+ lng: z.number().min(-180).max(180),
471
506
  radius_km: z.number().min(1).max(500).optional(),
472
507
  task_type: TaskTypeEnum.optional(),
473
508
  min_rating: z.number().min(0).max(5).optional(),
@@ -482,7 +517,7 @@ const PostTaskInputSchema = z.object({
482
517
  }),
483
518
  reward_usd: z.number().min(MIN_TASK_VALUE_USD).max(MAX_TASK_VALUE_USD),
484
519
  deadline: z.string().datetime(),
485
- proof_requirements: z.array(z.string()).min(1).max(10),
520
+ proof_requirements: z.array(z.string().min(1).max(500)).min(1).max(10),
486
521
  task_type: TaskTypeEnum,
487
522
  callback_url: z
488
523
  .string()
@@ -497,12 +532,11 @@ const PostTaskInputSchema = z.object({
497
532
  const GetTaskResultInputSchema = z.object({
498
533
  task_id: z.string().min(1),
499
534
  });
500
- const USDCChainEnum = z.enum(["base", "polygon", "ethereum", "arbitrum"]);
501
535
  const FundAccountInputSchema = z.object({
502
536
  amount_usd: z.number().min(MIN_DEPOSIT_USD).max(MAX_DEPOSIT_USD),
503
537
  payment_method: z.enum(["usdc", "card", "bank_transfer"]).default("usdc"),
504
- tx_hash: z.string().min(1).optional(),
505
- chain: USDCChainEnum.optional(),
538
+ tx_hash: z.string().regex(/^0x[a-fA-F0-9]{64}$/, "Invalid transaction hash (must be 0x-prefixed 64 hex chars)").optional(),
539
+ chain: z.enum(["base"]).optional(),
506
540
  return_url: z
507
541
  .string()
508
542
  .url()
@@ -514,6 +548,16 @@ const FundAccountInputSchema = z.object({
514
548
  const RequestPayoutInputSchema = z.object({
515
549
  amount_usd: z.number().min(MIN_PAYOUT_USD),
516
550
  });
551
+ const TaskStatusEnum = z.enum([
552
+ "PENDING", "ESTIMATE_PENDING", "ACCEPTED", "SUBMITTED",
553
+ "VERIFIED", "REJECTED", "COMPLETED", "CANCELLED", "EXPIRED", "DISPUTED",
554
+ ]);
555
+ const ListTasksInputSchema = z.object({
556
+ status: TaskStatusEnum.optional(),
557
+ domain: z.enum(["physical", "digital", "all"]).optional(),
558
+ limit: z.number().int().min(1).max(100).optional(),
559
+ offset: z.number().int().min(0).optional(),
560
+ });
517
561
  const ALL_TASK_TYPES = [
518
562
  "VERIFICATION", "PHOTO", "DELIVERY", "INSPECTION",
519
563
  "CAPTCHA_SOLVING", "FORM_FILLING", "BROWSER_INTERACTION", "CONTENT_REVIEW", "DATA_VALIDATION",
@@ -521,7 +565,7 @@ const ALL_TASK_TYPES = [
521
565
  ];
522
566
  const server = new Server({
523
567
  name: "humanops",
524
- version: "0.2.1",
568
+ version: "0.3.1",
525
569
  }, {
526
570
  capabilities: { tools: {} },
527
571
  });
@@ -675,7 +719,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
675
719
  },
676
720
  {
677
721
  name: "get_deposit_address",
678
- description: "Get your USDC deposit address. Send USDC to this address on a supported chain (Base, Polygon, Ethereum, Arbitrum) to fund your HumanOps account. This is the recommended way to add funds.",
722
+ description: "Get your USDC deposit address. Send USDC on Base L2 to fund your HumanOps account. This is the recommended way to add funds.",
679
723
  inputSchema: {
680
724
  type: "object",
681
725
  properties: {},
@@ -684,7 +728,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
684
728
  },
685
729
  {
686
730
  name: "fund_account",
687
- description: "Add funds to your HumanOps account. **Recommended: use USDC deposits** (default). Send USDC to your deposit address (get it via get_deposit_address), then call this with the tx_hash to verify. Fiat methods (card, bank_transfer) are coming soon.",
731
+ description: "Add funds to your HumanOps account. **Recommended: use USDC deposits** (default). Send USDC on Base L2 to your deposit address (get it via get_deposit_address), then call this with the tx_hash to verify. Fiat methods (card, bank_transfer) are coming soon.",
688
732
  inputSchema: {
689
733
  type: "object",
690
734
  properties: {
@@ -698,8 +742,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
698
742
  tx_hash: { type: "string", description: "On-chain transaction hash (required for USDC deposits)" },
699
743
  chain: {
700
744
  type: "string",
701
- enum: ["base", "polygon", "ethereum", "arbitrum"],
702
- description: "Blockchain network the USDC was sent on (required for USDC deposits). Base recommended for lowest fees.",
745
+ enum: ["base"],
746
+ description: "Blockchain network the USDC was sent on (required for USDC deposits). Only Base L2 is supported.",
703
747
  },
704
748
  return_url: { type: "string", description: "Optional return URL after fiat payment (not used for USDC)" },
705
749
  },
@@ -708,7 +752,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
708
752
  },
709
753
  {
710
754
  name: "request_payout",
711
- description: "Request a payout of available earnings. Currently only available through the HumanOps operator portal.",
755
+ description: "Request a payout of available earnings. Operators can withdraw via USDC on Base L2 through the operator portal or API.",
712
756
  inputSchema: {
713
757
  type: "object",
714
758
  properties: { amount_usd: { type: "number", description: "Amount to withdraw in USD (min: $10)" } },
@@ -733,13 +777,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
733
777
  required: ["task_id"],
734
778
  },
735
779
  },
780
+ {
781
+ name: "approve_estimate",
782
+ description: "Approve an operator's time estimate for a task. The operator will be notified and can start working. Only works when task status is ESTIMATE_PENDING.",
783
+ inputSchema: {
784
+ type: "object",
785
+ properties: { task_id: { type: "string", description: "The task ID whose estimate to approve" } },
786
+ required: ["task_id"],
787
+ },
788
+ },
789
+ {
790
+ name: "reject_estimate",
791
+ description: "Reject an operator's time estimate for a task. The task returns to the available pool so another operator can claim it. Only works when task status is ESTIMATE_PENDING.",
792
+ inputSchema: {
793
+ type: "object",
794
+ properties: {
795
+ task_id: { type: "string", description: "The task ID whose estimate to reject" },
796
+ reason: { type: "string", description: "Optional reason for rejection (shown to operator)" },
797
+ },
798
+ required: ["task_id"],
799
+ },
800
+ },
736
801
  {
737
802
  name: "list_tasks",
738
803
  description: "List your tasks with optional status filter. Returns paginated results ordered by creation date.",
739
804
  inputSchema: {
740
805
  type: "object",
741
806
  properties: {
742
- status: { type: "string", description: "Filter by status (e.g., PENDING, ACCEPTED, COMPLETED)" },
807
+ status: {
808
+ type: "string",
809
+ enum: ["PENDING", "ESTIMATE_PENDING", "ACCEPTED", "SUBMITTED", "VERIFIED", "REJECTED", "COMPLETED", "CANCELLED", "EXPIRED", "DISPUTED"],
810
+ description: "Filter by task status",
811
+ },
812
+ domain: { type: "string", enum: ["physical", "digital", "all"], description: "Filter by task domain (default: all)" },
743
813
  limit: { type: "number", description: "Max results (default: 20, max: 100)" },
744
814
  offset: { type: "number", description: "Pagination offset (default: 0)" },
745
815
  },
@@ -792,6 +862,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
792
862
  body: JSON.stringify(body),
793
863
  headers,
794
864
  });
865
+ // Surface sandbox_notice prominently so the agent understands what happened
866
+ const resultObj = result;
867
+ if (resultObj.sandbox) {
868
+ return {
869
+ content: [
870
+ { type: "text", text: `SANDBOX MODE: ${resultObj.sandbox_notice ?? "This task will auto-complete with simulated proof. No real operator is involved."}\n\n${JSON.stringify(result, null, 2)}` },
871
+ ],
872
+ };
873
+ }
795
874
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
796
875
  }
797
876
  case "get_task_result": {
@@ -805,6 +884,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
805
884
  const result = await apiRequest(`/api/v1/tasks/${encodeURIComponent(parsed.data.task_id)}`, {
806
885
  method: "GET",
807
886
  });
887
+ // Surface sandbox notice prominently
888
+ const taskObj = result;
889
+ if (taskObj.sandbox) {
890
+ return {
891
+ content: [
892
+ { type: "text", text: `SANDBOX TASK: ${taskObj.sandbox_notice ?? "This task was simulated — operator, proof, and verification are all synthetic."}\n\n${JSON.stringify(result, null, 2)}` },
893
+ ],
894
+ };
895
+ }
808
896
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
809
897
  }
810
898
  case "check_verification_status": {
@@ -824,6 +912,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
824
912
  guardian_result: task.guardian_result,
825
913
  verified_at: task.verified_at ?? null,
826
914
  completed_at: task.completed_at ?? null,
915
+ ...(task.sandbox && { sandbox: true, sandbox_notice: "Verification result is synthetic — this was a simulated sandbox task." }),
827
916
  };
828
917
  return { content: [{ type: "text", text: JSON.stringify(focused, null, 2) }] };
829
918
  }
@@ -837,7 +926,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
837
926
  type: "text",
838
927
  text: JSON.stringify({
839
928
  ...result,
840
- _instructions: "Send USDC to this address on the specified chain. After sending, use fund_account with the tx_hash and chain to verify your deposit.",
929
+ _instructions: "Send USDC to this address on the specified chain. Production safety: you may need to bind the wallet you deposit from (POST /api/v1/agents/wallet/challenge + PUT /api/v1/agents/wallet) before verifying deposits. After sending, use fund_account with tx_hash + chain to verify.",
841
930
  }, null, 2),
842
931
  },
843
932
  ],
@@ -851,8 +940,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
851
940
  isError: true,
852
941
  };
853
942
  }
854
- const { amount_usd, payment_method, tx_hash, chain, return_url } = parsed.data;
855
- const method = payment_method ?? "usdc";
943
+ const { amount_usd, payment_method: method, tx_hash, chain, return_url } = parsed.data;
856
944
  // Fiat methods are coming soon
857
945
  if (method === "card" || method === "bank_transfer") {
858
946
  return {
@@ -882,7 +970,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
882
970
  text: JSON.stringify({
883
971
  status: "awaiting_deposit",
884
972
  ...addressResult,
885
- message: "No tx_hash provided. Send USDC to the address above, then call fund_account again with the tx_hash and chain to verify your deposit.",
973
+ message: "No tx_hash provided. Send USDC to the address above. If the API says your deposit wallet is not verified, bind it first via POST /api/v1/agents/wallet/challenge + PUT /api/v1/agents/wallet. Then call fund_account again with tx_hash + chain to verify your deposit.",
886
974
  }, null, 2),
887
975
  },
888
976
  ],
@@ -891,7 +979,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
891
979
  const result = await apiRequest("/api/v1/agents/deposit/usdc", {
892
980
  method: "POST",
893
981
  body: JSON.stringify({
894
- amount_usd,
982
+ amount_usdc: amount_usd,
895
983
  tx_hash,
896
984
  chain: chain ?? "base",
897
985
  }),
@@ -911,9 +999,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
911
999
  {
912
1000
  type: "text",
913
1001
  text: JSON.stringify({
914
- status: "unavailable",
1002
+ status: "operator_only",
915
1003
  amount_requested: parsed.data.amount_usd,
916
- message: "Payouts are currently only available through the HumanOps operator portal (https://humanops.io). MCP-based payouts will be available in a future release.",
1004
+ message: "Payouts are processed via the operator portal. Operators can withdraw USDC on Base L2 by setting a wallet address and requesting a payout through the operator API (PUT /operator/wallet + POST /operator/payout).",
917
1005
  }, null, 2),
918
1006
  },
919
1007
  ],
@@ -921,6 +1009,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
921
1009
  }
922
1010
  case "get_balance": {
923
1011
  const data = await apiRequest("/api/v1/agents/balance", { method: "GET" });
1012
+ const balanceObj = data;
1013
+ const depositBalance = Number(balanceObj.deposit_balance ?? 0);
1014
+ const hints = [];
1015
+ if (depositBalance === 0) {
1016
+ hints.push("Your balance is $0. To create real tasks, deposit USDC via get_deposit_address + fund_account.");
1017
+ }
1018
+ if (hints.length > 0) {
1019
+ return {
1020
+ content: [
1021
+ { type: "text", text: `${hints.join(" ")}\n\n${JSON.stringify(data, null, 2)}` },
1022
+ ],
1023
+ };
1024
+ }
924
1025
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
925
1026
  }
926
1027
  case "cancel_task": {
@@ -936,16 +1037,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
936
1037
  });
937
1038
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
938
1039
  }
1040
+ case "approve_estimate": {
1041
+ const parsed = GetTaskResultInputSchema.safeParse(args);
1042
+ if (!parsed.success) {
1043
+ return {
1044
+ content: [{ type: "text", text: `Error: invalid input: ${parsed.error.message}` }],
1045
+ isError: true,
1046
+ };
1047
+ }
1048
+ const result = await apiRequest(`/api/v1/tasks/${encodeURIComponent(parsed.data.task_id)}/estimate/approve`, {
1049
+ method: "POST",
1050
+ });
1051
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1052
+ }
1053
+ case "reject_estimate": {
1054
+ const rejectSchema = z.object({
1055
+ task_id: z.string().min(1),
1056
+ reason: z.string().max(1000).optional(),
1057
+ });
1058
+ const parsed = rejectSchema.safeParse(args);
1059
+ if (!parsed.success) {
1060
+ return {
1061
+ content: [{ type: "text", text: `Error: invalid input: ${parsed.error.message}` }],
1062
+ isError: true,
1063
+ };
1064
+ }
1065
+ const body = parsed.data.reason ? JSON.stringify({ reason: parsed.data.reason }) : undefined;
1066
+ const result = await apiRequest(`/api/v1/tasks/${encodeURIComponent(parsed.data.task_id)}/estimate/reject`, {
1067
+ method: "POST",
1068
+ ...(body ? { body } : {}),
1069
+ });
1070
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1071
+ }
939
1072
  case "list_tasks": {
940
- const params = new URLSearchParams();
941
- if (args && typeof args === "object") {
942
- if (args.status)
943
- params.set("status", String(args.status));
944
- if (args.limit)
945
- params.set("limit", String(args.limit));
946
- if (args.offset)
947
- params.set("offset", String(args.offset));
1073
+ const parsed = ListTasksInputSchema.safeParse(args ?? {});
1074
+ if (!parsed.success) {
1075
+ return {
1076
+ content: [{ type: "text", text: `Error: invalid input: ${parsed.error.message}` }],
1077
+ isError: true,
1078
+ };
948
1079
  }
1080
+ const params = new URLSearchParams();
1081
+ if (parsed.data.status)
1082
+ params.set("status", parsed.data.status);
1083
+ if (parsed.data.domain)
1084
+ params.set("domain", parsed.data.domain);
1085
+ if (parsed.data.limit !== undefined)
1086
+ params.set("limit", String(parsed.data.limit));
1087
+ if (parsed.data.offset !== undefined)
1088
+ params.set("offset", String(parsed.data.offset));
949
1089
  const query = params.toString();
950
1090
  const data = await apiRequest(`/api/v1/tasks${query ? `?${query}` : ""}`, { method: "GET" });
951
1091
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
@@ -990,6 +1130,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
990
1130
  body: JSON.stringify(payload),
991
1131
  headers,
992
1132
  });
1133
+ const digitalResultObj = result;
1134
+ if (digitalResultObj.sandbox) {
1135
+ return {
1136
+ content: [
1137
+ { type: "text", text: `SANDBOX MODE: ${digitalResultObj.sandbox_notice ?? "This task will auto-complete with simulated proof."}\n\n${JSON.stringify(result, null, 2)}` },
1138
+ ],
1139
+ };
1140
+ }
993
1141
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
994
1142
  }
995
1143
  case "dispatch_credential_task": {
@@ -1040,6 +1188,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1040
1188
  private_key: keyPair.privateKey,
1041
1189
  _notice: "IMPORTANT: Save the private_key securely. You need it to decrypt the credential using retrieve_credential.",
1042
1190
  };
1191
+ const credResultObj = result;
1192
+ if (credResultObj.sandbox) {
1193
+ return {
1194
+ content: [
1195
+ { type: "text", text: `SANDBOX MODE: ${credResultObj.sandbox_notice ?? "This credential task will auto-complete with simulated data. No real credentials will be delivered."}\n\n${JSON.stringify(response, null, 2)}` },
1196
+ ],
1197
+ };
1198
+ }
1043
1199
  return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] };
1044
1200
  }
1045
1201
  case "retrieve_credential": {
@@ -1079,7 +1235,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1079
1235
  }
1080
1236
  throw retrieveErr;
1081
1237
  }
1082
- const plaintext = await decryptCredential(retrieveResult.encrypted_credential, private_key);
1238
+ let plaintext;
1239
+ try {
1240
+ plaintext = await decryptCredential(retrieveResult.encrypted_credential, private_key);
1241
+ }
1242
+ catch (decryptErr) {
1243
+ return {
1244
+ content: [
1245
+ {
1246
+ type: "text",
1247
+ text: JSON.stringify({
1248
+ task_id,
1249
+ error: "Decryption failed. This usually means the private_key does not match the keypair used when creating the task. Ensure you are using the exact private_key returned by dispatch_credential_task.",
1250
+ }, null, 2),
1251
+ },
1252
+ ],
1253
+ isError: true,
1254
+ };
1255
+ }
1083
1256
  return {
1084
1257
  content: [
1085
1258
  {
@@ -1114,13 +1287,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1114
1287
  }
1115
1288
  }
1116
1289
  catch (err) {
1117
- const errorMessage = err.message || "Unknown error";
1118
- const isNetworkError = errorMessage.includes("fetch failed") || errorMessage.includes("ECONNREFUSED");
1290
+ const rawMessage = err.message || "Unknown error";
1291
+ const isNetworkError = rawMessage.includes("fetch failed") || rawMessage.includes("ECONNREFUSED");
1292
+ // Sanitize: strip stack traces, internal paths, and limit length
1293
+ let safeMessage = rawMessage
1294
+ .replace(/\bat\s+.+\(.+\)/g, "")
1295
+ .replace(/\/[^\s:]+\.(ts|js):\d+/g, "[internal]")
1296
+ .slice(0, 500);
1119
1297
  const hint = isNetworkError
1120
1298
  ? " (Network error — check your HUMANOPS_API_URL and HUMANOPS_API_KEY configuration.)"
1121
1299
  : "";
1122
1300
  return {
1123
- content: [{ type: "text", text: `Error: ${errorMessage}${hint}` }],
1301
+ content: [{ type: "text", text: `Error: ${safeMessage}${hint}` }],
1124
1302
  isError: true,
1125
1303
  };
1126
1304
  }
@@ -1128,12 +1306,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1128
1306
  async function main() {
1129
1307
  const transport = new StdioServerTransport();
1130
1308
  await server.connect(transport);
1131
- if (process.env.NODE_ENV !== "production") {
1132
- console.error(`HumanOps MCP Server running on stdio (api=${getApiUrl()})`);
1133
- }
1134
- else {
1135
- console.error("HumanOps MCP Server running on stdio");
1136
- }
1309
+ console.error("HumanOps MCP Server running on stdio");
1137
1310
  }
1138
1311
  main().catch((err) => {
1139
1312
  console.error("Failed to start MCP server:", err);
package/package.json CHANGED
@@ -1,21 +1,25 @@
1
1
  {
2
2
  "name": "@humanops/mcp-server",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "mcpName": "io.github.thepianistdirector/humanops",
5
5
  "description": "MCP server for AI agents to dispatch real-world tasks to verified human operators via HumanOps",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "bin": {
9
- "@humanops/mcp-server": "dist/index.js"
9
+ "humanops-mcp": "dist/index.js"
10
10
  },
11
11
  "files": ["dist/*.js", "dist/*.d.ts", "README.md"],
12
12
  "scripts": {
13
13
  "start": "node dist/index.js",
14
14
  "dev": "tsx watch src/index.ts",
15
15
  "build": "tsc",
16
+ "smoke:local": "node scripts/smoke-local.mjs",
16
17
  "prepublishOnly": "npm run build",
17
18
  "lint": "tsc --noEmit"
18
19
  },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
19
23
  "dependencies": {
20
24
  "@modelcontextprotocol/sdk": "^1.0.0",
21
25
  "zod": "^3.24.0"
@@ -26,8 +30,13 @@
26
30
  },
27
31
  "license": "MIT",
28
32
  "keywords": ["humanops", "mcp", "ai", "agents", "tasks", "model-context-protocol"],
33
+ "author": "HumanOps <hello@humanops.io>",
34
+ "homepage": "https://humanops.io",
35
+ "bugs": {
36
+ "url": "https://github.com/thepianistdirector/humanops/issues"
37
+ },
29
38
  "repository": {
30
39
  "type": "git",
31
- "url": "https://github.com/ThePianistDirector/real-auto-code"
40
+ "url": "git+https://github.com/thepianistdirector/humanops.git"
32
41
  }
33
42
  }