diff --git a/.gitignore b/.gitignore index 3c6bfdf..1e28bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ coverage/ *.tgz # Local editor / agent tooling -.serena/ .opencode/ .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff30c6..37820fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,7 @@ and a permission-gated delegation tool surface. - `"blocks"` (default) emits structured, provider-executed **dynamic** `tool-call` / `tool-result` parts so opencode renders native tool blocks. Names are `cursor_`-prefixed and sanitized (`shell` → `cursor_shell`, - `serena/find_symbol` → `cursor_serena_find_symbol`) so they can't collide + `myserver/find_symbol` → `cursor_myserver_find_symbol`) so they can't collide with opencode-registered tools, and carry `providerExecuted: true` + `dynamic: true` so ai v6's `parseToolCall` accepts them without registered-tool validation. Tool-results use the V3-spec `result` + @@ -97,7 +97,7 @@ and a permission-gated delegation tool surface. rather than showing only the fallback snapshot. - **MCP server forwarding** — opencode's configured `config.mcp` entries are translated to Cursor `McpServerConfig` and passed to the local agent so it can - use the same servers (e.g. Serena). Opt out with `provider.cursor.options.forwardMcp`. + use the same servers. Opt out with `provider.cursor.options.forwardMcp`. - **Model discovery** with a 24-hour cache (keyed by key fingerprint) and a built-in fallback snapshot (composer-2.5, claude-opus-4-8, claude-sonnet-4-6, gpt-5.5) for use without an API key. diff --git a/README.md b/README.md index 25aedcc..1aa91a1 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,9 @@ The Cursor agent can use the **same MCP servers you've configured in opencode**. | `{ type: "local", command: [cmd, ...args], environment }` | `{ type: "stdio", command: cmd, args, env }` | | `{ type: "remote", url, headers }` | `{ type: "http", url, headers }` | -So if your `opencode.json` defines Serena, your Cursor agent connects to that same Serena — MCP -servers are independent processes, so opencode and the agent each connect to them directly. +So whatever MCP servers your `opencode.json` defines, your Cursor agent connects to those same +servers — MCP servers are independent processes, so opencode and the agent each connect to them +directly. Disabled entries (`enabled: false`) are skipped. Turn this off with `forwardMcp: false`. > Scope note: this forwards **MCP servers**. opencode's *loop-internal* features — its own skills diff --git a/src/plugin/index.ts b/src/plugin/index.ts index c86d00a..8459666 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -7,7 +7,7 @@ import { translateMcpServers } from "./mcp-config.js"; import { buildCursorTools } from "./cursor-tools.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { - return auth?.type === "api" ? auth.key : undefined; + return auth?.type === "api" ? auth.key : undefined; } /** @@ -23,117 +23,130 @@ function apiKeyFromAuth(auth: Auth | undefined): string | undefined { * - `tool.cursor_refresh_models`: force-refresh the model catalog. */ export const CursorPlugin: Plugin = async (input) => { - // The Cursor API key resolved by opencode's auth loader, captured so the - // delegation tools (which don't receive auth directly) can reuse it. Falls - // back to the CURSOR_API_KEY env var when the loader hasn't run. - let capturedApiKey: string | undefined; + // The Cursor API key resolved by opencode's auth loader, captured so the + // delegation tools (which don't receive auth directly) can reuse it. Falls + // back to the CURSOR_API_KEY env var when the loader hasn't run. + let capturedApiKey: string | undefined; - return { - auth: { - provider: PROVIDER_ID, - loader: async (getAuth) => { - const apiKey = resolveCursorApiKey(apiKeyFromAuth(await getAuth().catch(() => undefined))); - if (apiKey) { - capturedApiKey = apiKey; - // The `config` hook (which seeds opencode's model picker) runs without - // a key. Warm the catalog cache here — the loader is the hook that - // reliably has the key — so the next launch seeds the full live - // catalog instead of the static fallback. Fire-and-forget: discovery - // never throws and must not block auth/provider load. - void discoverModels({ apiKey }); - } - return apiKey ? { apiKey } : {}; - }, - // A single API-key method. opencode always shows its built-in "Enter your - // API key" prompt for `type: "api"`, so we intentionally do NOT declare - // custom `prompts` (that asks for the key a second time) or an `authorize` - // callback. opencode only passes `authorize` the *custom-prompt* inputs — - // never the built-in key — so validating the key in `authorize` would - // force that redundant extra prompt. Instead the key is validated on first - // use (model discovery / the first call both surface a bad key clearly). - methods: [{ type: "api", label: "Cursor API Key" }], - }, + return { + auth: { + provider: PROVIDER_ID, + loader: async (getAuth) => { + const apiKey = resolveCursorApiKey( + apiKeyFromAuth(await getAuth().catch(() => undefined)), + ); + if (apiKey) { + capturedApiKey = apiKey; + // The `config` hook (which seeds opencode's model picker) runs without + // a key. Warm the catalog cache here — the loader is the hook that + // reliably has the key — so the next launch seeds the full live + // catalog instead of the static fallback. Fire-and-forget: discovery + // never throws and must not block auth/provider load. + void discoverModels({ apiKey }); + } + return apiKey ? { apiKey } : {}; + }, + // A single API-key method. opencode always shows its built-in "Enter your + // API key" prompt for `type: "api"`, so we intentionally do NOT declare + // custom `prompts` (that asks for the key a second time) or an `authorize` + // callback. opencode only passes `authorize` the *custom-prompt* inputs — + // never the built-in key — so validating the key in `authorize` would + // force that redundant extra prompt. Instead the key is validated on first + // use (model discovery / the first call both surface a bad key clearly). + methods: [{ type: "api", label: "Cursor API Key" }], + }, - config: async (config) => { - const { models } = await discoverModels({}); - config.provider ??= {}; - const existing = config.provider[PROVIDER_ID] ?? {}; - const existingOptions = (existing.options ?? {}) as Record; + config: async (config) => { + const { models } = await discoverModels({}); + config.provider ??= {}; + const existing = config.provider[PROVIDER_ID] ?? {}; + const existingOptions = (existing.options ?? {}) as Record< + string, + unknown + >; - // Forward opencode's configured MCP servers (e.g. Serena) to the Cursor - // agent so it can use the same servers. Opt out via - // `provider.cursor.options.forwardMcp: false`. - const forwardMcp = existingOptions["forwardMcp"] !== false; - const userMcp = (existingOptions["mcpServers"] ?? {}) as Record; - const mcpServers = forwardMcp - ? { ...userMcp, ...translateMcpServers(config.mcp) } - : userMcp; + // Forward opencode's configured MCP servers to the Cursor + // agent so it can use the same servers. Opt out via + // `provider.cursor.options.forwardMcp: false`. + const forwardMcp = existingOptions["forwardMcp"] !== false; + const userMcp = (existingOptions["mcpServers"] ?? {}) as Record< + string, + unknown + >; + const mcpServers = forwardMcp + ? { ...userMcp, ...translateMcpServers(config.mcp) } + : userMcp; - config.provider[PROVIDER_ID] = { - name: "Cursor", - npm: providerNpm(), - ...existing, - options: { - ...existingOptions, - ...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}), - }, - models: { ...toOpencodeModels(models), ...(existing.models ?? {}) }, - }; - }, + config.provider[PROVIDER_ID] = { + name: "Cursor", + npm: providerNpm(), + ...existing, + options: { + ...existingOptions, + ...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}), + }, + models: { ...toOpencodeModels(models), ...(existing.models ?? {}) }, + }; + }, - provider: { - id: PROVIDER_ID, - models: async (_provider, ctx) => { - const apiKey = apiKeyFromAuth(ctx.auth); - const { models } = await discoverModels({ apiKey }); - return buildModelV2Map(models); - }, - }, + provider: { + id: PROVIDER_ID, + models: async (_provider, ctx) => { + const apiKey = apiKeyFromAuth(ctx.auth); + const { models } = await discoverModels({ apiKey }); + return buildModelV2Map(models); + }, + }, - // Bridge opencode's session id to the provider: it lands in - // providerOptions.cursor.sessionID, which the provider reads to pool/resume a - // Cursor agent per session (when the `session` option is enabled). - // - // Also map opencode's plan AGENT to Cursor's plan mode. This hook fires - // after opencode merges the selected variant into `output.options`, so an - // explicit mode from the `plan` variant (or model options) wins — the - // agent-based default only applies when no mode was set. - "chat.params": async (input, output) => { - if (input.model?.providerID !== PROVIDER_ID) return; - output.options = { ...(output.options ?? {}), sessionID: input.sessionID }; - if (input.agent === "plan" && output.options["mode"] === undefined) { - output.options["mode"] = "plan"; - } - }, + // Bridge opencode's session id to the provider: it lands in + // providerOptions.cursor.sessionID, which the provider reads to pool/resume a + // Cursor agent per session (when the `session` option is enabled). + // + // Also map opencode's plan AGENT to Cursor's plan mode. This hook fires + // after opencode merges the selected variant into `output.options`, so an + // explicit mode from the `plan` variant (or model options) wins — the + // agent-based default only applies when no mode was set. + "chat.params": async (input, output) => { + if (input.model?.providerID !== PROVIDER_ID) return; + output.options = { + ...(output.options ?? {}), + sessionID: input.sessionID, + }; + if (input.agent === "plan" && output.options["mode"] === undefined) { + output.options["mode"] = "plan"; + } + }, - tool: { - cursor_refresh_models: { - description: - "Refresh the live Cursor model catalog (bypasses the 24h cache) and report the available models.", - args: {}, - execute: async () => { - const result = await discoverModels({ forceRefresh: true }); - const lines = result.models.map((m) => `- ${m.id} — ${m.displayName}`); - const header = - result.source === "live" - ? `Refreshed ${result.models.length} Cursor models (live):` - : `Could not fetch live models (${result.source}). ${result.warning ?? ""}`.trim(); - return { - title: `Cursor models (${result.source})`, - output: [header, ...lines].join("\n"), - metadata: { source: result.source, count: result.models.length }, - }; - }, - }, - // Delegation tools that complement the provider: a cloud/background agent - // and a permission-gated local delegate. They resolve the Cursor key from - // the auth loader (captured above) or CURSOR_API_KEY. - ...buildCursorTools({ - resolveApiKey: () => resolveCursorApiKey(capturedApiKey), - defaultCwd: () => input?.directory ?? process.cwd(), - }), - }, - }; + tool: { + cursor_refresh_models: { + description: + "Refresh the live Cursor model catalog (bypasses the 24h cache) and report the available models.", + args: {}, + execute: async () => { + const result = await discoverModels({ forceRefresh: true }); + const lines = result.models.map( + (m) => `- ${m.id} — ${m.displayName}`, + ); + const header = + result.source === "live" + ? `Refreshed ${result.models.length} Cursor models (live):` + : `Could not fetch live models (${result.source}). ${result.warning ?? ""}`.trim(); + return { + title: `Cursor models (${result.source})`, + output: [header, ...lines].join("\n"), + metadata: { source: result.source, count: result.models.length }, + }; + }, + }, + // Delegation tools that complement the provider: a cloud/background agent + // and a permission-gated local delegate. They resolve the Cursor key from + // the auth loader (captured above) or CURSOR_API_KEY. + ...buildCursorTools({ + resolveApiKey: () => resolveCursorApiKey(capturedApiKey), + defaultCwd: () => input?.directory ?? process.cwd(), + }), + }, + }; }; export default CursorPlugin; diff --git a/src/plugin/mcp-config.ts b/src/plugin/mcp-config.ts index d46148d..97a4f06 100644 --- a/src/plugin/mcp-config.ts +++ b/src/plugin/mcp-config.ts @@ -7,7 +7,7 @@ type OpencodeMcpEntry = OpencodeMcp[string]; /** * Translate opencode's configured MCP servers (`config.mcp`) into the Cursor - * SDK's `McpServerConfig` shape so the same servers (e.g. Serena) can be handed + * SDK's `McpServerConfig` shape so the same servers can be handed * to the Cursor agent via `Agent.create({ mcpServers })`. * * MCP servers are independent processes addressed by a launch spec, so opencode @@ -15,35 +15,39 @@ type OpencodeMcpEntry = OpencodeMcp[string]; * (`enabled: false`) are skipped. opencode-only fields with no Cursor * equivalent (timeout, oauth) are dropped. */ -export function translateMcpServers(mcp: Config["mcp"]): Record { - const out: Record = {}; - if (!mcp) return out; +export function translateMcpServers( + mcp: Config["mcp"], +): Record { + const out: Record = {}; + if (!mcp) return out; - for (const [name, entry] of Object.entries(mcp) as Array<[string, OpencodeMcpEntry]>) { - if (!entry || entry.enabled === false) continue; + for (const [name, entry] of Object.entries(mcp) as Array< + [string, OpencodeMcpEntry] + >) { + if (!entry || entry.enabled === false) continue; - if (entry.type === "local") { - const [command, ...args] = entry.command ?? []; - if (!command) continue; - out[name] = { - type: "stdio", - command, - ...(args.length > 0 ? { args } : {}), - ...(entry.environment && Object.keys(entry.environment).length > 0 - ? { env: entry.environment } - : {}), - }; - } else if (entry.type === "remote") { - if (!entry.url) continue; - out[name] = { - type: "http", - url: entry.url, - ...(entry.headers && Object.keys(entry.headers).length > 0 - ? { headers: entry.headers } - : {}), - }; - } - } + if (entry.type === "local") { + const [command, ...args] = entry.command ?? []; + if (!command) continue; + out[name] = { + type: "stdio", + command, + ...(args.length > 0 ? { args } : {}), + ...(entry.environment && Object.keys(entry.environment).length > 0 + ? { env: entry.environment } + : {}), + }; + } else if (entry.type === "remote") { + if (!entry.url) continue; + out[name] = { + type: "http", + url: entry.url, + ...(entry.headers && Object.keys(entry.headers).length > 0 + ? { headers: entry.headers } + : {}), + }; + } + } - return out; + return out; } diff --git a/src/provider/index.ts b/src/provider/index.ts index d677c11..d3f9f58 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -35,8 +35,8 @@ export interface CursorProviderOptions { /** * MCP servers to make available to the Cursor agent, keyed by name. The * plugin's `config` hook populates this by translating opencode's configured - * `config.mcp` servers, so the agent can use the same MCP servers (e.g. - * Serena) that opencode does. + * `config.mcp` servers, so the agent can use the same MCP servers that + * opencode does. */ mcpServers?: Record; /** diff --git a/src/provider/language-model.ts b/src/provider/language-model.ts index a0e60c1..de5e40f 100644 --- a/src/provider/language-model.ts +++ b/src/provider/language-model.ts @@ -35,7 +35,7 @@ export interface CursorModelConfig { mode: AgentModeOption; /** Default Cursor model params (id -> value); overridable per-request. */ params?: Record; - /** MCP servers forwarded to the Cursor agent (e.g. opencode's Serena). */ + /** MCP servers forwarded to the Cursor agent from opencode's config. */ mcpServers?: Record; /** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */ settingSources?: SettingSource[]; diff --git a/src/provider/stream-map.ts b/src/provider/stream-map.ts index 4899aed..2da15d6 100644 --- a/src/provider/stream-map.ts +++ b/src/provider/stream-map.ts @@ -43,7 +43,7 @@ function safeJsonString(input: unknown): string { /** * Tool name as it crosses into opencode in "blocks" mode. Prefixed so it can * never collide with a tool opencode has registered, and sanitized because MCP - * names contain `/` (e.g. `serena/find_symbol` → `cursor_serena_find_symbol`). + * names contain `/` (e.g. `myserver/find_symbol` → `cursor_myserver_find_symbol`). */ function blockToolName(name: string): string { return `cursor_${name.replace(/[^A-Za-z0-9_-]/g, "_")}`; diff --git a/test/agent-events.test.ts b/test/agent-events.test.ts index 0a818f2..a6cf820 100644 --- a/test/agent-events.test.ts +++ b/test/agent-events.test.ts @@ -126,7 +126,7 @@ describe("streamAgentTurn MCP error surfacing", () => { callId: "c1", toolCall: { type: "mcp", - args: { toolName: "find_symbol", providerIdentifier: "serena" }, + args: { toolName: "find_symbol", providerIdentifier: "myserver" }, result: { status: "success", value: { content: [], isError: true }, @@ -141,6 +141,6 @@ describe("streamAgentTurn MCP error surfacing", () => { streamAgentTurn(agent, MESSAGE, { mode: "agent" }), ); const result = events.find((e) => e.type === "tool-result"); - expect(result).toMatchObject({ name: "serena/find_symbol", isError: true }); + expect(result).toMatchObject({ name: "myserver/find_symbol", isError: true }); }); }); diff --git a/test/mcp-config.test.ts b/test/mcp-config.test.ts index 6bea20c..2999fd3 100644 --- a/test/mcp-config.test.ts +++ b/test/mcp-config.test.ts @@ -4,69 +4,90 @@ import { translateMcpServers } from "../src/plugin/mcp-config.js"; import plugin from "../src/plugin/index.js"; describe("translateMcpServers", () => { - it("maps a local (stdio) server, splitting command/args and keeping env", () => { - const mcp: Config["mcp"] = { - serena: { - type: "local", - command: ["uvx", "--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"], - environment: { FOO: "bar" }, - }, - }; - expect(translateMcpServers(mcp)).toEqual({ - serena: { - type: "stdio", - command: "uvx", - args: ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"], - env: { FOO: "bar" }, - }, - }); - }); + it("maps a local (stdio) server, splitting command/args and keeping env", () => { + const mcp: Config["mcp"] = { + myserver: { + type: "local", + command: [ + "uvx", + "--from", + "git+https://example.com/org/myserver", + "myserver", + "start-mcp-server", + ], + environment: { FOO: "bar" }, + }, + }; + expect(translateMcpServers(mcp)).toEqual({ + myserver: { + type: "stdio", + command: "uvx", + args: [ + "--from", + "git+https://example.com/org/myserver", + "myserver", + "start-mcp-server", + ], + env: { FOO: "bar" }, + }, + }); + }); - it("maps a remote server to http with headers", () => { - const mcp: Config["mcp"] = { - ctx: { type: "remote", url: "https://mcp.example.com", headers: { Authorization: "Bearer x" } }, - }; - expect(translateMcpServers(mcp)).toEqual({ - ctx: { type: "http", url: "https://mcp.example.com", headers: { Authorization: "Bearer x" } }, - }); - }); + it("maps a remote server to http with headers", () => { + const mcp: Config["mcp"] = { + ctx: { + type: "remote", + url: "https://mcp.example.com", + headers: { Authorization: "Bearer x" }, + }, + }; + expect(translateMcpServers(mcp)).toEqual({ + ctx: { + type: "http", + url: "https://mcp.example.com", + headers: { Authorization: "Bearer x" }, + }, + }); + }); - it("skips disabled servers and ones missing a command/url", () => { - const mcp: Config["mcp"] = { - off: { type: "local", command: ["x"], enabled: false }, - empty: { type: "local", command: [] }, - noUrl: { type: "remote", url: "" }, - ok: { type: "local", command: ["node", "server.js"] }, - }; - expect(translateMcpServers(mcp)).toEqual({ - ok: { type: "stdio", command: "node", args: ["server.js"] }, - }); - }); + it("skips disabled servers and ones missing a command/url", () => { + const mcp: Config["mcp"] = { + off: { type: "local", command: ["x"], enabled: false }, + empty: { type: "local", command: [] }, + noUrl: { type: "remote", url: "" }, + ok: { type: "local", command: ["node", "server.js"] }, + }; + expect(translateMcpServers(mcp)).toEqual({ + ok: { type: "stdio", command: "node", args: ["server.js"] }, + }); + }); - it("returns an empty map for undefined", () => { - expect(translateMcpServers(undefined)).toEqual({}); - }); + it("returns an empty map for undefined", () => { + expect(translateMcpServers(undefined)).toEqual({}); + }); }); describe("plugin config hook MCP forwarding", () => { - it("forwards opencode's configured MCP servers into provider.cursor.options", async () => { - const hooks = await plugin({} as never); - const config: Config = { - mcp: { serena: { type: "local", command: ["uvx", "serena"] } }, - }; - await hooks.config!(config); - const opts = config.provider!.cursor!.options as Record; - expect(opts.mcpServers).toEqual({ serena: { type: "stdio", command: "uvx", args: ["serena"] } }); - }); + it("forwards opencode's configured MCP servers into provider.cursor.options", async () => { + const hooks = await plugin({} as never); + const config: Config = { + mcp: { myserver: { type: "local", command: ["uvx", "myserver"] } }, + }; + await hooks.config!(config); + const opts = config.provider!.cursor!.options as Record; + expect(opts.mcpServers).toEqual({ + myserver: { type: "stdio", command: "uvx", args: ["myserver"] }, + }); + }); - it("respects forwardMcp:false opt-out", async () => { - const hooks = await plugin({} as never); - const config: Config = { - mcp: { serena: { type: "local", command: ["uvx", "serena"] } }, - provider: { cursor: { options: { forwardMcp: false } } }, - }; - await hooks.config!(config); - const opts = config.provider!.cursor!.options as Record; - expect(opts.mcpServers).toBeUndefined(); - }); + it("respects forwardMcp:false opt-out", async () => { + const hooks = await plugin({} as never); + const config: Config = { + mcp: { myserver: { type: "local", command: ["uvx", "myserver"] } }, + provider: { cursor: { options: { forwardMcp: false } } }, + }; + await hooks.config!(config); + const opts = config.provider!.cursor!.options as Record; + expect(opts.mcpServers).toBeUndefined(); + }); }); diff --git a/test/stream-map.test.ts b/test/stream-map.test.ts index 781030d..8624d35 100644 --- a/test/stream-map.test.ts +++ b/test/stream-map.test.ts @@ -145,11 +145,11 @@ describe("cursorEventsToStream", () => { result: { ok: true }, isError: false, }, - { type: "tool-call", id: "c2", name: "serena/find_symbol", input: {} }, + { type: "tool-call", id: "c2", name: "myserver/find_symbol", input: {} }, { type: "tool-result", id: "c2", - name: "serena/find_symbol", + name: "myserver/find_symbol", result: { e: 1 }, isError: true, }, @@ -176,7 +176,7 @@ describe("cursorEventsToStream", () => { expect(reasoning).toContain("[tool] write"); expect(reasoning).toContain('{"path":"a.txt"}'); // A failed tool surfaces its failure; a successful one does not add a status line. - expect(reasoning).toContain("[tool] serena/find_symbol failed"); + expect(reasoning).toContain("[tool] myserver/find_symbol failed"); expect(reasoning).not.toContain("write failed"); // The final answer text is unpolluted by tool noise. @@ -205,13 +205,13 @@ describe("cursorEventsToStream", () => { { type: "tool-call", id: "c2", - name: "serena/find_symbol", + name: "myserver/find_symbol", input: { q: "x" }, }, { type: "tool-result", id: "c2", - name: "serena/find_symbol", + name: "myserver/find_symbol", result: { err: "no" }, isError: true, }, @@ -255,7 +255,7 @@ describe("cursorEventsToStream", () => { }); expect(results[1]).toMatchObject({ toolCallId: "c2", - toolName: "cursor_serena_find_symbol", + toolName: "cursor_myserver_find_symbol", result: { err: "no" }, isError: true, });