cursor-opencode-provider 0.2.3 → 0.2.5

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.
@@ -1,6 +1,8 @@
1
+ import fs from "node:fs";
1
2
  import { encodeMessage } from "./messages.js";
2
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
3
4
  import { trace } from "../debug.js";
5
+ import { cursorExecVariantByRequestName } from "./exec-variants.js";
4
6
  // Exec variant field number whose reply is the server-initiated request_context
5
7
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
6
8
  // field number for every exec variant, so this is also the result field.
@@ -132,8 +134,13 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
132
134
  // glob_args on the exec channel — Cursor's EditToolCall is display-only, and
133
135
  // glob/edit from opencode are advertised as MCP tools and arrive as mcp_args.
134
136
  //
135
- // OpenCode has no `ls` / `delete` builtins: ls read (dirs are readable),
136
- // delete bash `rm`. Arg key remapping happens in mapCursorArgsToOpencode.
137
+ // Design note (F11): Cursor has native delete / background-shell tools; OpenCode
138
+ // does not. This provider intentionally remaps:
139
+ // - delete_args → bash `rm -f -- <quoted-path>`
140
+ // - background_shell_spawn_args → bash + nohup detach wrapper
141
+ // Permissions still flow through OpenCode's advertised `bash` tool. Soft-
142
+ // background / detached children can outlive the OpenCode tool call; leftover
143
+ // process cleanup is left to the user / OS. Arg key remapping happens below.
137
144
  const cursorToolToOpencode = {
138
145
  read_args: "read",
139
146
  write_args: "write",
@@ -148,6 +155,8 @@ const cursorToolToOpencode = {
148
155
  ls_args: "read",
149
156
  delete_args: "bash",
150
157
  shell_stream_args: "bash",
158
+ background_shell_spawn_args: "bash",
159
+ subagent_args: "task",
151
160
  mcp_args: "mcp",
152
161
  };
153
162
  const opencodeToolToCursor = {
@@ -155,6 +164,7 @@ const opencodeToolToCursor = {
155
164
  write: "write_args",
156
165
  grep: "grep_args",
157
166
  bash: "shell_stream_args",
167
+ task: "subagent_args",
158
168
  mcp: "mcp_args",
159
169
  };
160
170
  /** Cursor-only fields that must not be forwarded as OpenCode tool input. */
@@ -203,26 +213,7 @@ export function mapExecServerToToolName(execField) {
203
213
  export function mapToolNameToExecField(toolName) {
204
214
  return opencodeToolToCursor[toolName];
205
215
  }
206
- // Cursor exec-request variant → the ExecClientMessage result field the client
207
- // must reply with. This is keyed off the REQUEST variant, not the opencode tool
208
- // name, because an MCP call must always answer with `mcp_result` even though the
209
- // resolved tool name (e.g. "read") looks like a built-in.
210
- const execVariantToResultField = {
211
- read_args: "read_result",
212
- write_args: "write_result",
213
- pi_read_args: "pi_read_result",
214
- pi_bash_args: "pi_bash_result",
215
- pi_edit_args: "pi_edit_result",
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",
220
- grep_args: "grep_result",
221
- ls_args: "ls_result",
222
- delete_args: "delete_result",
223
- shell_stream_args: "shell_stream",
224
- mcp_args: "mcp_result",
225
- };
216
+ const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
226
217
  export function parseExecServerMessage(msg) {
227
218
  const id = msg.id;
228
219
  if (id === undefined)
@@ -233,12 +224,74 @@ export function parseExecServerMessage(msg) {
233
224
  "pi_read_args", "pi_bash_args", "pi_edit_args", "pi_write_args",
234
225
  "pi_grep_args", "pi_find_args", "pi_ls_args",
235
226
  "grep_args", "ls_args",
236
- "delete_args", "shell_stream_args", "mcp_args",
227
+ "delete_args", "shell_stream_args", "background_shell_spawn_args", "mcp_args",
228
+ "subagent_args",
237
229
  ]);
238
230
  if (!execVariant)
239
231
  return undefined;
240
- const resultField = execVariantToResultField[execVariant];
232
+ // Use the complete canonical request/result table. In particular, Pi request
233
+ // fields #45..#51 pair with result fields #46..#52 rather than matching ids.
234
+ const resultField = cursorExecVariantByRequestName(execVariant)?.resultName;
235
+ if (!resultField)
236
+ return undefined;
241
237
  const execId = msg.exec_id ?? "";
238
+ // F11: Cursor native background shell → OpenCode bash. The wrapper detaches
239
+ // via nohup immediately; OpenCode only sees the spawn marker (pid + log path).
240
+ // Residual: the child can keep running after this tool call completes.
241
+ if (execVariant === "background_shell_spawn_args") {
242
+ const raw = msg.background_shell_spawn_args ?? {};
243
+ const command = str(raw.command);
244
+ const workingDirectory = str(raw.working_directory) ?? "";
245
+ const args = {};
246
+ if (command)
247
+ args.command = buildBackgroundShellCommand(command);
248
+ if (workingDirectory)
249
+ args.workdir = workingDirectory;
250
+ return {
251
+ id,
252
+ execId,
253
+ toolName: "bash",
254
+ args,
255
+ resultField,
256
+ resultMetadata: { command: command ?? "", working_directory: workingDirectory },
257
+ localError: raw.enable_write_shell_stdin_tool === true
258
+ ? "Interactive background shells are not available through OpenCode's bash tool."
259
+ : command
260
+ ? undefined
261
+ : "Cursor background shell request is missing a command.",
262
+ };
263
+ }
264
+ if (execVariant === "subagent_args") {
265
+ const raw = msg.subagent_args ?? {};
266
+ const prompt = str(raw.prompt);
267
+ const cursorSubagentType = str(raw.subagent_type);
268
+ const subagentType = cursorSubagentType
269
+ ? mapCursorSubagentTypeToOpenCode(cursorSubagentType)
270
+ : undefined;
271
+ const args = {
272
+ description: describeSubagentTask(prompt, subagentType),
273
+ prompt: prompt ?? "",
274
+ subagent_type: subagentType ?? "",
275
+ };
276
+ const resumeAgentId = str(raw.resume_agent_id);
277
+ if (resumeAgentId)
278
+ args.task_id = resumeAgentId;
279
+ // protobufjs materializes an absent proto3 optional bool as false in this
280
+ // reflection schema. OpenCode's foreground default is already false, so
281
+ // only forward the meaningful opt-in value.
282
+ if (raw.run_in_background === true)
283
+ args.background = true;
284
+ return {
285
+ id,
286
+ execId,
287
+ toolName: "task",
288
+ args,
289
+ resultField,
290
+ localError: prompt && cursorSubagentType
291
+ ? undefined
292
+ : "Cursor subagent request is missing a required prompt or subagent type.",
293
+ };
294
+ }
242
295
  if (execVariant === "mcp_args") {
243
296
  // An MCP call to one of the tools we advertised. Resolve the real opencode
244
297
  // tool name (Cursor's model may have shortened "opencode-read" → "read") and
@@ -284,12 +337,50 @@ export function parseExecServerMessage(msg) {
284
337
  if (!toolName)
285
338
  return undefined;
286
339
  const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
340
+ const rawArgs = msg[execVariant] ?? {};
341
+ const resultMetadata = execVariant === "shell_stream_args"
342
+ ? shellStreamResultMetadata(rawArgs)
343
+ : execVariant === "read_args"
344
+ ? {
345
+ path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
346
+ ...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
347
+ ...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
348
+ }
349
+ : undefined;
350
+ if (resultMetadata
351
+ && resultMetadata.timeout_behavior !== 2
352
+ && typeof resultMetadata.timeout_ms === "number") {
353
+ // Cursor's protobuf default (timeout=0) means 30 seconds for an ordinary
354
+ // foreground shell. OpenCode instead treats zero literally, so pass the
355
+ // effective native value rather than the raw protobuf default.
356
+ mapped.args.timeout = resultMetadata.timeout_ms;
357
+ }
287
358
  return {
288
359
  id,
289
360
  execId,
290
361
  toolName: mapped.toolName,
291
362
  args: mapped.args,
292
363
  resultField,
364
+ ...(resultMetadata ? { resultMetadata } : {}),
365
+ };
366
+ }
367
+ function shellStreamResultMetadata(raw) {
368
+ const timeout = num(raw.timeout) ?? 0;
369
+ const timeoutBehavior = num(raw.timeout_behavior) ?? 0;
370
+ const hardTimeout = num(raw.hard_timeout);
371
+ // Cursor CLI: a nonzero timeout is used verbatim. A zero foreground timeout
372
+ // defaults to 30s; zero with background/hard-timeout semantics means an
373
+ // immediate soft handoff governed by the separate hard deadline.
374
+ const effectiveTimeout = timeout !== 0
375
+ ? timeout
376
+ : (timeoutBehavior === 2 || (hardTimeout !== undefined && hardTimeout > 0) ? 0 : 30_000);
377
+ return {
378
+ shell_stream: true,
379
+ command: str(raw.command) ?? "",
380
+ working_directory: str(raw.working_directory) ?? "",
381
+ timeout_ms: effectiveTimeout,
382
+ timeout_behavior: timeoutBehavior,
383
+ ...(hardTimeout !== undefined && hardTimeout > 0 ? { hard_timeout_ms: hardTimeout } : {}),
293
384
  };
294
385
  }
295
386
  /**
@@ -315,7 +406,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
315
406
  const filePath = str(cleaned.path) ?? str(cleaned.filePath);
316
407
  return { toolName: "read", args: filePath ? { filePath } : {} };
317
408
  }
318
- // Native delete_args → bash rm (OpenCode has no delete builtin).
409
+ // F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
410
+ // we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
411
+ // as any other bash tool call.
319
412
  if (execVariant === "delete_args") {
320
413
  const target = str(cleaned.path) ?? str(cleaned.filePath);
321
414
  return {
@@ -425,9 +518,47 @@ function num(v) {
425
518
  return Number(v);
426
519
  return undefined;
427
520
  }
521
+ function describeSubagentTask(prompt, subagentType) {
522
+ const words = prompt?.replace(/\s+/g, " ").trim().split(" ").filter(Boolean).slice(0, 5);
523
+ if (words?.length)
524
+ return words.join(" ");
525
+ return `${subagentType || "Delegated"} task`;
526
+ }
527
+ function mapCursorSubagentTypeToOpenCode(subagentType) {
528
+ // Cursor's built-in general agent uses a camelCase protocol identifier, and
529
+ // its native Bugbot reviewer has no OpenCode-specific agent definition.
530
+ // OpenCode `general` is the general-purpose equivalent; `explore` preserves
531
+ // Bugbot's review-only/read-oriented semantics while retaining grep/read/bash.
532
+ // Preserve every other value so `explore` and configured custom agents keep
533
+ // their exact advertised names.
534
+ if (subagentType === "generalPurpose")
535
+ return "general";
536
+ if (subagentType === "bugbot")
537
+ return "explore";
538
+ return subagentType;
539
+ }
428
540
  function shellQuote(s) {
429
541
  return `'${s.replace(/'/g, `'\\''`)}'`;
430
542
  }
543
+ /**
544
+ * F11 / background_shell_spawn_args helper.
545
+ *
546
+ * OpenCode's bash tool is foreground-only. Detach the requested command inside
547
+ * that one foreground call (`nohup … &`) and print a private marker containing
548
+ * the spawned PID and log path. With stdin and all output redirected, the host
549
+ * shell can return immediately instead of retaining OpenCode's tool pipe.
550
+ *
551
+ * Residual: the detached child is not reaped by this provider after OpenCode
552
+ * completes the tool call; cleanup is left to the user / OS.
553
+ */
554
+ function buildBackgroundShellCommand(command) {
555
+ return [
556
+ 'bg_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-bg.XXXXXX")" || exit 1',
557
+ `nohup sh -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
558
+ "bg_pid=$!",
559
+ `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
560
+ ].join("\n");
561
+ }
431
562
  /**
432
563
  * Map Cursor McpArgs back to the OpenCode tool id.
433
564
  * Prefers provider_identifier + bare tool_name (github + create_pull_request
@@ -505,9 +636,32 @@ export function buildExecClientMessages(input) {
505
636
  if (input.output) {
506
637
  frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: input.output } }));
507
638
  }
508
- frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
509
- exit: { code: 0, aborted: false },
510
- }));
639
+ if (input.shellOutcome?.kind === "backgrounded") {
640
+ frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
641
+ backgrounded: {
642
+ shell_id: input.shellOutcome.shellId,
643
+ command: input.shellOutcome.command,
644
+ working_directory: input.shellOutcome.workingDirectory,
645
+ pid: input.shellOutcome.pid,
646
+ ms_to_wait: input.shellOutcome.msToWait,
647
+ reason: input.shellOutcome.reason,
648
+ },
649
+ }));
650
+ }
651
+ else if (input.shellOutcome?.kind === "timeout") {
652
+ frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
653
+ // Native CLI represents timeout structurally. ShellAbortReason.TIMEOUT=2.
654
+ exit: { code: 0, aborted: true, abort_reason: 2 },
655
+ }));
656
+ }
657
+ else {
658
+ const exitCode = input.shellOutcome?.kind === "exit"
659
+ ? Math.max(0, Math.min(0xffff_ffff, input.shellOutcome.code))
660
+ : 0;
661
+ frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
662
+ exit: { code: exitCode, aborted: false },
663
+ }));
664
+ }
511
665
  }
512
666
  }
513
667
  else {
@@ -515,7 +669,7 @@ export function buildExecClientMessages(input) {
515
669
  id: input.execId,
516
670
  local_execution_time_ms: input.executionTimeMs ?? 0,
517
671
  };
518
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName);
672
+ clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata);
519
673
  frames.push(encodeMessage("AgentClientMessage", {
520
674
  exec_client_message: clientMsg,
521
675
  }));
@@ -602,22 +756,31 @@ export function unwrapReadOutput(output) {
602
756
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
603
757
  * server accepts (verified against agent.v1 wire captures).
604
758
  */
605
- export function buildTypedExecResult(resultField, output, error, toolName) {
759
+ export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata) {
606
760
  switch (resultField) {
607
- case "read_result":
761
+ case "read_result": {
762
+ const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
608
763
  if (error)
609
- return { error: { path: "", error } };
764
+ return { error: { path: readPath, error } };
610
765
  // Strip opencode's <path>/<content> envelope so Cursor's model receives
611
766
  // raw file content and can't echo the wrapper into subsequent writes.
612
767
  // reads route here only for native read_args; most arrive via mcp_result.
613
768
  const content = unwrapReadOutput(output);
769
+ const statPath = extractPathTag(output) ?? readPath;
770
+ const outputMetadata = parseOpenCodeReadMetadata(output);
771
+ const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
772
+ const rangeApplied = readRangeApplied(resultMetadata, totalLines);
614
773
  return {
615
774
  success: {
616
- path: extractPathTag(output) ?? "",
775
+ path: readPath,
617
776
  content,
618
- total_lines: countLines(content),
777
+ total_lines: totalLines,
778
+ file_size: readFileSize(statPath),
779
+ truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
780
+ range_applied: rangeApplied,
619
781
  },
620
782
  };
783
+ }
621
784
  case "grep_result": {
622
785
  if (error)
623
786
  return { error: { error } };
@@ -673,6 +836,31 @@ export function buildTypedExecResult(resultField, output, error, toolName) {
673
836
  if (error)
674
837
  return { error: { path: "", error } };
675
838
  return { success: { path: "", deleted_file: "" } };
839
+ case "background_shell_spawn_result": {
840
+ const command = str(resultMetadata?.command) ?? "";
841
+ const workingDirectory = str(resultMetadata?.working_directory) ?? "";
842
+ if (error)
843
+ return { error: { command, working_directory: workingDirectory, error } };
844
+ const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):([^\\r\\n]+)`).exec(output);
845
+ const pid = match ? Number(match[1]) : 0;
846
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
847
+ return {
848
+ error: {
849
+ command,
850
+ working_directory: workingDirectory,
851
+ error: "OpenCode did not return a valid background shell process id.",
852
+ },
853
+ };
854
+ }
855
+ return {
856
+ success: {
857
+ shell_id: pid,
858
+ command,
859
+ working_directory: workingDirectory,
860
+ pid,
861
+ },
862
+ };
863
+ }
676
864
  case "ls_result": {
677
865
  if (error)
678
866
  return { error: { path: "", error } };
@@ -706,6 +894,27 @@ export function buildTypedExecResult(resultField, output, error, toolName) {
706
894
  is_error: false,
707
895
  },
708
896
  };
897
+ case "subagent_result": {
898
+ const task = parseOpenCodeTaskOutput(output);
899
+ if (error || task.state === "error") {
900
+ return {
901
+ error: {
902
+ ...(task.agentId ? { agent_id: task.agentId } : {}),
903
+ error: error ?? task.message ?? output,
904
+ },
905
+ };
906
+ }
907
+ return {
908
+ success: {
909
+ agent_id: task.agentId ?? "",
910
+ ...(task.message !== undefined ? { final_message: task.message } : {}),
911
+ tool_call_count: 0,
912
+ // OpenCode marks an asynchronous launch as state="running". Cursor's
913
+ // canonical USER_REQUEST enum value is 2; foreground/default is 0.
914
+ background_reason: task.state === "running" ? 2 : 0,
915
+ },
916
+ };
917
+ }
709
918
  default:
710
919
  // Unknown variant: best-effort success wrapper so the server sees a oneof.
711
920
  if (error)
@@ -713,10 +922,116 @@ export function buildTypedExecResult(resultField, output, error, toolName) {
713
922
  return { success: { content: output } };
714
923
  }
715
924
  }
925
+ function parseOpenCodeTaskOutput(output) {
926
+ // Attribute order is not guaranteed; accept id/state in either order and
927
+ // ignore additional attributes OpenCode may emit on the <task> open tag.
928
+ const open = /<task\b([^>]*)>/i.exec(output);
929
+ if (!open)
930
+ return { message: output };
931
+ const attrs = open[1];
932
+ const agentId = /\bid="([^"]+)"/i.exec(attrs)?.[1];
933
+ const state = /\bstate="(running|completed|error)"/i.exec(attrs)?.[1];
934
+ if (!agentId || !state)
935
+ return { message: output };
936
+ const tag = state === "error" ? "task_error" : "task_result";
937
+ const body = new RegExp(`<${tag}>\\n?([\\s\\S]*?)\\n?</${tag}>`, "i").exec(output);
938
+ return {
939
+ agentId,
940
+ state,
941
+ message: body?.[1] ?? output,
942
+ };
943
+ }
716
944
  function extractPathTag(output) {
717
945
  const m = output.match(/<path>([^<]+)<\/path>/);
718
946
  return m?.[1];
719
947
  }
948
+ /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
949
+ function parseOpenCodeReadMetadata(output) {
950
+ const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(output);
951
+ if (showing) {
952
+ return {
953
+ startLine: Number(showing[1]),
954
+ endLine: Number(showing[2]),
955
+ ...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
956
+ outputCapped: output.includes("(Output capped at "),
957
+ };
958
+ }
959
+ const complete = /\(End of file - total (\d+) lines?\)/.exec(output);
960
+ if (complete)
961
+ return { totalLines: Number(complete[1]) };
962
+ return {};
963
+ }
964
+ function readRangeApplied(resultMetadata, totalLines) {
965
+ const offset = num(resultMetadata?.offset);
966
+ const limit = num(resultMetadata?.limit);
967
+ if (offset === undefined && limit === undefined)
968
+ return false;
969
+ if (totalLines === 0)
970
+ return false;
971
+ const startLine = offset ?? 1;
972
+ return startLine < 0 || startLine <= totalLines;
973
+ }
974
+ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
975
+ if (outputMetadata.outputCapped)
976
+ return true;
977
+ const returnedEnd = outputMetadata.endLine;
978
+ if (returnedEnd === undefined || totalLines === 0)
979
+ return false;
980
+ const offset = num(resultMetadata?.offset);
981
+ const limit = num(resultMetadata?.limit);
982
+ const startLine = offset ?? 1;
983
+ if (startLine < 0)
984
+ return false;
985
+ const expectedEnd = limit === undefined
986
+ ? totalLines
987
+ : Math.min(totalLines, Math.max(1, startLine) + limit - 1);
988
+ return returnedEnd < expectedEnd;
989
+ }
990
+ function readFileSize(filePath) {
991
+ if (!filePath)
992
+ return 0;
993
+ try {
994
+ return fs.statSync(filePath).size;
995
+ }
996
+ catch {
997
+ return 0;
998
+ }
999
+ }
1000
+ function readFileLineCount(filePath) {
1001
+ if (!filePath)
1002
+ return undefined;
1003
+ let fd;
1004
+ try {
1005
+ fd = fs.openSync(filePath, "r");
1006
+ const buffer = Buffer.allocUnsafe(64 * 1024);
1007
+ let totalBytes = 0;
1008
+ let lines = 1;
1009
+ while (true) {
1010
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
1011
+ if (bytesRead === 0)
1012
+ break;
1013
+ totalBytes += bytesRead;
1014
+ for (let index = 0; index < bytesRead; index++) {
1015
+ if (buffer[index] === 0x0a)
1016
+ lines++;
1017
+ }
1018
+ }
1019
+ return totalBytes === 0 ? 0 : lines;
1020
+ }
1021
+ catch {
1022
+ return undefined;
1023
+ }
1024
+ finally {
1025
+ if (fd !== undefined) {
1026
+ try {
1027
+ fs.closeSync(fd);
1028
+ }
1029
+ catch {
1030
+ /* best effort */
1031
+ }
1032
+ }
1033
+ }
1034
+ }
720
1035
  function extractPathLines(output) {
721
1036
  const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
722
1037
  // Prefer absolute / relative path-looking lines; fall back to all non-empty.
@@ -824,16 +1139,28 @@ export function buildMcpStateResult(execId, args, requestContext) {
824
1139
  ? fsOptions.mcp_descriptors.map(recordValue).filter((d) => !!d)
825
1140
  : [];
826
1141
  const descriptors = nested.length > 0 ? nested : descriptorsFromFlatTools(requestContext.tools);
1142
+ const flatTools = Array.isArray(requestContext.tools)
1143
+ ? requestContext.tools.map(recordValue).filter((tool) => !!tool)
1144
+ : [];
827
1145
  const servers = descriptors
828
1146
  .filter((descriptor) => {
829
1147
  const id = stringValue(descriptor.server_identifier);
830
1148
  return requested.size === 0 || (id !== undefined && requested.has(id));
831
1149
  })
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
- }));
1150
+ .map((descriptor) => {
1151
+ const serverIdentifier = stringValue(descriptor.server_identifier) ?? stringValue(descriptor.server_name) ?? "";
1152
+ const tools = Array.isArray(descriptor.tools)
1153
+ ? descriptor.tools
1154
+ .map(recordValue)
1155
+ .filter((tool) => !!tool)
1156
+ .map((tool) => mcpStateToolDefinition(serverIdentifier, tool, flatTools))
1157
+ : [];
1158
+ return {
1159
+ server_name: stringValue(descriptor.server_name) ?? serverIdentifier,
1160
+ server_identifier: serverIdentifier,
1161
+ tools,
1162
+ };
1163
+ });
837
1164
  return encodeMessage("AgentClientMessage", {
838
1165
  exec_client_message: {
839
1166
  id: execId,
@@ -841,6 +1168,24 @@ export function buildMcpStateResult(execId, args, requestContext) {
841
1168
  },
842
1169
  });
843
1170
  }
1171
+ /**
1172
+ * Exec #36 uses McpToolDefinition, not the narrower McpToolDescriptor used by
1173
+ * RequestContext's filesystem/meta-tool catalogs. Rehydrate the full identity
1174
+ * from RequestContext.tools so Cursor's native get_mcp_tools can correlate the
1175
+ * discovered definition with the later provider_identifier/tool_name request.
1176
+ */
1177
+ function mcpStateToolDefinition(serverIdentifier, descriptor, flatTools) {
1178
+ const toolName = stringValue(descriptor.tool_name) ?? "";
1179
+ const advertised = flatTools.find((tool) => stringValue(tool.provider_identifier) === serverIdentifier
1180
+ && stringValue(tool.tool_name) === toolName);
1181
+ return {
1182
+ name: stringValue(advertised?.name) ?? `${serverIdentifier}-${toolName}`,
1183
+ description: stringValue(advertised?.description) ?? stringValue(descriptor.description) ?? "",
1184
+ input_schema: advertised?.input_schema ?? descriptor.input_schema,
1185
+ provider_identifier: serverIdentifier,
1186
+ tool_name: toolName,
1187
+ };
1188
+ }
844
1189
  function recordValue(value) {
845
1190
  return value && typeof value === "object" && !Array.isArray(value)
846
1191
  ? value