borgmcp 2.12.1 → 2.13.0

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/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  createRole,
42
42
  updateRole,
43
43
  patchRoleSection,
44
+ sanitizeServerAdvisory,
44
45
  patchTaxonomyClass,
45
46
  deleteRole,
46
47
  getCube,
@@ -132,7 +133,9 @@ import { resolveReportableSessionAgentKind } from './agent-runtime.js';
132
133
  import {
133
134
  connectOpenCodeDrone,
134
135
  injectOpenCodeEntry,
135
- computeOpenCodePort,
136
+ configuredOpenCodePort,
137
+ OPEN_CODE_PORT_MISSING_DIAGNOSTIC,
138
+ openCodeLaunchBinding,
136
139
  } from './opencode-drone.js';
137
140
  import { installBorgPlugin } from './opencode-plugin.js';
138
141
  import { setModuleInjectOpenCode } from './log-stream.js';
@@ -216,6 +219,57 @@ async function requireActiveCube() {
216
219
  return active;
217
220
  }
218
221
 
222
+ export function appendServerAdvisory(text: string, advisory: unknown): string {
223
+ const sanitized = sanitizeServerAdvisory(advisory);
224
+ return sanitized === undefined ? text : `${text}\n\nAdvisory: ${sanitized}`;
225
+ }
226
+
227
+ export function formatUpdatedCubeResult(cube: { name: string; id: string }, advisory?: unknown): string {
228
+ return appendServerAdvisory(`Updated cube **${cube.name}** (id: ${cube.id}).`, advisory);
229
+ }
230
+
231
+ export function formatUpdatedRoleResult(role: { name: string; id: string; role_class?: string; is_human_seat?: boolean; is_default?: boolean; is_mandatory?: boolean }, advisory?: unknown): string {
232
+ const tags = [
233
+ role.role_class === 'queen' ? 'Queen' : null,
234
+ role.is_human_seat ? 'human-seat' : null,
235
+ role.is_default ? 'default' : null,
236
+ role.is_mandatory ? 'mandatory' : null,
237
+ ].filter(Boolean).join(', ');
238
+ const tag = tags ? ` (${tags})` : '';
239
+ return appendServerAdvisory(`Updated role **${role.name}**${tag} (id: ${role.id}).`, advisory);
240
+ }
241
+
242
+ export function formatPatchedRoleSectionResult(action: 'replace' | 'insert' | 'delete', heading: string, role: { name: string; id: string }, advisory?: unknown): string {
243
+ const verb = action === 'replace' ? 'Replaced' : action === 'insert' ? 'Inserted' : 'Deleted';
244
+ return appendServerAdvisory(`${verb} section **${heading}** in role **${role.name}** (id: ${role.id}).`, advisory);
245
+ }
246
+
247
+ export async function connectOpenCodeRuntime(
248
+ active: {
249
+ worktree?: string;
250
+ droneLabel: string;
251
+ name: string;
252
+ },
253
+ env: NodeJS.ProcessEnv = process.env,
254
+ deps: {
255
+ connect?: typeof connectOpenCodeDrone;
256
+ } = {},
257
+ ): Promise<boolean> {
258
+ const configuredPort = configuredOpenCodePort(env);
259
+ if (configuredPort === null) {
260
+ console.error(OPEN_CODE_PORT_MISSING_DIAGNOSTIC);
261
+ return false;
262
+ }
263
+ const binding = openCodeLaunchBinding(configuredPort);
264
+ await (deps.connect ?? connectOpenCodeDrone)({
265
+ serverUrl: binding.serverUrl,
266
+ directory: active.worktree ?? findProjectRoot(),
267
+ droneLabel: active.droneLabel,
268
+ cubeName: active.name,
269
+ });
270
+ return true;
271
+ }
272
+
219
273
  /**
220
274
  * Main entry point - MCP stdio server
221
275
  */
@@ -266,15 +320,7 @@ export async function main() {
266
320
  installBorgPlugin();
267
321
  const active = await getActiveCube();
268
322
  if (active && openCodeRuntime) {
269
- const port = computeOpenCodePort(active.droneId);
270
- const serverUrl = `http://127.0.0.1:${port}`;
271
- await connectOpenCodeDrone({
272
- serverUrl,
273
- directory: active.worktree ?? findProjectRoot(),
274
- droneLabel: active.droneLabel,
275
- cubeName: active.name,
276
- });
277
- setModuleInjectOpenCode(injectOpenCodeEntry);
323
+ if (await connectOpenCodeRuntime(active)) setModuleInjectOpenCode(injectOpenCodeEntry);
278
324
  }
279
325
  },
