cursor-opencode-provider 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,28 +4,45 @@ export type OpencodeToolDef = {
4
4
  description?: string;
5
5
  inputSchema?: unknown;
6
6
  };
7
+ export type ToolServerIdentity = {
8
+ /** Cursor MCP server id / provider_identifier (e.g. opencode, github). */
9
+ server: string;
10
+ /** Bare tool name inside that server (Cursor McpArgs.tool_name). */
11
+ toolName: string;
12
+ /** Full OpenCode tool id used for local execution (e.g. github_create_pull_request). */
13
+ opencodeName: string;
14
+ };
15
+ /** Match OpenCode's McpCatalog.sanitize for config server ids. */
16
+ export declare function sanitizeMcpServerId(value: string): string;
17
+ /**
18
+ * Resolve an OpenCode tool id using known MCP server ids from merged config.
19
+ * Unknown names remain under the synthetic default server: a flattened tool
20
+ * name alone cannot distinguish plugin/custom tools from `<server>_<tool>`.
21
+ */
22
+ export declare function resolveToolServerIdentity(opencodeName: string, defaultServer?: string, knownMcpServers?: Iterable<string>): ToolServerIdentity;
7
23
  /**
8
24
  * Convert opencode's per-turn tool list into Cursor `McpToolDefinition`
9
25
  * entries for `request_context.tools` (#7) and `AgentRunRequest.mcp_tools`.
10
26
  *
11
- * opencode tools are already namespaced (e.g. `read`, `grep`, or
12
- * `<server>_<tool>` for MCP). We advertise them all under a synthetic
13
- * `opencode` provider so Cursor routes their calls back to us as
14
- * `mcp_args` on the exec channel. The composite `name` is what the model
15
- * calls and what comes back as `McpArgs.name`.
27
+ * Builtins and unknown plugin/custom tools are advertised under the synthetic
28
+ * default server (`opencode`). Tools whose prefixes match configured MCP
29
+ * servers keep those server ids (`github`, …). Composite `name` is
30
+ * `<server>-<bareTool>`; local execution still uses the full OpenCode id
31
+ * reconstructed in `mcpRealToolName`.
16
32
  */
17
- export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string): Array<Record<string, unknown>>;
33
+ export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
18
34
  /**
19
35
  * Build the nested McpFileSystemOptions / McpMetaToolOptions shape used by
20
- * requestContext.#23 / #34. Groups every tool under one synthetic server so
21
- * Cursor's IDE-style decoder also sees the list.
36
+ * requestContext.#23 / #34. One `McpDescriptor` per resolved server (builtins
37
+ * and unknown tools under the synthetic default; configured MCP tools under
38
+ * their upstream server id).
22
39
  */
23
- export declare function toolsToMcpDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string): Array<Record<string, unknown>>;
40
+ export declare function toolsToMcpDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
24
41
  /**
25
42
  * @deprecated Prefer `buildRequestContext` from `../context/build.js`.
26
43
  * Kept as a sync tools-only fallback for unit tests that don't need collectors.
27
44
  */
