Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ coverage/
*.tgz

# Local editor / agent tooling
.serena/
.opencode/
.claude/

Expand Down
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` +
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
223 changes: 118 additions & 105 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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<string, unknown>;
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<string, unknown>;
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;
62 changes: 33 additions & 29 deletions src/plugin/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,47 @@ 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
* and the Cursor agent can each connect to the same server. Disabled entries
* (`enabled: false`) are skipped. opencode-only fields with no Cursor
* equivalent (timeout, oauth) are dropped.
*/
export function translateMcpServers(mcp: Config["mcp"]): Record<string, McpServerConfig> {
const out: Record<string, McpServerConfig> = {};
if (!mcp) return out;
export function translateMcpServers(
mcp: Config["mcp"],
): Record<string, McpServerConfig> {
const out: Record<string, McpServerConfig> = {};
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;
}
4 changes: 2 additions & 2 deletions src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, McpServerConfig>;
/**
Expand Down
2 changes: 1 addition & 1 deletion src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface CursorModelConfig {
mode: AgentModeOption;
/** Default Cursor model params (id -> value); overridable per-request. */
params?: Record<string, string>;
/** 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<string, McpServerConfig>;
/** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */
settingSources?: SettingSource[];
Expand Down
2 changes: 1 addition & 1 deletion src/provider/stream-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "_")}`;
Expand Down
Loading