280
326
  };
@@ -1018,8 +1064,8 @@ export async function main() {
1018
1064
  if (typeof args?.cube_directive === 'string') updates.cube_directive = args.cube_directive as string;
1019
1065
  if (Array.isArray(args?.message_taxonomy)) updates.message_taxonomy = args.message_taxonomy as MessageTaxonomy;
1020
1066
  if (Object.keys(updates).length === 0) throw new Error('Pass at least one of: cube_directive, message_taxonomy.');
1021
- const { cube } = await updateCube(cubeId, updates);
1022
- return { content: [{ type: 'text', text: `Updated cube **${cube.name}** (id: ${cube.id}).` }] };
1067
+ const { cube, advisory } = await updateCube(cubeId, updates);
1068
+ return { content: [{ type: 'text', text: formatUpdatedCubeResult(cube, advisory) }] };
1023
1069
  }
1024
1070
 
1025
1071
  case 'borg_patch-taxonomy-class': {
@@ -1108,15 +1154,8 @@ export async function main() {
1108
1154
  if (typeof args?.receives_all_direct === 'boolean') updates.receives_all_direct = args.receives_all_direct as boolean;
1109
1155
  if (typeof args?.default_model === 'string') updates.default_model = args.default_model as string;
1110
1156
  if (Object.keys(updates).length === 0) throw new Error('Pass at least one of: name, short_description, detailed_description, is_default, is_mandatory, is_human_seat, can_broadcast, receives_all_direct.');
1111
- const { role } = await updateRole(roleId, updates);
1112
- const tags = [
1113
- role.role_class === 'queen' ? 'Queen' : null,
1114
- role.is_human_seat ? 'human-seat' : null,
1115
- role.is_default ? 'default' : null,
1116
- role.is_mandatory ? 'mandatory' : null,
1117
- ].filter(Boolean).join(', ');
1118
- const tag = tags ? ` (${tags})` : '';
1119
- return { content: [{ type: 'text', text: `Updated role **${role.name}**${tag} (id: ${role.id}).` }] };
1157
+ const { role, advisory } = await updateRole(roleId, updates);
1158
+ return { content: [{ type: 'text', text: formatUpdatedRoleResult(role, advisory) }] };
1120
1159
  }
1121
1160
 
1122
1161
  case 'borg_patch-role-section': {
@@ -1129,8 +1168,9 @@ export async function main() {
1129
1168
  const heading = args?.heading as string;
1130
1169
  if (!heading) throw new Error('heading is required');
1131
1170
  let role: any;
1171
+ let advisory: unknown;
1132
1172
  if (action === 'delete') {
1133
- ({ role } = await patchRoleSection(roleId, { action, heading }));
1173
+ ({ role, advisory } = await patchRoleSection(roleId, { action, heading }));
1134
1174
  } else {
1135
1175
  const body = args?.body as string;
1136
1176
  if (typeof body !== 'string') {
@@ -1138,13 +1178,12 @@ export async function main() {
1138
1178
  }
1139
1179
  if (action === 'insert') {
1140
1180
  const after = (typeof args?.after === 'string' ? args.after : null) as string | null;
1141
- ({ role } = await patchRoleSection(roleId, { action, heading, body, after }));
1181
+ ({ role, advisory } = await patchRoleSection(roleId, { action, heading, body, after }));
1142
1182
  } else {
1143
- ({ role } = await patchRoleSection(roleId, { action, heading, body }));
1183
+ ({ role, advisory } = await patchRoleSection(roleId, { action, heading, body }));
1144
1184
  }
1145
1185
  }
1146
- const verb = action === 'replace' ? 'Replaced' : action === 'insert' ? 'Inserted' : 'Deleted';
1147
- return { content: [{ type: 'text', text: `${verb} section **${heading}** in role **${role.name}** (id: ${role.id}).` }] };
1186
+ return { content: [{ type: 'text', text: formatPatchedRoleSectionResult(action, heading, role, advisory) }] };
1148
1187
  }
1149
1188
 
1150
1189
  case 'borg_delete-role': {
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
2
2
  import { createHash, randomUUID } from 'crypto';
3
+ import { createServer } from 'node:net';
3
4
  import { join } from 'path';
4
5
  import { tmpdir } from 'os';
5
6
 
@@ -749,6 +750,62 @@ export function computeOpenCodePort(droneId: string, base: number = 14096): numb
749
750
  return base + (Math.abs(hash) % 1024);
750
751
  }
751
752
 
753
+ /**
754
+ * Ask the OS for an available loopback port. The old deterministic hash is
755
+ * retained above only for compatibility fixtures; launch paths must not use a
756
+ * bounded shared port space where two drones can collide.
757
+ */
758
+ async function canBindOpenCodePort(port: number): Promise<boolean> {
759
+ return new Promise((resolve) => {
760
+ const probe = createServer();
761
+ probe.once('error', () => resolve(false));
762
+ probe.listen(port, '127.0.0.1', () => {
763
+ probe.close(() => resolve(true));
764
+ });
765
+ });
766
+ }
767
+
768
+ export function configuredOpenCodePort(env: NodeJS.ProcessEnv = process.env): number | null {
769
+ const port = Number(env.BORG_OPENCODE_PORT);
770
+ return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
771
+ }
772
+
773
+ export const OPEN_CODE_PORT_MISSING_DIAGNOSTIC =
774
+ 'OpenCode launch port is missing; skipping OpenCode entry injection. Relaunch through borg.';
775
+
776
+ export function openCodeLaunchBinding(port: number): {
777
+ cliPort: string;
778
+ envPort: string;
779
+ serverUrl: string;
780
+ } {
781
+ const value = String(port);
782
+ return { cliPort: value, envPort: value, serverUrl: `http://127.0.0.1:${value}` };
783
+ }
784
+
785
+ export async function allocateOpenCodePort(
786
+ isPortAvailable: (port: number) => Promise<boolean> = canBindOpenCodePort,
787
+ ): Promise<number> {
788
+ for (let attempt = 0; attempt < 8; attempt++) {
789
+ const port = await new Promise<number>((resolve, reject) => {
790
+ const probe = createServer();
791
+ const fail = (error: Error) => {
792
+ probe.close(() => reject(error));
793
+ };
794
+ probe.once('error', fail);
795
+ probe.listen(0, '127.0.0.1', () => {
796
+ const address = probe.address();
797
+ if (address === null || typeof address === 'string') {
798
+ fail(new Error('OpenCode port allocation returned no TCP address'));
799
+ return;
800
+ }
801
+ probe.close((error) => error ? reject(error) : resolve(address.port));
802
+ });
803
+ });
804
+ if (await isPortAvailable(port)) return port;
805
+ }
806
+ throw new Error('OpenCode port allocation could not claim an available loopback port');
807
+ }
808
+
752
809
  /** Test-only cleanup for module state and the local cross-process binding. */
753
810
  export function __resetOpenCodeDroneForTests(): void {
754
811
  abandonOpenCodeDeliveries(state);
@@ -85,10 +85,27 @@ export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
85
85
  // bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
86
86
  const AUTH_ERROR_ENVELOPE_LIMIT_BYTES = 64 * 1024;
87
87
  const ROLE_SECTION_CONFLICT_CODE = 'ROLE_SECTION_CONFLICT';
88
+ const CAPACITY_EXCEEDED_CODE = 'CAPACITY_EXCEEDED';
88
89
  export const LOCAL_SERVER_REQUEST_TIMEOUT_MS = 5_000;
89
90
  const LOCAL_SERVER_RESPONSE_LIMIT_MESSAGE =
90
91
  'Local Borg server response exceeded the response limit';
91
92
 
93
+ function sanitizeServerMessage(message: string): string {
94
+ return message
95
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
96
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
97
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, '')
98
+ .replace(/\\u(?:000[0-9a-f]|001[0-9a-f]|007f|008[0-9a-f]|009[0-9a-f])/gi, '');
99
+ }
100
+
101
+ export const SERVER_ADVISORY_MAX_CHARS = 512;
102
+
103
+ export function sanitizeServerAdvisory(value: unknown): string | undefined {
104
+ if (typeof value !== 'string') return undefined;
105
+ const sanitized = sanitizeServerMessage(value).trim();
106
+ return sanitized.length > 0 ? sanitized.slice(0, SERVER_ADVISORY_MAX_CHARS) : undefined;
107
+ }
108
+
92
109
  /**
93
110
  * Parse a `Retry-After` header (delta-seconds form, which the worker
94
111
  * emits — mcp-server.ts:382/583) into milliseconds. Returns null when
@@ -725,11 +742,11 @@ async function authedFetch(
725
742
  }
726
743
 
727
744
  if (!response.ok) {
728
- // Do not copy a server response body into errors or debug output: a malicious or
729
- // misconfigured server could reflect bearer/invitation material or inject
730
- // terminal controls. Decode only the bounded protocol error code for typed
731
- // branching; never surface the server-provided message or details.
745
+ // Decode only the bounded protocol error envelope. Its message is the
746
+ // server's operator-facing action guidance; details and unrecognized bodies
747
+ // remain excluded from the client error.
732
748
  let code: ErrorCode | undefined;
749
+ let serverMessage: string | undefined;
733
750
  let protocolMismatch = false;
734
751
  try {
735
752
  const body = await readBoundedResponseBody(
@@ -739,7 +756,11 @@ async function authedFetch(
739
756
  );
740
757
  const parsed = JSON.parse(body);
741
758
  try {
742
- code = decodeProtocolErrorEnvelope(parsed).error.code;
759
+ const decoded = decodeProtocolErrorEnvelope(parsed);
760
+ code = decoded.error.code;
761
+ serverMessage = response.status === 404
762
+ ? undefined
763
+ : sanitizeServerMessage(decoded.error.message);
743
764
  } catch (error) {
744
765
  if (
745
766
  error instanceof ProtocolContractError &&
@@ -750,21 +771,25 @@ async function authedFetch(
750
771
  if (
751
772
  parsed !== null && typeof parsed === 'object' &&
752
773
  parsed.error !== null && typeof parsed.error === 'object' &&
753
- parsed.error.code === ROLE_SECTION_CONFLICT_CODE
774
+ (parsed.error.code === ROLE_SECTION_CONFLICT_CODE
775
+ || parsed.error.code === CAPACITY_EXCEEDED_CODE)
754
776
  ) {
755
777
  // The shared protocol intentionally omits this server-local code. Re-validate the whole
756
778
  // envelope through the strict shared decoder with only the recognized
757
- // code substituted; no server-provided diagnostic is ever surfaced.
758
- decodeProtocolErrorEnvelope({
779
+ // code substituted. The original message remains in place so the
780
+ // shared decoder validates its length and diagnostic shape.
781
+ const decoded = decodeProtocolErrorEnvelope({
759
782
  ...parsed,
760
783
  error: {
761
784
  ...parsed.error,
762
785
  code: ErrorCode.INVALID_INPUT,
763
- message: 'Role section conflict.',
764
786
  ...(Object.hasOwn(parsed.error, 'details') ? { details: 'Redacted.' } : {}),
765
787
  },
766
788
  });
767
- code = ROLE_SECTION_CONFLICT_CODE as ErrorCode;
789
+ code = parsed.error.code as ErrorCode;
790
+ serverMessage = response.status === 404
791
+ ? undefined
792
+ : sanitizeServerMessage(decoded.error.message);
768
793
  }
769
794
  }
770
795
  } catch {
@@ -782,7 +807,9 @@ async function authedFetch(
782
807
  }
783
808
  throw new BorgServerHttpError(
784
809
  response.status,
785
- `Borg server request failed (HTTP ${response.status})`,
810
+ serverMessage
811
+ ? `Borg server request failed (HTTP ${response.status}): ${serverMessage}`
812
+ : `Borg server request failed (HTTP ${response.status})`,
786
813
  code,
787
814
  );
788
815
  }
@@ -1302,7 +1329,7 @@ export async function updateCube(
1302
1329
  updates: { name?: string; cube_directive?: string; message_taxonomy?: MessageTaxonomy | null },
1303
1330
  activeOverride?: ActiveCube,
1304
1331
  connectionOverride?: RemoteConnection,
1305
- ): Promise<{ cube: any }> {
1332
+ ): Promise<{ cube: any; advisory?: unknown }> {
1306
1333
  assertUuidShape(cubeId, 'cube_id');
1307
1334
  const active = activeOverride ?? await getActiveCube();
1308
1335
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
@@ -1438,7 +1465,7 @@ export async function updateRole(
1438
1465
  targetCubeId?: string,
1439
1466
  activeOverride?: ActiveCube,
1440
1467
  connectionOverride?: RemoteConnection,
1441
- ): Promise<{ role: any }> {
1468
+ ): Promise<{ role: any; advisory?: unknown }> {
1442
1469
  assertUuidShape(roleId, 'role_id');
1443
1470
  const active = activeOverride ?? await getActiveCube();
1444
1471
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
@@ -1514,7 +1541,7 @@ export async function patchRoleSection(
1514
1541
  targetCubeId?: string,
1515
1542
  activeOverride?: ActiveCube,
1516
1543
  connectionOverride?: RemoteConnection,
1517
- ): Promise<{ role: any }> {
1544
+ ): Promise<{ role: any; advisory?: unknown }> {
1518
1545
  assertUuidShape(roleId, 'role_id');
1519
1546
  const active = activeOverride ?? await getActiveCube();
1520
1547
  if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');