fix(cursor): gate resource downloads, fix pi_grep cap and MCP transcript

Download-mode resource reads created and overwrote workspace files
without running a registry tool - the same hole the native `delete`
frame had - so a session that withheld `write`/`edit`, or whose `write`
tier is `deny`/`always-ask`, still had files written. Both frames now
share one grant and one policy check, and the download refuses before
the read so a blocked call never fetches the resource.

`allowNativeDelete` is renamed `allowDirectFileMutation`: it now gates
more than deletion. The primary session derives it from the registry
BEFORE its own rewriting (Cursor moves `edit` out of the tool map and
`write` may be auto-registered later, so reading the map at bridge
construction would misjudge both) and unconditionally, since the bridge
is installed for every session and one that starts on another provider
can switch to Cursor later.

`pi_grep` with a match cap: the local tool windows to 20 files and
suggests `skip`, which `PiGrepExecArgs` cannot express - 100 matches
requested over 25 one-match files returned 20, with the cap reported
unreached. A capped search now reads cap+1 files, so a result landing
exactly on the cap is distinguishable from a clipped one, and
`match_limit_reached` is truthful either way.

`read_mcp_resource` synthesized no transcript block and paired no
result, so a read - including a download that mutates the workspace -
was invisible in the UI and stripped from every rebuilt history. It now
synthesizes a `read_mcp_resource` block (not `read`: the name drives
rendering and prune semantics) and pairs success, not-found and error.

(cherry picked from commit 5ff27a3efe8bec522d9d5dbd7763055eb03eae3b)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 15:38:44 -03:00
committed by can1357
parent 821fe75f5d
commit 59434149d1
9 changed files with 321 additions and 42 deletions
@@ -649,6 +649,84 @@ describe("Cursor MCP resource frames answer from the host's servers", () => {
expect(brokenAnswer.value.result.value.error).toContain("server disconnected");
});
it("records a resource read as a paired transcript block", async () => {
// The read runs locally and, in download mode, writes a workspace file.
// An exec frame with no synthesized block is invisible in the UI, and an
// unpaired call is stripped by `buildSessionContext` — taking the whole
// interaction out of every rebuilt transcript.
const { output, results } = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, {
server: "docs",
uri: "docs://readme",
downloadPath: "assets/readme.md",
}),
}),
{
execHandlers: {
readMcpResource: async ({ uri, downloadPath }) => ({ uri, mimeType: "text/markdown", downloadPath }),
},
},
);
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks).toHaveLength(1);
// Not `read`: this is a remote MCP operation, and the name drives
// rendering and prune semantics.
expect(blocks[0].name).toBe("read_mcp_resource");
expect(blocks[0].arguments).toEqual({
server: "docs",
uri: "docs://readme",
download_path: "assets/readme.md",
});
// Paired under the same id, and a success is not filed as a failure.
expect(results.map(r => r.toolCallId)).toEqual([blocks[0].id]);
expect(results[0].isError).toBe(false);
expect(results[0].content).toEqual([{ type: "text", text: "Downloaded docs://readme to assets/readme.md" }]);
});
it("pairs a failed resource read as an error, still under one block", async () => {
// A refused download (no write grant, a path outside the workspace) and
// a dead server both land here. The block must resolve — an unpaired
// call strips the interaction — and must resolve as an error, or the
// transcript shows a read that never happened as having succeeded.
const { output, results } = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }),
}),
{
execHandlers: {
readMcpResource: async () => {
throw new Error("Refusing to download outside the workspace: ../escape");
},
},
},
);
const blocks = output.content.filter((block): block is ToolCallState => block.type === "toolCall");
expect(blocks).toHaveLength(1);
expect(results.map(r => r.toolCallId)).toEqual([blocks[0].id]);
expect(results[0].isError).toBe(true);
expect(results[0].content).toEqual([
{ type: "text", text: "Refusing to download outside the workspace: ../escape" },
]);
});
it("leaves no block when no handler ran", async () => {
// Without a handler the frame is answered from a fixed `not_found` and
// nothing executed, so a block would claim work that never happened.
const { output, results } = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }),
}),
);
expect(output.content.filter(block => block.type === "toolCall")).toHaveLength(0);
expect(results).toHaveLength(0);
});
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.