28
- export declare function buildLiveRequestContext(tools: OpencodeToolDef[], providerIdentifier?: string): Record<string, unknown>;
45
+ export declare function buildLiveRequestContext(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Record<string, unknown>;
29
46
  export declare function mapExecServerToToolName(execField: string): string | undefined;
30
47
  export declare function mapToolNameToExecField(toolName: string): string | undefined;
31
48
  export type ParsedExecRequest = {
@@ -35,6 +52,8 @@ export type ParsedExecRequest = {
35
52
  args: Record<string, unknown>;
36
53
  /** ExecClientMessage result field to reply with (matches the request variant). */
37
54
  resultField: string;
55
+ /** Typed error to return without asking OpenCode to execute invalid args. */
56
+ localError?: string;
38
57
  };
39
58
  export declare function parseExecServerMessage(msg: Record<string, unknown>): ParsedExecRequest | undefined;
40
59
  /**
@@ -46,6 +65,12 @@ export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<st
46
65
  toolName: string;
47
66
  args: Record<string, unknown>;
48
67
  };
68
+ /**
69
+ * Map Cursor McpArgs back to the OpenCode tool id.
70
+ * Prefers provider_identifier + bare tool_name (github + create_pull_request
71
+ * → github_create_pull_request). Builtins under the default server stay bare.
72
+ */
73
+ export declare function mcpRealToolName(mcpArgs: Record<string, unknown>, defaultServer?: string): string;
49
74
  export type ToolResultInput = {
50
75
  execId: number;
51
76
  /** ExecClientMessage result field (from ParsedExecRequest.resultField). */
@@ -113,9 +138,13 @@ export declare function parseExecIdFromToolCallId(toolCallId: string): {
113
138
  * Returns undefined if there is no exec_server_message or no variant set.
114
139
  */
115
140
  export declare function detectExecVariantField(agentServerPayload: Uint8Array): number | undefined;
116
- /** Build ExecClientMessage{1:id, <resultField>: empty} as raw bytes. */
117
- export declare function buildRawEmptyExecReply(execId: number, resultField: number): Uint8Array;
118
141
  /**
119
142
  * Encode exec #10 request_context_result from a prebuilt RequestContext payload.
120
143
  */
121
144
  export declare function buildRequestContextResult(execId: number, requestContext: Record<string, unknown>): Uint8Array;
145
+ /**
146
+ * Answer Cursor's exec #36 MCP-state probe from the same descriptors advertised
147
+ * in RequestContext. OpenCode remains the executor; this only confirms that the
148
+ * provider's virtual MCP servers and their tools are available.
149
+ */
150
+ export declare function buildMcpStateResult(execId: number, args: Record<string, unknown>, requestContext: Record<string, unknown>): Uint8Array;
@@ -5,24 +5,56 @@ import { trace } from "../debug.js";
5
5
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
6
6
  // field number for every exec variant, so this is also the result field.
7
7
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
8
+ /** Match OpenCode's McpCatalog.sanitize for config server ids. */
9
+ export function sanitizeMcpServerId(value) {
10
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_");
11
+ }
12
+ /**
13
+ * Resolve an OpenCode tool id using known MCP server ids from merged config.
14
+ * Unknown names remain under the synthetic default server: a flattened tool
15
+ * name alone cannot distinguish plugin/custom tools from `<server>_<tool>`.
16
+ */
17
+ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencode", knownMcpServers = []) {
18
+ if (!opencodeName) {
19
+ return { server: defaultServer, toolName: "mcp", opencodeName: "mcp" };
20
+ }
21
+ // Longest first handles configured ids where one is a prefix of another
22
+ // (e.g. "git" and "git_hub"). OpenCode flattens with the sanitized id.
23
+ const servers = [...new Set([...knownMcpServers].map(sanitizeMcpServerId).filter(Boolean))]
24
+ .sort((a, b) => b.length - a.length);
25
+ for (const server of servers) {
26
+ const prefix = `${server}_`;
27
+ if (!opencodeName.startsWith(prefix) || opencodeName.length === prefix.length)
28
+ continue;
29
+ return {
30
+ server,
31
+ toolName: opencodeName.slice(prefix.length),
32
+ opencodeName,
33
+ };
34
+ }
35
+ return { server: defaultServer, toolName: opencodeName, opencodeName };
36
+ }
8
37
  /**
9
38
  * Convert opencode's per-turn tool list into Cursor `McpToolDefinition`
10
39
  * entries for `request_context.tools` (#7) and `AgentRunRequest.mcp_tools`.
11
40
  *
12
- * opencode tools are already namespaced (e.g. `read`, `grep`, or
13
- * `<server>_<tool>` for MCP). We advertise them all under a synthetic
14
- * `opencode` provider so Cursor routes their calls back to us as
15
- * `mcp_args` on the exec channel. The composite `name` is what the model
16
- * calls and what comes back as `McpArgs.name`.
41
+ * Builtins and unknown plugin/custom tools are advertised under the synthetic
42
+ * default server (`opencode`). Tools whose prefixes match configured MCP
43
+ * servers keep those server ids (`github`, …). Composite `name` is
44
+ * `<server>-<bareTool>`; local execution still uses the full OpenCode id
45
+ * reconstructed in `mcpRealToolName`.
17
46
  */
18
- export function toolsToDescriptors(tools, providerIdentifier = "opencode") {
19
- return tools.map((t) => ({
20
- name: `${providerIdentifier}-${t.name}`,
21
- description: t.description ?? "",
22
- input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
23
- provider_identifier: providerIdentifier,
24
- tool_name: t.name,
25
- }));
47
+ export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
48
+ return tools.map((t) => {
49
+ const id = resolveToolServerIdentity(t.name, providerIdentifier, knownMcpServers);
50
+ return {
51
+ name: `${id.server}-${id.toolName}`,
52
+ description: t.description ?? "",
53
+ input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
54
+ provider_identifier: id.server,
55
+ tool_name: id.toolName,
56
+ };
57
+ });
26
58
  }
27
59
  function normalizeInputSchema(schema) {
28
60
  if (schema && typeof schema === "object" && !Array.isArray(schema)) {
@@ -32,31 +64,42 @@ function normalizeInputSchema(schema) {
32
64
  }
33
65
  /**
34
66
  * Build the nested McpFileSystemOptions / McpMetaToolOptions shape used by
35
- * requestContext.#23 / #34. Groups every tool under one synthetic server so
36
- * Cursor's IDE-style decoder also sees the list.
67
+ * requestContext.#23 / #34. One `McpDescriptor` per resolved server (builtins
68
+ * and unknown tools under the synthetic default; configured MCP tools under
69
+ * their upstream server id).
37
70
  */
38
- export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode") {
71
+ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
39
72
  if (tools.length === 0)
40
73
  return [];
41
- return [
42
- {
43
- server_name: providerIdentifier,
44
- server_identifier: providerIdentifier,
45
- tools: tools.map((t) => ({
46
- tool_name: t.name,
47
- description: t.description ?? "",
48
- input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
49
- })),
50
- },
51
- ];
74
+ const order = [];
75
+ const byServer = new Map();
76
+ for (const t of tools) {
77
+ const id = resolveToolServerIdentity(t.name, providerIdentifier, knownMcpServers);
78
+ let list = byServer.get(id.server);
79
+ if (!list) {
80
+ list = [];
81
+ byServer.set(id.server, list);
82
+ order.push(id.server);
83
+ }
84
+ list.push({
85
+ tool_name: id.toolName,
86
+ description: t.description ?? "",
87
+ input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
88
+ });
89
+ }
90
+ return order.map((server) => ({
91
+ server_name: server,
92
+ server_identifier: server,
93
+ tools: byServer.get(server),
94
+ }));
52
95
  }
53
96
  /**
54
97
  * @deprecated Prefer `buildRequestContext` from `../context/build.js`.
55
98
  * Kept as a sync tools-only fallback for unit tests that don't need collectors.
56
99
  */
57
- export function buildLiveRequestContext(tools, providerIdentifier = "opencode") {
58
- const flat = toolsToDescriptors(tools, providerIdentifier);
59
- const nested = toolsToMcpDescriptors(tools, providerIdentifier);
100
+ export function buildLiveRequestContext(tools, providerIdentifier = "opencode", knownMcpServers = []) {
101
+ const flat = toolsToDescriptors(tools, providerIdentifier, knownMcpServers);
102
+ const nested = toolsToMcpDescriptors(tools, providerIdentifier, knownMcpServers);
60
103
  const cwd = process.cwd();
61
104
  return {
62
105
  env: {
@@ -94,7 +137,13 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode")
94
137
  const cursorToolToOpencode = {
95
138
  read_args: "read",
96
139
  write_args: "write",
140
+ pi_read_args: "read",
141
+ pi_bash_args: "bash",
142
+ pi_edit_args: "edit",
97
143
  pi_write_args: "write",
144
+ pi_grep_args: "grep",
145
+ pi_find_args: "glob",
146
+ pi_ls_args: "read",
98
147
  grep_args: "grep",
99
148
  ls_args: "read",
100
149
  delete_args: "bash",
@@ -137,6 +186,17 @@ const CURSOR_INTERNAL_KEYS = new Set([
137
186
  "encoding_hint",
138
187
  "ignore",
139
188
  ]);
189
+ /** Required content fields where an empty string is meaningful (for example, truncating a file). */
190
+ const PRESERVE_EMPTY_STRING_KEYS = new Set([
191
+ "content",
192
+ "file_text",
193
+ "fileText",
194
+ "stream_content",
195
+ "oldString",
196
+ "old_string",
197
+ "newString",
198
+ "new_string",
199
+ ]);
140
200
  export function mapExecServerToToolName(execField) {
141
201
  return cursorToolToOpencode[execField];
142
202
  }
@@ -150,22 +210,28 @@ export function mapToolNameToExecField(toolName) {
150
210
  const execVariantToResultField = {
151
211
  read_args: "read_result",
152
212
  write_args: "write_result",
153
- // Pi write: args #48, result #49 (not the same field number).
213
+ pi_read_args: "pi_read_result",
214
+ pi_bash_args: "pi_bash_result",
215
+ pi_edit_args: "pi_edit_result",
154
216
  pi_write_args: "pi_write_result",
217
+ pi_grep_args: "pi_grep_result",
218
+ pi_find_args: "pi_find_result",
219
+ pi_ls_args: "pi_ls_result",
155
220
  grep_args: "grep_result",
156
221
  ls_args: "ls_result",
157
222
  delete_args: "delete_result",
158
223
  shell_stream_args: "shell_stream",
159
224
  mcp_args: "mcp_result",
160
225
  };
161
- const MCP_PREFIX = "opencode-";
162
226
  export function parseExecServerMessage(msg) {
163
227
  const id = msg.id;
164
228
  if (id === undefined)
165
229
  return undefined;
166
230
  // Find which args variant is set
167
231
  const execVariant = findOneOfVariant(msg, [
168
- "read_args", "write_args", "pi_write_args",
232
+ "read_args", "write_args",
233
+ "pi_read_args", "pi_bash_args", "pi_edit_args", "pi_write_args",
234
+ "pi_grep_args", "pi_find_args", "pi_ls_args",
169
235
  "grep_args", "ls_args",
170
236
  "delete_args", "shell_stream_args", "mcp_args",
171
237
  ]);
@@ -187,6 +253,33 @@ export function parseExecServerMessage(msg) {
187
253
  resultField,
188
254
  };
189
255
  }
256
+ if (execVariant === "pi_edit_args") {
257
+ const raw = msg.pi_edit_args ?? {};
258
+ const edits = Array.isArray(raw.edits) ? raw.edits : [];
259
+ const replacement = edits.length === 1 && edits[0] && typeof edits[0] === "object"
260
+ ? edits[0]
261
+ : undefined;
262
+ const path = str(raw.path);
263
+ const oldString = replacement ? stringValue(replacement.old_text) : undefined;
264
+ const newString = replacement ? stringValue(replacement.new_text) : undefined;
265
+ const args = {};
266
+ if (path)
267
+ args.filePath = path;
268
+ if (oldString !== undefined)
269
+ args.oldString = oldString;
270
+ if (newString !== undefined)
271
+ args.newString = newString;
272
+ return {
273
+ id,
274
+ execId,
275
+ toolName: "edit",
276
+ args,
277
+ resultField,
278
+ localError: path && oldString && newString !== undefined
279
+ ? undefined
280
+ : "Cursor Pi edit cannot be represented safely: expected one non-empty replacement.",
281
+ };
282
+ }
190
283
  const toolName = cursorToolToOpencode[execVariant];
191
284
  if (!toolName)
192
285
  return undefined;
@@ -211,8 +304,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
211
304
  continue;
212
305
  if (CURSOR_INTERNAL_KEYS.has(k))
213
306
  continue;
214
- // Drop empty strings from optional protobuf defaults.
215
- if (typeof v === "string" && v.length === 0)
307
+ // Drop empty strings from optional protobuf defaults, but retain required
308
+ // content fields where empty means a valid destructive edit/write.
309
+ if (typeof v === "string" && v.length === 0 && !PRESERVE_EMPTY_STRING_KEYS.has(k))
216
310
  continue;
217
311
  cleaned[k] = v;
218
312
  }
@@ -250,7 +344,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
250
344
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
251
345
  if (filePath)
252
346
  args.filePath = filePath;
253
- const content = str(cleaned.content) ?? str(cleaned.file_text) ?? str(cleaned.fileText);
347
+ const content = stringValue(cleaned.content) ?? stringValue(cleaned.file_text) ?? stringValue(cleaned.fileText);
254
348
  if (content !== undefined)
255
349
  args.content = content;
256
350
  return { toolName: "write", args };
@@ -260,10 +354,10 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
260
354
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
261
355
  if (filePath)
262
356
  args.filePath = filePath;
263
- const oldString = str(cleaned.oldString) ?? str(cleaned.old_string);
357
+ const oldString = stringValue(cleaned.oldString) ?? stringValue(cleaned.old_string);
264
358
  if (oldString !== undefined)
265
359
  args.oldString = oldString;
266
- const newString = str(cleaned.newString) ?? str(cleaned.new_string);
360
+ const newString = stringValue(cleaned.newString) ?? stringValue(cleaned.new_string);
267
361
  if (newString !== undefined)
268
362
  args.newString = newString;
269
363
  if (typeof cleaned.replaceAll === "boolean")
@@ -321,6 +415,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
321
415
  function str(v) {
322
416
  return typeof v === "string" && v.length > 0 ? v : undefined;
323
417
  }
418
+ function stringValue(v) {
419
+ return typeof v === "string" ? v : undefined;
420
+ }
324
421
  function num(v) {
325
422
  if (typeof v === "number" && Number.isFinite(v))
326
423
  return v;
@@ -331,12 +428,35 @@ function num(v) {
331
428
  function shellQuote(s) {
332
429
  return `'${s.replace(/'/g, `'\\''`)}'`;
333
430
  }
334
- function mcpRealToolName(mcpArgs) {
335
- const toolName = mcpArgs.tool_name;
336
- if (toolName)
431
+ /**
432
+ * Map Cursor McpArgs back to the OpenCode tool id.
433
+ * Prefers provider_identifier + bare tool_name (github + create_pull_request
434
+ * → github_create_pull_request). Builtins under the default server stay bare.
435
+ */
436
+ export function mcpRealToolName(mcpArgs, defaultServer = "opencode") {
437
+ const toolName = typeof mcpArgs.tool_name === "string" ? mcpArgs.tool_name : undefined;
438
+ const provider = typeof mcpArgs.provider_identifier === "string" ? mcpArgs.provider_identifier : undefined;
439
+ if (toolName) {
440
+ if (provider && provider !== defaultServer) {
441
+ // Already a full OpenCode id (legacy ads or model echo).
442
+ if (toolName.startsWith(`${provider}_`))
443
+ return toolName;
444
+ return `${provider}_${toolName}`;
445
+ }
337
446
  return toolName;
338
- const name = mcpArgs.name ?? "";
339
- return name.startsWith(MCP_PREFIX) ? name.slice(MCP_PREFIX.length) : name || "mcp";
447
+ }
448
+ const name = typeof mcpArgs.name === "string" ? mcpArgs.name : "";
449
+ const dash = name.indexOf("-");
450
+ if (dash > 0) {
451
+ const server = name.slice(0, dash);
452
+ const bare = name.slice(dash + 1);
453
+ if (server && bare) {
454
+ if (server === defaultServer)
455
+ return bare;
456
+ return `${server}_${bare}`;
457
+ }
458
+ }
459
+ return name || "mcp";
340
460
  }
341
461
  function decodeMcpArgs(raw) {
342
462
  if (!Array.isArray(raw))
@@ -537,6 +657,18 @@ export function buildTypedExecResult(resultField, output, error, toolName) {
537
657
  if (error)
538
658
  return { error: { error } };
539
659
  return { success: { output: output || "Wrote file successfully." } };
660
+ case "pi_read_result":
661
+ if (error)
662
+ return { error: { error } };
663
+ return { success: { output: unwrapReadOutput(output) } };
664
+ case "pi_bash_result":
665
+ case "pi_edit_result":
666
+ case "pi_grep_result":
667
+ case "pi_find_result":
668
+ case "pi_ls_result":
669
+ if (error)
670
+ return { error: { error } };
671
+ return { success: { output } };
540
672
  case "delete_result":
541
673
  if (error)
542
674
  return { error: { path: "", error } };
@@ -639,15 +771,11 @@ export function parseExecIdFromToolCallId(toolCallId) {
639
771
  return undefined;
640
772
  return { sessionId: match[1], execId };
641
773
  }
642
- // ── Safety net: reply to exec variants we don't map to an opencode tool ──
774
+ // ── Unknown exec diagnostics ──
643
775
  //
644
- // Cursor's real server sends server-initiated exec probes (request_context #10,
645
- // and potentially diagnostics/smart-mode-classifier/etc.) that are NOT tool
646
- // calls. opencode owns the tool loop, so these have no opencode tool to route
647
- // to — but we MUST still reply on the Run stream or the server blocks forever
648
- // (endless heartbeats, no response). For any unmapped variant we emit an empty
649
- // result at the SAME field number (request/result field numbers are identical
650
- // for every exec variant: read #7→#7, mcp #11→#11, request_context #10→#10…).
776
+ // Request/result field numbers are not universally identical (the Pi range is
777
+ // offset by one), so unknown variants must never receive a guessed empty reply.
778
+ // The pump uses this raw detector to report schema drift and fail the Run.
651
779
  /**
652
780
  * Find the exec variant field number from the raw (gunzipped) AgentServerMessage
653
781
  * payload: peel field #2 (exec_server_message), then return the first
@@ -667,29 +795,6 @@ export function detectExecVariantField(agentServerPayload) {
667
795
  }
668
796
  return undefined;
669
797
  }
670
- /** Build ExecClientMessage{1:id, <resultField>: empty} as raw bytes. */
671
- export function buildRawEmptyExecReply(execId, resultField) {
672
- const inner = [];
673
- writeVarintRaw(inner, (1 << 3) | 0); // field 1 (id), wire 0 (varint)
674
- writeVarintRaw(inner, execId >>> 0);
675
- writeVarintRaw(inner, (resultField << 3) | 2); // field N, wire 2 (length-delimited)
676
- writeVarintRaw(inner, 0); // empty submessage
677
- // Wrap as AgentClientMessage.exec_client_message (field #2).
678
- const acm = [];
679
- writeVarintRaw(acm, (2 << 3) | 2);
680
- writeVarintRaw(acm, inner.length);
681
- for (const b of inner)
682
- acm.push(b);
683
- return new Uint8Array(acm);
684
- }
685
- function writeVarintRaw(out, n) {
686
- let v = n >>> 0;
687
- while (v > 0x7f) {
688
- out.push((v & 0x7f) | 0x80);
689
- v >>>= 7;
690
- }
691
- out.push(v);
692
- }
693
798
  /**
694
799
  * Encode exec #10 request_context_result from a prebuilt RequestContext payload.
695
800
  */
@@ -705,3 +810,62 @@ export function buildRequestContextResult(execId, requestContext) {
705
810
  },
706
811
  });
707
812
  }
813
+ /**
814
+ * Answer Cursor's exec #36 MCP-state probe from the same descriptors advertised
815
+ * in RequestContext. OpenCode remains the executor; this only confirms that the
816
+ * provider's virtual MCP servers and their tools are available.
817
+ */
818
+ export function buildMcpStateResult(execId, args, requestContext) {
819
+ const requested = new Set(Array.isArray(args.server_identifiers)
820
+ ? args.server_identifiers.filter((id) => typeof id === "string" && id.length > 0)
821
+ : []);
822
+ const fsOptions = recordValue(requestContext.mcp_file_system_options);
823
+ const nested = Array.isArray(fsOptions?.mcp_descriptors)
824
+ ? fsOptions.mcp_descriptors.map(recordValue).filter((d) => !!d)
825
+ : [];
826
+ const descriptors = nested.length > 0 ? nested : descriptorsFromFlatTools(requestContext.tools);
827
+ const servers = descriptors
828
+ .filter((descriptor) => {
829
+ const id = stringValue(descriptor.server_identifier);
830
+ return requested.size === 0 || (id !== undefined && requested.has(id));
831
+ })
832
+ .map((descriptor) => ({
833
+ server_name: stringValue(descriptor.server_name) ?? stringValue(descriptor.server_identifier) ?? "",
834
+ server_identifier: stringValue(descriptor.server_identifier) ?? stringValue(descriptor.server_name) ?? "",
835
+ tools: Array.isArray(descriptor.tools) ? descriptor.tools : [],
836
+ }));
837
+ return encodeMessage("AgentClientMessage", {
838
+ exec_client_message: {
839
+ id: execId,
840
+ mcp_state_exec_result: { success: { servers } },
841
+ },
842
+ });
843
+ }
844
+ function recordValue(value) {
845
+ return value && typeof value === "object" && !Array.isArray(value)
846
+ ? value
847
+ : undefined;
848
+ }
849
+ function descriptorsFromFlatTools(value) {
850
+ if (!Array.isArray(value))
851
+ return [];
852
+ const byServer = new Map();
853
+ for (const raw of value) {
854
+ const tool = recordValue(raw);
855
+ if (!tool)
856
+ continue;
857
+ const server = stringValue(tool.provider_identifier) ?? "opencode";
858
+ const tools = byServer.get(server) ?? [];
859
+ tools.push({
860
+ tool_name: stringValue(tool.tool_name) ?? stringValue(tool.name) ?? "",
861
+ description: stringValue(tool.description) ?? "",
862
+ input_schema: tool.input_schema,
863
+ });
864
+ byServer.set(server, tools);
865
+ }
866
+ return [...byServer].map(([server, tools]) => ({
867
+ server_name: server,
868
+ server_identifier: server,
869
+ tools,
870
+ }));
871
+ }
package/dist/session.d.ts CHANGED
@@ -19,6 +19,12 @@ export type PendingExec = {
19
19
  * mcp_result can unwrap read envelopes even if the prompt omits toolName.
20
20
  */
21
21
  toolName?: string;
22
+ /**
23
+ * True when this pending entry was synthesized from a Cursor display-only
24
+ * tool_call_* frame (no ExecServerMessage). Continuation must not write an
25
+ * exec result back to Cursor — just clear pending and keep pumping.
26
+ */
27
+ bridged?: boolean;
22
28
  };
23
29
  export type CursorSession = {
24
30
  /**
@@ -35,6 +41,14 @@ export type CursorSession = {
35
41
  stream: BidiStream;
36
42
  frames: AsyncIterator<Frame>;
37
43
  pending: Map<number, PendingExec>;
44
+ /**
45
+ * Cursor display tool calls (tool_call_started) awaiting either an exec or a
46
+ * tool_call_completed. Keyed by call_id. Cleared when exec handles the call
47
+ * or when we bridge the completed display call into an OpenCode tool-call.
48
+ */
49
+ displayToolCalls: Map<string, Record<string, unknown>>;
50
+ /** Monotonic synthetic exec ids for bridged (display-only) OpenCode tool calls. */
51
+ nextBridgedExecId: number;
38
52
  /** KV blob store: blob_id (hex) → data, for Cursor's out-of-band payload channel. */
39
53
  blobs: Map<string, Uint8Array>;
40
54
  /** McpToolDefinition list advertised this turn — echoed into the request_context reply. */
@@ -47,6 +61,17 @@ export type CursorSession = {
47
61
  * stream instead of emitting tool-call parts OpenCode will reject.
48
62
  */
49
63
  allowTools: boolean;
64
+ /**
65
+ * Best-effort token usage for this held-open Run. Updated from text/tool
66
+ * activity and replaced by TurnEnded when the turn completes. Emitted on
67
+ * tool-calls finishes so OpenCode does not store all-zero usage mid-loop.
68
+ */
69
+ usageEstimate: {
70
+ inputTokens: number;
71
+ outputTokens: number;
72
+ cacheRead: number;
73
+ cacheWrite: number;
74
+ };
50
75
  /**
51
76
  * True while a doStream pull() is actively reading this session's frames.
52
77
  * Prevents a late cancel/abort from a prior ReadableStream from destroying
@@ -63,7 +88,7 @@ export declare class SessionManager {
63
88
  constructor(idleTimeoutMs?: number);
64
89
  touch(session: CursorSession): void;
65
90
  /** Register that `session` is awaiting a result for `execId`. */
66
- registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string): void;
91
+ registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string, bridged?: boolean): void;
67
92
  /** The pending exec info for an id on a specific session, if still awaiting it. */
68
93
  pendingFor(sessionId: string, execId: number): PendingExec | undefined;
69
94
  /** Find the live session awaiting one of the given exec ids. */
package/dist/session.js CHANGED
@@ -12,8 +12,8 @@ export class SessionManager {
12
12
  session.expiresAt = Date.now() + this.idleTimeoutMs;
13
13
  }
14
14
  /** Register that `session` is awaiting a result for `execId`. */
15
- registerPending(execId, session, resultField, toolName) {
16
- session.pending.set(execId, { resultField, toolName });
15
+ registerPending(execId, session, resultField, toolName, bridged = false) {
16
+ session.pending.set(execId, { resultField, toolName, bridged });
17
17
  this.byExecId.set(this.key(session.sessionId, execId), session);
18
18
  this.touch(session);
19
19
  }
package/dist/shared.d.ts CHANGED
@@ -2,6 +2,8 @@ export declare const CURSOR_API_HOST = "api2.cursor.sh";
2
2
  export declare const CURSOR_WEBSITE_HOST = "cursor.com";
3
3
  export declare const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
4
4
  export declare const CURSOR_PROVIDER_ID = "cursor";
5
+ /** Private provider option injected by the OpenCode plugin for summary turns. */
6
+ export declare const CURSOR_COMPACTION_OPTION = "opencodeCompaction";
5
7
  export declare const TOKEN_EXPIRY_THRESHOLD_S = 300;
6
8
  export declare const RUN_PATH = "/agent.v1.AgentService/Run";
7
9
  export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
package/dist/shared.js CHANGED
@@ -2,6 +2,8 @@ export const CURSOR_API_HOST = "api2.cursor.sh";
2
2
  export const CURSOR_WEBSITE_HOST = "cursor.com";
3
3
  export const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
4
4
  export const CURSOR_PROVIDER_ID = "cursor";
5
+ /** Private provider option injected by the OpenCode plugin for summary turns. */
6
+ export const CURSOR_COMPACTION_OPTION = "opencodeCompaction";
5
7
  export const TOKEN_EXPIRY_THRESHOLD_S = 300;
6
8
  export const RUN_PATH = "/agent.v1.AgentService/Run";
7
9
  export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,7 @@
40
40
  "files": [
41
41
  "dist",
42
42
  "LICENSE",
43
+ "DISCLAIMER.md",
43
44
  "README.md"
44
45
  ],
45
46
  "scripts": {