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)
This commit is contained in:
committed by
can1357
parent
a363f95009
commit
0601ee7324
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,31 @@ export interface CursorPiCall<TArgs> {
|
||||
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<CursorExecHandlerResult<ReadResult>>;
|
||||
ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
|
||||
@@ -1043,6 +1068,17 @@ export interface CursorExecHandlers {
|
||||
piGrep?: (call: CursorPiCall<PiGrepExecArgs>) => Promise<CursorExecHandlerResult<PiGrepExecResult>>;
|
||||
piFind?: (call: CursorPiCall<PiFindExecArgs>) => Promise<CursorExecHandlerResult<PiFindExecResult>>;
|
||||
piLs?: (call: CursorPiCall<PiLsExecArgs>) => Promise<CursorExecHandlerResult<PiLsExecResult>>;
|
||||
/**
|
||||
* 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<CursorMcpResource[]>;
|
||||
/**
|
||||
* 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<CursorMcpResourceContent | null>;
|
||||
/** Mirror Cursor's server-owned todo list into local session state. */
|
||||
todoSync?: CursorTodoSyncHandler;
|
||||
onToolResult?: CursorToolResultHandler;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.<tool>` 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
|
||||
|
||||
|
||||
@@ -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<MCPResourceReadResult | undefined>;
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
);
|
||||
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<CursorMcpResource[]> {
|
||||
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<CursorMcpResourceContent | null> {
|
||||
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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user