@wrongstack/acp 1.0.3 → 1.0.5

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.
@@ -1,9 +1,9 @@
1
1
  // src/agent/wrongstack-acp-agent.ts
2
- import { timingSafeEqual } from "node:crypto";
3
2
  import { createServer } from "node:http";
4
3
  import { isIP } from "node:net";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import { expandIPv6, writeErr as writeErr2 } from "@wrongstack/core/utils";
6
+ import { timingSafeTokenEqual } from "@wrongstack/primitives";
7
7
 
8
8
  // src/agent/protocol-handler.ts
9
9
  import { randomUUID } from "node:crypto";
@@ -53,6 +53,64 @@ var DEFAULT_MODES = [
53
53
  description: "Default agent mode for code-generation tasks."
54
54
  }
55
55
  ];
56
+ function parseMcpServers(raw, onSkipped) {
57
+ if (!Array.isArray(raw)) return [];
58
+ const out = [];
59
+ for (const entry of raw) {
60
+ if (typeof entry !== "object" || entry === null) {
61
+ onSkipped?.("entry is not an object");
62
+ continue;
63
+ }
64
+ const e = entry;
65
+ const name = typeof e.name === "string" ? e.name.trim() : "";
66
+ if (name === "") {
67
+ onSkipped?.("entry has no name");
68
+ continue;
69
+ }
70
+ const type = typeof e.type === "string" ? e.type : "stdio";
71
+ if (type === "http" || type === "sse") {
72
+ if (typeof e.url !== "string" || e.url === "") {
73
+ onSkipped?.(`"${name}": ${type} server has no url`);
74
+ continue;
75
+ }
76
+ const headers = parseNameValuePairs(e.headers);
77
+ const url = e.url;
78
+ out.push(
79
+ type === "http" ? { type: "http", name, url, ...headers ? { headers } : {} } : { type: "sse", name, url, ...headers ? { headers } : {} }
80
+ );
81
+ continue;
82
+ }
83
+ if (type !== "stdio") {
84
+ onSkipped?.(`"${name}": unknown transport "${type}"`);
85
+ continue;
86
+ }
87
+ if (typeof e.command !== "string" || e.command === "") {
88
+ onSkipped?.(`"${name}": stdio server has no command`);
89
+ continue;
90
+ }
91
+ const args = Array.isArray(e.args) ? e.args.filter((a) => typeof a === "string") : void 0;
92
+ const env = parseNameValuePairs(e.env);
93
+ out.push({
94
+ name,
95
+ command: e.command,
96
+ ...args && args.length > 0 ? { args } : {},
97
+ ...env ? { env } : {}
98
+ });
99
+ }
100
+ return out;
101
+ }
102
+ function parseNameValuePairs(raw) {
103
+ if (!Array.isArray(raw)) return void 0;
104
+ const out = [];
105
+ for (const pair of raw) {
106
+ if (typeof pair !== "object" || pair === null) continue;
107
+ const p = pair;
108
+ if (typeof p.name === "string" && p.name !== "" && typeof p.value === "string") {
109
+ out.push({ name: p.name, value: p.value });
110
+ }
111
+ }
112
+ return out.length > 0 ? out : void 0;
113
+ }
56
114
  async function resolveSessionCwd(requested) {
57
115
  if (!path.isAbsolute(requested)) return null;
58
116
  const resolved = path.resolve(requested);
@@ -135,9 +193,16 @@ function buildInitializeResult(agentName, modes, configOptions) {
135
193
  audio: false,
136
194
  embeddedContext: true
137
195
  },
196
+ // All three ACP transports are supported. stdio is mandatory per spec
197
+ // and cannot be declined; http and sse are declared here because the
198
+ // agent now actually connects them (see `parseMcpServers` above and the
199
+ // per-session MCP registry in `buildAcpServerAgentFactory`). Before that
200
+ // wiring existed the array was destructured and thrown away at every
201
+ // entry point, so a client got a successful `session/new` and no tools —
202
+ // flip these back to false if that connection path is ever removed.
138
203
  mcpCapabilities: {
139
- http: false,
140
- sse: false
204
+ http: true,
205
+ sse: true
141
206
  },
142
207
  sessionCapabilities: {
143
208
  close: {},
@@ -177,6 +242,8 @@ async function handleSessionNewOp(ctx, id, params) {
177
242
  }
178
243
  cwd = resolved;
179
244
  }
245
+ const skipped = [];
246
+ const mcpServers = parseMcpServers(p.mcpServers, (reason) => skipped.push(reason));
180
247
  const sessionId = `sess_${ctx.allocId()}`;
181
248
  const now = (/* @__PURE__ */ new Date()).toISOString();
182
249
  const state = {
@@ -185,7 +252,8 @@ async function handleSessionNewOp(ctx, id, params) {
185
252
  abort: new AbortController(),
186
253
  modeId: DEFAULT_MODE_ID,
187
254
  createdAt: now,
188
- updatedAt: now
255
+ updatedAt: now,
256
+ ...mcpServers.length > 0 ? { mcpServers } : {}
189
257
  };
190
258
  ctx.sessions.set(sessionId, state);
191
259
  ctx.onSessionNew(state);
@@ -206,6 +274,7 @@ async function handleSessionNewOp(ctx, id, params) {
206
274
  }
207
275
  });
