@michael-joseph-miller/ant-bot 0.1.5 → 0.2.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/dist/server.js CHANGED
@@ -2,7 +2,9 @@ import { createRequire as __antbotCreateRequire } from 'node:module';
2
2
  const require = __antbotCreateRequire(import.meta.url);
3
3
  import {
4
4
  ApprovalDecisionRequest,
5
+ ConnectorConfigSchema,
5
6
  CreateBotRequest,
7
+ CreateConnectorRequest,
6
8
  CreateRoutineRequest,
7
9
  CreateRuleRequest,
8
10
  CreateSkillRequest,
@@ -14,8 +16,9 @@ import {
14
16
  ScreencastClientFrameSchema,
15
17
  SettingsPatchSchema,
16
18
  SettingsSchema,
17
- UpdateBotRequest
18
- } from "./chunk-4XML2GPY.js";
19
+ UpdateBotRequest,
20
+ UpdateConnectorRequest
21
+ } from "./chunk-F3D2K4WZ.js";
19
22
  import {
20
23
  findWebDist,
21
24
  nodeLocateDeps,
@@ -162,7 +165,27 @@ var MigrationError = class extends Error {
162
165
  var BASELINE_VERSION = 1;
163
166
  var BASELINE_SENTINEL_TABLE = "bots";
164
167
  var MIGRATIONS = [
165
- { version: BASELINE_VERSION, name: "baseline", up: SCHEMA_SQL }
168
+ { version: BASELINE_VERSION, name: "baseline", up: SCHEMA_SQL },
169
+ {
170
+ version: 2,
171
+ name: "connectors",
172
+ // Plain CREATE TABLE, not IF NOT EXISTS: the ledger already guarantees this runs once, and
173
+ // this module exists precisely because IF NOT EXISTS turns a real conflict into silence.
174
+ up: `
175
+ CREATE TABLE connectors (
176
+ id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL,
177
+ description TEXT NOT NULL DEFAULT '',
178
+ config_json TEXT NOT NULL,
179
+ enabled INTEGER NOT NULL DEFAULT 1,
180
+ created_at INTEGER NOT NULL
181
+ );
182
+ CREATE TABLE bot_connectors (
183
+ bot_id TEXT NOT NULL, connector_id TEXT NOT NULL,
184
+ enabled INTEGER NOT NULL DEFAULT 1,
185
+ PRIMARY KEY (bot_id, connector_id)
186
+ );
187
+ `
188
+ }
166
189
  ];
167
190
  var SCHEMA_VERSION_SQL = `
168
191
  CREATE TABLE IF NOT EXISTS schema_version (
@@ -338,6 +361,14 @@ var toSkill = (r) => ({
338
361
  source: r.source,
339
362
  createdAt: r.created_at
340
363
  });
364
+ var toConnector = (r) => ({
365
+ id: r.id,
366
+ name: r.name,
367
+ description: r.description,
368
+ config: ConnectorConfigSchema.parse(JSON.parse(r.config_json)),
369
+ enabled: b(r.enabled),
370
+ createdAt: r.created_at
371
+ });
341
372
  var toRoutine = (r) => ({
342
373
  id: r.id,
343
374
  botId: r.bot_id,
@@ -467,6 +498,28 @@ var Store = class {
467
498
  this.db.prepare(`DELETE FROM routines WHERE bot_id=?`).run(id);
468
499
  if (bot.threadId) this.db.prepare(`DELETE FROM threads WHERE id=?`).run(bot.threadId);
469
500
  }
501
+ /**
502
+ * Clear a bot's conversation and its SDK session, keeping everything that defines the bot.
503
+ *
504
+ * Deliberately narrow. Memory, skills, connectors, routines and the bot's files all survive —
505
+ * those are the bot. What goes is the accumulated conversation: the messages in its thread and
506
+ * the `session_id` the SDK resumes from, which is what makes a turn carry prior context.
507
+ *
508
+ * Messages are deleted rather than the thread, so the thread id every other row points at
509
+ * stays valid; the FTS triggers keep the search index in step.
510
+ */
511
+ resetBotSession(id) {
512
+ const bot = this.getBot(id);
513
+ if (!bot) return null;
514
+ let messagesDeleted = 0;
515
+ if (bot.threadId) {
516
+ const info = this.db.prepare(`DELETE FROM messages WHERE thread_id=?`).run(bot.threadId);
517
+ messagesDeleted = info.changes;
518
+ this.db.prepare(`UPDATE threads SET last_read_at=0 WHERE id=?`).run(bot.threadId);
519
+ }
520
+ this.db.prepare(`UPDATE bots SET session_id=NULL, attention='none' WHERE id=?`).run(id);
521
+ return { messagesDeleted };
522
+ }
470
523
  duplicateBot(id) {
471
524
  const src = this.getBot(id);
472
525
  if (!src) return null;
@@ -479,6 +532,8 @@ var Store = class {
479
532
  });
480
533
  for (const bs of this.db.prepare(`SELECT * FROM bot_skills WHERE bot_id=?`).all(id))
481
534
  this.db.prepare(`INSERT OR REPLACE INTO bot_skills (bot_id,skill_id,enabled) VALUES (?,?,?)`).run(copy.id, bs.skill_id, bs.enabled);
535
+ for (const bc of this.db.prepare(`SELECT * FROM bot_connectors WHERE bot_id=?`).all(id))
536
+ this.db.prepare(`INSERT OR REPLACE INTO bot_connectors (bot_id,connector_id,enabled) VALUES (?,?,?)`).run(copy.id, bc.connector_id, bc.enabled);
482
537
  for (const r of this.listRoutines(id))
483
538
  this.createRoutine({ botId: copy.id, name: r.name, cronExpr: r.cronExpr, timezone: r.timezone, instructionMd: r.instructionMd, enabled: false });
484
539
  return copy;
@@ -697,6 +752,53 @@ var Store = class {
697
752
  `SELECT s.* FROM skills s JOIN bot_skills bs ON bs.skill_id=s.id WHERE bs.bot_id=? AND bs.enabled=1`
698
753
  ).all(botId).map(toSkill);
699
754
  }
755
+ /* ---- connectors ---- */
756
+ createConnector(c) {
757
+ const id = newId();
758
+ this.db.prepare(
759
+ `INSERT INTO connectors (id,name,description,config_json,enabled,created_at) VALUES (?,?,?,?,?,?)`
760
+ ).run(id, c.name, c.description ?? "", JSON.stringify(c.config), i(c.enabled, true), now());
761
+ return this.getConnector(id);
762
+ }
763
+ getConnector(id) {
764
+ const r = this.db.prepare(`SELECT * FROM connectors WHERE id=?`).get(id);
765
+ return r ? toConnector(r) : null;
766
+ }
767
+ getConnectorByName(name) {
768
+ const r = this.db.prepare(`SELECT * FROM connectors WHERE name=?`).get(name);
769
+ return r ? toConnector(r) : null;
770
+ }
771
+ listConnectors() {
772
+ return this.db.prepare(`SELECT * FROM connectors ORDER BY name ASC`).all().map(toConnector);
773
+ }
774
+ /** Patch in place. No rename: the name is baked into every `mcp__<name>__<tool>` a rule may match. */
775
+ updateConnector(id, patch) {
776
+ const existing = this.getConnector(id);
777
+ if (!existing) return null;
778
+ this.db.prepare(`UPDATE connectors SET description=?, config_json=?, enabled=? WHERE id=?`).run(
779
+ patch.description ?? existing.description,
780
+ JSON.stringify(patch.config ?? existing.config),
781
+ i(patch.enabled ?? existing.enabled),
782
+ id
783
+ );
784
+ return this.getConnector(id);
785
+ }
786
+ deleteConnector(id) {
787
+ this.db.prepare(`DELETE FROM connectors WHERE id=?`).run(id);
788
+ this.db.prepare(`DELETE FROM bot_connectors WHERE connector_id=?`).run(id);
789
+ }
790
+ setBotConnectors(botId, connectorIds) {
791
+ this.db.prepare(`DELETE FROM bot_connectors WHERE bot_id=?`).run(botId);
792
+ const stmt = this.db.prepare(`INSERT OR REPLACE INTO bot_connectors (bot_id,connector_id,enabled) VALUES (?,?,1)`);
793
+ for (const c of connectorIds) stmt.run(botId, c);
794
+ }
795
+ /** Assigned AND account-wide enabled — disabling a connector takes it away from every bot at once. */
796
+ listBotConnectors(botId) {
797
+ return this.db.prepare(
798
+ `SELECT c.* FROM connectors c JOIN bot_connectors bc ON bc.connector_id=c.id
799
+ WHERE bc.bot_id=? AND bc.enabled=1 AND c.enabled=1 ORDER BY c.name ASC`
800
+ ).all(botId).map(toConnector);
801
+ }
700
802
  /* ---- routines ---- */
701
803
  createRoutine(r) {
702
804
  const count = this.db.prepare(`SELECT COUNT(*) c FROM routines WHERE bot_id=?`).get(r.botId).c;
@@ -1239,6 +1341,9 @@ async function* runTurn(req) {
1239
1341
  switch (m.type) {
1240
1342
  case "system":
1241
1343
  if (m.subtype === "init" && m.session_id) yield { kind: "session", sessionId: m.session_id };
1344
+ if (m.subtype === "init" && Array.isArray(m.mcp_servers)) {
1345
+ yield { kind: "mcp_status", mcpStatus: m.mcp_servers };
1346
+ }
1242
1347
  break;
1243
1348
  case "stream_event": {
1244
1349
  const ev = m.event;
@@ -1457,6 +1562,12 @@ You share one computer with every other bot on this account.
1457
1562
  parts.push(`## Your skills
1458
1563
  ${ctx.skills.map((s) => `- **${s.name}** (${s.slug}): ${s.description}`).join("\n")}
1459
1564
  Read the skill file before following it.`);
1565
+ }
1566
+ if (ctx.connectors?.length) {
1567
+ parts.push(`## Your connectors
1568
+ ${ctx.connectors.map((c) => `- **${c.name}**${c.description ? `: ${c.description}` : ""}`).join("\n")}
1569
+ Their tools appear as \`mcp__<connector>__<tool>\`. Prefer a connector's tools over driving the
1570
+ browser for the same service \u2014 it is faster, and it does not depend on a page's layout.`);
1460
1571
  }
