diff --git a/README.md b/README.md index 345e100..34485f9 100644 --- a/README.md +++ b/README.md @@ -474,6 +474,9 @@ PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1 pi --model cursor/composer-2-5 PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS=7200 pi --model cursor/composer-2-5 PI_CURSOR_MCP_TOOL_TIMEOUT_MS=7200000 pi --model cursor/composer-2-5 +# Fail a stranded pi bridge CallTool sooner than the effective MCP tool timeout. +PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS=120000 pi --model cursor/composer-2-5 + # Override known MCP initialize/listTools timeouts on first send (default 10s). PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS=5 pi --model cursor/composer-2-5 PI_CURSOR_MCP_CONNECT_TIMEOUT_MS=5000 pi --model cursor/composer-2-5 @@ -492,7 +495,7 @@ PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1 pi --model cursor/composer-2-5 On bootstrap sends, a compact **callable tool surfaces** block is injected into the Cursor prompt by default. It reminds the model that Cursor host/configured MCP tools are controlled by Cursor, while pi tool toggles only affect pi tools/bridge exposure; when bridge tools are exposed, it lists the current `pi__*` names. Disable with `PI_CURSOR_TOOL_MANIFEST=0`. -`PI_CURSOR_ASK_QUESTION=0` disables only `cursor_ask_question`, leaving the rest of the pi bridge available; it is enabled by default. `PI_CURSOR_PI_TOOL_BRIDGE=0` is the supported rollback flag and disables the bridge entirely. Both flags treat `false`, `off`, `none`, `no`, and `disabled` as off; `1`, `true`, `on`, `yes`, and `enabled` as on. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for. The installed Cursor SDK uses a 60-second MCP protocol default with no public per-server timeout option. pi-cursor-sdk overrides that seam in two directions by default: MCP `callTool` requests are extended to 3600 seconds for long-running local MCP tools (including the pi bridge and configured Cursor MCP servers), and known MCP initialize/listTools requests on first send are shortened to 10 seconds so unavailable configured MCP servers fail fast instead of blocking for a full minute. Unknown Cursor SDK MCP protocol timeout stacks keep the SDK default instead of being shortened. Override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and first-send initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. +`PI_CURSOR_ASK_QUESTION=0` disables only `cursor_ask_question`, leaving the rest of the pi bridge available; it is enabled by default. `PI_CURSOR_PI_TOOL_BRIDGE=0` is the supported rollback flag and disables the bridge entirely. Both flags treat `false`, `off`, `none`, `no`, and `disabled` as off; `1`, `true`, `on`, `yes`, and `enabled` as on. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for. The installed Cursor SDK uses a 60-second MCP protocol default with no public per-server timeout option. pi-cursor-sdk overrides that seam in two directions by default: MCP `callTool` requests are extended to 3600 seconds for long-running local MCP tools (including the pi bridge and configured Cursor MCP servers), and known MCP initialize/listTools requests on first send are shortened to 10 seconds so unavailable configured MCP servers fail fast instead of blocking for a full minute. Unknown Cursor SDK MCP protocol timeout stacks keep the SDK default instead of being shortened. Override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and first-send initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. Bridged calls also have a local fail-closed deadline that defaults to the effective MCP tool timeout; lower it with `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` when a lost pi result should fail sooner. On expiry, the bridge rejects and removes the pending call and aborts active pi execution when available. The bridge's `listTools` handler returns its snapshot synchronously, so a Cursor UI label such as `GetMcpTools` does not by itself identify a `listTools` deadlock; the durable bridge waiter is `CallTool` awaiting its matching pi result. `PI_CURSOR_HTTP_1_1=true` maps to the Cursor SDK `Cursor.configure({ local: { useHttp1ForAgent: true } })` compatibility mode for corporate VPN/proxy environments where HTTP/2 streams fail. In interactive sessions, `/cursor-http on`, `/cursor-http off`, and `/cursor-http toggle` set the branch-scoped session preference and save the user default as `local.useHttp1ForAgent` in `~/.pi/agent/cursor-sdk.json`; `/cursor-http` with no argument reports the effective state. Precedence is session command/history, explicit `PI_CURSOR_HTTP_1_1`, user config, then the built-in unset default; project config is ignored for this user-level compatibility choice. Unset performs no SDK configuration, preserving the existing default path. Session shutdown clears extension-owned SDK transport state before module reload. Changing the effective setting splits the local agent pool so an agent created under another transport is not reused. When enabled, the local Cursor footer shows `http1` (for example `cursor:local · fast:on · http1`); cloud status never does. This affects Cursor SDK local-agent backend streams only; it does not configure HTTP proxies, TLS certificates, or HTTP/3. @@ -644,6 +647,12 @@ PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS=7200 pi --model cursor/composer-2-5 PI_CURSOR_MCP_TOOL_TIMEOUT_MS=7200000 pi --model cursor/composer-2-5 ``` +A bridged pi call additionally uses a local deadline capped by that effective MCP timeout. To fail a stranded bridge call sooner without shortening other MCP servers: + +```bash +PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS=120000 pi --model cursor/composer-2-5 +``` + ### Tool calls appear as a plain text list instead of pi tool cards This usually needs session JSONL to classify. Common cases: diff --git a/docs/cursor-model-ux-spec.md b/docs/cursor-model-ux-spec.md index abab60e..eb69937 100644 --- a/docs/cursor-model-ux-spec.md +++ b/docs/cursor-model-ux-spec.md @@ -28,7 +28,7 @@ Current implementation notes: - Prompt text is the primary provider/bridge contract. Bootstrap prompts carry a short boundary block plus the callable-surface manifest by default (`PI_CURSOR_TOOL_MANIFEST=1`). MCP `listTools` descriptions use a one-line pointer to the bootstrap prompt instead of repeating the full contract (`buildCursorPiBridgeMcpToolDescription()`). Cursor must call the exposed `pi__*` MCP name, not the real pi tool name shown in pi history or transcripts. When exposed, `pi__mcp` takes preference over Cursor-configured MCP for MCP work and `pi__subagent` takes preference over Cursor-native subagents for delegation; the Cursor-native surfaces remain fallbacks when the matching pi bridge tool is absent or unavailable. Pi emits and executes the real pi tool name. Maintainer debug: `/cursor-tools` prints bridge/manifest enablement, effective `PI_CURSOR_SETTING_SOURCES`, and the current callable-surface snapshot. - The provider also registers `cursor_ask_question` for Cursor models when the bridge and default-on `PI_CURSOR_ASK_QUESTION` control are enabled. Cursor sees it as `pi__cursor_ask_question`, and pi executes it through the normal tool path so interactive users can choose options from pi UI. `PI_CURSOR_ASK_QUESTION=0` removes only this tool while preserving the rest of the bridge. In non-UI modes it reports that UI is unavailable so Cursor can state a default assumption instead. When pi has visible Agent Skills loaded, the provider rewrites the skill catalog for Cursor and registers `cursor_activate_skill` as `pi__cursor_activate_skill`; pi executes it through the normal tool path so Cursor can load the full `SKILL.md` and skill resource list for the current pi-loaded skill source of truth. `PI_CURSOR_PI_TOOL_BRIDGE=0` disables the local bridge, including question and skill activation bridging. Cloud Cursor agents remain out of scope for the bridge. - The bridge queues MCP calls, emits provider `toolcall_*` events, waits for matching pi `toolResult` messages by `toolCallId`, resolves the result back into the same live Cursor SDK run without creating a new `Agent`, and never calls tool `execute()` handlers directly. The same-run resume invariant holds unless the run was disposed, aborted, or cancelled. -- Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.23 still has a 60-second MCP request default with no public per-server timeout option. The extension extends the verified Cursor SDK MCP `callTool` timeout path to 3600 seconds by default and shortens the verified first-send MCP initialize/listTools timeout paths to 10 seconds by default so unavailable configured MCP servers do not block the first reply for a full minute; unknown MCP protocol timeout stacks keep the SDK default. Users can override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. +- Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.23 still has a 60-second MCP request default with no public per-server timeout option. The extension extends the verified Cursor SDK MCP `callTool` timeout path to 3600 seconds by default and shortens the verified first-send MCP initialize/listTools timeout paths to 10 seconds by default so unavailable configured MCP servers do not block the first reply for a full minute; unknown MCP protocol timeout stacks keep the SDK default. Users can override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. Bridged `CallTool` waits also have a local fail-closed deadline that defaults to and cannot exceed the effective MCP tool timeout; `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` can lower it, expiry or MCP cancellation aborts active pi execution when available, and expired bridge events are dropped before pi tool emission. - Cursor SDK local safety controls are off by default. `--cursor-auto-review` / `PI_CURSOR_AUTO_REVIEW` and `--cursor-sandbox` / `PI_CURSOR_SANDBOX` pass only explicit enabled values into `Agent.create({ local })`; user or trusted project config can set `local.autoReview` and `local.sandboxOptions.enabled`; project config is active only when Pi's project-trust flow reached the extension and approved the project or the run used explicit `--approve`, and project saves require the same immutable trust provenance rather than creating Pi trust resources automatically. Pi 0.80.9 loads `pi install -l` project-local extensions after the trust event, so those installs require `--approve` on every run that reads or writes `.pi/cursor-sdk.json`. Explicit runtime, fast-default, and HTTP transport saves preserve unrecognized config fields, reject malformed or non-object JSON without rewriting it, and use one lock-protected read-modify-write path; fast saves mutate only the selected model key. Because Pi can mutate its in-memory session branch before a journal append throws, a completed global save is authoritative and the command reports the partial journal failure instead of attempting an ambiguous rollback; the new global value stays authoritative over stale branch entries until a later successful save or session restart. - Local HTTP/1.1/SSE compatibility is strictly opt-in through `PI_CURSOR_HTTP_1_1`, `/cursor-http on|off|toggle`, or user `cursor-sdk.json` `local.useHttp1ForAgent`. Precedence is session, environment, user, then the built-in unset default; project config is excluded. Unset makes no `Cursor.configure()` call. Explicit values configure the installed SDK before local `Agent.create()`, extension-owned explicit state is cleared with the SDK's documented `null` reset when returning to unset and during session shutdown before module reload, and default/HTTP2/HTTP1 choices split pooled local agents. Pi's supported CLI/TUI/print/RPC lifecycle has one active session runtime per process; concurrent independent `AgentSession` embedding in one process is outside this transport toggle's contract because the installed SDK setting and executor cache are module-global. The footer adds `http1` only for enabled local runtime; cloud creation and status remain untouched. - Bridge diagnostics are opt-in only: `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` writes typed, allowlisted, scrubbed single-line JSONL records to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`. Diagnostics are scrubbed operational logs, not anonymous telemetry. They intentionally include tool names, safe correlation IDs, run lifecycle, exposed pi↔MCP name pairs, queued requests, result resolution, rejection, cancellation, and pending counts. Correlation IDs are generated independently from the tokenized endpoint path, and Cursor MCP call IDs are hashed before serialization. Diagnostics must not include endpoint paths/URLs/path components/tokens, API keys, bearer tokens, cookies, session credentials, raw args/results, stdout/stderr payloads, file contents, Cursor settings output, or local private session paths in tracked docs, and they must not call pi UI status, notification, or footer APIs. If tool names themselves are unacceptable for a release target, bridge debug diagnostics are not safe for shared logs under the current contract. diff --git a/docs/cursor-native-tool-replay.md b/docs/cursor-native-tool-replay.md index 8f5f7f0..cf0f32b 100644 --- a/docs/cursor-native-tool-replay.md +++ b/docs/cursor-native-tool-replay.md @@ -33,12 +33,13 @@ PI_CURSOR_PI_TOOL_BRIDGE=0 pi --model cursor/composer-2-5 PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1 pi --model cursor/composer-2-5 PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS=7200 pi --model cursor/composer-2-5 PI_CURSOR_MCP_TOOL_TIMEOUT_MS=7200000 pi --model cursor/composer-2-5 +PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS=120000 pi --model cursor/composer-2-5 PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS=5 pi --model cursor/composer-2-5 PI_CURSOR_MCP_CONNECT_TIMEOUT_MS=5000 pi --model cursor/composer-2-5 PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1 pi --model cursor/composer-2-5 ``` -`PI_CURSOR_ASK_QUESTION=0` disables only `cursor_ask_question` / `pi__cursor_ask_question`, leaving the rest of the pi bridge available; it is enabled by default. `PI_CURSOR_PI_TOOL_BRIDGE=0` disables the bridge, including `pi__cursor_ask_question`. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for (`read`, `bash`, `write`, `edit`, `grep`, `find`, and `ls`). By default those names are hidden even when pi's Cursor replay wrapper has registered them as extension tools; non-overlapping active built-ins remain bridgeable by default. The installed Cursor SDK uses a 60-second MCP protocol default; pi-cursor-sdk overrides that seam by default with 3600 seconds for MCP `callTool` requests and 10 seconds for verified initialize/listTools requests on first send. Unknown MCP protocol timeout stacks keep the SDK default. `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` emits typed, allowlisted, scrubbed single-line JSONL bridge diagnostics to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`; it is off by default, uses run-safe IDs that are not reused in endpoint paths, and does not print endpoint URLs/path components/tokens, raw args/results, file contents, or secrets. Cursor-native tools, Cursor settings, plugins, and configured Cursor MCP servers still come from the Cursor SDK local agent path. Cloud Cursor agents are out of scope for this bridge. +`PI_CURSOR_ASK_QUESTION=0` disables only `cursor_ask_question` / `pi__cursor_ask_question`, leaving the rest of the pi bridge available; it is enabled by default. `PI_CURSOR_PI_TOOL_BRIDGE=0` disables the bridge, including `pi__cursor_ask_question`. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for (`read`, `bash`, `write`, `edit`, `grep`, `find`, and `ls`). By default those names are hidden even when pi's Cursor replay wrapper has registered them as extension tools; non-overlapping active built-ins remain bridgeable by default. The installed Cursor SDK uses a 60-second MCP protocol default; pi-cursor-sdk overrides that seam by default with 3600 seconds for MCP `callTool` requests and 10 seconds for verified initialize/listTools requests on first send. Bridged calls also have a local fail-closed deadline capped by the effective MCP tool timeout; lower it with `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` to reject stale pending state and abort active pi execution sooner. Unknown MCP protocol timeout stacks keep the SDK default. `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` emits typed, allowlisted, scrubbed single-line JSONL bridge diagnostics to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`; it is off by default, uses run-safe IDs that are not reused in endpoint paths, and does not print endpoint URLs/path components/tokens, raw args/results, file contents, or secrets. Cursor-native tools, Cursor settings, plugins, and configured Cursor MCP servers still come from the Cursor SDK local agent path. Cloud Cursor agents are out of scope for this bridge. ## What gets replayed diff --git a/src/cursor-live-run-coordinator.ts b/src/cursor-live-run-coordinator.ts index db7a57f..9524c9e 100644 --- a/src/cursor-live-run-coordinator.ts +++ b/src/cursor-live-run-coordinator.ts @@ -105,6 +105,11 @@ export interface CursorLiveRunCoordinator { type CursorLiveBridgeMatcher = Pick; +function isPendingBridgeToolRequest(run: CursorLiveRun, request: CursorPiBridgeToolRequest): boolean { + const bridgeRun = [run.bridgeRun, run.sessionBridgeRun].find((candidate) => candidate?.id === request.runId); + return bridgeRun?.hasPendingPiToolCallId(request.piToolCallId) === true; +} + export interface CursorLiveRunRecord { id: string; disposed: boolean; @@ -384,7 +389,9 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep const requests: CursorPiBridgeToolRequest[] = []; while (run.pendingEvents[0]?.type === "bridge-tool") { const event = run.pendingEvents.shift(); - if (event?.type === "bridge-tool") requests.push(event.request); + if (event?.type === "bridge-tool" && isPendingBridgeToolRequest(run, event.request)) { + requests.push(event.request); + } } return requests; }, diff --git a/src/cursor-pi-tool-bridge-abort.ts b/src/cursor-pi-tool-bridge-abort.ts index 1a051f0..9143a10 100644 --- a/src/cursor-pi-tool-bridge-abort.ts +++ b/src/cursor-pi-tool-bridge-abort.ts @@ -54,11 +54,18 @@ class CursorPiToolBridgeToolExecutionAbortTracker { for (const toolCallId of [...this.activeExecutions.keys()]) this.finish(toolCallId); } + abort(toolCallId: string, reason: string): boolean { + const execution = this.activeExecutions.get(toolCallId); + if (!execution) return false; + this.cancelExecution(execution, reason); + this.abortExecution(execution); + this.finish(toolCallId); + return true; + } + abortAll(reason: string): void { for (const execution of [...this.activeExecutions.values()]) { - this.cancelExecution(execution, reason); - this.abortExecution(execution); - this.finish(execution.toolCallId); + this.abort(execution.toolCallId, reason); } } diff --git a/src/cursor-pi-tool-bridge-constants.ts b/src/cursor-pi-tool-bridge-constants.ts index 11b9587..6d70712 100644 --- a/src/cursor-pi-tool-bridge-constants.ts +++ b/src/cursor-pi-tool-bridge-constants.ts @@ -1,2 +1,8 @@ export const MCP_SERVER_NAME = "pi_tools"; export const MCP_ENDPOINT_ROOT = "/cursor-pi-tool-bridge"; + +const CURSOR_PI_BRIDGE_TOOL_CALL_ID_PATTERN = /^cursor-pi-bridge-run-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}-tool-\d+$/i; + +export function isCursorPiBridgeToolCallId(toolCallId: string): boolean { + return CURSOR_PI_BRIDGE_TOOL_CALL_ID_PATTERN.test(toolCallId); +} diff --git a/src/cursor-pi-tool-bridge-env.ts b/src/cursor-pi-tool-bridge-env.ts index c09445f..6771565 100644 --- a/src/cursor-pi-tool-bridge-env.ts +++ b/src/cursor-pi-tool-bridge-env.ts @@ -1,7 +1,9 @@ import { parseEnvBoolean } from "./cursor-env-boolean.js"; +import { resolveCursorMcpToolTimeoutMs } from "./cursor-mcp-timeout-override.js"; export const CURSOR_PI_TOOL_BRIDGE_ENV = "PI_CURSOR_PI_TOOL_BRIDGE"; export const CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV = "PI_CURSOR_EXPOSE_BUILTIN_TOOLS"; +export const CURSOR_PI_TOOL_BRIDGE_CALL_TIMEOUT_MS_ENV = "PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS"; export function resolveCursorPiToolBridgeEnabled(env: Record = process.env): boolean { return parseEnvBoolean(env[CURSOR_PI_TOOL_BRIDGE_ENV], true); @@ -10,3 +12,10 @@ export function resolveCursorPiToolBridgeEnabled(env: Record = process.env): boolean { return parseEnvBoolean(env[CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV], false); } + +export function resolveCursorPiToolBridgeCallTimeoutMs(env: Record = process.env): number { + const mcpToolTimeoutMs = resolveCursorMcpToolTimeoutMs(env); + const parsed = Number(env[CURSOR_PI_TOOL_BRIDGE_CALL_TIMEOUT_MS_ENV]?.trim()); + if (!Number.isFinite(parsed) || parsed <= 0) return mcpToolTimeoutMs; + return Math.min(Math.max(Math.trunc(parsed), 1), mcpToolTimeoutMs); +} diff --git a/src/cursor-pi-tool-bridge-run.ts b/src/cursor-pi-tool-bridge-run.ts index 1198bc7..4145965 100644 --- a/src/cursor-pi-tool-bridge-run.ts +++ b/src/cursor-pi-tool-bridge-run.ts @@ -9,6 +9,7 @@ import { ListToolsRequestSchema, type CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; +import { bridgeToolExecutionAbortTracker } from "./cursor-pi-tool-bridge-abort.js"; import { MCP_ENDPOINT_ROOT, MCP_SERVER_NAME } from "./cursor-pi-tool-bridge-constants.js"; import { type CursorPiToolBridgeDiagnosticEvent, @@ -17,6 +18,7 @@ import { type CursorPiToolBridgeRequestDiagnosticFields, writeCursorPiToolBridgeDiagnostic, } from "./cursor-pi-tool-bridge-diagnostics.js"; +import { resolveCursorPiToolBridgeCallTimeoutMs } from "./cursor-pi-tool-bridge-env.js"; import type { CursorPiBridgeToolRequest, CursorPiToolBridgeRun, @@ -46,6 +48,7 @@ interface PendingBridgeCall { reject: (error: Error) => void; signal?: AbortSignal; onAbort?: () => void; + timeout?: ReturnType; settled: boolean; } @@ -58,6 +61,7 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { private readonly registry: CursorPiToolBridgeRunHost; private readonly env: Record; private readonly endpointPath: string; + private readonly callTimeoutMs: number; private readonly knownMcpToolNames: ReadonlySet; private readonly knownCursorMcpCallIds = new Set(); private readonly queuedRequests: CursorPiBridgeToolRequest[] = []; @@ -87,6 +91,7 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { this.debugRecorder = options.debugRecorder; this.id = `cursor-pi-bridge-run-${randomUUID()}`; this.endpointPath = `${MCP_ENDPOINT_ROOT}/${randomUUID()}/mcp`; + this.callTimeoutMs = resolveCursorPiToolBridgeCallTimeoutMs(env); this.knownMcpToolNames = new Set(snapshot.tools.map((tool) => tool.mcpToolName)); } @@ -218,7 +223,7 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { } this.queuedRequests.splice(0); for (const pending of [...this.pendingByBridgeCallId.values()]) { - this.rejectPending(pending, error, "cancelled"); + this.rejectAndAbortPending(pending, error, "cancelled"); } } @@ -290,7 +295,7 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { settled: false, }; pending.onAbort = () => { - this.rejectPending(pending, new Error("Cursor MCP bridge tool request was aborted"), "cancelled"); + this.rejectAndAbortPending(pending, new Error("Cursor MCP bridge tool request was aborted"), "cancelled"); }; if (signal?.aborted) { pending.onAbort(); @@ -301,6 +306,11 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { this.pendingByBridgeCallId.set(request.bridgeCallId, pending); this.pendingByCursorMcpCallId.set(cursorMcpCallId, pending); this.knownCursorMcpCallIds.add(cursorMcpCallId); + pending.timeout = setTimeout(() => { + const reason = `Cursor pi bridge CallTool timed out after ${this.callTimeoutMs} ms`; + this.rejectAndAbortPending(pending, new Error(reason)); + }, this.callTimeoutMs); + pending.timeout.unref?.(); if (!this.onToolRequest) { if (this.liveRunHandlerDetached) { this.rejectPending(pending, new Error("Cursor pi tool bridge has no active live run"), "cancelled"); @@ -345,8 +355,8 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { pending.resolve(result); } - private rejectPending(pending: PendingBridgeCall, error: Error, kind: "cancelled" | "error" = "error"): void { - if (pending.settled) return; + private rejectPending(pending: PendingBridgeCall, error: Error, kind: "cancelled" | "error" = "error"): boolean { + if (pending.settled) return false; pending.settled = true; this.removePending(pending); this.emitRequestRejectedDiagnostic(pending.request, kind); @@ -357,6 +367,17 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { rejectionKind: kind, }); pending.reject(error); + return true; + } + + private rejectAndAbortPending( + pending: PendingBridgeCall, + error: Error, + kind: "cancelled" | "error" = "error", + ): void { + if (this.rejectPending(pending, error, kind)) { + bridgeToolExecutionAbortTracker.abort(pending.request.piToolCallId, error.message); + } } private lifecycleDiagnosticFields(pendingCount = this.pendingCount()): CursorPiToolBridgeLifecycleDiagnosticFields { @@ -401,7 +422,8 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun { } private removePending(pending: PendingBridgeCall): void { - pending.signal?.removeEventListener("abort", pending.onAbort ?? (() => undefined)); + if (pending.onAbort) pending.signal?.removeEventListener("abort", pending.onAbort); + if (pending.timeout) clearTimeout(pending.timeout); this.pendingByPiToolCallId.delete(pending.request.piToolCallId); this.pendingByBridgeCallId.delete(pending.request.bridgeCallId); if (pending.request.cursorMcpCallId) this.pendingByCursorMcpCallId.delete(pending.request.cursorMcpCallId); diff --git a/src/cursor-pi-tool-bridge.ts b/src/cursor-pi-tool-bridge.ts index fa8453d..88ed34f 100644 --- a/src/cursor-pi-tool-bridge.ts +++ b/src/cursor-pi-tool-bridge.ts @@ -7,10 +7,11 @@ import { } from "./cursor-pi-tool-bridge-diagnostics.js"; import { CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV, + CURSOR_PI_TOOL_BRIDGE_CALL_TIMEOUT_MS_ENV, CURSOR_PI_TOOL_BRIDGE_ENV, } from "./cursor-pi-tool-bridge-env.js"; import { bridgeToolExecutionAbortTracker } from "./cursor-pi-tool-bridge-abort.js"; -import { MCP_SERVER_NAME } from "./cursor-pi-tool-bridge-constants.js"; +import { isCursorPiBridgeToolCallId, MCP_SERVER_NAME } from "./cursor-pi-tool-bridge-constants.js"; import { LOOPBACK_HOST, CursorPiToolBridgeRegistry } from "./cursor-pi-tool-bridge-server.js"; import type { CursorPiToolBridge, @@ -34,8 +35,10 @@ export type { CursorPiToolBridgeDiagnosticEvent } from "./cursor-pi-tool-bridge- export { resolveCursorPiToolBridgeDebugEnabled } from "./cursor-pi-tool-bridge-diagnostics.js"; export { CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV, + CURSOR_PI_TOOL_BRIDGE_CALL_TIMEOUT_MS_ENV, CURSOR_PI_TOOL_BRIDGE_ENV, resolveCursorPiToolBridgeBuiltinsEnabled, + resolveCursorPiToolBridgeCallTimeoutMs, resolveCursorPiToolBridgeEnabled, } from "./cursor-pi-tool-bridge-env.js"; export { @@ -91,7 +94,11 @@ export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): const bridge = new CursorPiToolBridgeRegistry(pi); registeredCursorPiToolBridge = bridge; pi.on("tool_call", (event, ctx) => { - if (!bridge.hasPendingPiToolCallId(event.toolCallId)) return undefined; + if (!bridge.hasPendingPiToolCallId(event.toolCallId)) { + return isCursorPiBridgeToolCallId(event.toolCallId) + ? { block: true, reason: "Cursor pi bridge tool call is no longer pending" } + : undefined; + } const windowsAbortMarker = installWindowsBridgeBashAbortMarker(event); const trackingStarted = bridgeToolExecutionAbortTracker.track(event.toolCallId, { signal: ctx.signal, @@ -124,6 +131,7 @@ export function getRegisteredCursorPiToolBridge(): CursorPiToolBridge | undefine export const __testUtils = { CURSOR_PI_TOOL_BRIDGE_ENV, CURSOR_PI_TOOL_BRIDGE_BUILTINS_ENV, + CURSOR_PI_TOOL_BRIDGE_CALL_TIMEOUT_MS_ENV, CURSOR_PI_TOOL_BRIDGE_DEBUG_ENV, CURSOR_PI_TOOL_BRIDGE_DIAGNOSTIC_PREFIX, LOOPBACK_HOST, diff --git a/src/cursor-provider-live-run-drain.ts b/src/cursor-provider-live-run-drain.ts index ba0d72a..762ac03 100644 --- a/src/cursor-provider-live-run-drain.ts +++ b/src/cursor-provider-live-run-drain.ts @@ -278,9 +278,10 @@ async function emitCursorLiveRunPendingToolUseTurn( if (options.mode === "emit") turn.emitter.closeAll(); emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, active, debugRecorder); } else { + const requests = cursorLiveRuns.collectBridgeToolBatch(run); + if (requests.length === 0) return "handled"; if (!sdkTurnEnded) cursorLiveRuns.ignoreFutureSdkTurnUsage(run); if (options.mode === "emit") turn.emitter.closeAll(); - const requests = cursorLiveRuns.collectBridgeToolBatch(run); emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests); } return "tool_use"; diff --git a/test/cursor-live-run-coordinator.test.ts b/test/cursor-live-run-coordinator.test.ts index e6369a6..401f3ae 100644 --- a/test/cursor-live-run-coordinator.test.ts +++ b/test/cursor-live-run-coordinator.test.ts @@ -1,5 +1,6 @@ import type { SDKAgent } from "@cursor/sdk"; -import { makeContext } from "./helpers/pi-harness.js"; +import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat"; +import { makeAssistantMessage, makeContext, makeModel } from "./helpers/pi-harness.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createCursorLiveRunCoordinator, @@ -8,6 +9,10 @@ import { } from "../src/cursor-live-run-coordinator.js"; import type { CursorNativeToolDisplayItem } from "../src/cursor-native-tool-display-state.js"; import type { CursorPiToolBridgeRun } from "../src/cursor-pi-tool-bridge.js"; +import { + cursorLiveRuns, + drainCursorLiveRunTurn, +} from "../src/cursor-provider-live-run-drain.js"; import { __testUtils as cursorSdkProcessGuardTestUtils } from "../src/cursor-sdk-process-error-guard.js"; function makeAgent(agentId = "agent-1"): SDKAgent { @@ -109,6 +114,67 @@ describe("cursor live run coordinator", () => { expect(coordinator.getPendingFromContext(context, replayIdFromToolCallId)).toBeUndefined(); }); + it("drops bridge events whose pending call expired before tool emission", () => { + const { coordinator } = makeCoordinator(); + const bridgeRun = makeBridgeRun("bridge-1", ["tool-live"]); + const run = startRun(coordinator, { bridgeRun }); + for (const piToolCallId of ["tool-expired", "tool-live"]) { + coordinator.queueEvent(run, { + type: "bridge-tool", + request: { + runId: bridgeRun.id, + bridgeCallId: `call-${piToolCallId}`, + piToolCallId, + piToolName: "read", + mcpToolName: "pi__read", + args: {}, + }, + }); + } + + expect(coordinator.collectBridgeToolBatch(run).map((request) => request.piToolCallId)).toEqual(["tool-live"]); + expect(run.pendingEvents).toEqual([]); + }); + + it("does not emit an empty tool-use turn when every queued bridge call expired", async () => { + const bridgeRun = makeBridgeRun("bridge-expired"); + const run = cursorLiveRuns.start({ + id: "expired-bridge-drain", + agent: makeAgent(), + bridgeRun, + sessionAgentScopeKey: "expired-bridge-drain-scope", + promptInputTokens: 1, + }); + cursorLiveRuns.queueEvent(run, { + type: "bridge-tool", + request: { + runId: bridgeRun.id, + bridgeCallId: "expired-call", + piToolCallId: "expired-tool", + piToolName: "read", + mcpToolName: "pi__read", + args: {}, + }, + }); + cursorLiveRuns.markFinished(run, "done"); + const stream = createAssistantMessageEventStream(); + const push = vi.spyOn(stream, "push"); + + const outcome = await drainCursorLiveRunTurn( + stream, + makeAssistantMessage(""), + makeModel(), + makeContext(), + run, + 0, + { mode: "emit" }, + ); + + expect(outcome).toBe("stop"); + expect(push.mock.calls.map(([event]) => event.type)).not.toContain("toolcall_start"); + expect(push.mock.calls.some(([event]) => event.type === "done" && event.reason === "toolUse")).toBe(false); + }); + it("indexes active runs per scope without letting an older release clear a newer run", async () => { const { coordinator } = makeCoordinator(); const older = startRun(coordinator, { id: "older", scopeKey: "scope-a" }); diff --git a/test/cursor-pi-tool-bridge-call-timeout.test.ts b/test/cursor-pi-tool-bridge-call-timeout.test.ts new file mode 100644 index 0000000..74275c7 --- /dev/null +++ b/test/cursor-pi-tool-bridge-call-timeout.test.ts @@ -0,0 +1,178 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Type } from "typebox"; +import { + __testUtils, + registerCursorPiToolBridge, + resolveCursorPiToolBridgeCallTimeoutMs, + type CursorPiToolBridgeRun, +} from "../src/cursor-pi-tool-bridge.js"; +import { + createBridgePiHarness, + createBuiltinToolInfo, + getCursorPiBridgeMcpUrl, +} from "./helpers/pi-harness.js"; + +async function waitForQueuedRequest(run: CursorPiToolBridgeRun) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const [request] = run.takeQueuedToolRequests(); + if (request) return request; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Timed out waiting for queued bridge request"); +} + +describe("cursor pi tool bridge CallTool deadline", () => { + afterEach(async () => { + delete process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS; + delete process.env.PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS; + await __testUtils.resetRegisteredBridgeForTests(); + }); + + it("defaults to the effective MCP tool timeout and allows only a lower bridge deadline", () => { + expect(resolveCursorPiToolBridgeCallTimeoutMs({})).toBe(3_600_000); + expect(resolveCursorPiToolBridgeCallTimeoutMs({ PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS: "120000" })).toBe(120_000); + expect(resolveCursorPiToolBridgeCallTimeoutMs({ PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS: "7200000" })).toBe(3_600_000); + expect(resolveCursorPiToolBridgeCallTimeoutMs({ PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS: "invalid" })).toBe(3_600_000); + expect(resolveCursorPiToolBridgeCallTimeoutMs({ + PI_CURSOR_MCP_TOOL_TIMEOUT_MS: "60000", + PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS: "120000", + })).toBe(60_000); + }); + + it("rejects a stranded call, clears pending state, and aborts active pi execution", async () => { + process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS = "1"; + process.env.PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS = "500"; + const pi = createBridgePiHarness({ + active: ["bash"], + tools: [createBuiltinToolInfo("bash", Type.Object({ command: Type.String() }), "Run shell commands")], + }); + const run = await registerCursorPiToolBridge(pi).createRun(); + const client = new Client({ name: "pi-cursor-sdk-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(getCursorPiBridgeMcpUrl(run))); + await client.connect(transport); + try { + const callResult = client.callTool({ name: "pi__bash", arguments: { command: "sleep 30" } }).catch((error: unknown) => error); + const request = await waitForQueuedRequest(run); + const abort = vi.fn(); + await pi.runToolCall( + { type: "tool_call", toolCallId: request.piToolCallId, toolName: "bash", input: request.args }, + { signal: new AbortController().signal, abort }, + ); + + const result = await Promise.race([ + callResult, + new Promise((resolve) => setTimeout(() => resolve("still pending"), 2_000)), + ]); + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toMatch(/timed out.*500 ?ms|MCP error/i); + expect(abort).toHaveBeenCalledOnce(); + expect(__testUtils.getActiveBridgeToolExecutionAbortCount()).toBe(0); + expect(run.hasPendingPiToolCallId(request.piToolCallId)).toBe(false); + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + await run.dispose(); + } + }); + + it("aborts active pi execution when the MCP client cancels CallTool", async () => { + process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS = "1"; + const pi = createBridgePiHarness({ + active: ["bash"], + tools: [createBuiltinToolInfo("bash", Type.Object({ command: Type.String() }), "Run shell commands")], + }); + const run = await registerCursorPiToolBridge(pi).createRun(); + const client = new Client({ name: "pi-cursor-sdk-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(getCursorPiBridgeMcpUrl(run))); + await client.connect(transport); + try { + const clientAbort = new AbortController(); + const callResult = client.callTool( + { name: "pi__bash", arguments: { command: "sleep 30" } }, + undefined, + { signal: clientAbort.signal }, + ).catch((error: unknown) => error); + const request = await waitForQueuedRequest(run); + const abort = vi.fn(); + await pi.runToolCall( + { type: "tool_call", toolCallId: request.piToolCallId, toolName: "bash", input: request.args }, + { signal: new AbortController().signal, abort }, + ); + + clientAbort.abort(); + + expect(await callResult).toBeInstanceOf(Error); + await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce()); + expect(__testUtils.getActiveBridgeToolExecutionAbortCount()).toBe(0); + expect(run.hasPendingPiToolCallId(request.piToolCallId)).toBe(false); + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + await run.dispose(); + } + }); + + it("blocks a bridge tool event that reaches pi after its call expired", async () => { + process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS = "1"; + process.env.PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS = "500"; + const pi = createBridgePiHarness({ + active: ["bash"], + tools: [createBuiltinToolInfo("bash", Type.Object({ command: Type.String() }), "Run shell commands")], + }); + const run = await registerCursorPiToolBridge(pi).createRun(); + const client = new Client({ name: "pi-cursor-sdk-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(getCursorPiBridgeMcpUrl(run))); + await client.connect(transport); + try { + const callResult = client.callTool({ name: "pi__bash", arguments: { command: "sleep 30" } }).catch((error: unknown) => error); + const request = await waitForQueuedRequest(run); + expect(await callResult).toBeInstanceOf(Error); + + const hookResult = await pi.runToolCall({ + type: "tool_call", + toolCallId: request.piToolCallId, + toolName: "bash", + input: request.args, + }); + + expect(hookResult).toEqual({ block: true, reason: "Cursor pi bridge tool call is no longer pending" }); + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + await run.dispose(); + } + }); + + it("aborts active pi execution when its bridge run is cancelled", async () => { + process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS = "1"; + const pi = createBridgePiHarness({ + active: ["bash"], + tools: [createBuiltinToolInfo("bash", Type.Object({ command: Type.String() }), "Run shell commands")], + }); + const run = await registerCursorPiToolBridge(pi).createRun(); + const client = new Client({ name: "pi-cursor-sdk-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(getCursorPiBridgeMcpUrl(run))); + await client.connect(transport); + try { + const callResult = client.callTool({ name: "pi__bash", arguments: { command: "sleep 30" } }).catch((error: unknown) => error); + const request = await waitForQueuedRequest(run); + const abort = vi.fn(); + await pi.runToolCall( + { type: "tool_call", toolCallId: request.piToolCallId, toolName: "bash", input: request.args }, + { signal: new AbortController().signal, abort }, + ); + + run.cancel("cancelled by test"); + + expect(await callResult).toBeInstanceOf(Error); + expect(abort).toHaveBeenCalledOnce(); + expect(__testUtils.getActiveBridgeToolExecutionAbortCount()).toBe(0); + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + await run.dispose(); + } + }); +}); -- 2.51.2 From ab605cc77c1025f4f17582723fcb728a670d6d9b Mon Sep 17 00:00:00 2001 From: Mitch Fultz <57411549+fitchmultz@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:12:23 -0600 Subject: [PATCH 2/2] fix: harden bridge timeout lifecycle --- CHANGELOG.md | 1 + docs/cursor-model-ux-spec.md | 3 +- docs/cursor-tool-surfaces.md | 5 +- src/cursor-pi-tool-bridge.ts | 1 + ...cursor-pi-tool-bridge-call-timeout.test.ts | 54 +++++++++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9a633..c0526b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Initialize `CURSOR_RIPGREP_PATH` from the installed Cursor SDK platform package before local agent creation, including nested npm dependency layouts, so Cursor-native Grep/Glob can use the bundled executable. +- Bound pending pi bridge `CallTool` waits to the effective MCP tool timeout, with a lower-only `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` override; expiry and cancellation remove stale calls and abort active pi execution when available. ### Changed diff --git a/docs/cursor-model-ux-spec.md b/docs/cursor-model-ux-spec.md index eb69937..8034d1e 100644 --- a/docs/cursor-model-ux-spec.md +++ b/docs/cursor-model-ux-spec.md @@ -737,11 +737,12 @@ Before calling done: - confirm requests use selected context, pi thinking, fast flag state, and SDK-native mode 4. Tool bridge and replay: - - `npm test -- test/cursor-pi-tool-bridge.test.ts test/cursor-provider.test.ts test/cursor-mcp-timeout-override.test.ts` + - `npm test -- test/cursor-pi-tool-bridge.test.ts test/cursor-pi-tool-bridge-call-timeout.test.ts test/cursor-provider-bridge-mcp.test.ts test/cursor-live-run-coordinator.test.ts test/cursor-mcp-timeout-override.test.ts` - confirm `Agent.create()` gets `mcpServers.pi_tools` when active pi tools exist and omits it when `PI_CURSOR_PI_TOOL_BRIDGE=0` or the active snapshot is empty - confirm bridged MCP requests emit real pi tool calls and resolve matching pi tool results back to the same live Cursor SDK run without creating a new `Agent`, unless the run was disposed, aborted, or cancelled - confirm bridge MCP activity is suppressed from Cursor replay while non-bridge Cursor MCP activity remains visible - confirm `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` and `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS` override the Cursor SDK MCP callTool timeout seam + - confirm `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` can only lower the bridge deadline; expiry and cancellation clear pending state, abort active pi execution, suppress stale events/empty drain turns, and superseded registration handlers do not block replacement runs - confirm `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` and `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS` override the Cursor SDK MCP initialize/listTools timeout seam while unknown protocol timeout stacks keep the SDK default - confirm `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` emits typed, allowlisted, scrubbed JSONL to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`, omits endpoint URLs/path components/tokens, and unset/false leaves output unchanged - run the visual audit workflow when replay card visuals or bridge card visuals change; JSONL should show real pi tool names for bridged calls and no duplicate MCP replay for bridge calls diff --git a/docs/cursor-tool-surfaces.md b/docs/cursor-tool-surfaces.md index 8ef666d..9d248e7 100644 --- a/docs/cursor-tool-surfaces.md +++ b/docs/cursor-tool-surfaces.md @@ -30,7 +30,7 @@ Default behavior: - The pi bridge exposes **active pi tools** as `pi__*` MCP names when `PI_CURSOR_PI_TOOL_BRIDGE` is enabled (default on). - Overlapping pi builtins (`read`, `bash`, `write`, `edit`, `grep`, `find`, `ls`) are **hidden** from the bridge unless `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1`. -`pi-cursor-sdk` registers `cursor_ask_question` for Cursor models when the bridge is on and `PI_CURSOR_ASK_QUESTION` is enabled (the default); Cursor sees `pi__cursor_ask_question`. Set `PI_CURSOR_ASK_QUESTION=0` to remove only this tool while preserving the rest of the bridge. When pi has visible Agent Skills loaded, the extension also rewrites pi's skill catalog for Cursor and activates `cursor_activate_skill`; Cursor sees `pi__cursor_activate_skill` and should call it with a listed skill name before applying that skill. The activation result returns the full `SKILL.md`, the skill directory for relative paths, and a bounded list of bundled `scripts/`, `references/`, and `assets/` files without eagerly reading those resources. +`pi-cursor-sdk` registers `cursor_ask_question` for Cursor models when the bridge is on and `PI_CURSOR_ASK_QUESTION` is enabled (the default); Cursor sees `pi__cursor_ask_question`. Set `PI_CURSOR_ASK_QUESTION=0` to remove only this tool while preserving the rest of the bridge. Pending bridged calls use a local deadline capped by the effective MCP tool timeout; `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` can lower it. When pi has visible Agent Skills loaded, the extension also rewrites pi's skill catalog for Cursor and activates `cursor_activate_skill`; Cursor sees `pi__cursor_activate_skill` and should call it with a listed skill name before applying that skill. The activation result returns the full `SKILL.md`, the skill directory for relative paths, and a bounded list of bundled `scripts/`, `references/`, and `assets/` files without eagerly reading those resources. ```bash # Disable only Cursor's interactive question tool @@ -42,6 +42,9 @@ PI_CURSOR_PI_TOOL_BRIDGE=0 pi --model cursor/composer-2-5 # Expose overlapping pi builtins through the bridge PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1 pi --model cursor/composer-2-5 +# Fail a stranded bridge call sooner than the effective MCP tool timeout +PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS=120000 pi --model cursor/composer-2-5 + # Disable bootstrap tool manifest PI_CURSOR_TOOL_MANIFEST=0 pi --model cursor/composer-2-5 ``` diff --git a/src/cursor-pi-tool-bridge.ts b/src/cursor-pi-tool-bridge.ts index 88ed34f..4a93e17 100644 --- a/src/cursor-pi-tool-bridge.ts +++ b/src/cursor-pi-tool-bridge.ts @@ -94,6 +94,7 @@ export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): const bridge = new CursorPiToolBridgeRegistry(pi); registeredCursorPiToolBridge = bridge; pi.on("tool_call", (event, ctx) => { + if (registeredCursorPiToolBridge !== bridge) return undefined; if (!bridge.hasPendingPiToolCallId(event.toolCallId)) { return isCursorPiBridgeToolCallId(event.toolCallId) ? { block: true, reason: "Cursor pi bridge tool call is no longer pending" } diff --git a/test/cursor-pi-tool-bridge-call-timeout.test.ts b/test/cursor-pi-tool-bridge-call-timeout.test.ts index 74275c7..f49fd56 100644 --- a/test/cursor-pi-tool-bridge-call-timeout.test.ts +++ b/test/cursor-pi-tool-bridge-call-timeout.test.ts @@ -145,6 +145,60 @@ describe("cursor pi tool bridge CallTool deadline", () => { } }); + it("does not let a superseded tool_call handler block the replacement bridge", async () => { + process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS = "1"; + const pi = createBridgePiHarness({ + active: ["bash"], + tools: [createBuiltinToolInfo("bash", Type.Object({ command: Type.String() }), "Run shell commands")], + }); + registerCursorPiToolBridge(pi); + const run = await registerCursorPiToolBridge(pi).createRun(); + const client = new Client({ name: "pi-cursor-sdk-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(getCursorPiBridgeMcpUrl(run))); + await client.connect(transport); + try { + const callPromise = client.callTool({ name: "pi__bash", arguments: { command: "echo ok" } }); + const request = await waitForQueuedRequest(run); + + const hookResult = await pi.runToolCall( + { + type: "tool_call", + toolCallId: request.piToolCallId, + toolName: "bash", + input: request.args, + }, + { + signal: new AbortController().signal, + abort: vi.fn(), + }, + ); + + expect(hookResult).toBeUndefined(); + expect(run.hasPendingPiToolCallId(request.piToolCallId)).toBe(true); + + await run.resolveToolResultsFromContext({ + systemPrompt: "", + messages: [ + { + role: "toolResult", + toolCallId: request.piToolCallId, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: 1, + }, + ], + }); + await expect(callPromise).resolves.toMatchObject({ + content: [{ type: "text", text: "ok" }], + }); + } finally { + await client.close().catch(() => undefined); + await transport.close().catch(() => undefined); + await run.dispose(); + } + }); + it("aborts active pi execution when its bridge run is cancelled", async () => { process.env.PI_CURSOR_EXPOSE_BUILTIN_TOOLS = "1"; const pi = createBridgePiHarness({