208
276
  }
277
+ await reportSkippedMcpServers(ctx, sessionId, skipped);
209
278
  await ctx.sendResult(id, {
210
279
  sessionId,
211
280
  modes: ctx.modes,
@@ -217,6 +286,8 @@ async function handleSessionLoadOp(ctx, id, params) {
217
286
  const p = params ?? {};
218
287
  const sessionId = typeof p.sessionId === "string" ? p.sessionId : null;
219
288
  const loadCwd = typeof p.cwd === "string" ? p.cwd : void 0;
289
+ const loadSkipped = [];
290
+ const loadMcpServers = parseMcpServers(p.mcpServers, (reason) => loadSkipped.push(reason));
220
291
  const existing = sessionId ? ctx.sessions.get(sessionId) : void 0;
221
292
  if (!existing && sessionId && ctx.store) {
222
293
  const persisted = await ctx.store.load(sessionId);
@@ -238,7 +309,8 @@ async function handleSessionLoadOp(ctx, id, params) {
238
309
  modeId: persisted.modeId ?? DEFAULT_MODE_ID,
239
310
  createdAt: persisted.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
240
311
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
241
- ...persisted.title !== void 0 ? { title: persisted.title } : {}
312
+ ...persisted.title !== void 0 ? { title: persisted.title } : {},
313
+ ...loadMcpServers.length > 0 ? { mcpServers: loadMcpServers } : {}
242
314
  };
243
315
  ctx.sessions.set(sessionId, restored);
244
316
  ctx.seedFor?.(sessionId, persisted.history ?? []);
@@ -249,6 +321,7 @@ async function handleSessionLoadOp(ctx, id, params) {
249
321
  sessionId,
250
322
  update: { sessionUpdate: "current_mode_update", modeId: restored.modeId }
251
323
  });
324
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
252
325
  await ctx.sendResult(id, {
253
326
  initialMode: { currentModeId: restored.modeId, availableModes: ctx.modes }
254
327
  });
@@ -257,6 +330,9 @@ async function handleSessionLoadOp(ctx, id, params) {
257
330
  }
258
331
  if (existing) {
259
332
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
333
+ if (loadMcpServers.length > 0) {
334
+ existing.mcpServers = loadMcpServers;
335
+ }
260
336
  const replay = ctx.replayFor?.(sessionId);
261
337
  if (replay) {
262
338
  for (const update of replay) {
@@ -277,6 +353,7 @@ async function handleSessionLoadOp(ctx, id, params) {
277
353
  modeId: existing.modeId
278
354
  }
279
355
  });
356
+ await reportSkippedMcpServers(ctx, sessionId, loadSkipped);
280
357
  await ctx.sendResult(id, {
281
358
  initialMode: {
282
359
  currentModeId: existing.modeId,
@@ -309,6 +386,9 @@ async function handleSessionForkOp(ctx, id, params) {
309
386
  }
310
387
  forkCwd = resolved;
311
388
  }
389
+ const forkSkipped = [];
390
+ const forkRequested = parseMcpServers(p.mcpServers, (reason) => forkSkipped.push(reason));
391
+ const forkMcpServers = forkRequested.length > 0 ? forkRequested : source.mcpServers;
312
392
  const now = (/* @__PURE__ */ new Date()).toISOString();
313
393
  const sessionId = `sess_${ctx.allocId()}`;
314
394
  const forked = {
@@ -318,7 +398,8 @@ async function handleSessionForkOp(ctx, id, params) {
318
398
  modeId: source.modeId,
319
399
  createdAt: now,
320
400
  updatedAt: now,
321
- ...source.title !== void 0 ? { title: source.title } : {}
401
+ ...source.title !== void 0 ? { title: source.title } : {},
402
+ ...forkMcpServers && forkMcpServers.length > 0 ? { mcpServers: forkMcpServers } : {}
322
403
  };
323
404
  const history = (ctx.replayFor?.(sourceId) ?? []).map((update) => ({
324
405
  sessionUpdate: update.sessionUpdate,
@@ -332,6 +413,7 @@ async function handleSessionForkOp(ctx, id, params) {
332
413
  sessionId,
333
414
  update: { sessionUpdate: "current_mode_update", modeId: forked.modeId }
334
415
  });
416
+ await reportSkippedMcpServers(ctx, sessionId, forkSkipped);
335
417
  await ctx.sendResult(id, {
336
418
  sessionId,
337
419
  modes: ctx.modes,
@@ -371,7 +453,13 @@ async function handleSessionPromptOp(ctx, id, params) {
371
453
  };
372
454
  try {
373
455
  result = await ctx.runTurn(
374
- { sessionId, prompt: p.prompt, signal: turnSignal.signal },
456
+ {
457
+ sessionId,
458
+ prompt: p.prompt,
459
+ signal: turnSignal.signal,
460
+ cwd: session.cwd,
461
+ ...session.mcpServers ? { mcpServers: session.mcpServers } : {}
462
+ },
375
463
  emit,
376
464
  api
377
465
  );
@@ -429,6 +517,22 @@ async function handleSetConfigOptionOp(ctx, id, params) {
429
517
  await ctx.sendResult(id, { configOptions: [...ctx.configOptions] });
430
518
  return false;
431
519
  }
520
+ async function reportSkippedMcpServers(ctx, sessionId, skipped) {
521
+ if (skipped.length === 0) return;
522
+ try {
523
+ await ctx.sendNotification({
524
+ sessionId,
525
+ update: {
526
+ sessionUpdate: "agent_message_chunk",
527
+ content: {
528
+ type: "text",
529
+ text: `Ignored ${skipped.length} malformed mcpServers entr${skipped.length === 1 ? "y" : "ies"}: ${skipped.join("; ")}`
530
+ }
531
+ }
532
+ });
533
+ } catch {
534
+ }
535
+ }
432
536
 
433
537
  // src/agent/protocol-handler.ts
434
538
  var ACPProtocolHandler = class {
@@ -627,7 +731,7 @@ var ACPProtocolHandler = class {
627
731
  return false;
628
732
  }
629
733
  async handleAuthenticate(id, _params) {
630
- await this.sendResult(id, { outcome: "unauthenticated" });
734
+ await this.sendResult(id, {});
631
735
  return false;
632
736
  }
633
737
  async handleLogout(id, _params) {
@@ -640,6 +744,10 @@ var ACPProtocolHandler = class {
640
744
  const existing = sessionId ? this.sessions.get(sessionId) : void 0;
641
745
  if (existing) {
642
746
  existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
747
+ const resumeServers = parseMcpServers(p.mcpServers);
748
+ if (resumeServers.length > 0) {
749
+ existing.mcpServers = resumeServers;
750
+ }
643
751
  await this.sendResult(id, {
644
752
  initialMode: {
645
753
  currentModeId: existing.modeId,
@@ -1175,13 +1283,6 @@ var WrongStackACPServer = class {
1175
1283
  var defaultEchoRunTurn = async (_input, _emit) => {
1176
1284
  return { stopReason: "end_turn" };
1177
1285
  };
1178
- function timingSafeTokenEqual(supplied, expected) {
1179
- if (!supplied || !expected) return false;
1180
- const a = Buffer.from(supplied);
1181
- const b = Buffer.from(expected);
1182
- if (a.length !== b.length) return false;
1183
- return timingSafeEqual(a, b);
1184
- }
1185
1286
  function isLoopbackPeer(req) {
1186
1287
  const address = req.socket.remoteAddress?.replace(/^::ffff:/i, "");
1187
1288
  return address !== void 0 && isLoopbackHost(address);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/acp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "license": "MIT",
5
5
  "description": "ACP (Agent Client Protocol) integration for WrongStack — client + agent support",
6
6
  "keywords": [
@@ -52,7 +52,8 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@agentclientprotocol/sdk": "^1.4.0",
55
- "@wrongstack/core": "1.0.3"
55
+ "@wrongstack/core": "1.0.5",
56
+ "@wrongstack/primitives": "1.0.5"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/node": "^26.2.0",