pi-cursor-sdk 0.1.15 → 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.
@@ -2,8 +2,16 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
3
3
  import type { AddressInfo } from "node:net";
4
4
  import type { McpServerConfig } from "@cursor/sdk";
5
- import type { Context } from "@earendil-works/pi-ai";
6
- import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent";
5
+ import type { Context, ToolResultMessage } from "@earendil-works/pi-ai";
6
+ import type {
7
+ ExtensionAPI,
8
+ ExtensionHandler,
9
+ SessionShutdownEvent,
10
+ ToolCallEvent,
11
+ ToolCallEventResult,
12
+ ToolInfo,
13
+ ToolResultEvent,
14
+ } from "@earendil-works/pi-coding-agent";
7
15
  import { Server as McpProtocolServer } from "@modelcontextprotocol/sdk/server/index.js";
8
16
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
17
  import {
@@ -12,10 +20,13 @@ import {
12
20
  type CallToolResult,
13
21
  type Tool,
14
22
  } from "@modelcontextprotocol/sdk/types.js";
23
+ import { buildCursorPiBridgeMcpToolDescription, CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX } from "./cursor-bridge-contract.js";
15
24
  import { isExcludedFromCursorBridgeExposure } from "./cursor-tool-names.js";
16
25
 
17
26
  const CURSOR_PI_TOOL_BRIDGE_ENV = "PI_CURSOR_PI_TOOL_BRIDGE";
18
27
  const CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV = "PI_CURSOR_EXPOSE_BUILTIN_TOOLS";
28
+ const CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV = "PI_CURSOR_PI_TOOL_BRIDGE_DEBUG";
29
+ const CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX = "[pi-cursor-sdk:bridge]";
19
30
  const LOOPBACK_HOST = "127.0.0.1";
20
31
  const MCP_SERVER_NAME = "pi_tools";
21
32
  const MCP_ENDPOINT_ROOT = "/cursor-pi-tool-bridge";
@@ -25,6 +36,74 @@ const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "none", "no", "disable
25
36
  const ENABLED_ENV_VALUES = new Set(["1", "true", "on", "yes", "enabled"]);
26
37
  const OVERLAPPING_CURSOR_NATIVE_PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "write", "edit", "grep", "find", "ls"]);
27
38
 
