@iicp/client 0.7.54 → 0.7.57

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
@@ -317,6 +317,8 @@ const node = new IicpNode({
317
317
  });
318
318
  ```
319
319
 
320
+ > **Security note**: relay bind authentication is pending ([#510](https://github.com/RobLe3/iicp.network/issues/510)) — only run a relay accept port on networks you trust until the signed-bind mechanism ships.
321
+
320
322
  ### Opt-out / override
321
323
 
322
324
  ```bash
package/dist/cli.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface ServeOpts {
20
20
  externalIpProbeUrl: string;
21
21
  relayWorkerEndpoint: string;
22
22
  relayCapable?: boolean;
23
+ /** #520 rung 5 tri-state: true=forced, false=disabled, undefined=auto. */
24
+ tunnel?: boolean;
23
25
  relayAcceptPort?: number;
24
26
  node: string;
25
27
  logDir?: string;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAwCA,OAAO,EAeL,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iGAAiG;IACjG,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAoaD,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,YAAY,GAAG,SAAS,CAsB9E;AA+pBD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA+D5D;AA8dD,wBAAsB,IAAI,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAmBlF"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAyCA,OAAO,EAeL,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,qGAAqG;IACrG,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,aAAa,EAAE,OAAO,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iGAAiG;IACjG,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAuaD,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,YAAY,GAAG,SAAS,CAsB9E;AA0uBD;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA+D5D;AA8dD,wBAAsB,IAAI,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAmBlF"}
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ const node_crypto_1 = require("node:crypto");
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const SDK_VERSION = require("../package.json").version;
28
28
  const net = require("node:net");
29
+ const http = require("node:http");
29
30
  const node_child_process_1 = require("node:child_process");
30
31
  const readline = require("node:readline/promises");
31
32
  const node_process_1 = require("node:process");
