Skip to content

Commit 6c089c9

Browse files
committed
feat: re-forward live MCP servers per turn with OAuth mapping
The config hook only snapshots opencode's MCP set once at startup, so mid-session enable/disable never reached the Cursor agent. Re-forward the live set from chat.params using client.mcp.status() (runtime truth) + client.config.get() (launch specs), and force a fresh Agent.create when the forwarded set changes between turns (a resumed agent keeps its original servers). Map remote OAuth client registration (clientId/clientSecret/scope) onto the Cursor SDK's auth block so the agent runs its own OAuth flow. opencode's access token never lands in config.mcp, so servers needing OAuth without a shareable clientId (dynamic registration / needs_auth) are skipped and the user is notified via a one-time toast instead of forwarding a spec that 401s.
1 parent 361f152 commit 6c089c9

8 files changed

Lines changed: 504 additions & 17 deletions

File tree

src/plugin/index.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import type { Plugin } from "@opencode-ai/plugin";
1+
import type { Config, Plugin } from "@opencode-ai/plugin";
22
import type { Auth } from "@opencode-ai/sdk/v2";
3+
import type { McpServerConfig } from "@cursor/sdk";
34
import { resolveCursorApiKey } from "../api-key.js";
45
import { discoverModels, toOpencodeModels } from "../model-discovery.js";
56
import { buildModelV2Map, PROVIDER_ID, providerNpm } from "./model-v2.js";
6-
import { translateMcpServers } from "./mcp-config.js";
7+
import {
8+
findUnshareableOAuthServers,
9+
type McpStatusMap,
10+
translateMcpServers,
11+
} from "./mcp-config.js";
712
import { buildCursorTools } from "./cursor-tools.js";
813

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