39
+ type CursorPiToolBridgeSkippedReason = "disabled" | "no_exposed_tools";
40
+ type CursorPiToolBridgeRejectionKind = "cancelled" | "error";
41
+
42
+ interface CursorPiToolBridgeLifecycleDiagnosticFields {
43
+ runId: string;
44
+ enabled: boolean;
45
+ exposedToolCount: number;
46
+ pendingCount: number;
47
+ }
48
+
49
+ interface CursorPiToolBridgeRunCreatedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
50
+ event: "run_created";
51
+ }
52
+
53
+ interface CursorPiToolBridgeRunSkippedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
54
+ event: "run_skipped";
55
+ reason: CursorPiToolBridgeSkippedReason;
56
+ }
57
+
58
+ interface CursorPiToolBridgeToolsExposedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
59
+ event: "tools_exposed";
60
+ pairs: Array<{ piToolName: string; mcpToolName: string }>;
61
+ }
62
+
63
+ interface CursorPiToolBridgeRunCancelledDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
64
+ event: "run_cancelled";
65
+ queuedCount: number;
66
+ cancelledRequestCount: number;
67
+ }
68
+
69
+ interface CursorPiToolBridgeRunDisposedDiagnostic extends CursorPiToolBridgeLifecycleDiagnosticFields {
70
+ event: "run_disposed";
71
+ }
72
+
73
+ interface CursorPiToolBridgeRequestDiagnosticFields {
74
+ runId: string;
75
+ bridgeCallId: string;
76
+ cursorMcpCallId?: string;
77
+ piToolCallId: string;
78
+ mcpToolName: string;
79
+ piToolName: string;
80
+ pendingCount: number;
81
+ }
82
+
83
+ interface CursorPiToolBridgeRequestQueuedDiagnostic extends CursorPiToolBridgeRequestDiagnosticFields {
84
+ event: "request_queued";
85
+ }
86
+
87
+ interface CursorPiToolBridgeRequestResolvedDiagnostic extends CursorPiToolBridgeRequestDiagnosticFields {
88
+ event: "request_resolved";
89
+ isError: boolean;
90
+ }
91
+
92
+ interface CursorPiToolBridgeRequestRejectedDiagnostic extends CursorPiToolBridgeRequestDiagnosticFields {
93
+ event: "request_rejected";
94
+ rejectionKind: CursorPiToolBridgeRejectionKind;
95
+ }
96
+
97
+ export type CursorPiToolBridgeDiagnosticEvent =
98
+ | CursorPiToolBridgeRunCreatedDiagnostic
99
+ | CursorPiToolBridgeRunSkippedDiagnostic
100
+ | CursorPiToolBridgeToolsExposedDiagnostic
101
+ | CursorPiToolBridgeRunCancelledDiagnostic
102
+ | CursorPiToolBridgeRunDisposedDiagnostic
103
+ | CursorPiToolBridgeRequestQueuedDiagnostic
104
+ | CursorPiToolBridgeRequestResolvedDiagnostic
105
+ | CursorPiToolBridgeRequestRejectedDiagnostic;
106
+
28
107
  export interface CursorPiMcpInputSchema {
29
108
  type: "object";
30
109
  properties?: Record<string, object>;
@@ -66,23 +145,34 @@ export interface CursorPiToolBridgeRun {
66
145
  mcpServers?: Record<string, McpServerConfig>;
67
146
  snapshot: CursorPiToolBridgeSnapshot;
68
147
  takeQueuedToolRequests(): CursorPiBridgeToolRequest[];
148
+ resolveToolResults(toolResults: readonly ToolResultMessage[]): void;
69
149
  resolveToolResultsFromContext(context: Context): void;
70
150
  hasPendingPiToolCallId(piToolCallId: string): boolean;
71
151
  isBridgeMcpToolCall(toolCall: unknown): boolean;
152
+ setOnToolRequest(handler?: (request: CursorPiBridgeToolRequest) => void): void;
72
153
  cancel(reason: string): void;
73
154
  dispose(): Promise<void>;
74
155
  }
75
156
 
76
- export interface CursorPiToolBridgeRunOptions {
77
- onToolRequest?: (request: CursorPiBridgeToolRequest) => void;
78
- }
79
-
80
157
  export interface CursorPiToolBridge {
81
158
  isEnabled(): boolean;
159
+ getToolSurfaceSignature(): string;
82
160
  createRun(options?: CursorPiToolBridgeRunOptions): Promise<CursorPiToolBridgeRun>;
83
161
  disposeAll(reason?: string): Promise<void>;
84
162
  }
85
163
 
164
+ export interface CursorPiToolBridgeRunOptions {
165
+ onToolRequest?: (request: CursorPiBridgeToolRequest) => void;
166
+ }
167
+
168
+ type CursorPiToolBridgeSnapshotApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools">;
169
+
170
+ interface CursorPiToolBridgeExtensionApi extends CursorPiToolBridgeSnapshotApi {
171
+ on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
172
+ on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent>): void;
173
+ on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
174
+ }
175
+
86
176
  interface PendingBridgeCall {
87
177
  request: CursorPiBridgeToolRequest;
88
178
  resolve: (result: CallToolResult) => void;
@@ -92,6 +182,140 @@ interface PendingBridgeCall {
92
182
  settled: boolean;
93
183
  }
94
184
 
185
+ interface CursorPiToolBridgeActiveToolExecution {
186
+ toolCallId: string;
187
+ abort: () => Promise<void> | void;
188
+ cancelPending: (reason: string) => void;
189
+ signal?: AbortSignal;
190
+ onAbort?: () => void;
191
+ }
192
+
193
+ class CursorPiToolBridgeToolExecutionAbortTracker {
194
+ private readonly activeExecutions = new Map<string, CursorPiToolBridgeActiveToolExecution>();
195
+ private processSignalHandlersInstalled = false;
196
+
197
+ track(
198
+ toolCallId: string,
199
+ options: {
200
+ signal?: AbortSignal;
201
+ abort: () => Promise<void> | void;
202
+ cancelPending: (reason: string) => void;
203
+ },
204
+ ): boolean {
205
+ this.finish(toolCallId);
206
+ const execution: CursorPiToolBridgeActiveToolExecution = {
207
+ toolCallId,
208
+ abort: options.abort,
209
+ cancelPending: options.cancelPending,
210
+ signal: options.signal,
211
+ };
212
+ if (options.signal?.aborted) {
213
+ this.cancelExecution(execution, "Cursor pi bridge tool execution was already aborted");
214
+ this.abortExecution(execution);
215
+ return false;
216
+ }
217
+
218
+ execution.onAbort = () => {
219
+ this.cancelExecution(execution, "Cursor pi bridge tool execution was aborted");
220
+ this.finish(toolCallId);
221
+ };
222
+ execution.signal?.addEventListener("abort", execution.onAbort, { once: true });
223
+ this.activeExecutions.set(toolCallId, execution);
224
+ this.installProcessSignalHandlers();
225
+ return true;
226
+ }
227
+
228
+ finish(toolCallId: string): void {
229
+ const execution = this.activeExecutions.get(toolCallId);
230
+ if (!execution) return;
231
+ if (execution.onAbort) execution.signal?.removeEventListener("abort", execution.onAbort);
232
+ this.activeExecutions.delete(toolCallId);
233
+ this.uninstallProcessSignalHandlersIfIdle();
234
+ }
235
+
236
+ finishAll(): void {
237
+ for (const toolCallId of [...this.activeExecutions.keys()]) this.finish(toolCallId);
238
+ }
239
+
240
+ abortAll(reason: string): void {
241
+ for (const execution of [...this.activeExecutions.values()]) {
242
+ this.cancelExecution(execution, reason);
243
+ this.abortExecution(execution);
244
+ this.finish(execution.toolCallId);
245
+ }
246
+ }
247
+
248
+ getActiveCount(): number {
249
+ return this.activeExecutions.size;
250
+ }
251
+
252
+ emitProcessAbortSignalForTests(signal: NodeJS.Signals): void {
253
+ this.abortActiveExecutions(signal, { preserveProcessSignalBehavior: true });
254
+ }
255
+
256
+ private readonly handleSigint = (): void => {
257
+ this.abortActiveExecutions("SIGINT");
258
+ };
259
+
260
+ private readonly handleSigterm = (): void => {
261
+ this.abortActiveExecutions("SIGTERM");
262
+ };
263
+
264
+ private installProcessSignalHandlers(): void {
265
+ if (this.processSignalHandlersInstalled) return;
266
+ this.processSignalHandlersInstalled = true;
267
+ process.on("SIGINT", this.handleSigint);
268
+ process.on("SIGTERM", this.handleSigterm);
269
+ }
270
+
271
+ private uninstallProcessSignalHandlersIfIdle(): void {
272
+ if (!this.processSignalHandlersInstalled || this.activeExecutions.size > 0) return;
273
+ this.processSignalHandlersInstalled = false;
274
+ process.off("SIGINT", this.handleSigint);
275
+ process.off("SIGTERM", this.handleSigterm);
276
+ }
277
+
278
+ private abortActiveExecutions(
279
+ signal: NodeJS.Signals,
280
+ options: { preserveProcessSignalBehavior?: boolean } = {},
281
+ ): void {
282
+ if (this.activeExecutions.size === 0) return;
283
+ const shouldRestoreDefaultSignalBehavior =
284
+ options.preserveProcessSignalBehavior !== true && !this.hasExternalProcessSignalListeners(signal);
285
+ this.abortAll(`Cursor pi bridge tool execution interrupted by ${signal}`);
286
+ if (shouldRestoreDefaultSignalBehavior) this.restoreDefaultProcessSignalBehavior(signal);
287
+ }
288
+
289
+ private cancelExecution(execution: CursorPiToolBridgeActiveToolExecution, reason: string): void {
290
+ try {
291
+ execution.cancelPending(reason);
292
+ } catch {
293
+ // Cancellation is best-effort during process abort/shutdown cleanup; keep aborting siblings.
294
+ }
295
+ }
296
+
297
+ private abortExecution(execution: CursorPiToolBridgeActiveToolExecution): void {
298
+ try {
299
+ Promise.resolve(execution.abort()).catch(() => undefined);
300
+ } catch {
301
+ // Abort is best-effort during process abort/shutdown cleanup; keep aborting siblings.
302
+ }
303
+ }
304
+
305
+ private hasExternalProcessSignalListeners(signal: NodeJS.Signals): boolean {
306
+ const ownHandler = signal === "SIGINT" ? this.handleSigint : this.handleSigterm;
307
+ return process.listeners(signal).some((listener) => listener !== ownHandler);
308
+ }
309
+
310
+ private restoreDefaultProcessSignalBehavior(signal: NodeJS.Signals): void {
311
+ setImmediate(() => {
312
+ process.kill(process.pid, signal);
313
+ });
314
+ }
315
+ }
316
+
317
+ const bridgeToolExecutionAbortTracker = new CursorPiToolBridgeToolExecutionAbortTracker();
318
+
95
319
  function isRecord(value: unknown): value is Record<string, unknown> {
96
320
  return typeof value === "object" && value !== null;
97
321
  }
@@ -121,8 +345,12 @@ function stableNameHash(value: string): string {
121
345
  return createHash("sha256").update(value).digest("hex").slice(0, 8);
122
346
  }
123
347
 
348
+ function createCursorMcpCallDiagnosticId(cursorMcpCallId: string | undefined): string | undefined {
349
+ return cursorMcpCallId ? `cursor-mcp-call-${stableNameHash(cursorMcpCallId)}` : undefined;
350
+ }
351
+
124
352
  function createMcpToolName(piToolName: string, usedMcpToolNames: Set<string>): string {
125
- const baseName = `pi__${sanitizeMcpToolNameStem(piToolName)}`;
353
+ const baseName = `${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}${sanitizeMcpToolNameStem(piToolName)}`;
126
354
  if (!usedMcpToolNames.has(baseName)) {
127
355
  usedMcpToolNames.add(baseName);
128
356
  return baseName;
@@ -168,12 +396,137 @@ export function resolveCursorPiToolBridgeBuiltinsEnabled(env: Record<string, str
168
396
  return false;
169
397
  }
170
398
 
399
+ export function resolveCursorPiToolBridgeDebugEnabled(env: Record<string, string | undefined> = process.env): boolean {
400
+ const raw = env[CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV]?.trim().toLowerCase();
401
+ if (!raw) return false;
402
+ if (ENABLED_ENV_VALUES.has(raw)) return true;
403
+ if (DISABLED_ENV_VALUES.has(raw)) return false;
404
+ return false;
405
+ }
406
+
407
+ function assertNeverDiagnosticEvent(_event: never): never {
408
+ throw new Error("Unhandled Cursor pi tool bridge diagnostic event");
409
+ }
410
+
411
+ function serializeCursorPiToolBridgeDiagnostic(event: CursorPiToolBridgeDiagnosticEvent): Record<string, unknown> {
412
+ switch (event.event) {
413
+ case "run_created":
414
+ return {
415
+ event: event.event,
416
+ runId: event.runId,
417
+ enabled: event.enabled,
418
+ exposedToolCount: event.exposedToolCount,
419
+ pendingCount: event.pendingCount,
420
+ };
421
+ case "run_skipped":
422
+ return {
423
+ event: event.event,
424
+ runId: event.runId,
425
+ enabled: event.enabled,
426
+ exposedToolCount: event.exposedToolCount,
427
+ pendingCount: event.pendingCount,
428
+ reason: event.reason,
429
+ };
430
+ case "tools_exposed":
431
+ return {
432
+ event: event.event,
433
+ runId: event.runId,
434
+ enabled: event.enabled,
435
+ exposedToolCount: event.exposedToolCount,
436
+ pendingCount: event.pendingCount,
437
+ pairs: event.pairs.map((pair) => ({ piToolName: pair.piToolName, mcpToolName: pair.mcpToolName })),
438
+ };
439
+ case "run_cancelled":
440
+ return {
441
+ event: event.event,
442
+ runId: event.runId,
443
+ enabled: event.enabled,
444
+ exposedToolCount: event.exposedToolCount,
445
+ pendingCount: event.pendingCount,
446
+ queuedCount: event.queuedCount,
447
+ cancelledRequestCount: event.cancelledRequestCount,
448
+ };
449
+ case "run_disposed":
450
+ return {
451
+ event: event.event,
452
+ runId: event.runId,
453
+ enabled: event.enabled,
454
+ exposedToolCount: event.exposedToolCount,
455
+ pendingCount: event.pendingCount,
456
+ };
457
+ case "request_queued":
458
+ return {
459
+ event: event.event,
460
+ runId: event.runId,
461
+ bridgeCallId: event.bridgeCallId,
462
+ cursorMcpCallId: createCursorMcpCallDiagnosticId(event.cursorMcpCallId),
463
+ piToolCallId: event.piToolCallId,
464
+ mcpToolName: event.mcpToolName,
465
+ piToolName: event.piToolName,
466
+ pendingCount: event.pendingCount,
467
+ };
468
+ case "request_resolved":
469
+ return {
470
+ event: event.event,
471
+ runId: event.runId,
472
+ bridgeCallId: event.bridgeCallId,
473
+ cursorMcpCallId: createCursorMcpCallDiagnosticId(event.cursorMcpCallId),
474
+ piToolCallId: event.piToolCallId,
475
+ mcpToolName: event.mcpToolName,
476
+ piToolName: event.piToolName,
477
+ pendingCount: event.pendingCount,
478
+ isError: event.isError,
479
+ };
480
+ case "request_rejected":
481
+ return {
482
+ event: event.event,
483
+ runId: event.runId,
484
+ bridgeCallId: event.bridgeCallId,
485
+ cursorMcpCallId: createCursorMcpCallDiagnosticId(event.cursorMcpCallId),
486
+ piToolCallId: event.piToolCallId,
487
+ mcpToolName: event.mcpToolName,
488
+ piToolName: event.piToolName,
489
+ pendingCount: event.pendingCount,
490
+ rejectionKind: event.rejectionKind,
491
+ };
492
+ }
493
+ return assertNeverDiagnosticEvent(event);
494
+ }
495
+
496
+ function writeCursorPiToolBridgeDiagnostic(env: Record<string, string | undefined>, event: CursorPiToolBridgeDiagnosticEvent): void {
497
+ if (!resolveCursorPiToolBridgeDebugEnabled(env)) return;
498
+ try {
499
+ process.stderr.write(`${CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX} ${JSON.stringify(serializeCursorPiToolBridgeDiagnostic(event))}\n`);
500
+ } catch {
501
+ // Diagnostics must never affect bridge execution.
502
+ }
503
+ }
504
+
171
505
  function isOverlappingCursorNativePiToolName(toolName: string): boolean {
172
506
  return OVERLAPPING_CURSOR_NATIVE_PI_BUILTIN_TOOL_NAMES.has(toolName);
173
507
  }
174
508
 
509
+ export function buildCursorPiToolBridgeSurfaceSignature(snapshot: CursorPiToolBridgeSnapshot): string {
510
+ if (snapshot.tools.length === 0) return "bridge:empty";
511
+ const serializedTools = snapshot.tools
512
+ .map((tool) =>
513
+ JSON.stringify({
514
+ piToolName: tool.piToolName,
515
+ mcpToolName: tool.mcpToolName,
516
+ description: tool.description,
517
+ inputSchema: tool.inputSchema,
518
+ source: tool.sourceInfo?.source,
519
+ path: tool.sourceInfo?.path,
520
+ scope: tool.sourceInfo?.scope,
521
+ }),
522
+ )
523
+ .sort()
524
+ .join("\0");
525
+ return `bridge:on:${stableNameHash(serializedTools)}`;
526
+ }
527
+
175
528
  export function buildCursorPiToolBridgeSnapshot(
176
- pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
529
+ pi: CursorPiToolBridgeSnapshotApi,
177
530
  options: CursorPiToolBridgeSnapshotOptions = {},
178
531
  ): CursorPiToolBridgeSnapshot {
179
532
  const activeToolNames = new Set(pi.getActiveTools());
@@ -209,7 +562,11 @@ export function buildCursorPiToolBridgeSnapshot(
209
562
  function snapshotToolToMcpTool(tool: CursorPiBridgeToolDefinition): Tool {
210
563
  return {
211
564
  name: tool.mcpToolName,
212
- description: tool.description,
565
+ description: buildCursorPiBridgeMcpToolDescription({
566
+ piToolName: tool.piToolName,
567
+ mcpToolName: tool.mcpToolName,
568
+ piToolDescription: tool.description,
569
+ }),
213
570
  inputSchema: tool.inputSchema,
214
571
  _meta: { piToolName: tool.piToolName },
215
572
  };
@@ -237,7 +594,7 @@ function convertPiContentToMcpContent(content: unknown): CallToolResult["content
237
594
  return mcpContent.length > 0 ? mcpContent : [{ type: "text", text: "" }];
238
595
  }
239
596
 
240
- function asToolResultMessage(value: Context["messages"][number]): Extract<Context["messages"][number], { role: "toolResult" }> | undefined {
597
+ function asToolResultMessage(value: Context["messages"][number]): ToolResultMessage | undefined {
241
598
  return value.role === "toolResult" ? value : undefined;
242
599
  }
243
600
 
@@ -273,6 +630,7 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
273
630
  mcpServers?: Record<string, McpServerConfig>;
274
631
 
275
632
  private readonly registry: CursorPiToolBridgeRegistry;
633
+ private readonly env: Record<string, string | undefined>;
276
634
  private readonly endpointPath: string;
277
635
  private readonly knownMcpToolNames: ReadonlySet<string>;
278
636
  private readonly knownCursorMcpCallIds = new Set<string>();
@@ -280,7 +638,8 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
280
638
  private readonly pendingByPiToolCallId = new Map<string, PendingBridgeCall>();
281
639
  private readonly pendingByBridgeCallId = new Map<string, PendingBridgeCall>();
282
640
  private readonly pendingByCursorMcpCallId = new Map<string, PendingBridgeCall>();
283
- private readonly onToolRequest?: (request: CursorPiBridgeToolRequest) => void;
641
+ private onToolRequest?: (request: CursorPiBridgeToolRequest) => void;
642
+ private liveRunHandlerDetached = false;
284
643
  private mcpServer?: McpProtocolServer;
285
644
  private mcpTransport?: StreamableHTTPServerTransport;
286
645
  private toolCallCounter = 0;
@@ -288,16 +647,18 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
288
647
 
289
648
  constructor(
290
649
  registry: CursorPiToolBridgeRegistry,
650
+ env: Record<string, string | undefined>,
291
651
  snapshot: CursorPiToolBridgeSnapshot,
292
652
  enabled: boolean,
293
653
  options: CursorPiToolBridgeRunOptions = {},
294
654
  ) {
295
655
  this.registry = registry;
656
+ this.env = env;
296
657
  this.snapshot = snapshot;
297
658
  this.enabled = enabled;
298
659
  this.onToolRequest = options.onToolRequest;
299
- this.id = `cursor-pi-bridge-${randomUUID()}`;
300
- this.endpointPath = `${MCP_ENDPOINT_ROOT}/${this.id}/${randomUUID()}/mcp`;
660
+ this.id = `cursor-pi-bridge-run-${randomUUID()}`;
661
+ this.endpointPath = `${MCP_ENDPOINT_ROOT}/${randomUUID()}/mcp`;
301
662
  this.knownMcpToolNames = new Set(snapshot.tools.map((tool) => tool.mcpToolName));
302
663
  }
303
664
 
@@ -308,6 +669,27 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
308
669
  this.mcpServers = { [MCP_SERVER_NAME]: { type: "http", url: endpointUrl } };
309
670
  }
310
671
 
672
+ emitStartDiagnostics(bridgeEnabled: boolean): void {
673
+ const base = this.lifecycleDiagnosticFields();
674
+ this.emitDiagnostic({ event: "run_created", ...base });
675
+ if (!this.enabled) {
676
+ this.emitDiagnostic({
677
+ event: "run_skipped",
678
+ ...base,
679
+ reason: bridgeEnabled ? "no_exposed_tools" : "disabled",
680
+ });
681
+ return;
682
+ }
683
+ this.emitDiagnostic({
684
+ event: "tools_exposed",
685
+ ...base,
686
+ pairs: this.snapshot.tools.map((tool) => ({
687
+ piToolName: tool.piToolName,
688
+ mcpToolName: tool.mcpToolName,
689
+ })),
690
+ });
691
+ }
692
+
311
693
  async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
312
694
  if (this.disposed || !this.mcpTransport) {
313
695
  res.writeHead(410, { "content-type": "application/json" }).end(JSON.stringify({ error: "Cursor pi tool bridge run is disposed" }));
@@ -320,10 +702,23 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
320
702
  return this.queuedRequests.splice(0);
321
703
  }
322
704
 
323
- resolveToolResultsFromContext(context: Context): void {
324
- for (const message of context.messages) {
325
- const toolResult = asToolResultMessage(message);
326
- if (!toolResult) continue;
705
+ setOnToolRequest(handler?: (request: CursorPiBridgeToolRequest) => void): void {
706
+ if (!handler) {
707
+ this.liveRunHandlerDetached = true;
708
+ this.rejectQueuedToolRequestsWithoutHandler("Cursor pi tool bridge has no active live run");
709
+ } else {
710
+ this.liveRunHandlerDetached = false;
711
+ }
712
+ this.onToolRequest = handler;
713
+ if (handler) {
714
+ for (const request of this.queuedRequests.splice(0)) {
715
+ handler(request);
716
+ }
717
+ }
718
+ }
719
+
720
+ resolveToolResults(toolResults: readonly ToolResultMessage[]): void {
721
+ for (const toolResult of toolResults) {
327
722
  const pending = this.pendingByPiToolCallId.get(toolResult.toolCallId);
328
723
  if (!pending || pending.settled) continue;
329
724
  this.resolvePending(pending, {
@@ -333,10 +728,21 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
333
728
  }
334
729
  }
335
730
 
731
+ resolveToolResultsFromContext(context: Context): void {
732
+ this.resolveToolResults(context.messages.map(asToolResultMessage).filter((message): message is ToolResultMessage => message !== undefined));
733
+ }
734
+
336
735
  hasPendingPiToolCallId(piToolCallId: string): boolean {
337
736
  return this.pendingByPiToolCallId.has(piToolCallId);
338
737
  }
339
738
 
739
+ cancelPendingPiToolCallId(piToolCallId: string, reason: string): boolean {
740
+ const pending = this.pendingByPiToolCallId.get(piToolCallId);
741
+ if (!pending) return false;
742
+ this.rejectPending(pending, new Error(reason), "cancelled");
743
+ return true;
744
+ }
745
+
340
746
  isBridgeMcpToolCall(toolCall: unknown): boolean {
341
747
  if (!isRecord(toolCall)) return false;
342
748
  const toolName = getStringField(toolCall, ["name", "toolName", "mcpToolName"]);
@@ -352,9 +758,19 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
352
758
 
353
759
  cancel(reason: string): void {
354
760
  const error = new Error(reason);
761
+ const pendingCount = this.pendingCount();
762
+ const queuedCount = this.queuedRequests.length;
763
+ if (pendingCount > 0 || queuedCount > 0) {
764
+ this.emitDiagnostic({
765
+ event: "run_cancelled",
766
+ ...this.lifecycleDiagnosticFields(pendingCount),
767
+ queuedCount,
768
+ cancelledRequestCount: pendingCount,
769
+ });
770
+ }
355
771
  this.queuedRequests.splice(0);
356
772
  for (const pending of [...this.pendingByBridgeCallId.values()]) {
357
- this.rejectPending(pending, error);
773
+ this.rejectPending(pending, error, "cancelled");
358
774
  }
359
775
  }
360
776
 
@@ -368,6 +784,10 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
368
784
  this.mcpServer?.close(),
369
785
  ]);
370
786
  await this.registry.unregisterRun(this.endpointPath, this);
787
+ this.emitDiagnostic({
788
+ event: "run_disposed",
789
+ ...this.lifecycleDiagnosticFields(),
790
+ });
371
791
  }
372
792
 
373
793
  private async createMcpServer(): Promise<void> {
@@ -422,7 +842,7 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
422
842
  settled: false,
423
843
  };
424
844
  pending.onAbort = () => {
425
- this.rejectPending(pending, new Error("Cursor MCP bridge tool request was aborted"));
845
+ this.rejectPending(pending, new Error("Cursor MCP bridge tool request was aborted"), "cancelled");
426
846
  };
427
847
  if (signal?.aborted) {
428
848
  pending.onAbort();
@@ -433,35 +853,97 @@ class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
433
853
  this.pendingByBridgeCallId.set(request.bridgeCallId, pending);
434
854
  this.pendingByCursorMcpCallId.set(cursorMcpCallId, pending);
435
855
  this.knownCursorMcpCallIds.add(cursorMcpCallId);
436
- this.queuedRequests.push(request);
437
- this.onToolRequest?.(request);
856
+ if (!this.onToolRequest) {
857
+ if (this.liveRunHandlerDetached) {
858
+ this.rejectPending(pending, new Error("Cursor pi tool bridge has no active live run"), "cancelled");
859
+ return;
860
+ }
861
+ this.queuedRequests.push(request);
862
+ this.emitRequestQueuedDiagnostic(request);
863
+ return;
864
+ }
865
+ this.emitRequestQueuedDiagnostic(request);
866
+ this.onToolRequest(request);
438
867
  });
439
868
  }
440
869
 
870
+ private rejectQueuedToolRequestsWithoutHandler(reason: string): void {
871
+ while (this.queuedRequests.length > 0) {
872
+ const request = this.queuedRequests.shift()!;
873
+ const pending = this.pendingByPiToolCallId.get(request.piToolCallId);
874
+ if (pending) this.rejectPending(pending, new Error(reason), "cancelled");
875
+ }
876
+ }
877
+
441
878
  private resolvePending(pending: PendingBridgeCall, result: CallToolResult): void {
442
879
  if (pending.settled) return;
443
880
  pending.settled = true;
444
881
  this.removePending(pending);
882
+ this.emitRequestResolvedDiagnostic(pending.request, result.isError === true);
445
883
  pending.resolve(result);
446
884
  }
447
885
 
448
- private rejectPending(pending: PendingBridgeCall, error: Error): void {
886
+ private rejectPending(pending: PendingBridgeCall, error: Error, kind: "cancelled" | "error" = "error"): void {
449
887
  if (pending.settled) return;
450
888
  pending.settled = true;
451
889
  this.removePending(pending);
890
+ this.emitRequestRejectedDiagnostic(pending.request, kind);
452
891
  pending.reject(error);
453
892
  }
454
893
 
894
+ private lifecycleDiagnosticFields(pendingCount = this.pendingCount()): CursorPiToolBridgeLifecycleDiagnosticFields {
895
+ return {
896
+ runId: this.id,
897
+ enabled: this.enabled,
898
+ exposedToolCount: this.snapshot.tools.length,
899
+ pendingCount,
900
+ };
901
+ }
902
+
903
+ private requestDiagnosticFields(request: CursorPiBridgeToolRequest): CursorPiToolBridgeRequestDiagnosticFields {
904
+ return {
905
+ runId: this.id,
906
+ bridgeCallId: request.bridgeCallId,
907
+ cursorMcpCallId: request.cursorMcpCallId,
908
+ piToolCallId: request.piToolCallId,
909
+ mcpToolName: request.mcpToolName,
910
+ piToolName: request.piToolName,
911
+ pendingCount: this.pendingCount(),
912
+ };
913
+ }
914
+
915
+ private emitRequestQueuedDiagnostic(request: CursorPiBridgeToolRequest): void {
916
+ this.emitDiagnostic({ event: "request_queued", ...this.requestDiagnosticFields(request) });
917
+ }
918
+
919
+ private emitRequestResolvedDiagnostic(request: CursorPiBridgeToolRequest, isError: boolean): void {
920
+ this.emitDiagnostic({ event: "request_resolved", ...this.requestDiagnosticFields(request), isError });
921
+ }
922
+
923
+ private emitRequestRejectedDiagnostic(request: CursorPiBridgeToolRequest, rejectionKind: CursorPiToolBridgeRejectionKind): void {
924
+ this.emitDiagnostic({ event: "request_rejected", ...this.requestDiagnosticFields(request), rejectionKind });
925
+ }
926
+
927
+ private emitDiagnostic(event: CursorPiToolBridgeDiagnosticEvent): void {
928
+ writeCursorPiToolBridgeDiagnostic(this.env, event);
929
+ }
930
+
931
+ private pendingCount(): number {
932
+ return this.pendingByBridgeCallId.size;
933
+ }
934
+
455
935
  private removePending(pending: PendingBridgeCall): void {
456
936
  pending.signal?.removeEventListener("abort", pending.onAbort ?? (() => undefined));
457
937
  this.pendingByPiToolCallId.delete(pending.request.piToolCallId);
458
938
  this.pendingByBridgeCallId.delete(pending.request.bridgeCallId);
459
939
  if (pending.request.cursorMcpCallId) this.pendingByCursorMcpCallId.delete(pending.request.cursorMcpCallId);
940
+ const queuedIndex = this.queuedRequests.findIndex((request) => request.bridgeCallId === pending.request.bridgeCallId);
941
+ if (queuedIndex >= 0) this.queuedRequests.splice(queuedIndex, 1);
460
942
  }
461
943
  }
462
944
 
463
945
  class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
464
- private readonly pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">;
946
+ private readonly pi: CursorPiToolBridgeSnapshotApi;
465
947
  private readonly env: Record<string, string | undefined>;
466
948
  private readonly runs = new Set<CursorPiToolBridgeRunImpl>();
467
949
  private readonly routes = new Map<string, CursorPiToolBridgeRunImpl>();
@@ -469,7 +951,7 @@ class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
469
951
  private listenPromise?: Promise<void>;
470
952
 
471
953
  constructor(
472
- pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
954
+ pi: CursorPiToolBridgeSnapshotApi,
473
955
  env: Record<string, string | undefined> = process.env,
474
956
  ) {
475
957
  this.pi = pi;
@@ -480,6 +962,14 @@ class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
480
962
  return resolveCursorPiToolBridgeEnabled(this.env);
481
963
  }
482
964
 
965
+ getToolSurfaceSignature(): string {
966
+ if (!this.isEnabled()) return "bridge:off";
967
+ const snapshot = buildCursorPiToolBridgeSnapshot(this.pi, {
968
+ exposeOverlappingBuiltins: resolveCursorPiToolBridgeBuiltinsEnabled(this.env),
969
+ });
970
+ return buildCursorPiToolBridgeSurfaceSignature(snapshot);
971
+ }
972
+
483
973
  async createRun(options: CursorPiToolBridgeRunOptions = {}): Promise<CursorPiToolBridgeRun> {
484
974
  const bridgeEnabled = this.isEnabled();
485
975
  const snapshot = bridgeEnabled
@@ -487,9 +977,10 @@ class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
487
977
  exposeOverlappingBuiltins: resolveCursorPiToolBridgeBuiltinsEnabled(this.env),
488
978
  })
489
979
  : createEmptySnapshot();
490
- const run = new CursorPiToolBridgeRunImpl(this, snapshot, bridgeEnabled && snapshot.tools.length > 0, options);
980
+ const run = new CursorPiToolBridgeRunImpl(this, this.env, snapshot, bridgeEnabled && snapshot.tools.length > 0, options);
491
981
  this.runs.add(run);
492
982
  await run.start();
983
+ run.emitStartDiagnostics(bridgeEnabled);
493
984
  return run;
494
985
  }
495
986
 
@@ -523,6 +1014,20 @@ class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
523
1014
  return this.routes.size;
524
1015
  }
525
1016
 
1017
+ hasPendingPiToolCallId(piToolCallId: string): boolean {
1018
+ for (const run of this.runs) {
1019
+ if (run.hasPendingPiToolCallId(piToolCallId)) return true;
1020
+ }
1021
+ return false;
1022
+ }
1023
+
1024
+ cancelPendingPiToolCallId(piToolCallId: string, reason: string): boolean {
1025
+ for (const run of this.runs) {
1026
+ if (run.cancelPendingPiToolCallId(piToolCallId, reason)) return true;
1027
+ }
1028
+ return false;
1029
+ }
1030
+
526
1031
  private async ensureHttpServer(): Promise<void> {
527
1032
  if (this.httpServer) {
528
1033
  await this.listenPromise;
@@ -601,12 +1106,32 @@ class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
601
1106
 
602
1107
  let registeredCursorPiToolBridge: CursorPiToolBridgeRegistry | undefined;
603
1108
 
604
- export function registerCursorPiToolBridge(pi: ExtensionAPI): CursorPiToolBridge {
1109
+ export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
1110
+ bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
605
1111
  void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
606
1112
  const bridge = new CursorPiToolBridgeRegistry(pi);
607
1113
  registeredCursorPiToolBridge = bridge;
1114
+ pi.on("tool_call", (event, ctx) => {
1115
+ if (!bridge.hasPendingPiToolCallId(event.toolCallId)) return undefined;
1116
+ const trackingStarted = bridgeToolExecutionAbortTracker.track(event.toolCallId, {
1117
+ signal: ctx.signal,
1118
+ abort: () => {
1119
+ void ctx.abort();
1120
+ },
1121
+ cancelPending: (reason) => {
1122
+ bridge.cancelPendingPiToolCallId(event.toolCallId, reason);
1123
+ },
1124
+ });
1125
+ if (trackingStarted) return undefined;
1126
+ return { block: true, reason: "Cursor pi bridge tool execution was aborted before it started" };
1127
+ });
1128
+ pi.on("tool_result", (event) => {
1129
+ bridgeToolExecutionAbortTracker.finish(event.toolCallId);
1130
+ });
608
1131
  pi.on("session_shutdown", async (event) => {
609
- await bridge.disposeAll(`Cursor pi tool bridge session shutdown: ${event.reason}`);
1132
+ const reason = `Cursor pi tool bridge session shutdown: ${event.reason}`;
1133
+ bridgeToolExecutionAbortTracker.abortAll(reason);
1134
+ await bridge.disposeAll(reason);
610
1135
  });
611
1136
  return bridge;
612
1137
  }
@@ -618,10 +1143,12 @@ export function getRegisteredCursorPiToolBridge(): CursorPiToolBridge | undefine
618
1143
  export const __testUtils = {
619
1144
  CURSOR_PI_TOOL_BRIDGE_ENV,
620
1145
  CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV,
1146
+ CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV,
1147
+ CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX,
621
1148
  LOOPBACK_HOST,
622
1149
  MCP_SERVER_NAME,
623
1150
  createRegistry(
624
- pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
1151
+ pi: CursorPiToolBridgeSnapshotApi,
625
1152
  env: Record<string, string | undefined> = process.env,
626
1153
  ) {
627
1154
  return new CursorPiToolBridgeRegistry(pi, env);
@@ -629,7 +1156,17 @@ export const __testUtils = {
629
1156
  getRegisteredBridgeForTests() {
630
1157
  return registeredCursorPiToolBridge;
631
1158
  },
1159
+ serializeDiagnosticForTests(event: CursorPiToolBridgeDiagnosticEvent) {
1160
+ return serializeCursorPiToolBridgeDiagnostic(event);
1161
+ },
1162
+ getActiveBridgeToolExecutionAbortCount() {
1163
+ return bridgeToolExecutionAbortTracker.getActiveCount();
1164
+ },
1165
+ emitBridgeToolExecutionProcessAbortSignalForTests(signal: NodeJS.Signals) {
1166
+ bridgeToolExecutionAbortTracker.emitProcessAbortSignalForTests(signal);
1167
+ },
632
1168
  resetRegisteredBridgeForTests() {
1169
+ bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge test reset");
633
1170
  const bridge = registeredCursorPiToolBridge;
634
1171
  registeredCursorPiToolBridge = undefined;
635
1172
  return bridge?.disposeAll("Cursor pi tool bridge test reset") ?? Promise.resolve();