@@ -64,6 +65,7 @@ function printHelp() {
64
65
  ` query <prompt> Discover mesh nodes and submit a chat task\n` +
65
66
  ` credits Show this node's earned / spent / balance credits\n` +
66
67
  ` proxy Run the local OpenAI/Ollama/Anthropic-compat gateway (loopback; no registration)\n` +
68
+ ` mcp-gateway Bridge a local MCP server as an IICP provider node (registers + serves)\n` +
67
69
  ` operator rename <name> Change your public display_name (signed by your operator key)\n` +
68
70
  ` operator encrypt Password-encrypt the operator secret at rest ($IICP_OPERATOR_PASSPHRASE)\n` +
69
71
  ` operator decrypt Remove at-rest encryption of the operator secret\n\n` +
@@ -90,7 +92,9 @@ function printHelp() {
90
92
  ` --external-ip-probe-url U IICP_EXTERNAL_IP_PROBE_URL — fallback IPv4 probe\n` +
91
93
  ` --relay-worker-endpoint H IICP_RELAY_WORKER_ENDPOINT — <host>:<port> of a relay node (R2 last-resort)\n` +
92
94
  ` --relay-capable IICP_RELAY_CAPABLE — advertise as relay server for CGNAT/tier-4 operators\n` +
93
- ` --relay-accept-port N IICP_RELAY_ACCEPT_PORT — TCP port for relay accept server (default 9485)\n` +
95
+ ` --relay-accept-port N IICP_RELAY_ACCEPT_PORT — TCP port for relay accept server (default 9485).\n` +
96
+ ` Note: relay bind authentication is pending (#510) — only run a relay\n` +
97
+ ` accept port on networks you trust until the signed-bind mechanism ships.\n` +
94
98
  ` --log-dir DIR IICP_LOG_DIR — directory for persistent log files (<node_id>.log + events.jsonl)\n` +
95
99
  ` --with-proxy IICP_WITH_PROXY — also run the loopback compat proxy (127.0.0.1:9483) in-process\n\n` +
96
100
  `query optional:\n` +
@@ -541,6 +545,29 @@ async function runServe(opts) {
541
545
  }
542
546
  }
543
547
  let publicEndpoint = opts.publicEndpoint || `http://localhost:${opts.port}`;
548
+ // #520 rung 5 — Quick Tunnel escalation state (see src/tunnel.ts).
549
+ const tunnelPref = opts.tunnel;
550
+ let tunnelHandle = null;
551
+ const openTunnelRung = async (localPort, forced) => {
552
+ const { INSTALL_HINT, cloudflaredPath, openQuickTunnel } = await import("./tunnel.js");
553
+ if (!cloudflaredPath()) {
554
+ // eslint-disable-next-line no-console
555
+ console.warn(`[iicp-node] ${INSTALL_HINT}`);
556
+ return null;
557
+ }
558
+ try {
559
+ const t = await openQuickTunnel(localPort);
560
+ // eslint-disable-next-line no-console
561
+ console.log(`[iicp-node] NAT rung 5${forced ? " (forced)" : ""}: public https endpoint via ` +
562
+ `Quick Tunnel — ${t.url} (zero-account; URL rotates on restart)`);
563
+ return t;
564
+ }
565
+ catch (exc) {
566
+ // eslint-disable-next-line no-console
567
+ console.warn(`[iicp-node] Quick Tunnel failed to start: ${exc instanceof Error ? exc.message : exc} — continuing without it`);
568
+ return null;
569
+ }
570
+ };
544
571
  // ADR-043 §5 / #343 — Tier-0 IPv6 pinhole attempt. Runs unconditionally
545
572
  // when the operator's public_endpoint is bracketed-IPv6, even without
546
573
  // --auto-detect-nat. Mirrors Python's cli.py path: try AddPinhole on
@@ -596,10 +623,22 @@ async function runServe(opts) {
596
623
  // eslint-disable-next-line no-console
597
624
  console.log(`[iicp-node] auto-elected relay: ${relayHost}:${relayPort}`);
598
625
  }
626
+ else if (tunnelPref !== false) {
627
+ // #520 rung 5: no relay anywhere → Quick Tunnel (zero-account),
628
+ // unless disabled via --no-tunnel / IICP_TUNNEL=0.
629
+ tunnelHandle = await openTunnelRung(opts.port, false);
630
+ if (tunnelHandle)
631
+ publicEndpoint = tunnelHandle.url;
632
+ if (!tunnelHandle) {
633
+ // eslint-disable-next-line no-console
634
+ console.warn(`[iicp-node] NAT tier=${natProfile.tier}: no relay-capable peers and no tunnel ` +
635
+ `available. Set IICP_RELAY_WORKER_ENDPOINT=<host>:<port> to specify a relay manually.`);
636
+ }
637
+ }
599
638
  else {
600
639
  // eslint-disable-next-line no-console
601
- console.warn(`[iicp-node] NAT tier=${natProfile.tier}: no relay-capable peers in directory. ` +
602
- `Set IICP_RELAY_WORKER_ENDPOINT=<host>:<port> to specify a relay manually.`);
640
+ console.warn(`[iicp-node] NAT tier=${natProfile.tier}: no relay-capable peers in directory ` +
641
+ `(tunnel escalation disabled). Set IICP_RELAY_WORKER_ENDPOINT to specify a relay.`);
603
642
  }
604
643
  }
605
644
  }
@@ -609,6 +648,14 @@ async function runServe(opts) {
609
648
  console.warn(`[iicp-node] NAT auto-detect failed: ${msg} — continuing with configured endpoint`);
610
649
  }
611
650
  }