36+
// opencode client + MCP-forwarding settings captured at config time so the
37+
// per-turn chat.params hook can re-forward the *live* MCP server set
38+
// (reflecting mid-session enable/disable) rather than the startup snapshot.
39+
const client = input?.client;
40+
const directory = input?.directory;
41+
let forwardMcp = true;
42+
let userMcp: Record<string, McpServerConfig> = {};
43+
// OAuth servers we've already warned about, so the toast fires once per
44+
// server rather than on every turn.
45+
const warnedOAuth = new Set<string>();
46+
3147
return {
3248
auth: {
3349
provider: PROVIDER_ID,
@@ -68,10 +84,10 @@ export const CursorPlugin: Plugin = async (input) => {
6884
// Forward opencode's configured MCP servers to the Cursor
6985
// agent so it can use the same servers. Opt out via
7086
// `provider.cursor.options.forwardMcp: false`.
71-
const forwardMcp = existingOptions["forwardMcp"] !== false;
72-
const userMcp = (existingOptions["mcpServers"] ?? {}) as Record<
87+
forwardMcp = existingOptions["forwardMcp"] !== false;
88+
userMcp = (existingOptions["mcpServers"] ?? {}) as Record<
7389
string,
74-
unknown
90+
McpServerConfig
7591
>;
7692
const mcpServers = forwardMcp
7793
? { ...userMcp, ...translateMcpServers(config.mcp) }
@@ -115,6 +131,54 @@ export const CursorPlugin: Plugin = async (input) => {
115131
if (input.agent === "plan" && output.options["mode"] === undefined) {
116132
output.options["mode"] = "plan";
117133
}
134+
135+
// Dynamically re-forward MCP servers from opencode's *live* state so
136+
// mid-session enable/disable reaches the Cursor agent (the config hook
137+
// only snapshots the set once, at startup). `client.mcp.status()` is the
138+
// runtime truth (connected/disabled/...) and `client.config.get()`
139+
// supplies the launch specs. On any failure we leave the static snapshot
140+
// (already baked into the provider options) in place.
141+
if (forwardMcp && client) {
142+
try {
143+
const query = directory ? { query: { directory } } : undefined;
144+
const [cfgRes, statusRes] = await Promise.all([
145+
client.config.get(),
146+
client.mcp.status(query),
147+
]);
148+
const liveMcp = (cfgRes?.data as Config | undefined)?.mcp;
149+
const status = statusRes?.data as McpStatusMap | undefined;
150+
if (status) {
151+
output.options["mcpServers"] = {
152+
...userMcp,
153+
...translateMcpServers(liveMcp, status),
154+
};
155+
// Notify (once) about OAuth servers we can't forward: opencode
156+
// holds their token and it never reaches config.mcp, so the
157+
// Cursor agent can't connect. Only those without a shareable
158+
// client registration are skipped; ones with a clientId are
159+
// forwarded with an `auth` block for the agent's own OAuth flow.
160+
const unshareable = findUnshareableOAuthServers(
161+
liveMcp,
162+
status,
163+
).filter((name) => !warnedOAuth.has(name));
164+
if (unshareable.length > 0) {
165+
for (const name of unshareable) warnedOAuth.add(name);
166+
const plural = unshareable.length > 1;
167+
void client.tui
168+
.showToast({
169+
body: {
170+
title: "Cursor MCP",
171+
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"}.`,
172+
variant: "warning",
173+
},
174+
})
175+
.catch(() => {});
176+
}
177+
}
178+
} catch {
179+
// Keep the static snapshot; live forwarding is best-effort.
180+
}
181+
}
118182
},
119183

120184
tool: {

src/plugin/mcp-config.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,112 @@ import type { McpServerConfig } from "@cursor/sdk";
55
type OpencodeMcp = NonNullable<Config["mcp"]>;
66
type OpencodeMcpEntry = OpencodeMcp[string];
77

8+
/**
9+
* Live MCP server status, keyed by server name, as reported by opencode's
10+
* `client.mcp.status()`. Only the `status` field is consumed; `"connected"`
11+
* means the server is currently usable. Mirrors the SDK's `McpStatus` union
12+
* without importing it (keeps this module dependency-light).
13+
*/
14+
export type McpStatusMap = Record<string, { status?: string } | undefined>;
15+
16+
/** opencode runtime statuses that mean a server still needs OAuth to connect. */
17+
const NEEDS_AUTH_STATUS = new Set(["needs_auth", "needs_client_registration"]);
18+
19+
/** The OAuth client registration on a remote entry, or undefined when none. */
20+
function oauthConfig(
21+
entry: OpencodeMcpEntry,
22+
): { clientId?: string; clientSecret?: string; scope?: string } | undefined {
23+
if (entry.type !== "remote") return undefined;
24+
// `oauth` is `McpOAuthConfig | false | undefined`; both false and undefined
25+
// are falsy, so a truthy value is the client-registration object.
26+
return entry.oauth ? entry.oauth : undefined;
27+
}
28+
29+
/**
30+
* Map opencode's OAuth client registration to the Cursor SDK's `auth` block so
31+
* the Cursor agent can run its own OAuth flow. Returns undefined when there is
32+
* no `clientId` to share (e.g. RFC 7591 dynamic registration) — opencode's
33+
* access token itself never reaches `config.mcp`, so a bare URL would fail.
34+
*/
35+
function toCursorAuth(
36+
oauth:
37+
| { clientId?: string; clientSecret?: string; scope?: string }
38+
| undefined,
39+
):
40+
| { CLIENT_ID: string; CLIENT_SECRET?: string; scopes?: string[] }
41+
| undefined {
42+
if (!oauth?.clientId) return undefined;
43+
const scopes = oauth.scope?.split(/\s+/).filter(Boolean);
44+
return {
45+
CLIENT_ID: oauth.clientId,
46+
...(oauth.clientSecret ? { CLIENT_SECRET: oauth.clientSecret } : {}),
47+
...(scopes && scopes.length > 0 ? { scopes } : {}),
48+
};
49+
}
50+
51+
/**
52+
* Names of remote servers that require OAuth but cannot be forwarded to the
53+
* Cursor agent because no shareable client registration exists (dynamic
54+
* registration, or a `needs_auth` runtime status with no configured
55+
* `clientId`). The plugin surfaces these to the user instead of silently
56+
* forwarding a spec that would 401.
57+
*/
58+
export function findUnshareableOAuthServers(
59+
mcp: Config["mcp"],
60+
status?: McpStatusMap,
61+
): string[] {
62+
const names: string[] = [];
63+
if (!mcp) return names;
64+
for (const [name, entry] of Object.entries(mcp) as Array<
65+
[string, OpencodeMcpEntry]
66+
>) {
67+
if (!entry || entry.type !== "remote") continue;
68+
if (!status && entry.enabled === false) continue;
69+
const s = status?.[name]?.status;
70+
if (status && s !== "connected" && !NEEDS_AUTH_STATUS.has(s ?? ""))
71+
continue;
72+
const oauth = oauthConfig(entry);
73+
const needsOAuth = Boolean(oauth) || NEEDS_AUTH_STATUS.has(s ?? "");
74+
if (needsOAuth && !toCursorAuth(oauth)) names.push(name);
75+
}
76+
return names;
77+
}
78+
879
/**
980
* Translate opencode's configured MCP servers (`config.mcp`) into the Cursor
1081
* SDK's `McpServerConfig` shape so the same servers can be handed
1182
* to the Cursor agent via `Agent.create({ mcpServers })`.
1283
*
1384
* MCP servers are independent processes addressed by a launch spec, so opencode
1485
* and the Cursor agent can each connect to the same server. Disabled entries
15-
* (`enabled: false`) are skipped. opencode-only fields with no Cursor
16-
* equivalent (timeout, oauth) are dropped.
86+
* (`enabled: false`) are skipped. The `timeout` field is dropped (no Cursor
87+
* equivalent). OAuth is mapped where possible: a remote server's `oauth` client
88+
* registration becomes Cursor's `auth` block so the agent runs its own OAuth
89+
* flow; servers needing OAuth with no shareable `clientId` are skipped (the
90+
* plugin reports them via {@link findUnshareableOAuthServers}).
1791
*/
1892
export function translateMcpServers(
1993
mcp: Config["mcp"],
94+
status?: McpStatusMap,
2095
): Record<string, McpServerConfig> {
2196
const out: Record<string, McpServerConfig> = {};
2297
if (!mcp) return out;
2398

2499
for (const [name, entry] of Object.entries(mcp) as Array<
25100
[string, OpencodeMcpEntry]
26101
>) {
27-
if (!entry || entry.enabled === false) continue;
102+
if (!entry) continue;
103+
104+
// When a live status map is supplied (per-turn dynamic forwarding), it is
105+
// the source of truth: forward only servers opencode has currently
106+
// connected, so mid-session enable/disable propagates to the Cursor agent.
107+
// Without it (the startup config snapshot), fall back to the static
108+
// `enabled` flag.
109+
if (status) {
110+
if (status[name]?.status !== "connected") continue;
111+
} else if (entry.enabled === false) {
112+
continue;
113+
}
28114

29115
if (entry.type === "local") {
30116
const [command, ...args] = entry.command ?? [];
@@ -39,12 +125,20 @@ export function translateMcpServers(
39125
};
40126
} else if (entry.type === "remote") {
41127
if (!entry.url) continue;
128+
const oauth = oauthConfig(entry);
129+
const auth = toCursorAuth(oauth);
130+
// OAuth server with no shareable client registration: opencode holds the
131+
// token and it never lands in config.mcp, so skip rather than forward a
132+
// bare URL that would 401. The plugin notifies the user (see
133+
// findUnshareableOAuthServers).
134+
if (oauth && !auth) continue;
42135
out[name] = {
43136
type: "http",
44137
url: entry.url,
45138
...(entry.headers && Object.keys(entry.headers).length > 0
46139
? { headers: entry.headers }
47140
: {}),
141+
...(auth ? { auth } : {}),
48142
};
49143
}
50144
}

src/provider/language-model.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import {
2323
} from "./stream-map.js";
2424
import { resolveControls } from "./controls.js";
2525
import { acquireAgent, getSessionRecord } from "./session-pool.js";
26-
import { classifyTurn, fingerprint } from "./transcript-fingerprint.js";
26+
import {
27+
classifyTurn,
28+
fingerprint,
29+
mcpServersFingerprint,
30+
} from "./transcript-fingerprint.js";
2731

2832
export interface CursorModelConfig {
2933
/** Provider id used for logging and the providerOptions key (e.g. "cursor"). */
@@ -112,6 +116,14 @@ export class CursorLanguageModel implements LanguageModelV3 {
112116
typeof providerOptions?.["sessionID"] === "string"
113117
? (providerOptions["sessionID"] as string)
114118
: undefined;
119+
// MCP servers may be re-forwarded per turn by the plugin's chat.params hook
120+
// (reflecting live opencode enable/disable). When present, the dynamic set
121+
// wins over the static startup snapshot baked into config.mcpServers.
122+
const dynamicMcp = providerOptions?.["mcpServers"] as
123+
| Record<string, McpServerConfig>
124+
| undefined;
125+
const mcpServers = dynamicMcp ?? this.config.mcpServers;
126+
const mcpHash = mcpServersFingerprint(mcpServers);
115127
// `session` defaults to "auto" (fingerprint-guarded reuse); `true` is an
116128
// alias for "auto"; `false` keeps the per-turn-fresh full-transcript path.
117129
const sessionEnabled = (this.config.session ?? "auto") !== false;
@@ -129,7 +141,9 @@ export class CursorLanguageModel implements LanguageModelV3 {
129141
const usePool = sessionEnabled && Boolean(sessionID) && !explicitAgentId;
130142
let resumeAgentId: string | undefined = explicitAgentId;
131143
let poolKey: string | undefined;
132-
let record: { systemHash: string; userHashes: string[] } | undefined;
144+
let record:
145+
| { systemHash: string; userHashes: string[]; mcpHash?: string }
146+
| undefined;
133147
if (usePool) {
134148
const classification = ephemeral
135149
? {
@@ -138,15 +152,22 @@ export class CursorLanguageModel implements LanguageModelV3 {
138152
}
139153
: classifyTurn(getSessionRecord(sessionID!), options.prompt);
140154
switch (classification.kind) {
141-
case "continuation":
142-
resumeAgentId = getSessionRecord(sessionID!)?.agentId;
155+
case "continuation": {
156+
const prev = getSessionRecord(sessionID!);
157+
// A resumed agent keeps its original MCP servers, so only resume
158+
// when the live MCP set is unchanged; otherwise create fresh so the
159+
// new server set takes effect (re-pooled under the same session).
160+
if (prev?.mcpHash === mcpHash) {
161+
resumeAgentId = prev?.agentId;
162+
}
143163
poolKey = sessionID;
144-
record = classification.fingerprint;
164+
record = { ...classification.fingerprint, mcpHash };
145165
break;
166+
}
146167
case "new":
147168
case "divergence":
148169
poolKey = sessionID;
149-
record = classification.fingerprint;
170+
record = { ...classification.fingerprint, mcpHash };
150171
break;
151172
case "side-call":
152173
// fresh ephemeral agent; pool left untouched.
@@ -174,7 +195,7 @@ export class CursorLanguageModel implements LanguageModelV3 {
174195
...(this.config.sandbox !== undefined
175196
? { sandbox: this.config.sandbox }
176197
: {}),
177-
...(this.config.mcpServers ? { mcpServers: this.config.mcpServers } : {}),
198+
...(mcpServers ? { mcpServers } : {}),
178199
...(this.config.agents ? { agents: this.config.agents } : {}),
179200
...(poolKey ? { name: `opencode/${sessionID!.slice(-8)}` } : {}),
180201
...(resumeAgentId ? { resumeAgentId } : {}),

src/provider/session-pool.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export interface AcquireAgentParams {
5050
*/
5151
poolKey?: string;
5252
/** Fingerprint of the current prompt, stored when `poolKey` is set. */
53-
record?: { systemHash: string; userHashes: string[] };
53+
record?: { systemHash: string; userHashes: string[]; mcpHash?: string };
5454
}
5555

5656
export interface AcquiredAgent {
@@ -111,6 +111,9 @@ export async function acquireAgent(
111111
agentId: agent.agentId,
112112
systemHash: params.record.systemHash,
113113
userHashes: params.record.userHashes,
114+
...(params.record.mcpHash !== undefined
115+
? { mcpHash: params.record.mcpHash }
116+
: {}),
114117
});
115118
}
116119

src/provider/transcript-fingerprint.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash } from "node:crypto";
22
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
3+
import type { McpServerConfig } from "@cursor/sdk";
34

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

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

54+
/**
55+
* Stable hash of the MCP server set handed to `Agent.create`. Keys are sorted
56+
* so map ordering never changes the result; empty/undefined sets hash to "".
57+
*/
58+
export function mcpServersFingerprint(
59+
servers: Record<string, McpServerConfig> | undefined,
60+
): string {
61+
if (!servers) return "";
62+
const keys = Object.keys(servers).sort();
63+
if (keys.length === 0) return "";
64+
return sha(JSON.stringify(keys.map((k) => [k, servers[k]])));
65+
}
66+
4767
/** Stable key for one user message: its text plus a token per attached image. */
4868
function userMessageKey(
4969
message: Extract<LanguageModelV3Prompt[number], { role: "user" }>,

0 commit comments

Comments
 (0)