fix(cursor): exposed mounted xd devices to cursor-agent

Forwarded the session xd registry into Cursor provider tool contexts.

Routed Cursor MCP execution through the mounted registry fallback and added regression coverage for built-in devices and external MCP tools.

Fixes #5650
This commit is contained in:
roboomp
2026-07-16 03:15:32 +00:00
parent ed4ddcba6c
commit 8386ab2c0b
8 changed files with 150 additions and 4 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Cursor provider contexts omitting host-supplied MCP tools from main and side-channel requests ([#5650](https://github.com/can1357/oh-my-pi/issues/5650)).
## [17.0.0] - 2026-07-15
### Breaking Changes
+22 -2
View File
@@ -267,6 +267,8 @@ export interface AgentOptions {
* Cursor exec handlers for local tool execution.
*/
cursorExecHandlers?: CursorExecHandlers;
/** Additional tools Cursor executes through its MCP request-context bridge, resolved before each provider call. */
getCursorTools?: () => AgentTool[];
/**
* Cursor tool result callback for exec tool responses.
@@ -368,6 +370,7 @@ export class Agent {
#maxRetryDelayMs?: number;
#getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
#cursorExecHandlers?: CursorExecHandlers;
#getCursorTools?: () => AgentTool[];
#cursorOnToolResult?: CursorToolResultHandler;
#cwd?: string;
#cwdResolver?: () => string | undefined;
@@ -450,6 +453,7 @@ export class Agent {
this.#onSseEvent = opts.onSseEvent;
this.#getToolContext = opts.getToolContext;
this.#cursorExecHandlers = opts.cursorExecHandlers;
this.#getCursorTools = opts.getCursorTools;
this.#cursorOnToolResult = opts.cursorOnToolResult;
this.#cwd = opts.cwd;
this.#cwdResolver = opts.cwdResolver;
@@ -694,6 +698,22 @@ export class Agent {
this.#appendOnlyContext = manager;
}
#toolsForModel(model: Model): AgentTool[] {
if (model.api !== "cursor-agent" || !this.#getCursorTools) return this.#state.tools;
const cursorTools = this.#getCursorTools();
if (cursorTools.length === 0) return this.#state.tools;
const names = new Set(this.#state.tools.map(tool => tool.name));
let merged: AgentTool[] | undefined;
for (const tool of cursorTools) {
if (names.has(tool.name)) continue;
merged ??= this.#state.tools.slice();
merged.push(tool);
names.add(tool.name);
}
return merged ?? this.#state.tools;
}
/**
* Assemble the provider Context for a side-channel (no-loop) request, mirroring
* the main loop's prefix (system + normalized tools) so it shares the prompt
@@ -718,7 +738,7 @@ export class Agent {
const tools = ownedDialect
? []
: (normalizeTools(
this.#state.tools,
this.#toolsForModel(model),
this.#intentTracing,
preferredDialect(model.id),
this.#pruneToolDescriptions,
@@ -1152,7 +1172,7 @@ export class Agent {
await Bun.sleep(0);
}
context.systemPrompt = this.#state.systemPrompt;
context.tools = this.#state.tools;
context.tools = this.#toolsForModel(model);
},
cursorExecHandlers: this.#cursorExecHandlers,
cursorOnToolResult,
@@ -2,6 +2,7 @@ import { describe, expect, it, mock } from "bun:test";
import { type AssistantMessage, type Context, z } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Agent } from "../src/agent";
import type { AgentTool } from "../src/types";
@@ -39,6 +40,19 @@ function testAssistantMessage(text: string): AssistantMessage {
};
}
const cursorModel = buildModel({
id: "cursor-test",
name: "Cursor Test",
api: "cursor-agent",
provider: "cursor",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8_192,
maxTokens: 2_048,
});
describe("Agent — buildSideRequestContext", () => {
const model = createMockModel({ responses: [] });
const tool: AgentTool = {
@@ -109,6 +123,43 @@ describe("Agent — buildSideRequestContext", () => {
});
});
it("adds mounted Cursor tools to main and side provider contexts", async () => {
await withNativeDialectEnv(async () => {
const mountedTool: AgentTool = {
...tool,
name: "mcp__fixture_report",
label: "Fixture Report",
};
let mainContext: Context | undefined;
const agent = new Agent({
initialState: {
model: cursorModel,
systemPrompt: ["system"],
tools: [tool],
},
getCursorTools: () => [tool, mountedTool],
streamFn: (_model, context) => {
mainContext = context;
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const message = testAssistantMessage("ok");
stream.push({ type: "text_delta", contentIndex: 0, delta: "ok", partial: message });
stream.push({ type: "done", reason: "stop", message });
});
return stream;
},
});
await agent.prompt("Q?");
const sideContext = await agent.buildSideRequestContext([
{ role: "user", content: [{ type: "text", text: "Q?" }], timestamp: Date.now() },
]);
expect(mainContext?.tools?.map(entry => entry.name)).toEqual(["test_tool", "mcp__fixture_report"]);
expect(sideContext.tools?.map(entry => entry.name)).toEqual(["test_tool", "mcp__fixture_report"]);
});
});
it("returns empty tools when owned dialect is active", async () => {
const agent = new Agent({
initialState: {
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- Fixed Cursor models receiving only top-level tools by forwarding mounted `xd://` devices, including user-configured MCP servers, through Cursor's request-context MCP catalog and execution bridge ([#5650](https://github.com/can1357/oh-my-pi/issues/5650)).
- Fixed the `omp grep` CLI subcommand failing on paths with a stray leading colon (e.g. `:/abs/path`); it now routes the path argument through `expandPath` like `read`/`edit`/in-agent `grep`. Broadened `expandPath`'s leading-colon strip to also recover Windows-style shapes (`:C:\repo\file`, `:.\src`, `:..\rel`, `:\\server\share`) ([#5624](https://github.com/can1357/oh-my-pi/issues/5624)).
- Fixed the `tail` builtin exiting the entire omp process with code 13 on Windows when its output pipe broke (e.g. `seq ... | tail -n 3 | head -n 0`); a broken pipe now surfaces as a normal error instead of calling `std::process::exit` ([#5609](https://github.com/can1357/oh-my-pi/issues/5609)).
- Fixed a late advisor `blocker` after a terminal primary answer being deferred to the next user turn instead of continuing the current turn: `resolveAdvisorDeliveryChannel` preserved every interrupting severity as a passive card once the primary ended with a terminal text answer and no queued work remained, so a `blocker` flagging a mistake in the final output sat idle until the next prompt. A `blocker` now steers a triggered turn so the primary acknowledges and continues before the turn is considered done; a late `concern` still preserves as a visible card ([#5628](https://github.com/can1357/oh-my-pi/issues/5628)).
+3 -2
View File
@@ -19,6 +19,7 @@ import { resolveToCwd } from "./tools/path-utils";
interface CursorExecBridgeOptions {
cwd: string;
tools: Map<string, AgentTool>;
getTool?: (name: string) => AgentTool | undefined;
getToolContext?: () => AgentToolContext | undefined;
emitEvent?: (event: AgentEvent) => void;
}
@@ -53,7 +54,7 @@ async function executeTool(
toolCallId: string,
args: Record<string, unknown>,
): Promise<ToolResultMessage> {
const tool = options.tools.get(toolName);
const tool = options.tools.get(toolName) ?? options.getTool?.(toolName);
if (!tool) {
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
return createToolResultMessage(toolCallId, toolName, result, true);
@@ -327,7 +328,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
async mcp(call: CursorMcpCall) {
const toolName = call.toolName || call.name;
const toolCallId = decodeToolCallId(call.toolCallId);
const tool = this.options.tools.get(toolName);
const tool = this.options.tools.get(toolName) ?? this.options.getTool?.(toolName);
if (!tool) {
const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
const message = formatMcpToolErrorMessage(toolName, availableTools);
+2
View File
@@ -2272,6 +2272,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const cursorExecHandlers = new CursorExecHandlers({
cwd,
tools: toolRegistry,
getTool: name => toolSession.xdevRegistry?.get(name),
getToolContext: () => toolContextStore.getContext(),
emitEvent: event => cursorEventEmitter?.(event),
});
@@ -2629,6 +2630,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return settingsAwareStreamFn(streamModel, context, streamOptions);
},
cursorExecHandlers,
getCursorTools: () => [...(toolSession.xdevRegistry?.list() ?? [])],
transformToolCallArguments: (args, _toolName) => {
let result = args;
const maxTimeout = settings.get("tools.maxTimeout");
@@ -121,3 +121,34 @@ describe("CursorExecHandlers error results", () => {
expect(end?.isError).toBe(true);
});
});
describe("CursorExecHandlers mounted tool bridge", () => {
it("executes MCP tools resolved from the xd:// registry", async () => {
const mountedTool: AgentTool = {
name: "mcp__fixture_report",
label: "Fixture Report",
description: "reports a fixture result",
parameters: type({}),
async execute() {
return { content: [{ type: "text", text: "reported" }], details: {} };
},
};
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map(),
getTool: name => (name === mountedTool.name ? mountedTool : undefined),
});
const result = await handlers.mcp({
name: mountedTool.name,
providerIdentifier: "pi-agent",
toolName: mountedTool.name,
toolCallId: "call-mounted",
args: {},
rawArgs: {},
});
expect(result.isError).toBe(false);
expect(result.content).toEqual([{ type: "text", text: "reported" }]);
});
});
@@ -5,6 +5,7 @@ import * as path from "node:path";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types";
import {
type CreateAgentSessionOptions,
createAgentSession,
@@ -124,6 +125,41 @@ describe("createAgentSession defaultInactive tool activation", () => {
}
});
it("forwards built-in and external xd:// devices to Cursor provider contexts", async () => {
const tempDir = makeTempDir();
const cursorModel = getBundledModel("cursor", "composer-1.5");
if (!cursorModel) throw new Error("expected bundled Cursor model");
const { session } = await createAgentSession({
...baseOptions(tempDir),
model: cursorModel,
});
const externalMcpTool: CustomTool = {
name: "mcp__fixture_report",
label: "fixture/report",
description: "Report a fixture result.",
parameters: type({}),
strict: true,
mcpServerName: "fixture",
mcpToolName: "report",
async execute() {
return { content: [{ type: "text", text: "reported" }] };
},
};
try {
await session.refreshMCPTools([externalMcpTool]);
const deviceNames = session.getXdevToolEntries().map(entry => entry.name);
expect(deviceNames).toEqual(expect.arrayContaining(["ast_edit", "mcp__fixture_report"]));
expect(session.getActiveToolNames()).not.toContain("mcp__fixture_report");
const context = await session.agent.buildSideRequestContext([]);
const providerToolNames = context.tools?.map(tool => tool.name);
expect(providerToolNames).toEqual(expect.arrayContaining(["ast_edit", "mcp__fixture_report"]));
} finally {
await session.dispose();
}
});
it("allows explicitly requested defaultInactive extension tools into the initial active set", async () => {
const tempDir = makeTempDir();