651
+ // #520 — `--tunnel` forces rung 5 regardless of NAT tier (e.g. an operator
652
+ // who KNOWS they're unreachable, or wants an https endpoint for browser
653
+ // consumers without touching the router).
654
+ if (tunnelPref === true && tunnelHandle === null) {
655
+ tunnelHandle = await openTunnelRung(opts.port, true);
656
+ if (tunnelHandle)
657
+ publicEndpoint = tunnelHandle.url;
658
+ }
612
659
  const backendFlavor = await detectBackendFlavor(opts.backendUrl, opts.backendApiKey, opts.backendType);
613
660
  process.stderr.write(`backend detected: ${backendFlavor}\n`);
614
661
  // #463/#464 — bind the operator identity: issue a delegation FROM the (key-backed) operator
@@ -820,6 +867,22 @@ async function runServe(opts) {
820
867
  (0, node_log_js_1.writeNodeEvent)(nodeId, "serve_start", `port=${opts.port} model=${opts.model} intent=${opts.intent}`, logDir);
821
868
  // serve() returns a stop() handle but never resolves on its own; we wait for
822
869
  // SIGINT/SIGTERM to terminate.
870
+ if (tunnelHandle) {
871
+ // The endpoint was already the tunnel URL at construction; mark the
872
+ // transport and arm the watchdog (URL rotates per process → re-register).
873
+ node["_cfg"].transportMethod = "external_tunnel";
874
+ tunnelHandle.watch((url) => {
875
+ node["_cfg"].endpoint = url;
876
+ void node.register().catch((exc) => {
877
+ // eslint-disable-next-line no-console
878
+ console.warn(`[iicp-node] tunnel re-register failed: ${exc instanceof Error ? exc.message : exc}`);
879
+ });
880
+ }, () => {
881
+ // eslint-disable-next-line no-console
882
+ console.error("[iicp-node] Quick Tunnel permanently down — this node is no longer " +
883
+ "publicly reachable. Restart `iicp-node serve` to recover.");
884
+ });
885
+ }
823
886
  const stop = node.serve(handler, { host: opts.host, port: opts.port, nodeToken: token });
824
887
  await new Promise((resolve) => {
825
888
  const shutdown = async (sig) => {
@@ -844,6 +907,7 @@ async function runServe(opts) {
844
907
  console.warn(`[iicp-node] deregister failed: ${deregMsg}`);
845
908
  (0, node_log_js_1.writeNodeEvent)(nodeId, "deregister_fail", `error=${deregMsg}`, logDir);
846
909
  }
910
+ tunnelHandle?.close(); // #520 — tear the Quick Tunnel down with the node
847
911
  stop();
848
912
  instanceLock.release(); // #405 — free the pidfile on shutdown
849
913
  resolve();
@@ -1591,6 +1655,187 @@ async function main(argv = process.argv.slice(2)) {
1591
1655
  throw exc;
1592
1656
  }
1593
1657
  }
1658
+ // ── mcp-gateway (#512) ───────────────────────────────────────────────────────
1659
+ const _MCP_DANGEROUS = new Set(["bash", "shell", "exec", "run_command", "eval"]);
1660
+ function _toolToIntent(name) {
1661
+ const safe = name.toLowerCase().replace(/[^a-z0-9_]/g, "_");
1662
+ return `urn:iicp:intent:mcp:${safe}:v1`;
1663
+ }
1664
+ async function runMcpGateway(argv) {
1665
+ const helpFlag = argv.includes("--help") || argv.includes("-h");
1666
+ if (helpFlag) {
1667
+ process.stdout.write(`usage: iicp-node mcp-gateway [options]\n\n` +
1668
+ `Bridge a local MCP server into the IICP mesh as a registered provider node.\n\n` +
1669
+ `Options:\n` +
1670
+ ` --mcp-url URL IICP_MCP_URL — MCP server base URL (default http://localhost:8001)\n` +
1671
+ ` --tools NAMES IICP_MCP_TOOLS — comma-separated tool names to advertise (required)\n` +
1672
+ ` --node-id ID IICP_NODE_ID — auto-generated if absent\n` +
1673
+ ` --public-endpoint U IICP_PUBLIC_ENDPOINT — externally reachable URL of this gateway\n` +
1674
+ ` --directory-url URL IICP_DIRECTORY_URL (default https://iicp.network/api/v1)\n` +
1675
+ ` --region REGION IICP_REGION (default local)\n` +
1676
+ ` --port N IICP_PORT (default 9484)\n` +
1677
+ ` --host HOST IICP_HOST (default ::)\n`);
1678
+ return 0;
1679
+ }
1680
+ const { values } = safeParseArgs({
1681
+ args: argv,
1682
+ options: {
1683
+ "mcp-url": { type: "string" },
1684
+ tools: { type: "string" },
1685
+ "node-id": { type: "string" },
1686
+ "public-endpoint": { type: "string" },
1687
+ "directory-url": { type: "string" },
1688
+ region: { type: "string" },
1689
+ port: { type: "string" },
1690
+ host: { type: "string" },
1691
+ },
1692
+ allowPositionals: false,
1693
+ });
1694
+ const mcpUrl = (values["mcp-url"] ?? envOr("IICP_MCP_URL") ?? "http://localhost:8001").replace(/\/$/, "");
1695
+ const rawTools = (values["tools"] ?? envOr("IICP_MCP_TOOLS") ?? "")
1696
+ .split(",").map((t) => t.trim()).filter(Boolean);
1697
+ const activeTools = rawTools.filter((t) => !_MCP_DANGEROUS.has(t.toLowerCase()));
1698
+ if (activeTools.length === 0) {
1699
+ process.stderr.write(`ERROR: --tools is required. Provide a comma-separated list of MCP tool names.\n` +
1700
+ ` Example: iicp-node mcp-gateway --tools read_file,list_dir --mcp-url http://localhost:8001\n`);
1701
+ return 2;
1702
+ }
1703
+ const nodeId = values["node-id"] ?? envOr("IICP_NODE_ID") ?? `gateway-mcp-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`;
1704
+ const directoryUrl = (values["directory-url"] ?? envOr("IICP_DIRECTORY_URL") ?? "https://iicp.network/api/v1").replace(/\/$/, "");
1705
+ const region = values["region"] ?? envOr("IICP_REGION") ?? "local";
1706
+ const port = parsePort(values["port"], 9484);
1707
+ const host = values["host"] ?? envOr("IICP_HOST") ?? "::";
1708
+ const publicEndpoint = values["public-endpoint"] ?? envOr("IICP_PUBLIC_ENDPOINT") ?? `http://localhost:${port}`;
1709
+ const intents = activeTools.map(_toolToIntent);
1710
+ let nodeToken = envOr("IICP_NODE_TOKEN") ?? "";
1711
+ async function doRegister() {
1712
+ const payload = { node_id: nodeId, region, endpoint: publicEndpoint, intents, mcp_tools: activeTools, protocol_version: "1.0" };
1713
+ const headers = { "Content-Type": "application/json" };
1714
+ if (nodeToken)
1715
+ headers["Authorization"] = `Bearer ${nodeToken}`;
1716
+ const resp = await fetch(`${directoryUrl}/register`, { method: "POST", headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(10_000) });
1717
+ if (!resp.ok)
1718
+ throw new Error(`register ${resp.status}`);
1719
+ const data = await resp.json();
1720
+ return data["node_token"] ?? nodeToken;
1721
+ }
1722
+ async function doHeartbeat() {
1723
+ const payload = { node_id: nodeId, intents, load: 0.0, status: "active" };
1724
+ try {
1725
+ await fetch(`${directoryUrl}/heartbeat`, {
1726
+ method: "POST",
1727
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${nodeToken}` },
1728
+ body: JSON.stringify(payload),
1729
+ signal: AbortSignal.timeout(10_000),
1730
+ });
1731
+ }
1732
+ catch {
1733
+ // heartbeat failures are non-fatal
1734
+ }
1735
+ }
1736
+ let mcpRpcId = 0;
1737
+ async function callMcp(toolName, args) {
1738
+ mcpRpcId += 1;
1739
+ const rpc = { jsonrpc: "2.0", id: mcpRpcId, method: "tools/call", params: { name: toolName, arguments: args } };
1740
+ const resp = await fetch(`${mcpUrl}/mcp`, {
1741
+ method: "POST",
1742
+ headers: { "Content-Type": "application/json" },
1743
+ body: JSON.stringify(rpc),
1744
+ signal: AbortSignal.timeout(30_000),
1745
+ });
1746
+ if (!resp.ok)
1747
+ throw new Error(`MCP server unreachable: ${resp.status}`);
1748
+ const data = await resp.json();
1749
+ if (data["error"])
1750
+ throw new Error(data["error"]["message"] ?? "MCP error");
1751
+ return data["result"];
1752
+ }
1753
+ try {
1754
+ nodeToken = await doRegister();
1755
+ process.stdout.write(`iicp-node mcp-gateway registered as '${nodeId}' with ${activeTools.length} tool(s): ${activeTools.join(", ")}\n` +
1756
+ ` IICP endpoint: ${publicEndpoint}\n MCP server: ${mcpUrl}\n`);
1757
+ }
1758
+ catch (err) {
1759
+ process.stderr.write(`WARNING: directory registration failed (${err.message}) — running without listing\n`);
1760
+ }
1761
+ const hbInterval = setInterval(() => { void doHeartbeat(); }, 30_000);
1762
+ const server = http.createServer(async (req, res) => {
1763
+ if (req.method === "GET" && req.url === "/iicp/health") {
1764
+ const body = JSON.stringify({ status: "ok", node_id: nodeId, active_tools: activeTools, mcp_server: mcpUrl, timestamp: Math.floor(Date.now() / 1000) });
1765
+ res.writeHead(200, { "Content-Type": "application/json" });
1766
+ res.end(body);
1767
+ return;
1768
+ }
1769
+ if (req.method === "POST" && req.url === "/v1/task") {
1770
+ const auth = req.headers["authorization"] ?? "";
1771
+ if (!nodeToken || auth !== `Bearer ${nodeToken}`) {
1772
+ res.writeHead(401, { "Content-Type": "application/json" });
1773
+ res.end(JSON.stringify({ error: "Unauthorized" }));
1774
+ return;
1775
+ }
1776
+ const chunks = [];
1777
+ for await (const chunk of req)
1778
+ chunks.push(chunk);
1779
+ let body;
1780
+ try {
1781
+ body = JSON.parse(Buffer.concat(chunks).toString());
1782
+ }
1783
+ catch {
1784
+ res.writeHead(400, { "Content-Type": "application/json" });
1785
+ res.end(JSON.stringify({ error: "invalid JSON" }));
1786
+ return;
1787
+ }
1788
+ const payload = (body["payload"] ?? {});
1789
+ let toolName = payload["tool_name"] ?? "";
1790
+ if (!toolName) {
1791
+ const m = /urn:iicp:intent:mcp:([^:]+):v1/.exec(body["intent"] ?? "");
1792
+ if (m)
1793
+ toolName = m[1];
1794
+ }
1795
+ if (!toolName) {
1796
+ res.writeHead(400, { "Content-Type": "application/json" });
1797
+ res.end(JSON.stringify({ error: "Cannot determine tool name" }));
1798
+ return;
1799
+ }
1800
+ if (_MCP_DANGEROUS.has(toolName.toLowerCase())) {
1801
+ res.writeHead(403, { "Content-Type": "application/json" });
1802
+ res.end(JSON.stringify({ error: "Tool not permitted" }));
1803
+ return;
1804
+ }
1805
+ if (activeTools.length && !activeTools.includes(toolName)) {
1806
+ res.writeHead(404, { "Content-Type": "application/json" });
1807
+ res.end(JSON.stringify({ error: "Tool not available" }));
1808
+ return;
1809
+ }
1810
+ const taskId = body["task_id"] ?? (0, node_crypto_1.randomUUID)();
1811
+ try {
1812
+ const result = await callMcp(toolName, (payload["arguments"] ?? {}));
1813
+ res.writeHead(200, { "Content-Type": "application/json" });
1814
+ res.end(JSON.stringify({ task_id: taskId, status: "completed", result }));
1815
+ }
1816
+ catch (err) {
1817
+ const msg = err.message ?? "error";
1818
+ const code = msg.includes("unreachable") ? 502 : 422;
1819
+ res.writeHead(code, { "Content-Type": "application/json" });
1820
+ res.end(JSON.stringify({ error: msg }));
1821
+ }
1822
+ return;
1823
+ }
1824
+ res.writeHead(404, { "Content-Type": "application/json" });
1825
+ res.end(JSON.stringify({ error: "not found" }));
1826
+ });
1827
+ await new Promise((resolve) => {
1828
+ server.listen(port, host === "::" ? "::" : host, () => {
1829
+ process.stdout.write(` Listening on ${host}:${port}\n`);
1830
+ resolve();
1831
+ });
1832
+ });
1833
+ await new Promise((resolve) => {
1834
+ process.on("SIGINT", () => { clearInterval(hbInterval); server.close(); resolve(); });
1835
+ process.on("SIGTERM", () => { clearInterval(hbInterval); server.close(); resolve(); });
1836
+ });
1837
+ return 0;
1838
+ }
1594
1839
  /** Command dispatch — separated so main() can wrap parse failures as clean CliError output. */
1595
1840
  async function dispatch(argv) {
1596
1841
  const cmd = argv[0];
@@ -1606,6 +1851,8 @@ async function dispatch(argv) {
1606
1851
  return runOperator(argv.slice(1));
1607
1852
  if (cmd === "proxy")
1608
1853
  return runProxyCmd(argv.slice(1));
1854
+ if (cmd === "mcp-gateway")
1855
+ return runMcpGateway(argv.slice(1));
1609
1856
  if (cmd !== "serve") {
1610
1857
  process.stderr.write(`unknown command: ${cmd}\n`);
1611
1858
  printHelp();
@@ -1628,6 +1875,8 @@ async function dispatch(argv) {
1628
1875
  port: { type: "string" },
1629
1876
  host: { type: "string" },
1630
1877
  "skip-registration": { type: "boolean" },
1878
+ tunnel: { type: "boolean" },
1879
+ "no-tunnel": { type: "boolean" },
1631
1880
  force: { type: "boolean" },
1632
1881
  "auto-detect-nat": { type: "boolean" },
1633
1882
  // Parity with Python's BooleanOptionalAction: an explicit off-switch for NAT
@@ -1683,6 +1932,14 @@ async function dispatch(argv) {
1683
1932
  ?? envOr("IICP_EXTERNAL_IP_PROBE_URL")
1684
1933
  ?? "https://api.ipify.org",
1685
1934
  relayWorkerEndpoint: values["relay-worker-endpoint"] ?? envOr("IICP_RELAY_WORKER_ENDPOINT") ?? "",
1935
+ // #520 rung 5 tri-state: --tunnel > --no-tunnel > IICP_TUNNEL env > auto (undefined).
1936
+ tunnel: values["tunnel"]
1937
+ ? true
1938
+ : values["no-tunnel"]
1939
+ ? false
1940
+ : process.env.IICP_TUNNEL !== undefined
1941
+ ? envBool("IICP_TUNNEL")
1942
+ : undefined,
1686
1943
  relayCapable: values["relay-capable"] !== undefined
1687
1944
  ? Boolean(values["relay-capable"])
1688
1945
  : envBool("IICP_RELAY_CAPABLE"),