1461
1572
  const others = ctx.roster.filter((r) => r.slug !== bot.slug);
1462
1573
  if (others.length) {
@@ -1713,10 +1824,12 @@ Pick the one you need and install just it, e.g. ${args.source.replace(/^https?:\
1713
1824
  const botDir = path5.join(workspace, "bots", bot.slug);
1714
1825
  fs4.mkdirSync(botDir, { recursive: true });
1715
1826
  const botSkills = store.listBotSkills(bot.id);
1827
+ const connectors = await this.deps.connectorServers?.(bot.id);
1716
1828
  const systemPrompt = buildSystemPrompt({
1717
1829
  bot,
1718
1830
  workspace,
1719
1831
  skills: botSkills,
1832
+ connectors: connectors?.mounted ?? [],
1720
1833
  roster: store.listBots().map((x) => ({ slug: x.slug, name: x.name, title: x.title })),
1721
1834
  isGroup
1722
1835
  });
@@ -1727,6 +1840,7 @@ Pick the one you need and install just it, e.g. ${args.source.replace(/^https?:\
1727
1840
  const mcpServers = { antbot: this.buildToolServer(bot, job.threadId, job.hops) };
1728
1841
  const browser = this.deps.browserTools?.(bot.id);
1729
1842
  if (browser) mcpServers.browser = browser;
1843
+ if (connectors) Object.assign(mcpServers, connectors.servers);
1730
1844
  try {
1731
1845
  for await (const ev of runTurn({
1732
1846
  prompt: job.prompt,
@@ -1808,6 +1922,25 @@ Pick the one you need and install just it, e.g. ${args.source.replace(/^https?:\
1808
1922
  case "session":
1809
1923
  if (ev.sessionId) store.updateBot(bot.id, { sessionId: ev.sessionId });
1810
1924
  break;
1925
+ // A connector that does not come up gives the bot no tools and no way to say why — it
1926
+ // simply behaves as though the connector were never assigned. Surfacing the SDK's own
1927
+ // verdict is the difference between "my bot ignores my connector" and a stated reason.
1928
+ case "mcp_status": {
1929
+ const bad = (ev.mcpStatus ?? []).filter((m) => m.status !== "connected");
1930
+ if (!bad.length) break;
1931
+ for (const m of bad) {
1932
+ log6.warn(`connector "${m.name}" did not connect for ${bot.slug}: ${m.status}${m.error ? ` \u2014 ${m.error}` : ""}`);
1933
+ }
1934
+ bus.publish({
1935
+ type: "notify",
1936
+ botId: bot.id,
1937
+ threadId: job.threadId,
1938
+ title: "Connector unavailable",
1939
+ body: bad.map((m) => `${m.name}: ${describeMcpStatus(m.status)}`).join("; "),
1940
+ level: "warn"
1941
+ });
1942
+ break;
1943
+ }
1811
1944
  case "text":
1812
1945
  if (ev.text) {
1813
1946
  const cur = store.getMessage(msgId);
@@ -1871,6 +2004,20 @@ Pick the one you need and install just it, e.g. ${args.source.replace(/^https?:\
1871
2004
  }
1872
2005
  }
1873
2006
  };
2007
+ function describeMcpStatus(status) {
2008
+ switch (status) {
2009
+ case "needs-auth":
2010
+ return "needs authentication \u2014 the server rejected the credentials it was given (or was given none)";
2011
+ case "failed":
2012
+ return "failed to start \u2014 check the command or URL with `antbot connector test`";
2013
+ case "pending":
2014
+ return "did not finish connecting in time";
2015
+ case "disabled":
2016
+ return "is disabled";
2017
+ default:
2018
+ return status;
2019
+ }
2020
+ }
1874
2021
  function summarizeTool(name, input) {
1875
2022
  const o = input ?? {};
1876
2023
  const s = (k) => typeof o[k] === "string" ? o[k] : "";
@@ -1883,10 +2030,73 @@ function summarizeTool(name, input) {
1883
2030
  if (name.includes("list_skills")) return "list installed skills";
1884
2031
  if (name.includes("remember")) return `memory: ${s("title")}`;
1885
2032
  if (name.startsWith("browser_") || name.includes("browser")) return [s("url"), s("selector"), s("text")].filter(Boolean).join(" ").slice(0, 160);
2033
+ const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(name);
2034
+ if (mcp) return `${mcp[1]}: ${mcp[2]} ${JSON.stringify(input ?? {})}`.slice(0, 160);
1886
2035
  const j = JSON.stringify(input ?? {});
1887
2036
  return j.length > 160 ? `${j.slice(0, 160)}\u2026` : j;
1888
2037
  }
1889
2038
 
2039
+ // daemon/src/bots/connectors.ts
2040
+ var SECRET_REF_RE = /\{\{secret:([A-Za-z0-9_.-]+)\}\}/g;
2041
+ function extractSecretRefs(config) {
2042
+ const values = config.transport === "stdio" ? Object.values(config.env) : Object.values(config.headers);
2043
+ const names = [];
2044
+ for (const v of values) {
2045
+ for (const m of v.matchAll(SECRET_REF_RE)) {
2046
+ const name = m[1];
2047
+ if (!names.includes(name)) names.push(name);
2048
+ }
2049
+ }
2050
+ return names;
2051
+ }
2052
+ function computeMissingSecrets(connector, available) {
2053
+ return extractSecretRefs(connector.config).filter((n) => !available.has(n));
2054
+ }
2055
+ function planConnectorMount(assigned, available) {
2056
+ const plan = { mount: [], skipped: [] };
2057
+ for (const connector of assigned) {
2058
+ const missing = computeMissingSecrets(connector, available);
2059
+ if (missing.length) plan.skipped.push({ connector, missing });
2060
+ else plan.mount.push(connector);
2061
+ }
2062
+ return plan;
2063
+ }
2064
+ var MissingSecretError = class extends Error {
2065
+ constructor(connectorName, secretName) {
2066
+ super(`Connector "${connectorName}" references secret "${secretName}", which could not be read.`);
2067
+ this.connectorName = connectorName;
2068
+ this.secretName = secretName;
2069
+ this.name = "MissingSecretError";
2070
+ }
2071
+ connectorName;
2072
+ secretName;
2073
+ };
2074
+ function substitute(value, connectorName, secrets) {
2075
+ return value.replace(SECRET_REF_RE, (_full, name) => {
2076
+ const resolved = secrets.get(name);
2077
+ if (resolved == null) throw new MissingSecretError(connectorName, name);
2078
+ return resolved;
2079
+ });
2080
+ }
2081
+ var substituteAll = (record, connectorName, secrets) => Object.fromEntries(Object.entries(record).map(([k, v]) => [k, substitute(v, connectorName, secrets)]));
2082
+ function buildMcpServerConfig(connector, secrets) {
2083
+ const c = connector.config;
2084
+ if (c.transport === "stdio") {
2085
+ return {
2086
+ type: "stdio",
2087
+ command: c.command,
2088
+ args: c.args,
2089
+ env: substituteAll(c.env, connector.name, secrets)
2090
+ };
2091
+ }
2092
+ return {
2093
+ type: c.transport,
2094
+ url: c.url,
2095
+ headers: substituteAll(c.headers, connector.name, secrets),
2096
+ ...c.tools ? { tools: c.tools } : {}
2097
+ };
2098
+ }
2099
+
1890
2100
  // daemon/src/config/config.ts
1891
2101
  import fs6 from "node:fs";
1892
2102
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@@ -2132,7 +2342,29 @@ var SecretsService = class {
2132
2342
  list() {
2133
2343
  return [...this.names];
2134
2344
  }
2135
- /** Build an env overlay for a tool subprocess. Values never touch the transcript. */
2345
+ /**
2346
+ * Look up exactly the named secrets, for mounting a connector.
2347
+ *
2348
+ * Scoped on purpose. The unscoped `envOverlay()` below hands every stored secret to whatever
2349
+ * asks; a connector should only ever see the ones its own config references, so that adding a
2350
+ * third-party MCP server does not widen the blast radius of every other credential.
2351
+ *
2352
+ * A name with nothing behind it maps to null rather than being dropped, so the caller can tell
2353
+ * "missing" from "empty string" and skip the connector instead of mounting it half-configured.
2354
+ */
2355
+ async resolve(names) {
2356
+ const out = /* @__PURE__ */ new Map();
2357
+ for (const n of new Set(names)) {
2358
+ out.set(n, this.names.has(n) ? await this.backend.get(n) : null);
2359
+ }
2360
+ return out;
2361
+ }
2362
+ /**
2363
+ * Build an env overlay for a tool subprocess. Values never touch the transcript.
2364
+ *
2365
+ * Unused, and unscoped — it returns every secret at once. `resolve()` above is what connectors
2366
+ * use. Kept only because removing it is a separate decision; do not reach for it.
2367
+ */
2136
2368
  async envOverlay() {
2137
2369
  const out = {};
2138
2370
  for (const n of this.names) {
@@ -2212,6 +2444,36 @@ async function createApp(opts = {}) {
2212
2444
  } catch {
2213
2445
  return void 0;
2214
2446
  }
2447
+ },
2448
+ /**
2449
+ * Resolve this bot's connectors into mountable MCP servers.
2450
+ *
2451
+ * This is the only place a secret value is read for a turn, and the values live nowhere but
2452
+ * the returned config — not in the row, not in a log line, not in anything a route returns.
2453
+ * A connector missing a credential is dropped rather than mounted broken or allowed to fail
2454
+ * the turn; the human was already warned on the connectors screen.
2455
+ */
2456
+ connectorServers: async (botId) => {
2457
+ const assigned = store.listBotConnectors(botId);
2458
+ if (!assigned.length) return { servers: {}, mounted: [] };
2459
+ const available = new Set(app.secrets?.list() ?? []);
2460
+ const { mount, skipped } = planConnectorMount(assigned, available);
2461
+ for (const s of skipped) {
2462
+ log8.warn(`connector "${s.connector.name}" not mounted \u2014 missing secret(s): ${s.missing.join(", ")}`);
2463
+ }
2464
+ const servers = {};
2465
+ const mounted = [];
2466
+ for (const connector of mount) {
2467
+ const refs = extractSecretRefs(connector.config);
2468
+ try {
2469
+ const secrets = refs.length ? await app.secrets.resolve(refs) : /* @__PURE__ */ new Map();
2470
+ servers[connector.name] = buildMcpServerConfig(connector, secrets);
2471
+ mounted.push({ name: connector.name, description: connector.description });
2472
+ } catch (err) {
2473
+ log8.warn(`connector "${connector.name}" not mounted`, err.message);
2474
+ }
2475
+ }
2476
+ return { servers, mounted };
2215
2477
  }
2216
2478
  });
2217
2479
  try {
@@ -2456,6 +2718,17 @@ function registerCoreRoutes(f, app) {
2456
2718
  store.deleteBot(req.params.id);
2457
2719
  return { ok: true };
2458
2720
  });
2721
+ f.post("/api/bots/:id/reset", async (req, reply) => {
2722
+ const bot = store.getBot(req.params.id);
2723
+ if (!bot) return reply.code(404).send({ error: "No such bot" });
2724
+ if (bot.state === "running" || bot.state === "queued") {
2725
+ return reply.code(409).send({ error: "This bot is working. Stop it first, then start fresh." });
2726
+ }
2727
+ const result = store.resetBotSession(bot.id);
2728
+ if (!result) return reply.code(404).send({ error: "No such bot" });
2729
+ if (bot.threadId) bus.publish({ type: "thread.updated", threadId: bot.threadId, botId: bot.id, threadId2: bot.threadId });
2730
+ return { ok: true, ...result };
2731
+ });
2459
2732
  f.post("/api/bots/:id/duplicate", async (req, reply) => {
2460
2733
  try {
2461
2734
  const copy = store.duplicateBot(req.params.id);
@@ -2496,6 +2769,15 @@ function registerCoreRoutes(f, app) {
2496
2769
  store.setBotSkills(req.params.id, req.body?.skillIds ?? []);
2497
2770
  return { ok: true };
2498
2771
  });
2772
+ f.get("/api/bots/:id/connectors", async (req, reply) => {
2773
+ if (!store.getBot(req.params.id)) return reply.code(404).send({ error: "No such bot" });
2774
+ return store.listBotConnectors(req.params.id);
2775
+ });
2776
+ f.put("/api/bots/:id/connectors", async (req, reply) => {
2777
+ if (!store.getBot(req.params.id)) return reply.code(404).send({ error: "No such bot" });
2778
+ store.setBotConnectors(req.params.id, req.body?.connectorIds ?? []);
2779
+ return { ok: true };
2780
+ });
2499
2781
  f.get("/api/threads", async () => store.listThreads());
2500
2782
  f.post("/api/threads", async (req, reply) => {
2501
2783
  const parsed = CreateThreadRequest.safeParse(req.body);
@@ -2585,6 +2867,176 @@ ${attachments.map((a) => `- ${a.name} \u2192 ${a.path}`).join("\n")}` : "";
2585
2867
  // daemon/src/api/routes-ops.ts
2586
2868
  import fs9 from "node:fs";
2587
2869
  import path10 from "node:path";
2870
+
2871
+ // daemon/src/bots/mcpProbe.ts
2872
+ import { spawn } from "node:child_process";
2873
+ var log10 = logger("mcp-probe");
2874
+ var PROTOCOL_VERSION = "2025-06-18";
2875
+ var DEFAULT_TIMEOUT_MS = 1e4;
2876
+ var MAX_DESCRIPTION = 200;
2877
+ function parseToolsResult(result) {
2878
+ const tools = result?.tools;
2879
+ if (!Array.isArray(tools)) return [];
2880
+ return tools.filter((t) => typeof t === "object" && t !== null).map((t) => ({
2881
+ name: String(t.name ?? ""),
2882
+ description: String(t.description ?? "").slice(0, MAX_DESCRIPTION)
2883
+ })).filter((t) => t.name.length > 0);
2884
+ }
2885
+ var rpc = (id, method, params) => `${JSON.stringify({ jsonrpc: "2.0", id, method, ...params ? { params } : {} })}
2886
+ `;
2887
+ var notify = (method) => `${JSON.stringify({ jsonrpc: "2.0", method })}
2888
+ `;
2889
+ var failed = (error) => ({ ok: false, tools: [], error });
2890
+ async function probeStdio(cfg, timeoutMs) {
2891
+ return new Promise((resolve) => {
2892
+ let child;
2893
+ try {
2894
+ child = spawn(cfg.command, cfg.args ?? [], {
2895
+ // The server's own env plus the connector's — a connector that needs PATH still gets it.
2896
+ env: { ...process.env, ...cfg.env ?? {} },
2897
+ stdio: ["pipe", "pipe", "pipe"]
2898
+ });
2899
+ } catch (err) {
2900
+ return resolve(failed(err.message));
2901
+ }
2902
+ let settled = false;
2903
+ let stderr = "";
2904
+ let buffer = "";
2905
+ const finish = (r) => {
2906
+ if (settled) return;
2907
+ settled = true;
2908
+ clearTimeout(timer);
2909
+ try {
2910
+ child.kill("SIGKILL");
2911
+ } catch {
2912
+ }
2913
+ resolve(r);
2914
+ };
2915
+ const timer = setTimeout(
2916
+ () => finish(failed(`timed out after ${timeoutMs}ms${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`)),
2917
+ timeoutMs
2918
+ );
2919
+ child.on("error", (err) => finish(failed(err.message)));
2920
+ child.on(
2921
+ "exit",
2922
+ (code) => finish(failed(`server exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 200)}` : ""}`))
2923
+ );
2924
+ child.stderr?.on("data", (d) => {
2925
+ stderr += d.toString();
2926
+ });
2927
+ child.stdout?.on("data", (d) => {
2928
+ buffer += d.toString();
2929
+ const lines = buffer.split("\n");
2930
+ buffer = lines.pop() ?? "";
2931
+ for (const line of lines) {
2932
+ if (!line.trim()) continue;
2933
+ let msg;
2934
+ try {
2935
+ msg = JSON.parse(line);
2936
+ } catch {
2937
+ continue;
2938
+ }
2939
+ if (msg.id === 1) {
2940
+ try {
2941
+ child.stdin?.write(notify("notifications/initialized"));
2942
+ child.stdin?.write(rpc(2, "tools/list"));
2943
+ } catch (err) {
2944
+ finish(failed(err.message));
2945
+ }
2946
+ } else if (msg.id === 2) {
2947
+ if (msg.error) return finish(failed(JSON.stringify(msg.error).slice(0, 200)));
2948
+ finish({ ok: true, tools: parseToolsResult(msg.result) });
2949
+ }
2950
+ }
2951
+ });
2952
+ try {
2953
+ child.stdin?.write(
2954
+ rpc(1, "initialize", {
2955
+ protocolVersion: PROTOCOL_VERSION,
2956
+ capabilities: {},
2957
+ clientInfo: { name: "ant-bot", version: "1.0.0" }
2958
+ })
2959
+ );
2960
+ } catch (err) {
2961
+ finish(failed(err.message));
2962
+ }
2963
+ });
2964
+ }
2965
+ async function probeHttp(cfg, timeoutMs) {
2966
+ const ac = new AbortController();
2967
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
2968
+ const base = {
2969
+ "content-type": "application/json",
2970
+ accept: "application/json, text/event-stream",
2971
+ ...cfg.headers ?? {}
2972
+ };
2973
+ const readBody = async (res) => {
2974
+ const text = await res.text();
2975
+ const line = text.split("\n").find((l) => l.startsWith("data:"));
2976
+ try {
2977
+ return JSON.parse(line ? line.slice(5).trim() : text);
2978
+ } catch {
2979
+ return null;
2980
+ }
2981
+ };
2982
+ try {
2983
+ const initRes = await fetch(cfg.url, {
2984
+ method: "POST",
2985
+ signal: ac.signal,
2986
+ headers: base,
2987
+ body: rpc(1, "initialize", {
2988
+ protocolVersion: PROTOCOL_VERSION,
2989
+ capabilities: {},
2990
+ clientInfo: { name: "ant-bot", version: "1.0.0" }
2991
+ })
2992
+ });
2993
+ if (!initRes.ok) return failed(`initialize returned HTTP ${initRes.status}`);
2994
+ const session = initRes.headers.get("mcp-session-id");
2995
+ const withSession = session ? { ...base, "mcp-session-id": session } : base;
2996
+ await readBody(initRes);
2997
+ await fetch(cfg.url, { method: "POST", signal: ac.signal, headers: withSession, body: notify("notifications/initialized") }).catch(() => void 0);
2998
+ const listRes = await fetch(cfg.url, {
2999
+ method: "POST",
3000
+ signal: ac.signal,
3001
+ headers: withSession,
3002
+ body: rpc(2, "tools/list")
3003
+ });
3004
+ if (!listRes.ok) return failed(`tools/list returned HTTP ${listRes.status}`);
3005
+ const body = await readBody(listRes);
3006
+ if (body?.error) return failed(JSON.stringify(body.error).slice(0, 200));
3007
+ return { ok: true, tools: parseToolsResult(body?.result) };
3008
+ } catch (err) {
3009
+ const e = err;
3010
+ return failed(e.name === "AbortError" ? `timed out after ${timeoutMs}ms` : e.message);
3011
+ } finally {
3012
+ clearTimeout(timer);
3013
+ }
3014
+ }
3015
+ async function probeConnector(config, opts = {}) {
3016
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3017
+ const type = config.type;
3018
+ try {
3019
+ if (type === "stdio") {
3020
+ return await probeStdio(config, timeoutMs);
3021
+ }
3022
+ if (type === "http") {
3023
+ const result = await probeHttp(config, timeoutMs);
3024
+ const headers = config.headers ?? {};
3025
+ const authed = Object.keys(headers).some((h) => /^(authorization|x-api-key|api-key)$/i.test(h));
3026
+ if (result.ok && !authed) {
3027
+ result.authHint = "This server was reached without any credential. If it needs one, the tools will list here but a bot will still see none \u2014 add an Authorization header, e.g. {{secret:NAME}}.";
3028
+ }
3029
+ return result;
3030
+ }
3031
+ if (type === "sse") return failed("testing is not supported for sse connectors \u2014 assign it to a bot and run a turn");
3032
+ return failed(`unknown transport: ${String(type)}`);
3033
+ } catch (err) {
3034
+ log10.warn("probe threw", err);
3035
+ return failed(err.message);
3036
+ }
3037
+ }
3038
+
3039
+ // daemon/src/api/routes-ops.ts
2588
3040
  function registerOpsRoutes(f, app) {
2589
3041
  const { store, gateway, bus } = app;
2590
3042
  f.get("/api/approvals", async () => store.listPendingApprovals());
@@ -2618,6 +3070,45 @@ function registerOpsRoutes(f, app) {
2618
3070
  store.deleteRule(rule.id);
2619
3071
  return { ok: true };
2620
3072
  });
3073
+ f.get("/api/connectors", async () => {
3074
+ const available = new Set(app.secrets?.list() ?? []);
3075
+ return store.listConnectors().map((c) => ({ ...c, missingSecrets: computeMissingSecrets(c, available) }));
3076
+ });
3077
+ f.post("/api/connectors", async (req, reply) => {
3078
+ const parsed = CreateConnectorRequest.safeParse(req.body);
3079
+ if (!parsed.success) return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? "Invalid body" });
3080
+ if (store.getConnectorByName(parsed.data.name)) {
3081
+ return reply.code(409).send({ error: `A connector named "${parsed.data.name}" already exists` });
3082
+ }
3083
+ return store.createConnector(parsed.data);
3084
+ });
3085
+ f.patch("/api/connectors/:id", async (req, reply) => {
3086
+ const parsed = UpdateConnectorRequest.safeParse(req.body);
3087
+ if (!parsed.success) return reply.code(400).send({ error: "Invalid body" });
3088
+ const updated = store.updateConnector(req.params.id, parsed.data);
3089
+ if (!updated) return reply.code(404).send({ error: "No such connector" });
3090
+ return updated;
3091
+ });
3092
+ f.delete("/api/connectors/:id", async (req, reply) => {
3093
+ if (!store.getConnector(req.params.id)) return reply.code(404).send({ error: "No such connector" });
3094
+ store.deleteConnector(req.params.id);
3095
+ return { ok: true };
3096
+ });
3097
+ f.post("/api/connectors/:id/test", async (req, reply) => {
3098
+ const connector = store.getConnector(req.params.id);
3099
+ if (!connector) return reply.code(404).send({ error: "No such connector" });
3100
+ const refs = extractSecretRefs(connector.config);
3101
+ const missing = computeMissingSecrets(connector, new Set(app.secrets?.list() ?? []));
3102
+ if (missing.length) {
3103
+ return { ok: false, tools: [], error: `missing secret(s): ${missing.join(", ")}` };
3104
+ }
3105
+ try {
3106
+ const secrets = refs.length && app.secrets ? await app.secrets.resolve(refs) : /* @__PURE__ */ new Map();
3107
+ return await probeConnector(buildMcpServerConfig(connector, secrets));
3108
+ } catch (err) {
3109
+ return { ok: false, tools: [], error: err.message };
3110
+ }
3111
+ });
2621
3112
  f.get("/api/skills", async () => store.listSkills());
2622
3113
  f.post("/api/skills", async (req, reply) => {
2623
3114
  const parsed = CreateSkillRequest.safeParse(req.body);
@@ -2855,7 +3346,7 @@ function registerOpsRoutes(f, app) {
2855
3346
  }
2856
3347
 
2857
3348
  // daemon/src/api/server.ts
2858
- var log10 = logger("server");
3349
+ var log11 = logger("server");
2859
3350
  var require_ = createRequire(import.meta.url);
2860
3351
  function resolveWebDist() {
2861
3352
  return findWebDist(
@@ -2972,9 +3463,9 @@ async function startServer(opts = {}) {
2972
3463
  if (req.url.startsWith("/api")) return reply.code(404).send({ error: "Not found" });
2973
3464
  return reply.sendFile("index.html");
2974
3465
  });
2975
- log10.info(`serving UI from ${dist}`);
3466
+ log11.info(`serving UI from ${dist}`);
2976
3467
  } else {
2977
- log10.warn("web UI not built \u2014 run `pnpm --filter @antbot/ui build`");
3468
+ log11.warn("web UI not built \u2014 run `pnpm --filter @antbot/ui build`");
2978
3469
  fastify.setNotFoundHandler((req, reply) => {
2979
3470
  if (req.url.startsWith("/api")) return reply.code(404).send({ error: "Not found" });
2980
3471
  return reply.type("text/html").send(
@@ -2989,7 +3480,7 @@ async function startServer(opts = {}) {
2989
3480
  }
2990
3481
  }
2991
3482
  fastify.setErrorHandler((err, _req, reply) => {
2992
- log10.error("request failed", err);
3483
+ log11.error("request failed", err);
2993
3484
  const e = err;
2994
3485
  const code = e.statusCode && e.statusCode >= 400 ? e.statusCode : 500;
2995
3486
  reply.code(code).send({ error: e.message ?? "Internal error" });
@@ -2999,13 +3490,13 @@ async function startServer(opts = {}) {
2999
3490
  await fastify.listen({ port, host });
3000
3491
  const url = `http://${host}:${port}`;
3001
3492
  const delivered = drainMailbox(app);
3002
- if (delivered) log10.info(`redelivered ${delivered} queued handoff message(s)`);
3493
+ if (delivered) log11.info(`redelivered ${delivered} queued handoff message(s)`);
3003
3494
  const stale = app.store.listBots().filter((b2) => b2.state === "running" || b2.state === "queued");
3004
3495
  for (const b2 of stale) app.store.updateBot(b2.id, { state: "idle" });
3005
3496
  app.db.prepare(`UPDATE messages SET streaming=0 WHERE streaming=1`).run();
3006
3497
  app.db.prepare(`UPDATE approvals SET status='expired', reason='Daemon restarted' WHERE status='pending'`).run();
3007
3498
  app.db.prepare(`UPDATE routine_runs SET status='interrupted', finished_at=? WHERE status='running'`).run(Date.now());
3008
- log10.info(`ant-bot listening on ${url}`);
3499
+ log11.info(`ant-bot listening on ${url}`);
3009
3500
  return {
3010
3501
  fastify,
3011
3502
  app,