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
74 changes: 69 additions & 5 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { Plugin } from "@opencode-ai/plugin";
import type { Config, Plugin } from "@opencode-ai/plugin";
import type { Auth } from "@opencode-ai/sdk/v2";
import type { McpServerConfig } from "@cursor/sdk";
import { resolveCursorApiKey } from "../api-key.js";
import { discoverModels, toOpencodeModels } from "../model-discovery.js";
import { buildModelV2Map, PROVIDER_ID, providerNpm } from "./model-v2.js";
import { translateMcpServers } from "./mcp-config.js";
import {
findUnshareableOAuthServers,
type McpStatusMap,
translateMcpServers,
} from "./mcp-config.js";
import { buildCursorTools } from "./cursor-tools.js";

function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
Expand All @@ -28,6 +33,17 @@ export const CursorPlugin: Plugin = async (input) => {
// back to the CURSOR_API_KEY env var when the loader hasn't run.
let capturedApiKey: string | undefined;

// opencode client + MCP-forwarding settings captured at config time so the
// per-turn chat.params hook can re-forward the *live* MCP server set
// (reflecting mid-session enable/disable) rather than the startup snapshot.
const client = input?.client;
const directory = input?.directory;
let forwardMcp = true;
let userMcp: Record<string, McpServerConfig> = {};
// OAuth servers we've already warned about, so the toast fires once per
// server rather than on every turn.
const warnedOAuth = new Set<string>();

return {
auth: {
provider: PROVIDER_ID,
Expand Down Expand Up @@ -68,10 +84,10 @@ export const CursorPlugin: Plugin = async (input) => {
// 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<
forwardMcp = existingOptions["forwardMcp"] !== false;
userMcp = (existingOptions["mcpServers"] ?? {}) as Record<
string,
unknown
McpServerConfig
>;
const mcpServers = forwardMcp
? { ...userMcp, ...translateMcpServers(config.mcp) }
Expand Down Expand Up @@ -115,6 +131,54 @@ export const CursorPlugin: Plugin = async (input) => {
if (input.agent === "plan" && output.options["mode"] === undefined) {
output.options["mode"] = "plan";
}

// Dynamically re-forward MCP servers from opencode's *live* state so
// mid-session enable/disable reaches the Cursor agent (the config hook
// only snapshots the set once, at startup). `client.mcp.status()` is the
// runtime truth (connected/disabled/...) and `client.config.get()`
// supplies the launch specs. On any failure we leave the static snapshot
// (already baked into the provider options) in place.
if (forwardMcp && client) {
try {
const query = directory ? { query: { directory } } : undefined;
const [cfgRes, statusRes] = await Promise.all([
client.config.get(),
client.mcp.status(query),
]);
const liveMcp = (cfgRes?.data as Config | undefined)?.mcp;
const status = statusRes?.data as McpStatusMap | undefined;
if (status) {
output.options["mcpServers"] = {
...userMcp,
...translateMcpServers(liveMcp, status),
};
// Notify (once) about OAuth servers we can't forward: opencode
// holds their token and it never reaches config.mcp, so the
// Cursor agent can't connect. Only those without a shareable
// client registration are skipped; ones with a clientId are
// forwarded with an `auth` block for the agent's own OAuth flow.
const unshareable = findUnshareableOAuthServers(
liveMcp,
status,
).filter((name) => !warnedOAuth.has(name));
if (unshareable.length > 0) {
for (const name of unshareable) warnedOAuth.add(name);
const plural = unshareable.length > 1;
void client.tui
.showToast({
body: {
title: "Cursor MCP",
message: `Skipped OAuth MCP server${plural ? "s" : ""}: ${unshareable.join(", ")}. opencode's token can't be shared with the Cursor agent; configure an OAuth clientId to forward ${plural ? "them" : "it"}.`,
variant: "warning",
},
})
.catch(() => {});
}
}
} catch {
// Keep the static snapshot; live forwarding is best-effort.
}
}
},

tool: {
Expand Down
100 changes: 97 additions & 3 deletions src/plugin/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,112 @@ import type { McpServerConfig } from "@cursor/sdk";
type OpencodeMcp = NonNullable<Config["mcp"]>;
type OpencodeMcpEntry = OpencodeMcp[string];

/**
* Live MCP server status, keyed by server name, as reported by opencode's
* `client.mcp.status()`. Only the `status` field is consumed; `"connected"`
* means the server is currently usable. Mirrors the SDK's `McpStatus` union
* without importing it (keeps this module dependency-light).
*/
export type McpStatusMap = Record<string, { status?: string } | undefined>;

/** opencode runtime statuses that mean a server still needs OAuth to connect. */
const NEEDS_AUTH_STATUS = new Set(["needs_auth", "needs_client_registration"]);

/** The OAuth client registration on a remote entry, or undefined when none. */
function oauthConfig(
entry: OpencodeMcpEntry,
): { clientId?: string; clientSecret?: string; scope?: string } | undefined {
if (entry.type !== "remote") return undefined;
// `oauth` is `McpOAuthConfig | false | undefined`; both false and undefined
// are falsy, so a truthy value is the client-registration object.
return entry.oauth ? entry.oauth : undefined;
}

/**
* Map opencode's OAuth client registration to the Cursor SDK's `auth` block so
* the Cursor agent can run its own OAuth flow. Returns undefined when there is
* no `clientId` to share (e.g. RFC 7591 dynamic registration) — opencode's
* access token itself never reaches `config.mcp`, so a bare URL would fail.
*/
function toCursorAuth(
oauth:
| { clientId?: string; clientSecret?: string; scope?: string }
| undefined,
):
| { CLIENT_ID: string; CLIENT_SECRET?: string; scopes?: string[] }
| undefined {
if (!oauth?.clientId) return undefined;
const scopes = oauth.scope?.split(/\s+/).filter(Boolean);
return {
CLIENT_ID: oauth.clientId,
...(oauth.clientSecret ? { CLIENT_SECRET: oauth.clientSecret } : {}),
...(scopes && scopes.length > 0 ? { scopes } : {}),
};
}

/**
* Names of remote servers that require OAuth but cannot be forwarded to the
* Cursor agent because no shareable client registration exists (dynamic
* registration, or a `needs_auth` runtime status with no configured
* `clientId`). The plugin surfaces these to the user instead of silently
* forwarding a spec that would 401.
*/
export function findUnshareableOAuthServers(
mcp: Config["mcp"],
status?: McpStatusMap,
): string[] {
const names: string[] = [];
if (!mcp) return names;
for (const [name, entry] of Object.entries(mcp) as Array<
[string, OpencodeMcpEntry]
>) {
if (!entry || entry.type !== "remote") continue;
if (!status && entry.enabled === false) continue;
const s = status?.[name]?.status;
if (status && s !== "connected" && !NEEDS_AUTH_STATUS.has(s ?? ""))
continue;
const oauth = oauthConfig(entry);
const needsOAuth = Boolean(oauth) || NEEDS_AUTH_STATUS.has(s ?? "");
if (needsOAuth && !toCursorAuth(oauth)) names.push(name);
}
return names;
}

/**
* Translate opencode's configured MCP servers (`config.mcp`) into the Cursor
* 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.
* (`enabled: false`) are skipped. The `timeout` field is dropped (no Cursor
* equivalent). OAuth is mapped where possible: a remote server's `oauth` client
* registration becomes Cursor's `auth` block so the agent runs its own OAuth
* flow; servers needing OAuth with no shareable `clientId` are skipped (the
* plugin reports them via {@link findUnshareableOAuthServers}).
*/
export function translateMcpServers(
mcp: Config["mcp"],
status?: McpStatusMap,
): 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;
if (!entry) continue;

// When a live status map is supplied (per-turn dynamic forwarding), it is
// the source of truth: forward only servers opencode has currently
// connected, so mid-session enable/disable propagates to the Cursor agent.
// Without it (the startup config snapshot), fall back to the static
// `enabled` flag.
if (status) {
if (status[name]?.status !== "connected") continue;
} else if (entry.enabled === false) {
continue;
}

if (entry.type === "local") {
const [command, ...args] = entry.command ?? [];
Expand All @@ -39,12 +125,20 @@ export function translateMcpServers(
};
} else if (entry.type === "remote") {
if (!entry.url) continue;
const oauth = oauthConfig(entry);
const auth = toCursorAuth(oauth);
// OAuth server with no shareable client registration: opencode holds the
// token and it never lands in config.mcp, so skip rather than forward a
// bare URL that would 401. The plugin notifies the user (see
// findUnshareableOAuthServers).
if (oauth && !auth) continue;
out[name] = {
type: "http",
url: entry.url,
...(entry.headers && Object.keys(entry.headers).length > 0
? { headers: entry.headers }
: {}),
...(auth ? { auth } : {}),
};
}
}
Expand Down
35 changes: 28 additions & 7 deletions src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
} from "./stream-map.js";
import { resolveControls } from "./controls.js";
import { acquireAgent, getSessionRecord } from "./session-pool.js";
import { classifyTurn, fingerprint } from "./transcript-fingerprint.js";
import {
classifyTurn,
fingerprint,
mcpServersFingerprint,
} from "./transcript-fingerprint.js";

export interface CursorModelConfig {
/** Provider id used for logging and the providerOptions key (e.g. "cursor"). */
Expand Down Expand Up @@ -112,6 +116,14 @@ export class CursorLanguageModel implements LanguageModelV3 {
typeof providerOptions?.["sessionID"] === "string"
? (providerOptions["sessionID"] as string)
: undefined;
// MCP servers may be re-forwarded per turn by the plugin's chat.params hook
// (reflecting live opencode enable/disable). When present, the dynamic set
// wins over the static startup snapshot baked into config.mcpServers.
const dynamicMcp = providerOptions?.["mcpServers"] as
| Record<string, McpServerConfig>
| undefined;
const mcpServers = dynamicMcp ?? this.config.mcpServers;
const mcpHash = mcpServersFingerprint(mcpServers);
// `session` defaults to "auto" (fingerprint-guarded reuse); `true` is an
// alias for "auto"; `false` keeps the per-turn-fresh full-transcript path.
const sessionEnabled = (this.config.session ?? "auto") !== false;
Expand All @@ -129,7 +141,9 @@ export class CursorLanguageModel implements LanguageModelV3 {
const usePool = sessionEnabled && Boolean(sessionID) && !explicitAgentId;
let resumeAgentId: string | undefined = explicitAgentId;
let poolKey: string | undefined;
let record: { systemHash: string; userHashes: string[] } | undefined;
let record:
| { systemHash: string; userHashes: string[]; mcpHash?: string }
| undefined;
if (usePool) {
const classification = ephemeral
? {
Expand All @@ -138,15 +152,22 @@ export class CursorLanguageModel implements LanguageModelV3 {
}
: classifyTurn(getSessionRecord(sessionID!), options.prompt);
switch (classification.kind) {
case "continuation":
resumeAgentId = getSessionRecord(sessionID!)?.agentId;
case "continuation": {
const prev = getSessionRecord(sessionID!);
// A resumed agent keeps its original MCP servers, so only resume
// when the live MCP set is unchanged; otherwise create fresh so the
// new server set takes effect (re-pooled under the same session).
if (prev?.mcpHash === mcpHash) {
resumeAgentId = prev?.agentId;
}
poolKey = sessionID;
record = classification.fingerprint;
record = { ...classification.fingerprint, mcpHash };
break;
}
case "new":
case "divergence":
poolKey = sessionID;
record = classification.fingerprint;
record = { ...classification.fingerprint, mcpHash };
break;
case "side-call":
// fresh ephemeral agent; pool left untouched.
Expand Down Expand Up @@ -174,7 +195,7 @@ export class CursorLanguageModel implements LanguageModelV3 {
...(this.config.sandbox !== undefined
? { sandbox: this.config.sandbox }
: {}),
...(this.config.mcpServers ? { mcpServers: this.config.mcpServers } : {}),
...(mcpServers ? { mcpServers } : {}),
...(this.config.agents ? { agents: this.config.agents } : {}),
...(poolKey ? { name: `opencode/${sessionID!.slice(-8)}` } : {}),
...(resumeAgentId ? { resumeAgentId } : {}),
Expand Down
5 changes: 4 additions & 1 deletion src/provider/session-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface AcquireAgentParams {
*/
poolKey?: string;
/** Fingerprint of the current prompt, stored when `poolKey` is set. */
record?: { systemHash: string; userHashes: string[] };
record?: { systemHash: string; userHashes: string[]; mcpHash?: string };
}

export interface AcquiredAgent {
Expand Down Expand Up @@ -111,6 +111,9 @@ export async function acquireAgent(
agentId: agent.agentId,
systemHash: params.record.systemHash,
userHashes: params.record.userHashes,
...(params.record.mcpHash !== undefined
? { mcpHash: params.record.mcpHash }
: {}),
});
}

Expand Down
20 changes: 20 additions & 0 deletions src/provider/transcript-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
import type { McpServerConfig } from "@cursor/sdk";

/**
* Per-session bookkeeping that lets the provider decide, on each turn, whether
Expand All @@ -21,6 +22,12 @@ export interface TranscriptRecord {
systemHash: string;
/** Ordered hash per user message (text + a stable image token). */
userHashes: string[];
/**
* Hash of the MCP server set the pooled agent was created with. A resumed
* Cursor agent keeps its original MCP servers, so when this changes between
* turns the pool must create a fresh agent rather than resume.
*/
mcpHash?: string;
}

/** What kind of turn this is relative to the session's last recorded state. */
Expand All @@ -44,6 +51,19 @@ function sha(input: string): string {
return createHash("sha256").update(input).digest("hex");
}

/**
* Stable hash of the MCP server set handed to `Agent.create`. Keys are sorted
* so map ordering never changes the result; empty/undefined sets hash to "".
*/
export function mcpServersFingerprint(
servers: Record<string, McpServerConfig> | undefined,
): string {
if (!servers) return "";
const keys = Object.keys(servers).sort();
if (keys.length === 0) return "";
return sha(JSON.stringify(keys.map((k) => [k, servers[k]])));
}

/** Stable key for one user message: its text plus a token per attached image. */
function userMessageKey(
message: Extract<LanguageModelV3Prompt[number], { role: "user" }>,
Expand Down
Loading