@yitom/agy-acp-map 0.1.14 → 0.1.16

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.
Binary file
package/dist/bin.js CHANGED
@@ -12540,11 +12540,168 @@ class SessionHistoryStore {
12540
12540
  }
12541
12541
  }
12542
12542
 
12543
+ // src/lib/mcp-servers.ts
12544
+ import { execFile } from "node:child_process";
12545
+ function fail(msg) {
12546
+ throw new RequestError(-32602, msg);
12547
+ }
12548
+ function nonEmptyString(v) {
12549
+ return typeof v === "string" && v.length > 0;
12550
+ }
12551
+ function strArray(v, what) {
12552
+ if (v === undefined)
12553
+ return [];
12554
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
12555
+ fail(`mcpServers[].${what} must be an array of strings`);
12556
+ }
12557
+ return v;
12558
+ }
12559
+ function nameValueList(v, what) {
12560
+ if (v === undefined)
12561
+ return [];
12562
+ if (!Array.isArray(v))
12563
+ fail(`mcpServers[].${what} must be an array`);
12564
+ return v.map((e, i) => {
12565
+ if (!e || typeof e !== "object")
12566
+ fail(`mcpServers[].${what}[${i}] must be {name, value}`);
12567
+ const rec = e;
12568
+ if (!nonEmptyString(rec.name) || typeof rec.value !== "string") {
12569
+ fail(`mcpServers[].${what}[${i}] must be {name: string, value: string}`);
12570
+ }
12571
+ return { name: rec.name, value: rec.value };
12572
+ });
12573
+ }
12574
+ function normalizeMcpServer(input) {
12575
+ if (!input || typeof input !== "object")
12576
+ fail("mcpServers[] must be an object");
12577
+ const s = input;
12578
+ if (!nonEmptyString(s.name))
12579
+ fail("mcpServers[].name must be a non-empty string");
12580
+ const name = s.name.trim();
12581
+ if (name.length > 64 || /[\s\x00-\x1f]/.test(name)) {
12582
+ fail(`mcpServers[].name must be ≤64 chars with no whitespace: ${JSON.stringify(name)}`);
12583
+ }
12584
+ const t = typeof s.type === "string" ? s.type.trim().toLowerCase() : "";
12585
+ if (t === "sse" || t === "acp") {
12586
+ fail(`mcpServers[] '${name}': type '${t}' has no 'agy mcp add' equivalent ` + `(agy supports stdio|http only). Resend as stdio or http.`);
12587
+ }
12588
+ if (t !== "" && t !== "stdio" && t !== "http") {
12589
+ fail(`mcpServers[] '${name}': unknown type ${JSON.stringify(s.type)} (want stdio|http)`);
12590
+ }
12591
+ const hasCommand = nonEmptyString(s.command);
12592
+ const hasUrl = nonEmptyString(s.url);
12593
+ if (hasCommand && hasUrl) {
12594
+ fail(`mcpServers[] '${name}': ambiguous (both command and url set); send one`);
12595
+ }
12596
+ if (t === "http" || !hasCommand && hasUrl) {
12597
+ if (!hasUrl)
12598
+ fail(`mcpServers[] '${name}': http server needs a url`);
12599
+ const url = s.url;
12600
+ if (!/^https?:\/\//i.test(url))
12601
+ fail(`mcpServers[] '${name}': url must start with http(s)://`);
12602
+ return { name, kind: "http", args: [], env: [], url, headers: nameValueList(s.headers, "headers") };
12603
+ }
12604
+ if (!hasCommand) {
12605
+ fail(`mcpServers[] '${name}': stdio server needs a command (or send type:"http" with a url)`);
12606
+ }
12607
+ return {
12608
+ name,
12609
+ kind: "stdio",
12610
+ command: s.command,
12611
+ args: strArray(s.args, "args"),
12612
+ env: nameValueList(s.env, "env"),
12613
+ headers: []
12614
+ };
12615
+ }
12616
+ function validateMcpServers(input) {
12617
+ if (input === undefined)
12618
+ return [];
12619
+ if (!Array.isArray(input))
12620
+ fail("mcpServers must be an array");
12621
+ const seen = new Set;
12622
+ return input.map((e) => {
12623
+ const n = normalizeMcpServer(e);
12624
+ if (seen.has(n.name))
12625
+ fail(`mcpServers[] duplicate name: '${n.name}'`);
12626
+ seen.add(n.name);
12627
+ return n;
12628
+ });
12629
+ }
12630
+ function mcpServerToAgyAddArgs(s) {
12631
+ const argv = ["mcp", "add"];
12632
+ if (s.kind === "stdio") {
12633
+ for (const e of s.env)
12634
+ argv.push("--env", `${e.name}=${e.value}`);
12635
+ argv.push(s.name, s.command, ...s.args);
12636
+ return argv;
12637
+ }
12638
+ for (const h of s.headers)
12639
+ argv.push("--header", `${h.name}: ${h.value}`);
12640
+ argv.push("--type", "http", s.name, s.url);
12641
+ return argv;
12642
+ }
12643
+ function execFileAsync(bin, args, timeoutMs) {
12644
+ return new Promise((resolve) => {
12645
+ execFile(bin, args, { timeout: timeoutMs, maxBuffer: 512 * 1024, windowsHide: true }, (err, stdout, stderr) => {
12646
+ const e = err;
12647
+ resolve({
12648
+ status: typeof e?.code === "number" ? e.code : e ? 1 : 0,
12649
+ stdout: String(stdout ?? ""),
12650
+ stderr: e?.message ? `${e.message}
12651
+ ${String(stderr ?? "")}` : String(stderr ?? "")
12652
+ });
12653
+ });
12654
+ });
12655
+ }
12656
+ var defaultMcpRunFn = (bin, args, opts) => execFileAsync(bin, args, opts.timeoutMs);
12657
+ function tail(text, n = 600) {
12658
+ const t = String(text || "").trim();
12659
+ return t.length > n ? "…" + t.slice(-n) : t;
12660
+ }
12661
+ async function syncMcpServers(bin, servers, runFn = defaultMcpRunFn, timeoutMs = 30000) {
12662
+ const added = [];
12663
+ for (const s of servers) {
12664
+ let r;
12665
+ try {
12666
+ r = await runFn(bin, mcpServerToAgyAddArgs(s), { timeoutMs });
12667
+ } catch (err) {
12668
+ throw new RequestError(-32603, `failed to register MCP server '${s.name}': ${err?.message || err}`);
12669
+ }
12670
+ if (r.status !== 0) {
12671
+ throw new RequestError(-32603, `failed to register MCP server '${s.name}' (exit ${r.status}): ${tail(`${r.stdout}
12672
+ ${r.stderr}`)}`);
12673
+ }
12674
+ added.push(s.name);
12675
+ }
12676
+ return { added };
12677
+ }
12678
+ async function removeMcpServers(bin, names, runFn = defaultMcpRunFn, timeoutMs = 30000) {
12679
+ const removed = [];
12680
+ const warnings = [];
12681
+ for (const name of names) {
12682
+ try {
12683
+ const r = await runFn(bin, ["mcp", "remove", name], { timeoutMs });
12684
+ if (r.status !== 0) {
12685
+ warnings.push(`mcp remove '${name}' exit ${r.status}: ${tail(`${r.stdout}
12686
+ ${r.stderr}`, 200)}`);
12687
+ } else {
12688
+ removed.push(name);
12689
+ }
12690
+ } catch (err) {
12691
+ warnings.push(`mcp remove '${name}' threw: ${err?.message || err}`);
12692
+ }
12693
+ }
12694
+ if (warnings.length) {
12695
+ console.warn(`[ACP-MCP] cleanup warnings: ${warnings.join(" | ")}`);
12696
+ }
12697
+ return { removed, warnings };
12698
+ }
12699
+
12543
12700
  // src/core/types.ts
12544
12701
  var AGENT_INFO = {
12545
12702
  name: "agy-acp",
12546
12703
  title: "agy ACP (stream-json)",
12547
- version: "0.1.14"
12704
+ version: "0.1.16"
12548
12705
  };
12549
12706
  var BRIDGE_CAPABILITIES = {
12550
12707
  prompt: true,
@@ -12652,6 +12809,17 @@ function debugLog(msg) {
12652
12809
 
12653
12810
  // src/core/session-core.ts
12654
12811
  var EMPTY_SESSION_MAX_AGE_MS = 60 * 60 * 1000;
12812
+ function resolveAgyBin() {
12813
+ const rawBin = process.env.AGY_BIN;
12814
+ let bin = rawBin && rawBin !== "undefined" && rawBin !== "null" ? rawBin : "agy";
12815
+ if (bin === "agy" || bin === "agy.exe") {
12816
+ const geminiBin = path10.join(process.env.USERPROFILE || process.env.HOME || "", ".gemini", "bin", process.platform === "win32" ? "agy.exe" : "agy");
12817
+ if (fs9.existsSync(geminiBin)) {
12818
+ bin = geminiBin;
12819
+ }
12820
+ }
12821
+ return bin;
12822
+ }
12655
12823
  function deriveTitle(text, maxLen = 60) {
12656
12824
  const line = String(text || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || "";
12657
12825
  const flat = line.replace(/\s+/g, " ");
@@ -12663,7 +12831,10 @@ class AgySessionCore {
12663
12831
  sessionStore;
12664
12832
  historyStore;
12665
12833
  catalogPromise = null;
12834
+ mcpRunner;
12835
+ mcpRefs = new Map;
12666
12836
  constructor(options) {
12837
+ this.mcpRunner = options?.mcpRunner ?? defaultMcpRunFn;
12667
12838
  if (options?.sessionStore instanceof SessionStore) {
12668
12839
  this.sessionStore = options.sessionStore;
12669
12840
  } else if (typeof options?.sessionStore === "string") {
@@ -12712,14 +12883,8 @@ class AgySessionCore {
12712
12883
  disableSlashCommands: disableSlash,
12713
12884
  printTimeout
12714
12885
  });
12715
- const rawBin = process.env.AGY_BIN;
12716
- let bin = rawBin && rawBin !== "undefined" && rawBin !== "null" ? rawBin : "agy";
12717
- if (bin === "agy" || bin === "agy.exe") {
12718
- const geminiBin = path10.join(process.env.USERPROFILE || process.env.HOME || "", ".gemini", "bin", process.platform === "win32" ? "agy.exe" : "agy");
12719
- if (fs9.existsSync(geminiBin)) {
12720
- bin = geminiBin;
12721
- }
12722
- }
12886
+ const rawBin = resolveAgyBin();
12887
+ const bin = rawBin;
12723
12888
  let execBin = bin;
12724
12889
  let execArgs = args;
12725
12890
  if (/\.(js|cjs|mjs|ts)$/i.test(bin)) {
@@ -12850,6 +13015,43 @@ class AgySessionCore {
12850
13015
  if (!session.agent && agents.length)
12851
13016
  session.agent = agents[0].value;
12852
13017
  }
13018
+ async applySessionMcpServers(sessionId, input) {
13019
+ const servers = validateMcpServers(input ?? []);
13020
+ if (!servers.length)
13021
+ return [];
13022
+ const live = this.sessions.get(sessionId);
13023
+ const hadLiveProc = live ? live.proc.isWritable() : false;
13024
+ await syncMcpServers(resolveAgyBin(), servers, this.mcpRunner);
13025
+ const names = servers.map((s) => ({ name: s.name }));
13026
+ if (live)
13027
+ live.mcpServers = names;
13028
+ this.trackMcpServers(sessionId, names.map((n) => n.name));
13029
+ if (hadLiveProc) {
13030
+ console.warn(`[ACP-MCP] session ${sessionId}: MCP servers [${names.map((n) => n.name).join(", ")}] ` + `registered while the session process is live; they apply to fresh spawns (reconnect to use them).`);
13031
+ }
13032
+ return names;
13033
+ }
13034
+ trackMcpServers(sessionId, names) {
13035
+ for (const name of names) {
13036
+ let set = this.mcpRefs.get(name);
13037
+ if (!set) {
13038
+ set = new Set;
13039
+ this.mcpRefs.set(name, set);
13040
+ }
13041
+ set.add(sessionId);
13042
+ }
13043
+ }
13044
+ untrackMcpServers(sessionId) {
13045
+ const freed = [];
13046
+ for (const [name, set] of this.mcpRefs) {
13047
+ set.delete(sessionId);
13048
+ if (set.size === 0) {
13049
+ this.mcpRefs.delete(name);
13050
+ freed.push(name);
13051
+ }
13052
+ }
13053
+ return freed;
13054
+ }
12853
13055
  async createSession(params, protocolVersion = 1) {
12854
13056
  const cwd = params?.cwd;
12855
13057
  debugLog(`createSession v${protocolVersion} cwd=${cwd}`);
@@ -12872,6 +13074,7 @@ class AgySessionCore {
12872
13074
  if (params?.mcpServers !== undefined && !Array.isArray(params.mcpServers)) {
12873
13075
  throw new RequestError(-32602, "mcpServers must be an array");
12874
13076
  }
13077
+ validateMcpServers(params?.mcpServers);
12875
13078
  const launch = extractLaunchConfig(params);
12876
13079
  const discovery = await this.getDiscovery();
12877
13080
  const sessionId = randomUUID3();
@@ -12914,6 +13117,7 @@ class AgySessionCore {
12914
13117
  };
12915
13118
  this.applyCatalogDefaults(session, discovery);
12916
13119
  this.sessions.set(sessionId, session);
13120
+ await this.applySessionMcpServers(sessionId, params?.mcpServers);
12917
13121
  this.persistSession(session);
12918
13122
  this.warmupSession(sessionId);
12919
13123
  return {
@@ -12942,14 +13146,10 @@ class AgySessionCore {
12942
13146
  throw new RequestError(-32602, protocolVersion === 2 ? 'only replayFrom.type="start" is supported by this agent' : "session/resume with replayFrom is only supported by ACP v2");
12943
13147
  }
12944
13148
  }
12945
- if (params?.mcpServers !== undefined) {
12946
- if (!Array.isArray(params.mcpServers)) {
12947
- throw new RequestError(-32602, "mcpServers must be an array");
12948
- }
12949
- if (params.mcpServers.length > 0) {
12950
- throw new RequestError(-32602, "mcpServers are not supported by this agent");
12951
- }
13149
+ if (params?.mcpServers !== undefined && !Array.isArray(params.mcpServers)) {
13150
+ throw new RequestError(-32602, "mcpServers must be an array");
12952
13151
  }
13152
+ validateMcpServers(params?.mcpServers);
12953
13153
  let additionalDirectories = [];
12954
13154
  if (params?.additionalDirectories !== undefined) {
12955
13155
  if (!Array.isArray(params.additionalDirectories)) {
@@ -13024,6 +13224,7 @@ class AgySessionCore {
13024
13224
  }
13025
13225
  const discovery = await this.getDiscovery();
13026
13226
  this.applyCatalogDefaults(session, discovery);
13227
+ await this.applySessionMcpServers(sessionId, params?.mcpServers);
13027
13228
  if (!session.title) {
13028
13229
  try {
13029
13230
  const first = this.historyStore.firstUserText(sessionId);
@@ -13200,6 +13401,14 @@ class AgySessionCore {
13200
13401
  }
13201
13402
  this.sessionStore.delete(sessionId);
13202
13403
  this.historyStore.delete(sessionId);
13404
+ try {
13405
+ const freed = this.untrackMcpServers(sessionId);
13406
+ if (freed.length) {
13407
+ await removeMcpServers(resolveAgyBin(), freed, this.mcpRunner);
13408
+ }
13409
+ } catch (err) {
13410
+ console.warn(`[ACP-MCP] cleanup after delete ${sessionId} failed: ${err?.message || err}`);
13411
+ }
13203
13412
  return {};
13204
13413
  }
13205
13414
  async closeSession(params) {
@@ -1,14 +1,22 @@
1
1
  import { type DiscoveryResult } from '../lib/agy-discovery.ts';
2
2
  import { SessionStore } from '../lib/session-store.ts';
3
3
  import { SessionHistoryStore } from '../lib/session-history.ts';
4
+ import { type McpRunFn } from '../lib/mcp-servers.ts';
4
5
  import { type SdkSession, type ProtocolVersion } from './types.ts';
5
6
  export interface SessionCoreOptions {
6
7
  sessionStore?: SessionStore | string;
7
8
  historyStore?: SessionHistoryStore | string;
9
+ /**
10
+ * `agy mcp ...` runner override (tests inject a fake; prod spawns agy).
11
+ * Keeps MCP sync unit-testable without touching the real global config.
12
+ */
13
+ mcpRunner?: McpRunFn;
8
14
  }
9
15
  /** Rows without any completed turn older than this are hidden from
10
16
  * session/list (still resumable/deletable by id — the store keeps them). */
11
17
  export declare const EMPTY_SESSION_MAX_AGE_MS: number;
18
+ /** Resolve the agy binary (AGY_BIN, else ~/.gemini/bin/agy). Shared by spawn and `agy mcp ...` sync. */
19
+ export declare function resolveAgyBin(): string;
12
20
  /** First user prompt collapsed to one line, capped for list display. */
13
21
  export declare function deriveTitle(text: string, maxLen?: number): string;
14
22
  export declare class AgySessionCore {
@@ -16,6 +24,13 @@ export declare class AgySessionCore {
16
24
  readonly sessionStore: SessionStore;
17
25
  readonly historyStore: SessionHistoryStore;
18
26
  private catalogPromise;
27
+ private readonly mcpRunner;
28
+ /**
29
+ * MCP server name → sessionIds that registered it (this process only).
30
+ * Drives delete-time cleanup: a server is `agy mcp remove`d only when its
31
+ * last referencing session goes away.
32
+ */
33
+ private readonly mcpRefs;
19
34
  constructor(options?: SessionCoreOptions);
20
35
  getDiscovery(): Promise<DiscoveryResult>;
21
36
  /** Warm-up toggle: AGY_ACP_WARMUP=0/false/no disables connect-time pre-spawn. */
@@ -41,6 +56,19 @@ export declare class AgySessionCore {
41
56
  replayHistory(sessionId: string, protocolVersion: ProtocolVersion, notifyClient: (update: any) => Promise<void> | void): Promise<void>;
42
57
  sessionMeta(session: SdkSession): Record<string, unknown> | undefined;
43
58
  applyCatalogDefaults(session: SdkSession, discovery: DiscoveryResult): void;
59
+ /**
60
+ * Sync ACP mcpServers into agy's MCP config BEFORE the session process
61
+ * spawns, so tools are listed from the first turn. Throws loud on any
62
+ * failure (a client that asked for MCP must never get a silent
63
+ * tools-less session). Tracks names per session for delete-time cleanup.
64
+ */
65
+ private applySessionMcpServers;
66
+ private trackMcpServers;
67
+ /**
68
+ * Drop one session's references; returns names no other live session
69
+ * references anymore (safe to `agy mcp remove`).
70
+ */
71
+ private untrackMcpServers;
44
72
  createSession(params: any, protocolVersion?: ProtocolVersion): Promise<{
45
73
  session: SdkSession;
46
74
  discovery: DiscoveryResult;
@@ -58,6 +58,10 @@ export interface SdkSession {
58
58
  disableSlashCommands?: boolean;
59
59
  printTimeout?: string;
60
60
  deleted?: boolean;
61
+ /** MCP servers this session registered (tracked for delete-time cleanup). */
62
+ mcpServers?: Array<{
63
+ name: string;
64
+ }>;
61
65
  }
62
66
  /**
63
67
  * Sequential FIFO execution queue to eliminate notification ordering races.
package/dist/index.js CHANGED
@@ -12327,11 +12327,168 @@ class SessionHistoryStore {
12327
12327
  }
12328
12328
  }
12329
12329
 
12330
+ // src/lib/mcp-servers.ts
12331
+ import { execFile } from "node:child_process";
12332
+ function fail(msg) {
12333
+ throw new RequestError(-32602, msg);
12334
+ }
12335
+ function nonEmptyString(v) {
12336
+ return typeof v === "string" && v.length > 0;
12337
+ }
12338
+ function strArray(v, what) {
12339
+ if (v === undefined)
12340
+ return [];
12341
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) {
12342
+ fail(`mcpServers[].${what} must be an array of strings`);
12343
+ }
12344
+ return v;
12345
+ }
12346
+ function nameValueList(v, what) {
12347
+ if (v === undefined)
12348
+ return [];
12349
+ if (!Array.isArray(v))
12350
+ fail(`mcpServers[].${what} must be an array`);
12351
+ return v.map((e, i) => {
12352
+ if (!e || typeof e !== "object")
12353
+ fail(`mcpServers[].${what}[${i}] must be {name, value}`);
12354
+ const rec = e;
12355
+ if (!nonEmptyString(rec.name) || typeof rec.value !== "string") {
12356
+ fail(`mcpServers[].${what}[${i}] must be {name: string, value: string}`);
12357
+ }
12358
+ return { name: rec.name, value: rec.value };
12359
+ });
12360
+ }
12361
+ function normalizeMcpServer(input) {
12362
+ if (!input || typeof input !== "object")
12363
+ fail("mcpServers[] must be an object");
12364
+ const s = input;
12365
+ if (!nonEmptyString(s.name))
12366
+ fail("mcpServers[].name must be a non-empty string");
12367
+ const name = s.name.trim();
12368
+ if (name.length > 64 || /[\s\x00-\x1f]/.test(name)) {
12369
+ fail(`mcpServers[].name must be ≤64 chars with no whitespace: ${JSON.stringify(name)}`);
12370
+ }
12371
+ const t = typeof s.type === "string" ? s.type.trim().toLowerCase() : "";
12372
+ if (t === "sse" || t === "acp") {
12373
+ fail(`mcpServers[] '${name}': type '${t}' has no 'agy mcp add' equivalent ` + `(agy supports stdio|http only). Resend as stdio or http.`);
12374
+ }
12375
+ if (t !== "" && t !== "stdio" && t !== "http") {
12376
+ fail(`mcpServers[] '${name}': unknown type ${JSON.stringify(s.type)} (want stdio|http)`);
12377
+ }
12378
+ const hasCommand = nonEmptyString(s.command);
12379
+ const hasUrl = nonEmptyString(s.url);
12380
+ if (hasCommand && hasUrl) {
12381
+ fail(`mcpServers[] '${name}': ambiguous (both command and url set); send one`);
12382
+ }
12383
+ if (t === "http" || !hasCommand && hasUrl) {
12384
+ if (!hasUrl)
12385
+ fail(`mcpServers[] '${name}': http server needs a url`);
12386
+ const url = s.url;
12387
+ if (!/^https?:\/\//i.test(url))
12388
+ fail(`mcpServers[] '${name}': url must start with http(s)://`);
12389
+ return { name, kind: "http", args: [], env: [], url, headers: nameValueList(s.headers, "headers") };
12390
+ }
12391
+ if (!hasCommand) {
12392
+ fail(`mcpServers[] '${name}': stdio server needs a command (or send type:"http" with a url)`);
12393
+ }
12394
+ return {
12395
+ name,
12396
+ kind: "stdio",
12397
+ command: s.command,
12398
+ args: strArray(s.args, "args"),
12399
+ env: nameValueList(s.env, "env"),
12400
+ headers: []
12401
+ };
12402
+ }
12403
+ function validateMcpServers(input) {
12404
+ if (input === undefined)
12405
+ return [];
12406
+ if (!Array.isArray(input))
12407
+ fail("mcpServers must be an array");
12408
+ const seen = new Set;
12409
+ return input.map((e) => {
12410
+ const n = normalizeMcpServer(e);
12411
+ if (seen.has(n.name))
12412
+ fail(`mcpServers[] duplicate name: '${n.name}'`);
12413
+ seen.add(n.name);
12414
+ return n;
12415
+ });
12416
+ }
12417
+ function mcpServerToAgyAddArgs(s) {
12418
+ const argv = ["mcp", "add"];
12419
+ if (s.kind === "stdio") {
12420
+ for (const e of s.env)
12421
+ argv.push("--env", `${e.name}=${e.value}`);
12422
+ argv.push(s.name, s.command, ...s.args);
12423
+ return argv;
12424
+ }
12425
+ for (const h of s.headers)
12426
+ argv.push("--header", `${h.name}: ${h.value}`);
12427
+ argv.push("--type", "http", s.name, s.url);
12428
+ return argv;
12429
+ }
12430
+ function execFileAsync(bin, args, timeoutMs) {
12431
+ return new Promise((resolve) => {
12432
+ execFile(bin, args, { timeout: timeoutMs, maxBuffer: 512 * 1024, windowsHide: true }, (err, stdout, stderr) => {
12433
+ const e = err;
12434
+ resolve({
12435
+ status: typeof e?.code === "number" ? e.code : e ? 1 : 0,
12436
+ stdout: String(stdout ?? ""),
12437
+ stderr: e?.message ? `${e.message}
12438
+ ${String(stderr ?? "")}` : String(stderr ?? "")
12439
+ });
12440
+ });
12441
+ });
12442
+ }
12443
+ var defaultMcpRunFn = (bin, args, opts) => execFileAsync(bin, args, opts.timeoutMs);
12444
+ function tail(text, n = 600) {
12445
+ const t = String(text || "").trim();
12446
+ return t.length > n ? "…" + t.slice(-n) : t;
12447
+ }
12448
+ async function syncMcpServers(bin, servers, runFn = defaultMcpRunFn, timeoutMs = 30000) {
12449
+ const added = [];
12450
+ for (const s of servers) {
12451
+ let r;
12452
+ try {
12453
+ r = await runFn(bin, mcpServerToAgyAddArgs(s), { timeoutMs });
12454
+ } catch (err) {
12455
+ throw new RequestError(-32603, `failed to register MCP server '${s.name}': ${err?.message || err}`);
12456
+ }
12457
+ if (r.status !== 0) {
12458
+ throw new RequestError(-32603, `failed to register MCP server '${s.name}' (exit ${r.status}): ${tail(`${r.stdout}
12459
+ ${r.stderr}`)}`);
12460
+ }
12461
+ added.push(s.name);
12462
+ }
12463
+ return { added };
12464
+ }
12465
+ async function removeMcpServers(bin, names, runFn = defaultMcpRunFn, timeoutMs = 30000) {
12466
+ const removed = [];
12467
+ const warnings = [];
12468
+ for (const name of names) {
12469
+ try {
12470
+ const r = await runFn(bin, ["mcp", "remove", name], { timeoutMs });
12471
+ if (r.status !== 0) {
12472
+ warnings.push(`mcp remove '${name}' exit ${r.status}: ${tail(`${r.stdout}
12473
+ ${r.stderr}`, 200)}`);
12474
+ } else {
12475
+ removed.push(name);
12476
+ }
12477
+ } catch (err) {
12478
+ warnings.push(`mcp remove '${name}' threw: ${err?.message || err}`);
12479
+ }
12480
+ }
12481
+ if (warnings.length) {
12482
+ console.warn(`[ACP-MCP] cleanup warnings: ${warnings.join(" | ")}`);
12483
+ }
12484
+ return { removed, warnings };
12485
+ }
12486
+
12330
12487
  // src/core/types.ts
12331
12488
  var AGENT_INFO = {
12332
12489
  name: "agy-acp",
12333
12490
  title: "agy ACP (stream-json)",
12334
- version: "0.1.14"
12491
+ version: "0.1.16"
12335
12492
  };
12336
12493
  var BRIDGE_CAPABILITIES = {
12337
12494
  prompt: true,
@@ -12452,6 +12609,17 @@ function installFileLogging() {
12452
12609
 
12453
12610
  // src/core/session-core.ts
12454
12611
  var EMPTY_SESSION_MAX_AGE_MS = 60 * 60 * 1000;
12612
+ function resolveAgyBin() {
12613
+ const rawBin = process.env.AGY_BIN;
12614
+ let bin = rawBin && rawBin !== "undefined" && rawBin !== "null" ? rawBin : "agy";
12615
+ if (bin === "agy" || bin === "agy.exe") {
12616
+ const geminiBin = path10.join(process.env.USERPROFILE || process.env.HOME || "", ".gemini", "bin", process.platform === "win32" ? "agy.exe" : "agy");
12617
+ if (fs9.existsSync(geminiBin)) {
12618
+ bin = geminiBin;
12619
+ }
12620
+ }
12621
+ return bin;
12622
+ }
12455
12623
  function deriveTitle(text, maxLen = 60) {
12456
12624
  const line = String(text || "").split(/\r?\n/).map((s) => s.trim()).find(Boolean) || "";
12457
12625
  const flat = line.replace(/\s+/g, " ");
@@ -12463,7 +12631,10 @@ class AgySessionCore {
12463
12631
  sessionStore;
12464
12632
  historyStore;
12465
12633
  catalogPromise = null;
12634
+ mcpRunner;
12635
+ mcpRefs = new Map;
12466
12636
  constructor(options) {
12637
+ this.mcpRunner = options?.mcpRunner ?? defaultMcpRunFn;
12467
12638
  if (options?.sessionStore instanceof SessionStore) {
12468
12639
  this.sessionStore = options.sessionStore;
12469
12640
  } else if (typeof options?.sessionStore === "string") {
@@ -12512,14 +12683,8 @@ class AgySessionCore {
12512
12683
  disableSlashCommands: disableSlash,
12513
12684
  printTimeout
12514
12685
  });
12515
- const rawBin = process.env.AGY_BIN;
12516
- let bin = rawBin && rawBin !== "undefined" && rawBin !== "null" ? rawBin : "agy";
12517
- if (bin === "agy" || bin === "agy.exe") {
12518
- const geminiBin = path10.join(process.env.USERPROFILE || process.env.HOME || "", ".gemini", "bin", process.platform === "win32" ? "agy.exe" : "agy");
12519
- if (fs9.existsSync(geminiBin)) {
12520
- bin = geminiBin;
12521
- }
12522
- }
12686
+ const rawBin = resolveAgyBin();
12687
+ const bin = rawBin;
12523
12688
  let execBin = bin;
12524
12689
  let execArgs = args;
12525
12690
  if (/\.(js|cjs|mjs|ts)$/i.test(bin)) {
@@ -12650,6 +12815,43 @@ class AgySessionCore {
12650
12815
  if (!session.agent && agents.length)
12651
12816
  session.agent = agents[0].value;
12652
12817
  }
12818
+ async applySessionMcpServers(sessionId, input) {
12819
+ const servers = validateMcpServers(input ?? []);
12820
+ if (!servers.length)
12821
+ return [];
12822
+ const live = this.sessions.get(sessionId);
12823
+ const hadLiveProc = live ? live.proc.isWritable() : false;
12824
+ await syncMcpServers(resolveAgyBin(), servers, this.mcpRunner);
12825
+ const names = servers.map((s) => ({ name: s.name }));
12826
+ if (live)
12827
+ live.mcpServers = names;
12828
+ this.trackMcpServers(sessionId, names.map((n) => n.name));
12829
+ if (hadLiveProc) {
12830
+ console.warn(`[ACP-MCP] session ${sessionId}: MCP servers [${names.map((n) => n.name).join(", ")}] ` + `registered while the session process is live; they apply to fresh spawns (reconnect to use them).`);
12831
+ }
12832
+ return names;
12833
+ }
12834
+ trackMcpServers(sessionId, names) {
12835
+ for (const name of names) {
12836
+ let set = this.mcpRefs.get(name);
12837
+ if (!set) {
12838
+ set = new Set;
12839
+ this.mcpRefs.set(name, set);
12840
+ }
12841
+ set.add(sessionId);
12842
+ }
12843
+ }
12844
+ untrackMcpServers(sessionId) {
12845
+ const freed = [];
12846
+ for (const [name, set] of this.mcpRefs) {
12847
+ set.delete(sessionId);
12848
+ if (set.size === 0) {
12849
+ this.mcpRefs.delete(name);
12850
+ freed.push(name);
12851
+ }
12852
+ }
12853
+ return freed;
12854
+ }
12653
12855
  async createSession(params, protocolVersion = 1) {
12654
12856
  const cwd = params?.cwd;
12655
12857
  debugLog(`createSession v${protocolVersion} cwd=${cwd}`);
@@ -12672,6 +12874,7 @@ class AgySessionCore {
12672
12874
  if (params?.mcpServers !== undefined && !Array.isArray(params.mcpServers)) {
12673
12875
  throw new RequestError(-32602, "mcpServers must be an array");
12674
12876
  }
12877
+ validateMcpServers(params?.mcpServers);
12675
12878
  const launch = extractLaunchConfig(params);
12676
12879
  const discovery = await this.getDiscovery();
12677
12880
  const sessionId = randomUUID3();
@@ -12714,6 +12917,7 @@ class AgySessionCore {
12714
12917
  };
12715
12918
  this.applyCatalogDefaults(session, discovery);
12716
12919
  this.sessions.set(sessionId, session);
12920
+ await this.applySessionMcpServers(sessionId, params?.mcpServers);
12717
12921
  this.persistSession(session);
12718
12922
  this.warmupSession(sessionId);
12719
12923
  return {
@@ -12742,14 +12946,10 @@ class AgySessionCore {
12742
12946
  throw new RequestError(-32602, protocolVersion === 2 ? 'only replayFrom.type="start" is supported by this agent' : "session/resume with replayFrom is only supported by ACP v2");
12743
12947
  }
12744
12948
  }
12745
- if (params?.mcpServers !== undefined) {
12746
- if (!Array.isArray(params.mcpServers)) {
12747
- throw new RequestError(-32602, "mcpServers must be an array");
12748
- }
12749
- if (params.mcpServers.length > 0) {
12750
- throw new RequestError(-32602, "mcpServers are not supported by this agent");
12751
- }
12949
+ if (params?.mcpServers !== undefined && !Array.isArray(params.mcpServers)) {
12950
+ throw new RequestError(-32602, "mcpServers must be an array");
12752
12951
  }
12952
+ validateMcpServers(params?.mcpServers);
12753
12953
  let additionalDirectories = [];
12754
12954
  if (params?.additionalDirectories !== undefined) {
12755
12955
  if (!Array.isArray(params.additionalDirectories)) {
@@ -12824,6 +13024,7 @@ class AgySessionCore {
12824
13024
  }
12825
13025
  const discovery = await this.getDiscovery();
12826
13026
  this.applyCatalogDefaults(session, discovery);
13027
+ await this.applySessionMcpServers(sessionId, params?.mcpServers);
12827
13028
  if (!session.title) {
12828
13029
  try {
12829
13030
  const first = this.historyStore.firstUserText(sessionId);
@@ -13000,6 +13201,14 @@ class AgySessionCore {
13000
13201
  }
13001
13202
  this.sessionStore.delete(sessionId);
13002
13203
  this.historyStore.delete(sessionId);
13204
+ try {
13205
+ const freed = this.untrackMcpServers(sessionId);
13206
+ if (freed.length) {
13207
+ await removeMcpServers(resolveAgyBin(), freed, this.mcpRunner);
13208
+ }
13209
+ } catch (err) {
13210
+ console.warn(`[ACP-MCP] cleanup after delete ${sessionId} failed: ${err?.message || err}`);
13211
+ }
13003
13212
  return {};
13004
13213
  }
13005
13214
  async closeSession(params) {
@@ -17669,7 +17878,8 @@ __export(exports_core3, {
17669
17878
  BRIDGE_CAPABILITIES: () => BRIDGE_CAPABILITIES,
17670
17879
  EMPTY_SESSION_MAX_AGE_MS: () => EMPTY_SESSION_MAX_AGE_MS,
17671
17880
  catalogChoices: () => catalogChoices,
17672
- deriveTitle: () => deriveTitle
17881
+ deriveTitle: () => deriveTitle,
17882
+ resolveAgyBin: () => resolveAgyBin
17673
17883
  });
17674
17884
  // src/v1/index.ts
17675
17885
  var exports_v1 = {};
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ACP `mcpServers` → `agy mcp add/remove` bridging.
3
+ *
4
+ * The bridge owns MCP registration end-to-end:
5
+ * - `session/new|resume` accept the standard ACP `mcpServers` list and sync
6
+ * it into agy's MCP config (`~/.gemini/config/mcp_config.json`) via
7
+ * `agy mcp add` BEFORE the session process spawns, so tools are listed
8
+ * from the first turn.
9
+ * - `session/delete` removes the servers this session registered (refcounted
10
+ * per bridge process; best-effort, never breaks close/delete).
11
+ * - Permissions stay under the bridge safety policy: MCP tool calls run with
12
+ * the session's `--dangerously-skip-permissions`/`--sandbox` flags, and
13
+ * headless soft-denies still surface `permissions.allow` guidance.
14
+ *
15
+ * Supported ACP shapes: stdio `{name, command, args, env[]}` and http
16
+ * `{name, url, headers[], type:"http"}`. `sse`/`acp` transports have no
17
+ * `agy mcp add` equivalent and fail fast (no silent downgrade).
18
+ */
19
+ export interface AcpMcpServerInput {
20
+ name?: unknown;
21
+ type?: unknown;
22
+ command?: unknown;
23
+ args?: unknown;
24
+ env?: unknown;
25
+ url?: unknown;
26
+ headers?: unknown;
27
+ [k: string]: unknown;
28
+ }
29
+ export interface NormalizedMcpServer {
30
+ name: string;
31
+ kind: 'stdio' | 'http';
32
+ command?: string;
33
+ args: string[];
34
+ env: Array<{
35
+ name: string;
36
+ value: string;
37
+ }>;
38
+ url?: string;
39
+ headers: Array<{
40
+ name: string;
41
+ value: string;
42
+ }>;
43
+ }
44
+ /**
45
+ * Validate one ACP McpServer entry. Throws RequestError(-32602) — loud,
46
+ * so a client that sent an unusable server never gets a silent no-tools
47
+ * session.
48
+ */
49
+ export declare function normalizeMcpServer(input: unknown): NormalizedMcpServer;
50
+ /** Validate a full session/new|resume mcpServers list (non-array → loud). */
51
+ export declare function validateMcpServers(input: unknown): NormalizedMcpServer[];
52
+ /**
53
+ * Build `agy mcp add` argv. agy rejects flags placed after <name>, so all
54
+ * flags come first: mcp add [--env K=V] [--header K:V] [--type t] <name>
55
+ * <commandOrUrl> [args...].
56
+ */
57
+ export declare function mcpServerToAgyAddArgs(s: NormalizedMcpServer): string[];
58
+ export interface McpRunResult {
59
+ status: number | null;
60
+ stdout: string;
61
+ stderr: string;
62
+ }
63
+ /** Injectable `agy mcp ...` runner (tests stub it; prod spawns agy). */
64
+ export type McpRunFn = (bin: string, args: string[], opts: {
65
+ timeoutMs: number;
66
+ }) => Promise<McpRunResult>;
67
+ /** Production runner: real `agy mcp ...` subprocess. */
68
+ export declare const defaultMcpRunFn: McpRunFn;
69
+ /**
70
+ * Register every server via `agy mcp add` (idempotent: add == upsert).
71
+ * Any failure throws RequestError — a client that asked for MCP must never
72
+ * get a silent tools-less session.
73
+ */
74
+ export declare function syncMcpServers(bin: string, servers: NormalizedMcpServer[], runFn?: McpRunFn, timeoutMs?: number): Promise<{
75
+ added: string[];
76
+ }>;
77
+ /**
78
+ * Remove servers by name. Best-effort by design: returns per-name warnings
79
+ * instead of throwing, so session close/delete can never break on cleanup.
80
+ */
81
+ export declare function removeMcpServers(bin: string, names: string[], runFn?: McpRunFn, timeoutMs?: number): Promise<{
82
+ removed: string[];
83
+ warnings: string[];
84
+ }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yitom/agy-acp-map",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },