pi-cursor-sdk 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.
- package/CHANGELOG.md +57 -0
- package/README.md +68 -14
- package/docs/cursor-live-smoke-checklist.md +271 -0
- package/docs/cursor-model-ux-spec.md +27 -4
- package/docs/cursor-native-tool-replay.md +99 -0
- package/docs/cursor-native-tool-visual-audit.md +183 -0
- package/package.json +6 -2
- package/src/context.ts +214 -16
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-mcp-timeout-override.ts +111 -0
- package/src/cursor-native-tool-display.ts +409 -49
- package/src/cursor-pi-tool-bridge.ts +1174 -0
- package/src/cursor-provider.ts +614 -146
- package/src/cursor-question-tool.ts +252 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +28 -0
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-names.ts +67 -0
- package/src/cursor-tool-transcript.ts +730 -61
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +27 -3
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import type { McpServerConfig } from "@cursor/sdk";
|
|
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";
|
|
15
|
+
import { Server as McpProtocolServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
16
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
17
|
+
import {
|
|
18
|
+
CallToolRequestSchema,
|
|
19
|
+
ListToolsRequestSchema,
|
|
20
|
+
type CallToolResult,
|
|
21
|
+
type Tool,
|
|
22
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { buildCursorPiBridgeMcpToolDescription, CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX } from "./cursor-bridge-contract.js";
|
|
24
|
+
import { isExcludedFromCursorBridgeExposure } from "./cursor-tool-names.js";
|
|
25
|
+
|
|
26
|
+
const CURSOR_PI_TOOL_BRIDGE_ENV = "PI_CURSOR_PI_TOOL_BRIDGE";
|
|
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]";
|
|
30
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
31
|
+
const MCP_SERVER_NAME = "pi_tools";
|
|
32
|
+
const MCP_ENDPOINT_ROOT = "/cursor-pi-tool-bridge";
|
|
33
|
+
const MCP_SERVER_VERSION = "0.1.0";
|
|
34
|
+
const HTTP_SERVER_CLOSE_GRACE_MS = 250;
|
|
35
|
+
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "none", "no", "disabled"]);
|
|
36
|
+
const ENABLED_ENV_VALUES = new Set(["1", "true", "on", "yes", "enabled"]);
|
|
37
|
+
const OVERLAPPING_CURSOR_NATIVE_PI_BUILTIN_TOOL_NAMES = new Set(["read", "bash", "write", "edit", "grep", "find", "ls"]);
|
|
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
|
+
|
|
107
|
+
export interface CursorPiMcpInputSchema {
|
|
108
|
+
type: "object";
|
|
109
|
+
properties?: Record<string, object>;
|
|
110
|
+
required?: string[];
|
|
111
|
+
[key: string]: unknown;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CursorPiBridgeToolDefinition {
|
|
115
|
+
piToolName: string;
|
|
116
|
+
mcpToolName: string;
|
|
117
|
+
description: string;
|
|
118
|
+
inputSchema: CursorPiMcpInputSchema;
|
|
119
|
+
sourceInfo: ToolInfo["sourceInfo"];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface CursorPiToolBridgeSnapshot {
|
|
123
|
+
tools: CursorPiBridgeToolDefinition[];
|
|
124
|
+
mcpToolNameToPiToolName: ReadonlyMap<string, string>;
|
|
125
|
+
piToolNameToMcpToolName: ReadonlyMap<string, string>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface CursorPiToolBridgeSnapshotOptions {
|
|
129
|
+
exposeOverlappingBuiltins?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface CursorPiBridgeToolRequest {
|
|
133
|
+
runId: string;
|
|
134
|
+
bridgeCallId: string;
|
|
135
|
+
cursorMcpCallId?: string;
|
|
136
|
+
piToolCallId: string;
|
|
137
|
+
piToolName: string;
|
|
138
|
+
mcpToolName: string;
|
|
139
|
+
args: Record<string, unknown>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CursorPiToolBridgeRun {
|
|
143
|
+
id: string;
|
|
144
|
+
enabled: boolean;
|
|
145
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
146
|
+
snapshot: CursorPiToolBridgeSnapshot;
|
|
147
|
+
takeQueuedToolRequests(): CursorPiBridgeToolRequest[];
|
|
148
|
+
resolveToolResults(toolResults: readonly ToolResultMessage[]): void;
|
|
149
|
+
resolveToolResultsFromContext(context: Context): void;
|
|
150
|
+
hasPendingPiToolCallId(piToolCallId: string): boolean;
|
|
151
|
+
isBridgeMcpToolCall(toolCall: unknown): boolean;
|
|
152
|
+
setOnToolRequest(handler?: (request: CursorPiBridgeToolRequest) => void): void;
|
|
153
|
+
cancel(reason: string): void;
|
|
154
|
+
dispose(): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface CursorPiToolBridge {
|
|
158
|
+
isEnabled(): boolean;
|
|
159
|
+
getToolSurfaceSignature(): string;
|
|
160
|
+
createRun(options?: CursorPiToolBridgeRunOptions): Promise<CursorPiToolBridgeRun>;
|
|
161
|
+
disposeAll(reason?: string): Promise<void>;
|
|
162
|
+
}
|
|
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
|
+
|
|
176
|
+
interface PendingBridgeCall {
|
|
177
|
+
request: CursorPiBridgeToolRequest;
|
|
178
|
+
resolve: (result: CallToolResult) => void;
|
|
179
|
+
reject: (error: Error) => void;
|
|
180
|
+
signal?: AbortSignal;
|
|
181
|
+
onAbort?: () => void;
|
|
182
|
+
settled: boolean;
|
|
183
|
+
}
|
|
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
|
+
|
|
319
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
320
|
+
return typeof value === "object" && value !== null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeMcpInputSchema(schema: unknown): CursorPiMcpInputSchema {
|
|
324
|
+
if (isRecord(schema) && schema.type === "object") return schema as CursorPiMcpInputSchema;
|
|
325
|
+
return { type: "object", properties: {} };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeMcpArgs(args: unknown): Record<string, unknown> {
|
|
329
|
+
return isRecord(args) ? { ...args } : {};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function waitForProtocolFlush(): Promise<void> {
|
|
333
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function sanitizeMcpToolNameStem(toolName: string): string {
|
|
337
|
+
const stem = toolName
|
|
338
|
+
.trim()
|
|
339
|
+
.replace(/[^A-Za-z0-9_-]+/g, "_")
|
|
340
|
+
.replace(/^_+|_+$/g, "");
|
|
341
|
+
return stem || "tool";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function stableNameHash(value: string): string {
|
|
345
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function createCursorMcpCallDiagnosticId(cursorMcpCallId: string | undefined): string | undefined {
|
|
349
|
+
return cursorMcpCallId ? `cursor-mcp-call-${stableNameHash(cursorMcpCallId)}` : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function createMcpToolName(piToolName: string, usedMcpToolNames: Set<string>): string {
|
|
353
|
+
const baseName = `${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}${sanitizeMcpToolNameStem(piToolName)}`;
|
|
354
|
+
if (!usedMcpToolNames.has(baseName)) {
|
|
355
|
+
usedMcpToolNames.add(baseName);
|
|
356
|
+
return baseName;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const hashedName = `${baseName}__${stableNameHash(piToolName)}`;
|
|
360
|
+
if (!usedMcpToolNames.has(hashedName)) {
|
|
361
|
+
usedMcpToolNames.add(hashedName);
|
|
362
|
+
return hashedName;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let counter = 2;
|
|
366
|
+
let candidate = `${hashedName}_${counter}`;
|
|
367
|
+
while (usedMcpToolNames.has(candidate)) {
|
|
368
|
+
counter += 1;
|
|
369
|
+
candidate = `${hashedName}_${counter}`;
|
|
370
|
+
}
|
|
371
|
+
usedMcpToolNames.add(candidate);
|
|
372
|
+
return candidate;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function createEmptySnapshot(): CursorPiToolBridgeSnapshot {
|
|
376
|
+
return {
|
|
377
|
+
tools: [],
|
|
378
|
+
mcpToolNameToPiToolName: new Map(),
|
|
379
|
+
piToolNameToMcpToolName: new Map(),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function resolveCursorPiToolBridgeEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
384
|
+
const raw = env[CURSOR_PI_TOOL_BRIDGE_ENV]?.trim().toLowerCase();
|
|
385
|
+
if (!raw) return true;
|
|
386
|
+
if (DISABLED_ENV_VALUES.has(raw)) return false;
|
|
387
|
+
if (ENABLED_ENV_VALUES.has(raw)) return true;
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function resolveCursorPiToolBridgeBuiltinsEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
392
|
+
const raw = env[CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV]?.trim().toLowerCase();
|
|
393
|
+
if (!raw) return false;
|
|
394
|
+
if (ENABLED_ENV_VALUES.has(raw)) return true;
|
|
395
|
+
if (DISABLED_ENV_VALUES.has(raw)) return false;
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
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
|
+
|
|
505
|
+
function isOverlappingCursorNativePiToolName(toolName: string): boolean {
|
|
506
|
+
return OVERLAPPING_CURSOR_NATIVE_PI_BUILTIN_TOOL_NAMES.has(toolName);
|
|
507
|
+
}
|
|
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
|
+
|
|
528
|
+
export function buildCursorPiToolBridgeSnapshot(
|
|
529
|
+
pi: CursorPiToolBridgeSnapshotApi,
|
|
530
|
+
options: CursorPiToolBridgeSnapshotOptions = {},
|
|
531
|
+
): CursorPiToolBridgeSnapshot {
|
|
532
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
533
|
+
const allTools = pi.getAllTools();
|
|
534
|
+
const usedMcpToolNames = new Set<string>();
|
|
535
|
+
const mcpToolNameToPiToolName = new Map<string, string>();
|
|
536
|
+
const piToolNameToMcpToolName = new Map<string, string>();
|
|
537
|
+
const tools: CursorPiBridgeToolDefinition[] = [];
|
|
538
|
+
|
|
539
|
+
const exposeOverlappingBuiltins = options.exposeOverlappingBuiltins === true;
|
|
540
|
+
|
|
541
|
+
for (const tool of allTools) {
|
|
542
|
+
if (!activeToolNames.has(tool.name)) continue;
|
|
543
|
+
if (isExcludedFromCursorBridgeExposure(tool.name)) continue;
|
|
544
|
+
if (!exposeOverlappingBuiltins && isOverlappingCursorNativePiToolName(tool.name)) continue;
|
|
545
|
+
|
|
546
|
+
const mcpToolName = createMcpToolName(tool.name, usedMcpToolNames);
|
|
547
|
+
const description = tool.description || `Run pi tool ${tool.name}`;
|
|
548
|
+
mcpToolNameToPiToolName.set(mcpToolName, tool.name);
|
|
549
|
+
piToolNameToMcpToolName.set(tool.name, mcpToolName);
|
|
550
|
+
tools.push({
|
|
551
|
+
piToolName: tool.name,
|
|
552
|
+
mcpToolName,
|
|
553
|
+
description,
|
|
554
|
+
inputSchema: normalizeMcpInputSchema(tool.parameters),
|
|
555
|
+
sourceInfo: tool.sourceInfo,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return { tools, mcpToolNameToPiToolName, piToolNameToMcpToolName };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function snapshotToolToMcpTool(tool: CursorPiBridgeToolDefinition): Tool {
|
|
563
|
+
return {
|
|
564
|
+
name: tool.mcpToolName,
|
|
565
|
+
description: buildCursorPiBridgeMcpToolDescription({
|
|
566
|
+
piToolName: tool.piToolName,
|
|
567
|
+
mcpToolName: tool.mcpToolName,
|
|
568
|
+
piToolDescription: tool.description,
|
|
569
|
+
}),
|
|
570
|
+
inputSchema: tool.inputSchema,
|
|
571
|
+
_meta: { piToolName: tool.piToolName },
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function convertPiContentToMcpContent(content: unknown): CallToolResult["content"] {
|
|
576
|
+
if (!Array.isArray(content)) {
|
|
577
|
+
return [{ type: "text", text: typeof content === "string" ? content : JSON.stringify(content) }];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const mcpContent: CallToolResult["content"] = [];
|
|
581
|
+
for (const block of content) {
|
|
582
|
+
if (!isRecord(block)) continue;
|
|
583
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
584
|
+
mcpContent.push({ type: "text", text: block.text });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
|
|
588
|
+
mcpContent.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
mcpContent.push({ type: "text", text: JSON.stringify(block) });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return mcpContent.length > 0 ? mcpContent : [{ type: "text", text: "" }];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function asToolResultMessage(value: Context["messages"][number]): ToolResultMessage | undefined {
|
|
598
|
+
return value.role === "toolResult" ? value : undefined;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function getStringField(record: Record<string, unknown>, fields: string[]): string | undefined {
|
|
602
|
+
for (const field of fields) {
|
|
603
|
+
const value = record[field];
|
|
604
|
+
if (typeof value === "string" && value) return value;
|
|
605
|
+
}
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function containsKnownMcpToolName(value: unknown, knownMcpToolNames: ReadonlySet<string>, depth = 0): boolean {
|
|
610
|
+
if (depth > 4) return false;
|
|
611
|
+
if (Array.isArray(value)) return value.some((entry) => containsKnownMcpToolName(entry, knownMcpToolNames, depth + 1));
|
|
612
|
+
if (!isRecord(value)) return false;
|
|
613
|
+
|
|
614
|
+
for (const field of ["tool", "toolName", "name", "mcpToolName", "serverToolName"]) {
|
|
615
|
+
const fieldValue = value[field];
|
|
616
|
+
if (typeof fieldValue === "string" && knownMcpToolNames.has(fieldValue)) return true;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
for (const nestedField of ["args", "arguments", "input"]) {
|
|
620
|
+
if (containsKnownMcpToolName(value[nestedField], knownMcpToolNames, depth + 1)) return true;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
|
|
627
|
+
readonly id: string;
|
|
628
|
+
readonly enabled: boolean;
|
|
629
|
+
readonly snapshot: CursorPiToolBridgeSnapshot;
|
|
630
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
631
|
+
|
|
632
|
+
private readonly registry: CursorPiToolBridgeRegistry;
|
|
633
|
+
private readonly env: Record<string, string | undefined>;
|
|
634
|
+
private readonly endpointPath: string;
|
|
635
|
+
private readonly knownMcpToolNames: ReadonlySet<string>;
|
|
636
|
+
private readonly knownCursorMcpCallIds = new Set<string>();
|
|
637
|
+
private readonly queuedRequests: CursorPiBridgeToolRequest[] = [];
|
|
638
|
+
private readonly pendingByPiToolCallId = new Map<string, PendingBridgeCall>();
|
|
639
|
+
private readonly pendingByBridgeCallId = new Map<string, PendingBridgeCall>();
|
|
640
|
+
private readonly pendingByCursorMcpCallId = new Map<string, PendingBridgeCall>();
|
|
641
|
+
private onToolRequest?: (request: CursorPiBridgeToolRequest) => void;
|
|
642
|
+
private liveRunHandlerDetached = false;
|
|
643
|
+
private mcpServer?: McpProtocolServer;
|
|
644
|
+
private mcpTransport?: StreamableHTTPServerTransport;
|
|
645
|
+
private toolCallCounter = 0;
|
|
646
|
+
private disposed = false;
|
|
647
|
+
|
|
648
|
+
constructor(
|
|
649
|
+
registry: CursorPiToolBridgeRegistry,
|
|
650
|
+
env: Record<string, string | undefined>,
|
|
651
|
+
snapshot: CursorPiToolBridgeSnapshot,
|
|
652
|
+
enabled: boolean,
|
|
653
|
+
options: CursorPiToolBridgeRunOptions = {},
|
|
654
|
+
) {
|
|
655
|
+
this.registry = registry;
|
|
656
|
+
this.env = env;
|
|
657
|
+
this.snapshot = snapshot;
|
|
658
|
+
this.enabled = enabled;
|
|
659
|
+
this.onToolRequest = options.onToolRequest;
|
|
660
|
+
this.id = `cursor-pi-bridge-run-${randomUUID()}`;
|
|
661
|
+
this.endpointPath = `${MCP_ENDPOINT_ROOT}/${randomUUID()}/mcp`;
|
|
662
|
+
this.knownMcpToolNames = new Set(snapshot.tools.map((tool) => tool.mcpToolName));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async start(): Promise<void> {
|
|
666
|
+
if (!this.enabled) return;
|
|
667
|
+
await this.createMcpServer();
|
|
668
|
+
const endpointUrl = await this.registry.registerRun(this.endpointPath, this);
|
|
669
|
+
this.mcpServers = { [MCP_SERVER_NAME]: { type: "http", url: endpointUrl } };
|
|
670
|
+
}
|
|
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
|
+
|
|
693
|
+
async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
694
|
+
if (this.disposed || !this.mcpTransport) {
|
|
695
|
+
res.writeHead(410, { "content-type": "application/json" }).end(JSON.stringify({ error: "Cursor pi tool bridge run is disposed" }));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
await this.mcpTransport.handleRequest(req, res);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
takeQueuedToolRequests(): CursorPiBridgeToolRequest[] {
|
|
702
|
+
return this.queuedRequests.splice(0);
|
|
703
|
+
}
|
|
704
|
+
|
|
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) {
|
|
722
|
+
const pending = this.pendingByPiToolCallId.get(toolResult.toolCallId);
|
|
723
|
+
if (!pending || pending.settled) continue;
|
|
724
|
+
this.resolvePending(pending, {
|
|
725
|
+
content: convertPiContentToMcpContent(toolResult.content),
|
|
726
|
+
isError: toolResult.isError || undefined,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
resolveToolResultsFromContext(context: Context): void {
|
|
732
|
+
this.resolveToolResults(context.messages.map(asToolResultMessage).filter((message): message is ToolResultMessage => message !== undefined));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
hasPendingPiToolCallId(piToolCallId: string): boolean {
|
|
736
|
+
return this.pendingByPiToolCallId.has(piToolCallId);
|
|
737
|
+
}
|
|
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
|
+
|
|
746
|
+
isBridgeMcpToolCall(toolCall: unknown): boolean {
|
|
747
|
+
if (!isRecord(toolCall)) return false;
|
|
748
|
+
const toolName = getStringField(toolCall, ["name", "toolName", "mcpToolName"]);
|
|
749
|
+
if (toolName && this.knownMcpToolNames.has(toolName)) return true;
|
|
750
|
+
|
|
751
|
+
const cursorMcpCallId = getStringField(toolCall, ["call_id", "callId", "id", "toolCallId", "requestId"]);
|
|
752
|
+
if (cursorMcpCallId && this.knownCursorMcpCallIds.has(cursorMcpCallId)) return true;
|
|
753
|
+
|
|
754
|
+
if (containsKnownMcpToolName(toolCall, this.knownMcpToolNames)) return true;
|
|
755
|
+
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
cancel(reason: string): void {
|
|
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
|
+
}
|
|
771
|
+
this.queuedRequests.splice(0);
|
|
772
|
+
for (const pending of [...this.pendingByBridgeCallId.values()]) {
|
|
773
|
+
this.rejectPending(pending, error, "cancelled");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async dispose(): Promise<void> {
|
|
778
|
+
if (this.disposed) return;
|
|
779
|
+
this.disposed = true;
|
|
780
|
+
this.cancel("Cursor pi tool bridge run disposed");
|
|
781
|
+
await waitForProtocolFlush();
|
|
782
|
+
await Promise.allSettled([
|
|
783
|
+
this.mcpTransport?.close(),
|
|
784
|
+
this.mcpServer?.close(),
|
|
785
|
+
]);
|
|
786
|
+
await this.registry.unregisterRun(this.endpointPath, this);
|
|
787
|
+
this.emitDiagnostic({
|
|
788
|
+
event: "run_disposed",
|
|
789
|
+
...this.lifecycleDiagnosticFields(),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private async createMcpServer(): Promise<void> {
|
|
794
|
+
const server = new McpProtocolServer(
|
|
795
|
+
{ name: "pi-cursor-sdk-tool-bridge", version: MCP_SERVER_VERSION },
|
|
796
|
+
{ capabilities: { tools: {} } },
|
|
797
|
+
);
|
|
798
|
+
const transport = new StreamableHTTPServerTransport({
|
|
799
|
+
sessionIdGenerator: randomUUID,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
803
|
+
tools: this.snapshot.tools.map(snapshotToolToMcpTool),
|
|
804
|
+
}));
|
|
805
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
806
|
+
return this.enqueueToolRequest(request.params.name, request.params.arguments, String(extra.requestId), extra.signal);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
this.mcpServer = server;
|
|
810
|
+
this.mcpTransport = transport;
|
|
811
|
+
await server.connect(transport);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private enqueueToolRequest(mcpToolName: string, argsValue: unknown, cursorMcpCallId: string, signal?: AbortSignal): Promise<CallToolResult> {
|
|
815
|
+
const piToolName = this.snapshot.mcpToolNameToPiToolName.get(mcpToolName);
|
|
816
|
+
if (!piToolName) {
|
|
817
|
+
return Promise.resolve({
|
|
818
|
+
content: [{ type: "text", text: `Unknown pi bridge tool: ${mcpToolName}` }],
|
|
819
|
+
isError: true,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
if (this.disposed) return Promise.reject(new Error("Cursor pi tool bridge run is disposed"));
|
|
823
|
+
|
|
824
|
+
this.toolCallCounter += 1;
|
|
825
|
+
const bridgeCallId = `${this.id}-bridge-${this.toolCallCounter}`;
|
|
826
|
+
const request: CursorPiBridgeToolRequest = {
|
|
827
|
+
runId: this.id,
|
|
828
|
+
bridgeCallId,
|
|
829
|
+
cursorMcpCallId,
|
|
830
|
+
piToolCallId: `${this.id}-tool-${this.toolCallCounter}`,
|
|
831
|
+
piToolName,
|
|
832
|
+
mcpToolName,
|
|
833
|
+
args: normalizeMcpArgs(argsValue),
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
return new Promise<CallToolResult>((resolve, reject) => {
|
|
837
|
+
const pending: PendingBridgeCall = {
|
|
838
|
+
request,
|
|
839
|
+
resolve,
|
|
840
|
+
reject,
|
|
841
|
+
signal,
|
|
842
|
+
settled: false,
|
|
843
|
+
};
|
|
844
|
+
pending.onAbort = () => {
|
|
845
|
+
this.rejectPending(pending, new Error("Cursor MCP bridge tool request was aborted"), "cancelled");
|
|
846
|
+
};
|
|
847
|
+
if (signal?.aborted) {
|
|
848
|
+
pending.onAbort();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
signal?.addEventListener("abort", pending.onAbort, { once: true });
|
|
852
|
+
this.pendingByPiToolCallId.set(request.piToolCallId, pending);
|
|
853
|
+
this.pendingByBridgeCallId.set(request.bridgeCallId, pending);
|
|
854
|
+
this.pendingByCursorMcpCallId.set(cursorMcpCallId, pending);
|
|
855
|
+
this.knownCursorMcpCallIds.add(cursorMcpCallId);
|
|
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);
|
|
867
|
+
});
|
|
868
|
+
}
|
|
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
|
+
|
|
878
|
+
private resolvePending(pending: PendingBridgeCall, result: CallToolResult): void {
|
|
879
|
+
if (pending.settled) return;
|
|
880
|
+
pending.settled = true;
|
|
881
|
+
this.removePending(pending);
|
|
882
|
+
this.emitRequestResolvedDiagnostic(pending.request, result.isError === true);
|
|
883
|
+
pending.resolve(result);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private rejectPending(pending: PendingBridgeCall, error: Error, kind: "cancelled" | "error" = "error"): void {
|
|
887
|
+
if (pending.settled) return;
|
|
888
|
+
pending.settled = true;
|
|
889
|
+
this.removePending(pending);
|
|
890
|
+
this.emitRequestRejectedDiagnostic(pending.request, kind);
|
|
891
|
+
pending.reject(error);
|
|
892
|
+
}
|
|
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
|
+
|
|
935
|
+
private removePending(pending: PendingBridgeCall): void {
|
|
936
|
+
pending.signal?.removeEventListener("abort", pending.onAbort ?? (() => undefined));
|
|
937
|
+
this.pendingByPiToolCallId.delete(pending.request.piToolCallId);
|
|
938
|
+
this.pendingByBridgeCallId.delete(pending.request.bridgeCallId);
|
|
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);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
|
|
946
|
+
private readonly pi: CursorPiToolBridgeSnapshotApi;
|
|
947
|
+
private readonly env: Record<string, string | undefined>;
|
|
948
|
+
private readonly runs = new Set<CursorPiToolBridgeRunImpl>();
|
|
949
|
+
private readonly routes = new Map<string, CursorPiToolBridgeRunImpl>();
|
|
950
|
+
private httpServer?: HttpServer;
|
|
951
|
+
private listenPromise?: Promise<void>;
|
|
952
|
+
|
|
953
|
+
constructor(
|
|
954
|
+
pi: CursorPiToolBridgeSnapshotApi,
|
|
955
|
+
env: Record<string, string | undefined> = process.env,
|
|
956
|
+
) {
|
|
957
|
+
this.pi = pi;
|
|
958
|
+
this.env = env;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
isEnabled(): boolean {
|
|
962
|
+
return resolveCursorPiToolBridgeEnabled(this.env);
|
|
963
|
+
}
|
|
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
|
+
|
|
973
|
+
async createRun(options: CursorPiToolBridgeRunOptions = {}): Promise<CursorPiToolBridgeRun> {
|
|
974
|
+
const bridgeEnabled = this.isEnabled();
|
|
975
|
+
const snapshot = bridgeEnabled
|
|
976
|
+
? buildCursorPiToolBridgeSnapshot(this.pi, {
|
|
977
|
+
exposeOverlappingBuiltins: resolveCursorPiToolBridgeBuiltinsEnabled(this.env),
|
|
978
|
+
})
|
|
979
|
+
: createEmptySnapshot();
|
|
980
|
+
const run = new CursorPiToolBridgeRunImpl(this, this.env, snapshot, bridgeEnabled && snapshot.tools.length > 0, options);
|
|
981
|
+
this.runs.add(run);
|
|
982
|
+
await run.start();
|
|
983
|
+
run.emitStartDiagnostics(bridgeEnabled);
|
|
984
|
+
return run;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async disposeAll(reason = "Cursor pi tool bridge disposed"): Promise<void> {
|
|
988
|
+
await Promise.all([...this.runs].map(async (run) => {
|
|
989
|
+
run.cancel(reason);
|
|
990
|
+
await run.dispose();
|
|
991
|
+
}));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async registerRun(pathname: string, run: CursorPiToolBridgeRunImpl): Promise<string> {
|
|
995
|
+
await this.ensureHttpServer();
|
|
996
|
+
this.routes.set(pathname, run);
|
|
997
|
+
const address = this.getHttpServerAddress();
|
|
998
|
+
if (!address) throw new Error("Cursor pi tool bridge HTTP server is not listening");
|
|
999
|
+
return `http://${LOOPBACK_HOST}:${address.port}${pathname}`;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async unregisterRun(pathname: string, run: CursorPiToolBridgeRunImpl): Promise<void> {
|
|
1003
|
+
if (this.routes.get(pathname) === run) this.routes.delete(pathname);
|
|
1004
|
+
this.runs.delete(run);
|
|
1005
|
+
if (this.routes.size === 0) await this.closeHttpServer();
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
getHttpServerAddress(): AddressInfo | undefined {
|
|
1009
|
+
const address = this.httpServer?.address();
|
|
1010
|
+
return isRecord(address) && typeof address.port === "number" ? address as AddressInfo : undefined;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
getEndpointCount(): number {
|
|
1014
|
+
return this.routes.size;
|
|
1015
|
+
}
|
|
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
|
+
|
|
1031
|
+
private async ensureHttpServer(): Promise<void> {
|
|
1032
|
+
if (this.httpServer) {
|
|
1033
|
+
await this.listenPromise;
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const server = createServer((req, res) => {
|
|
1038
|
+
void this.handleHttpRequest(req, res);
|
|
1039
|
+
});
|
|
1040
|
+
this.httpServer = server;
|
|
1041
|
+
this.listenPromise = new Promise<void>((resolve, reject) => {
|
|
1042
|
+
const onError = (error: Error) => {
|
|
1043
|
+
server.off("listening", onListening);
|
|
1044
|
+
reject(error);
|
|
1045
|
+
};
|
|
1046
|
+
const onListening = () => {
|
|
1047
|
+
server.off("error", onError);
|
|
1048
|
+
resolve();
|
|
1049
|
+
};
|
|
1050
|
+
server.once("error", onError);
|
|
1051
|
+
server.once("listening", onListening);
|
|
1052
|
+
server.listen(0, LOOPBACK_HOST);
|
|
1053
|
+
});
|
|
1054
|
+
await this.listenPromise;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private async closeHttpServer(): Promise<void> {
|
|
1058
|
+
const server = this.httpServer;
|
|
1059
|
+
if (!server) return;
|
|
1060
|
+
this.httpServer = undefined;
|
|
1061
|
+
this.listenPromise = undefined;
|
|
1062
|
+
await new Promise<void>((resolve, reject) => {
|
|
1063
|
+
let settled = false;
|
|
1064
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1065
|
+
const settle = (error?: Error): void => {
|
|
1066
|
+
if (settled) return;
|
|
1067
|
+
settled = true;
|
|
1068
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
1069
|
+
if (error) reject(error);
|
|
1070
|
+
else resolve();
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
closeTimer = setTimeout(() => settle(), HTTP_SERVER_CLOSE_GRACE_MS);
|
|
1074
|
+
closeTimer.unref?.();
|
|
1075
|
+
|
|
1076
|
+
server.close((error) => {
|
|
1077
|
+
settle(error ?? undefined);
|
|
1078
|
+
});
|
|
1079
|
+
server.closeIdleConnections();
|
|
1080
|
+
server.closeAllConnections();
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
1085
|
+
if (req.socket.localAddress !== LOOPBACK_HOST) {
|
|
1086
|
+
res.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: "Cursor pi tool bridge only accepts loopback requests" }));
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
1091
|
+
const run = this.routes.get(url.pathname);
|
|
1092
|
+
if (!run) {
|
|
1093
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "Cursor pi tool bridge endpoint not found" }));
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
await run.handleHttpRequest(req, res);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
if (!res.headersSent) {
|
|
1101
|
+
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
let registeredCursorPiToolBridge: CursorPiToolBridgeRegistry | undefined;
|
|
1108
|
+
|
|
1109
|
+
export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
|
|
1110
|
+
bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
|
|
1111
|
+
void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
|
|
1112
|
+
const bridge = new CursorPiToolBridgeRegistry(pi);
|
|
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
|
+
});
|
|
1131
|
+
pi.on("session_shutdown", async (event) => {
|
|
1132
|
+
const reason = `Cursor pi tool bridge session shutdown: ${event.reason}`;
|
|
1133
|
+
bridgeToolExecutionAbortTracker.abortAll(reason);
|
|
1134
|
+
await bridge.disposeAll(reason);
|
|
1135
|
+
});
|
|
1136
|
+
return bridge;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
export function getRegisteredCursorPiToolBridge(): CursorPiToolBridge | undefined {
|
|
1140
|
+
return registeredCursorPiToolBridge;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export const __testUtils = {
|
|
1144
|
+
CURSOR_PI_TOOL_BRIDGE_ENV,
|
|
1145
|
+
CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV,
|
|
1146
|
+
CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV,
|
|
1147
|
+
CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX,
|
|
1148
|
+
LOOPBACK_HOST,
|
|
1149
|
+
MCP_SERVER_NAME,
|
|
1150
|
+
createRegistry(
|
|
1151
|
+
pi: CursorPiToolBridgeSnapshotApi,
|
|
1152
|
+
env: Record<string, string | undefined> = process.env,
|
|
1153
|
+
) {
|
|
1154
|
+
return new CursorPiToolBridgeRegistry(pi, env);
|
|
1155
|
+
},
|
|
1156
|
+
getRegisteredBridgeForTests() {
|
|
1157
|
+
return registeredCursorPiToolBridge;
|
|
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
|
+
},
|
|
1168
|
+
resetRegisteredBridgeForTests() {
|
|
1169
|
+
bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge test reset");
|
|
1170
|
+
const bridge = registeredCursorPiToolBridge;
|
|
1171
|
+
registeredCursorPiToolBridge = undefined;
|
|
1172
|
+
return bridge?.disposeAll("Cursor pi tool bridge test reset") ?? Promise.resolve();
|
|
1173
|
+
},
|
|
1174
|
+
};
|