From 0601ee73243c8511a192cd0a7d8673d15f63f62e Mon Sep 17 00:00:00 2001 From: Diogo Soares Rodrigues Date: Mon, 27 Jul 2026 13:23:33 -0300 Subject: [PATCH] fix(cursor): route MCP resource frames and gate native delete `list_mcp_resources` / `read_mcp_resource` answered as though this client hosted no MCP servers - a hardcoded empty catalog and `not_found`. The same session reads those resources through `mcp://` via `MCPManager.getServerResources` / `readServerResource`, so a Cursor model could not see resources its own session was connected to. `CursorExecHandlers` gained `listMcpResources`/`readMcpResource`, the bridge answers them from the manager's live connections, and the no-handler fallback is unchanged. A throwing lookup surfaces as an error: an empty success claims "asked, none exist", which the model cannot retry. The native `delete` frame also bypassed approval. Unlike every other frame it calls `fs.rmSync` directly rather than running a registry tool, so no `ExtensionToolWrapper` sat in front of it, and `allowNativeDelete` only answers whether a mutating tool was granted - not whether the user's policy allows the call. It now resolves the write tier against the session's approval mode and per-tool policies, failing closed on `always-ask`, which this channel cannot prompt in. (cherry picked from commit 44d36d1e8d35b0038d00e0202454d68fbcc53bce) --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/cursor.ts | 89 +++++++++++-- packages/ai/src/types.ts | 36 ++++++ packages/ai/test/cursor-exec-modern.test.ts | 117 ++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/cursor.ts | 91 ++++++++++++++ packages/coding-agent/src/sdk.ts | 7 ++ .../coding-agent/test/cursor-exec.test.ts | 98 +++++++++++++++ 8 files changed, 431 insertions(+), 10 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 726099d0e..0f613b605 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -78,6 +78,7 @@ ### Fixed - Fixed four Cursor exec frames answering with a result whose oneof was never set. In proto3 that is not an empty result — the server reads it as "the tool ran and produced nothing", indistinguishable from real success. `listMcpResourcesExecResult`, `readMcpResourceExecResult`, `recordScreenResult` and `computerUseResult` now send `ListMcpResourcesSuccess{resources: []}`, `ReadMcpResourceNotFound{uri}`, `RecordScreenFailure` and `ComputerUseError` respectively. +- The MCP resource frames now answer from the host instead of a fixed verdict. `CursorExecHandlers` gained `listMcpResources`/`readMcpResource`, so a host holding live MCP connections advertises them; the empty catalog and `not_found` above remain the answer when no handler is supplied. A handler that throws surfaces as `ListMcpResourcesError`/`ReadMcpResourceError` rather than collapsing into "none exist", which the model cannot retry. - Fixed Cursor `connect_scm` calls losing their repository and settling on a fabricated verdict. The target rides in the `ConnectScmArgs.target` oneof, so reading a flat `github` property always saw `undefined`; and the authoritative `success`/`error`/`rejected` result only arrives on the completion frame, so answering at the announcement persisted a fixed failure for every call — including the ones the server went on to accept. The block now opens on the start frame and settles from the completion's decoded result. - Fixed interleaved Cursor tool calls corrupting each other. The stream decoder tracked a single "current" block and settled it on any `toolCallCompleted`, ignoring the envelope's `call_id`: a completion for one call closed whichever block happened to be open and paired it with the wrong result, and `start A, start B` orphaned A entirely so its own completion settled B while A was never paired — which strips the whole interaction from every rebuilt transcript. Open blocks are now retained per envelope `call_id`, and end-of-stream closes all of them rather than only the last. - Fixed a Cursor `search_conversations` call leaving no transcript block. The frame is answered from a fixed verdict, so nothing downstream pairs a result for it, and an unpaired call takes its whole interaction out of every rebuilt transcript. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 2873e1731..19b58f5dc 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -61,6 +61,9 @@ import { GrepUnionResultSchema, KvClientMessageSchema, type KvServerMessage, + ListMcpResourcesErrorSchema, + type ListMcpResourcesExecResult, + ListMcpResourcesExecResult_McpResourceSchema, ListMcpResourcesExecResultSchema, ListMcpResourcesSuccessSchema, type LsDirectoryTreeNode, @@ -82,8 +85,11 @@ import { McpToolResultContentItemSchema, ModelDetailsSchema, ReadErrorSchema, + ReadMcpResourceErrorSchema, + type ReadMcpResourceExecResult, ReadMcpResourceExecResultSchema, ReadMcpResourceNotFoundSchema, + ReadMcpResourceSuccessSchema, ReadRejectedSchema, ReadResultSchema, ReadSuccessSchema, @@ -1535,21 +1541,84 @@ async function handleExecServerMessage( return; } case "listMcpResourcesExecArgs": { - // This client hosts no MCP servers, so it exposes no resources. An - // unset-oneof `ListMcpResourcesExecResult` would read as "the call - // produced nothing"; an explicit empty success says "asked, none exist". - const execResult = create(ListMcpResourcesExecResultSchema, { - result: { case: "success", value: create(ListMcpResourcesSuccessSchema, { resources: [] }) }, - }); + // A host holding live MCP connections answers from them; without a + // handler the honest answer is an explicit empty success. An + // unset-oneof result would read as "the call produced nothing". + const args = execMsg.message.value; + let execResult: ListMcpResourcesExecResult; + try { + const resources = (await execHandlers?.listMcpResources?.({ server: args.server })) ?? []; + execResult = create(ListMcpResourcesExecResultSchema, { + result: { + case: "success", + value: create(ListMcpResourcesSuccessSchema, { + resources: resources.map(resource => + create(ListMcpResourcesExecResult_McpResourceSchema, { + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + server: resource.server, + }), + ), + }), + }, + }); + } catch (error) { + execResult = create(ListMcpResourcesExecResultSchema, { + result: { + case: "error", + value: create(ListMcpResourcesErrorSchema, { + error: error instanceof Error ? error.message : String(error), + }), + }, + }); + } sendExecClientMessage(h2Request, execMsg, "listMcpResourcesExecResult", execResult); return; } case "readMcpResourceExecArgs": { const args = execMsg.message.value; - // No resources are advertised, so every uri is genuinely not found. - const execResult = create(ReadMcpResourceExecResultSchema, { - result: { case: "notFound", value: create(ReadMcpResourceNotFoundSchema, { uri: args.uri }) }, - }); + let execResult: ReadMcpResourceExecResult; + try { + // `null` is the handler's "no such server or uri", which is exactly + // `not_found`; a throw is a real failure and must not masquerade as + // a missing resource. + const content = await execHandlers?.readMcpResource?.({ server: args.server, uri: args.uri }); + execResult = content + ? create(ReadMcpResourceExecResultSchema, { + result: { + case: "success", + value: create(ReadMcpResourceSuccessSchema, { + uri: content.uri, + name: content.name, + description: content.description, + mimeType: content.mimeType, + // The wire's content oneof carries one of the two; text + // wins when a host supplies both. + content: + content.text !== undefined + ? { case: "text", value: content.text } + : content.blob !== undefined + ? { case: "blob", value: content.blob } + : { case: undefined }, + }), + }, + }) + : create(ReadMcpResourceExecResultSchema, { + result: { case: "notFound", value: create(ReadMcpResourceNotFoundSchema, { uri: args.uri }) }, + }); + } catch (error) { + execResult = create(ReadMcpResourceExecResultSchema, { + result: { + case: "error", + value: create(ReadMcpResourceErrorSchema, { + uri: args.uri, + error: error instanceof Error ? error.message : String(error), + }), + }, + }); + } sendExecClientMessage(h2Request, execMsg, "readMcpResourceExecResult", execResult); return; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index cf27c65bf..f18041f8c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1018,6 +1018,31 @@ export interface CursorPiCall { toolCallId: string; } +/** One resource a host's MCP servers advertise. */ +export interface CursorMcpResource { + uri: string; + name?: string; + description?: string; + mimeType?: string; + /** The server advertising it; Cursor addresses reads by this name. */ + server: string; +} + +/** + * The content of one resource read. + * + * `text` and `blob` are the wire's content oneof: exactly one is sent, with + * `text` winning when a host supplies both. + */ +export interface CursorMcpResourceContent { + uri: string; + name?: string; + description?: string; + mimeType?: string; + text?: string; + blob?: Uint8Array; +} + export interface CursorExecHandlers { read?: (args: ReadArgs) => Promise>; ls?: (args: LsArgs) => Promise>; @@ -1043,6 +1068,17 @@ export interface CursorExecHandlers { piGrep?: (call: CursorPiCall) => Promise>; piFind?: (call: CursorPiCall) => Promise>; piLs?: (call: CursorPiCall) => Promise>; + /** + * The resources the host's MCP servers advertise, optionally filtered to one + * server. Without a handler the provider answers an empty catalog, which + * hides resources a host is in fact holding live connections to. + */ + listMcpResources?: (args: { server?: string }) => Promise; + /** + * Read one resource. `null` means the server or uri is genuinely unknown, + * which the provider answers as `not_found`; throwing surfaces as `error`. + */ + readMcpResource?: (args: { server: string; uri: string }) => Promise; /** Mirror Cursor's server-owned todo list into local session state. */ todoSync?: CursorTodoSyncHandler; onToolResult?: CursorToolResultHandler; diff --git a/packages/ai/test/cursor-exec-modern.test.ts b/packages/ai/test/cursor-exec-modern.test.ts index 9ca09aec8..f3fd50ecf 100644 --- a/packages/ai/test/cursor-exec-modern.test.ts +++ b/packages/ai/test/cursor-exec-modern.test.ts @@ -518,6 +518,123 @@ describe("Cursor modern exec frames: no answer carries an unset oneof", () => { }); }); +describe("Cursor MCP resource frames answer from the host's servers", () => { + it("returns the resources the host advertises, with their server names", async () => { + // The empty catalog above is the no-handler fallback. A host holding live + // MCP connections must answer from them, or its resources are invisible + // to Cursor even though the same session is connected to the servers. + const { frames } = await dispatchExec( + buildExecMessage({ case: "listMcpResourcesExecArgs", value: create(ListMcpResourcesExecArgsSchema, {}) }), + { + execHandlers: { + listMcpResources: async () => [ + { uri: "docs://readme", name: "README", mimeType: "text/markdown", server: "docs" }, + ], + }, + }, + ); + const answer = soleResult(frames); + if (answer.case !== "listMcpResourcesExecResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`); + expect(answer.value.result.value.resources).toHaveLength(1); + const [resource] = answer.value.result.value.resources; + expect(resource.uri).toBe("docs://readme"); + // Cursor addresses the follow-up read by this name. + expect(resource.server).toBe("docs"); + expect(resource.mimeType).toBe("text/markdown"); + }); + + it("passes the frame's server filter through to the host", async () => { + let sawFilter: string | undefined; + await dispatchExec( + buildExecMessage({ + case: "listMcpResourcesExecArgs", + value: create(ListMcpResourcesExecArgsSchema, { server: "issues" }), + }), + { + execHandlers: { + listMcpResources: async ({ server }) => { + sawFilter = server; + return []; + }, + }, + }, + ); + expect(sawFilter).toBe("issues"); + }); + + it("answers a read with the host's text content", async () => { + const { frames } = await dispatchExec( + buildExecMessage({ + case: "readMcpResourceExecArgs", + value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }), + }), + { + execHandlers: { + readMcpResource: async ({ uri }) => ({ uri, mimeType: "text/markdown", text: "# Title" }), + }, + }, + ); + const answer = soleResult(frames); + if (answer.case !== "readMcpResourceExecResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`); + expect(answer.value.result.value.content).toEqual({ case: "text", value: "# Title" }); + }); + + it("distinguishes a missing resource from a failing host", async () => { + // `null` is "no such server or uri", which is `not_found`. A throw is a + // real failure and must not masquerade as a missing resource — the model + // would retry a different uri instead of surfacing the fault. + const missing = await dispatchExec( + buildExecMessage({ + case: "readMcpResourceExecArgs", + value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://gone" }), + }), + { execHandlers: { readMcpResource: async () => null } }, + ); + const missingAnswer = soleResult(missing.frames); + if (missingAnswer.case !== "readMcpResourceExecResult") throw new Error(`got ${missingAnswer.case}`); + expect(missingAnswer.value.result.case).toBe("notFound"); + + const broken = await dispatchExec( + buildExecMessage({ + case: "readMcpResourceExecArgs", + value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }), + }), + { + execHandlers: { + readMcpResource: async () => { + throw new Error("server disconnected"); + }, + }, + }, + ); + const brokenAnswer = soleResult(broken.frames); + if (brokenAnswer.case !== "readMcpResourceExecResult") throw new Error(`got ${brokenAnswer.case}`); + if (brokenAnswer.value.result.case !== "error") throw new Error(`got ${brokenAnswer.value.result.case}`); + expect(brokenAnswer.value.result.value.error).toContain("server disconnected"); + }); + + it("reports a failing list as an error, not an empty catalog", async () => { + // An empty success says "asked, none exist" — a lie when the lookup + // failed, and one the model cannot retry. + const { frames } = await dispatchExec( + buildExecMessage({ case: "listMcpResourcesExecArgs", value: create(ListMcpResourcesExecArgsSchema, {}) }), + { + execHandlers: { + listMcpResources: async () => { + throw new Error("registry unavailable"); + }, + }, + }, + ); + const answer = soleResult(frames); + if (answer.case !== "listMcpResourcesExecResult") throw new Error(`got ${answer.case}`); + if (answer.value.result.case !== "error") throw new Error(`got ${answer.value.result.case}`); + expect(answer.value.result.value.error).toContain("registry unavailable"); + }); +}); + describe("Cursor modern exec frames: status and precheck answers", () => { it("reports NOT_FOUND for force-background requests, since nothing runs in the background", async () => { const shell = await dispatchExec( diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2a768f008..1842ae1e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -143,6 +143,8 @@ - Fixed `pi_bash` killing commands that explicitly asked for no deadline. `timeout` is `optional int32` and `bash` documents `0` as "disables the command deadline", but a truthiness check folded a supplied `0` into unset, applying the 300s default instead. A present `0` now passes through; negatives, which have no local meaning and would otherwise clamp to the 1s floor, still fall back to the default. - Fixed the Cursor exec bridge granting `edit` and `grep` to sessions that withheld them. Both bridge-only tools are constructed rather than looked up, and `executeTool` prefers a constructed override over the registry, so a restricted tool set (`toolNames` without them, or `restrictToolNames`) still got a working `pi_edit`/`pi_grep` — native frames arrive regardless of the advertised catalog. Both are now gated on the session having actually granted the tool, matching the `delete` frame's existing check (issue #5680). - Fixed Cursor advisor bridge tools bypassing approval settings. The advisor's `pi_edit`/`pi_grep` instances are approval-wrapped, but the wrapper reads `tools.approvalMode`, per-tool `tools.approval.` policies and `autoApprove` only from the execute-time tool context — which the advisor bridge never supplied, so every native advisor frame resolved as `yolo` with empty policies and ran past a configured `ask` or `deny`. Advisors now receive the same context store as the primary bridge. +- Fixed Cursor's `list_mcp_resources`/`read_mcp_resource` frames answering as though the client hosted no MCP servers. The bridge hardcoded an empty catalog and `not_found`, so resources from servers the session held live connections to were invisible to the model even while the same session read them through `mcp://`. Both frames now answer from the session's `MCPManager`; a lookup failure surfaces as an error rather than an empty catalog, which would read as "asked, none exist". +- Fixed the Cursor native `delete` frame bypassing approval settings. Unlike every other frame it removes the file directly instead of running a registry tool, so no approval wrapper sat in front of it — `allowNativeDelete` answers whether a mutating tool was granted, which is a different question from whether the user's policy allows the call. A configured `tools.approval.delete: deny`, or an `always-ask` session that this channel cannot prompt in, now refuses the frame and keeps the file. ## [17.1.5] - 2026-07-27 diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index b93bb30ff..72d99a491 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -9,6 +9,8 @@ import type { } from "@oh-my-pi/pi-agent-core"; import type { CursorMcpCall, + CursorMcpResource, + CursorMcpResourceContent, CursorShellStreamCallbacks, CursorTodoSnapshot, CursorExecHandlers as ICursorExecHandlers, @@ -23,6 +25,9 @@ import { piTimeout, } from "@oh-my-pi/pi-ai/providers/cursor/exec-modern"; import { sanitizeText } from "@oh-my-pi/pi-utils"; +import type { MCPResourceReadResult } from "./mcp/types"; +import type { ApprovalMode } from "./tools/approval"; +import { resolveApproval } from "./tools/approval"; import { resolveToCwd } from "./tools/path-utils"; import type { TodoPhase, TodoStatus } from "./tools/todo"; @@ -79,6 +84,20 @@ interface CursorExecBridgeOptions { * goes through. */ createGrepTool?(options: { context?: number; totalMatchLimit?: number }): CursorBridgeTool | undefined; + /** + * The session's live MCP connections, for Cursor's resource frames. + * + * `list_mcp_resources` / `read_mcp_resource` ask what this client's servers + * advertise. Without this the bridge answers an empty catalog and + * `not_found`, hiding resources the session is in fact connected to. + */ + mcpResources?: { + serverNames(): string[]; + getServerResources( + name: string, + ): { resources: { uri: string; name?: string; description?: string; mimeType?: string }[] } | undefined; + readServerResource(name: string, uri: string): Promise; + }; } function createToolResultMessage( @@ -171,6 +190,31 @@ async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, return createToolResultMessage(toolCallId, toolName, result, true); } + // Unlike every other frame, this one mutates the filesystem directly instead + // of running a registry tool, so no approval wrapper sits in front of it. + // `allowNativeDelete` answers "was a mutating tool granted", which is a + // different question from "does the user's policy allow this call" — without + // this, a configured `deny` or an `always-ask` session still lost the file. + // `write` is the tier a file removal belongs to. + const context = options.getToolContext?.(); + const settings = context?.settings; + const approvalMode: ApprovalMode = + context?.autoApprove === true ? "yolo" : (settings?.get("tools.approvalMode") ?? "yolo"); + const approval = resolveApproval( + { name: toolName, approval: "write" }, + { path: pathArg }, + approvalMode, + (settings?.get("tools.approval") ?? {}) as Record, + ); + if (approval.policy !== "allow") { + const detail = + approval.policy === "deny" + ? `Tool "${toolName}" is blocked by user policy.` + : `Tool "${toolName}" requires approval, which this channel cannot request.`; + const result = buildToolErrorResult(detail); + return createToolResultMessage(toolCallId, toolName, result, true); + } + options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } }); const absolutePath = resolveToCwd(pathArg, options.getCwd?.() ?? options.cwd); @@ -544,6 +588,53 @@ export class CursorExecHandlers implements ICursorExecHandlers { return await executeTool(this.options, "read", call.toolCallId, { path: piLsPath(call.args.path) }); } + /** + * The resources this client's MCP servers advertise. + * + * Cursor addresses a later read by `server`, so every entry carries the name + * it came from. An absent `server` filter means "all of them". + */ + async listMcpResources({ server }: { server?: string }): Promise { + const mcp = this.options.mcpResources; + if (!mcp) return []; + const names = server ? [server] : mcp.serverNames(); + const listed: CursorMcpResource[] = []; + for (const name of names) { + for (const resource of mcp.getServerResources(name)?.resources ?? []) { + listed.push({ + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + server: name, + }); + } + } + return listed; + } + + /** + * Read one resource, or `null` when the server or uri is unknown. + * + * MCP returns a list of content items; the wire carries exactly one text or + * blob. Text items are joined, since a multi-part text resource is one + * document; otherwise the first blob stands in. `blob` arrives base64 and + * the wire wants bytes. + */ + async readMcpResource({ server, uri }: { server: string; uri: string }): Promise { + const mcp = this.options.mcpResources; + if (!mcp) return null; + const read = await mcp.readServerResource(server, uri); + if (!read) return null; + const texts = read.contents.filter(item => item.text !== undefined).map(item => item.text as string); + if (texts.length > 0) { + return { uri, mimeType: read.contents[0]?.mimeType, text: texts.join("\n") }; + } + const blob = read.contents.find(item => item.blob !== undefined)?.blob; + if (blob === undefined) return null; + return { uri, mimeType: read.contents[0]?.mimeType, blob: Buffer.from(blob, "base64") }; + } + /** * Settle a completed native Cursor todo call, mirroring its list when the * server supplied an authoritative one. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 1de5b8539..43c5dbcab 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2656,6 +2656,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: toolRegistry, getExecutableTool: resolveDeviceTool, getToolContext: () => toolContextStore.getContext(), + // Cursor's resource frames ask what THIS client's servers advertise; + // only live connections have any. + mcpResources: mcpManager && { + serverNames: () => mcpManager.getConnectedServers(), + getServerResources: name => mcpManager.getServerResources(name), + readServerResource: (name, uri) => mcpManager.readServerResource(name, uri), + }, emitEvent: event => cursorEventEmitter?.(event), getTodoPhases: () => session.getTodoPhases(), setTodoPhases: phases => session.setTodoPhases(phases), diff --git a/packages/coding-agent/test/cursor-exec.test.ts b/packages/coding-agent/test/cursor-exec.test.ts index 71385612b..507b9fb20 100644 --- a/packages/coding-agent/test/cursor-exec.test.ts +++ b/packages/coding-agent/test/cursor-exec.test.ts @@ -607,6 +607,62 @@ describe("CursorExecHandlers mounted tool bridge", () => { expect(executed).toBe(false); expect(result.content.find(block => block.type === "text")?.text).toContain("blocked by user policy"); }); + + it("lists resources from the session's live MCP servers", async () => { + // The provider used to answer an empty catalog unconditionally, hiding + // resources the session holds live connections to. Every entry must + // carry the server name, since that is how Cursor addresses the read. + const handlers = new CursorExecHandlers({ + cwd: ".", + tools: new Map(), + mcpResources: { + serverNames: () => ["docs", "issues"], + getServerResources: name => + name === "docs" + ? { resources: [{ uri: "docs://readme", name: "README", mimeType: "text/markdown" }] } + : { resources: [{ uri: "issues://open" }] }, + readServerResource: async () => undefined, + }, + }); + + expect(await handlers.listMcpResources({})).toEqual([ + { uri: "docs://readme", name: "README", description: undefined, mimeType: "text/markdown", server: "docs" }, + { uri: "issues://open", name: undefined, description: undefined, mimeType: undefined, server: "issues" }, + ]); + // A server filter narrows to that server alone. + expect((await handlers.listMcpResources({ server: "issues" })).map(r => r.uri)).toEqual(["issues://open"]); + }); + + it("reads a resource and decodes a blob payload into wire bytes", async () => { + // MCP hands back a list of content items with base64 blobs; the wire + // carries one text or one byte payload. + const handlers = new CursorExecHandlers({ + cwd: ".", + tools: new Map(), + mcpResources: { + serverNames: () => ["files"], + getServerResources: () => undefined, + readServerResource: async (name, uri) => + name === "files" && uri === "files://logo" + ? { contents: [{ uri, mimeType: "image/png", blob: Buffer.from("PNG").toString("base64") }] } + : undefined, + }, + }); + + const read = await handlers.readMcpResource({ server: "files", uri: "files://logo" }); + expect(read?.mimeType).toBe("image/png"); + expect(read?.blob && Buffer.from(read.blob).toString()).toBe("PNG"); + // An unknown uri is genuinely not found, not an error. + expect(await handlers.readMcpResource({ server: "files", uri: "files://missing" })).toBeNull(); + }); + + it("answers nothing when the session has no MCP manager", async () => { + // A host without MCP must still answer truthfully rather than throwing: + // an empty catalog and `not_found` are the honest responses. + const handlers = new CursorExecHandlers({ cwd: ".", tools: new Map() }); + expect(await handlers.listMcpResources({})).toEqual([]); + expect(await handlers.readMcpResource({ server: "docs", uri: "docs://x" })).toBeNull(); + }); }); function cursorAssistantMessage(): AssistantMessage { @@ -830,6 +886,48 @@ describe("CursorExecHandlers native delete gating (issue #5680)", () => { expect(await Bun.file(originalTarget).exists()).toBe(true); expect(await Bun.file(movedTarget).exists()).toBe(false); }); + + it("refuses a native delete the user's policy blocks", async () => { + // `allowNativeDelete` answers "was a mutating tool granted", not "does + // policy allow this call". The frame removes the file with `fs.rmSync` + // instead of running a registry tool, so no approval wrapper sits in + // front of it — a configured `deny` still lost the file. + const target = path.join(cwd, "protected.txt"); + await Bun.write(target, "keep me\n"); + const settings = Settings.isolated({ "tools.approval": { delete: "deny" } }); + const handlers = new CursorExecHandlers({ + cwd, + tools: new Map(), + allowNativeDelete: true, + getToolContext: () => ({ settings }) as AgentToolContext, + }); + + const result = await handlers.delete( + create(DeleteArgsSchema, { toolCallId: "call-deny", path: "protected.txt" }), + ); + + expect(result.isError).toBe(true); + expect(await Bun.file(target).exists()).toBe(true); + }); + + it("refuses a native delete in always-ask mode, which has no prompt channel", async () => { + // The exec channel cannot raise an interactive approval, so a mode that + // demands one must fail closed rather than silently auto-approving. + const target = path.join(cwd, "asked.txt"); + await Bun.write(target, "keep me\n"); + const settings = Settings.isolated({ "tools.approvalMode": "always-ask" }); + const handlers = new CursorExecHandlers({ + cwd, + tools: new Map(), + allowNativeDelete: true, + getToolContext: () => ({ settings }) as AgentToolContext, + }); + + const result = await handlers.delete(create(DeleteArgsSchema, { toolCallId: "call-ask", path: "asked.txt" })); + + expect(result.isError).toBe(true); + expect(await Bun.file(target).exists()).toBe(true); + }); }); // The Pi frames (`ExecServerMessage` 45-51) are a separate wire family from the