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:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user