← reports

#1612 · Recursive tool schemas (z.json()) break Grok 4.6: every turn fails with a generic provider error

Bug (untyped on GitHub) Priority: unset Effort: unset providers provider-acp open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: NOT REPRODUCED · root-cause confidence: medium (issue's mechanism) / high (the regression found on main) · linked PRs: #1613 (merged 2026-08-18T05:37Z as c25298f69, after the base commit), #1687, #1691 (open)

TL;DR

The reporter (bb 0.37.0, cursor-agent 2026.08.11-e8db854) saw every acp-cursor thread on cursor-grok-4.6-* fail immediately with Cursor's generic "Provider Error We're having trouble connecting to the model provider", and isolated it to the workflows plugin's tool schemas: zod 4's z.json() compiles bb_workflow_run.args / bb_workflow_result.value into a self-referential $ref: "#/$defs/__schema0", which Grok 4.6 (allegedly) rejects. I confirmed the schema shape exactly as described, but I could not reproduce the failure today with the same cursor-agent build: Grok 4.6 medium and high accepted the recursive schema over raw ACP (stub MCP server, 4 sessions), accepted bb's real tool list over raw ACP, and accepted it inside bb (2 threads) — every session answered ok and listed mcp_probe_probe_tool / mcp_bb-bridge_bb_workflow_run as available. Cursor's backend is what emits the error text, so the most likely explanation is that Cursor/xAI changed something server-side between 2026-08-14 and today; nothing in bb changed on that path.

While reproducing I found a separate, real regression on main: since #1640 (2026-08-17, unreleased) every provider bridge is spawned through the new bootstrap bridge-worker-entry, but the ACP bridge still builds its dynamic-tool MCP server command from process.argv[1], i.e. node <bridge-worker-entry> --mcp-stdio. The bootstrap rejects that argv (provider bridge bootstrap usage: …) and exits 1, so ACP agents (Cursor, and every other ACP provider) never receive any bb dynamic tool — no update_environment_directory, no bb_workflow_run. That means on main today the reporter's bug is unreachable for the wrong reason (Grok never sees the schema), and workflows/environment switching are silently broken for ACP providers. Existing bridge tests only check the MCP server's name, never that its command starts. A one-line fix (use import.meta.url) plus a test are linked below; the regression is still present on origin/main after #1613 merged.

Two things a reader must know before reproducing: (1) the workflows plugin is disabled by default on a fresh instance (builtin-registry.ts#L126-L131, defaultEnabled: false), so bb_workflow_run and its recursive schema only exist after POST /api/v1/plugins/workflows/enable; every bb step below enables it explicitly. (2) PR #1613 merged into main (c25298f69) six minutes before the first version of this report was written, so on current main the two workflow tools no longer carry a recursive $ref at all (z.unknown()) and the server additionally rejects any recursive plugin tool schema at registration; the review below is therefore post-merge follow-up, not a merge verdict.

Claims vs findings

ClaimStatusEvidence
bb_workflow_run.args / bb_workflow_result.value compile to a self-referential $defs.__schema0VerifiedDumped from the plugin at base commit: bb-workflow-tools.json (see Root cause). Same shape as the issue, incl. propertyNames.
Every Grok 4.6 thread in bb fails instantly with NonRetriableError: Provider Error …Not reproducedTwo bb threads (cursor-grok-4.6-medium, -high) with the recursive schema actually delivered (bridge fix applied so the MCP server starts) answered ok; out-bb-mainfix-grok46-toollist.txt, out-bb-mainfix-grok46-high.txt.
Grok 4.6 over raw ACP + one MCP tool carrying the z.json() schema → Provider ErrorRefuted todayacp-probe.mjs + stub-mcp.mjs: 4 sessions (medium ×3, high ×1) with the recursive schema, all stopReason=end_turn text="ok"; the model even lists mcp_probe_probe_tool (out, out, out, out). Stub log proves tools/list was served each time.
Disabling the workflows plugin makes Grok 4.6 workUnverifiableIt works with the plugin enabled too. On main it is additionally moot: the tools never reach Cursor (regression below).
Grok 4.5 accepts the recursive schema; only 4.6 rejectsNot tested4.6 accepts it now, so the comparison carries no information today.
Flat (non-recursive) schema worksVerifiedGrok 4.6 medium over raw ACP with the flat stub schema: out-grok46-medium-flat.txt (ok) and out-grok46-medium-flat-toollist.txt (lists mcp_probe_probe_tool); PR #1613's flat schema on codex: out-bb-pr1613-codex-toollist.txt.
The error text is misleading (nothing is wrong with connectivity)PlausibleThe text originates in cursor-agent (bb only relays it). I saw the sibling Error: RetriableError: [unavailable] getaddrinfo EAI_AGAIN agentn.global.api5.cursor.sh arrive as an agent message chunk during a DNS blip, so bb cannot cheaply distinguish it from model prose.
plugin-api.ts converts every plugin tool with z.toJSONSchema(parameters, { io: "input" }), so any plugin could ship a recursive schemaVerifiedplugin-api.ts#L988-L1003.

Environment

Minimal reproduction

A. The reported failure (raw ACP, no bb) — does not reproduce

Files: acp-probe.mjs spawns cursor-agent --model <model> acp, opens a session whose only MCP server is stub-mcp.mjs (serving one tool with exactly bb's recursive schema, or bb's three real tools with PROBE_SCHEMA=bb), and sends one prompt.

$ mkdir -p /tmp/bb-1612-scratch && cd /tmp/bb-reports/issues/1612/repro
$ node acp-probe.mjs cursor-grok-4.6-medium recursive
agent: {"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},...
session: 2fb5b076-c83c-4af5-b3a9-24578888f136 model= cursor-grok-4.6-medium schema= recursive
[update] session_info_update
RESULT stopReason= end_turn text= "ok" (10802ms)

$ PROBE_PROMPT="List the names of every tool available to you, comma separated, nothing else." node acp-probe.mjs cursor-grok-4.6-medium recursive
RESULT stopReason= end_turn text= "Shell, Grep, Delete, WebSearch, WebFetch, GenerateImage, ReadLints, EditNotebook, TodoWrite, StrReplace, Write, Read, Glob, Task, AwaitShell, ListMcpResources, FetchMcpResource, SwitchMode, mcp_probe_probe_tool" (13957ms)

$ tail -3 /tmp/bb-1612-scratch/stub-mcp.log      # the stub really served the recursive schema
2026-08-18T05:15:30.874Z initialize
2026-08-18T05:15:30.877Z notifications/initialized
2026-08-18T05:15:32.129Z tools/list

Expected per the issue: Provider Error We're having trouble connecting to the model provider. Actual: ok, and Grok lists the tool. Same result with PROBE_SCHEMA=bb (bb's exact three tools) on medium and high.

B. In bb on main: Grok 4.6 works, but bb's tools are not there at all

Run from the worktree root. Every bb … below is the dev-instance CLI (node packages/scripts/dist/commands/run-cli.js). Outputs are from the revision-pass instance (server :25774).

$ scripts/bb-dev-app current && eval "$(scripts/bb-dev-app env)"; unset BB_THREAD_ID BB_ENVIRONMENT_ID BB_THREAD_STORAGE BB_PROJECT_ID BB_CLI
$ mkdir -p /tmp/bb-1612-scratch && git -C /tmp/bb-1612-scratch init -q
$ pnpm bb:dev machine list
Name  ID               Status     Last seen
bee   host_g84bkqv3wg  connected  just now
$ curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' \
    -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb-1612-scratch","hostId":"host_g84bkqv3wg"}}'
{"id":"proj_zsbfqm6ahz","kind":"standard","name":"qa",…}

# The workflows plugin is OFF by default on a fresh instance -> bb_workflow_run does not exist yet:
$ curl -s $BB_SERVER_URL/api/v1/plugins | python3 -c 'import sys,json; [print(p["id"],p["status"],p["enabled"]) for p in json.load(sys.stdin) if p["id"]=="workflows"]'
workflows disabled False
$ curl -s -X POST $BB_SERVER_URL/api/v1/plugins/workflows/enable | head -c 120     # or: bash 1612/repro/toggle-workflows.sh on
{"ok":true,"plugin":{"id":"workflows","source":"builtin:workflows",…"enabled":true,…"status":"running"

# spawn-and-watch.sh = `bb thread spawn … --provider acp-cursor --model cursor-grok-4.6-medium --prompt "Reply only with ok." --json`
# plus a /proc watcher for a NEW `… --mcp-stdio` process (bb's dynamic-tool MCP server that Cursor should start at session/new)
$ BB_REPO=$PWD bash /tmp/bb-reports/issues/1612/repro/spawn-and-watch.sh proj_zsbfqm6ahz host_g84bkqv3wg cursor-grok-4.6-medium "1612 main (unpatched)" dynamic-tools-env-main-unpatched.txt
  "id": "thr_a58mp94x5m",
[procwatch] no new --mcp-stdio process seen in 120s
$ node packages/scripts/dist/commands/run-cli.js thread log thr_a58mp94x5m | tail -2
── Assistant ───────────────────────────────────────────────
ok
$ node packages/scripts/dist/commands/run-cli.js thread tell thr_a58mp94x5m "List the names of every tool available to you, comma separated, nothing else. Include any tool whose name starts with mcp_."
Thread thr_a58mp94x5m steered
$ sleep 45; node packages/scripts/dist/commands/run-cli.js thread log thr_a58mp94x5m | tail -2
── Assistant ───────────────────────────────────────────────
AwaitShell, Delete, EditNotebook, FetchMcpResource, GenerateImage, Glob, Grep, ListMcpResources, Read, ReadLints, Shell, StrReplace, SwitchMode, Task, TodoWrite, WebFetch, WebSearch, Write

Expected: the list includes mcp_bb-bridge_bb_workflow_run, mcp_bb-bridge_update_environment_directory (bb's dynamic tools, exposed to ACP agents through the bridge's MCP server) and a --mcp-stdio child process appears. Actual: neither (out-bb-main-grok46-toollist-v2.txt; first-pass run out-bb-main-grok46-toollist.txt). Why: the bridge process on main is the bootstrap, and the bridge re-executes argv[1]:

$ pgrep -af bridge-worker-entry | grep $PWD
node --conditions=source --import file://<repo>/node_modules/.pnpm/tsx@4.23.1/node_modules/tsx/dist/loader.mjs <repo>/packages/provider-bridge-protocol/src/bridge-worker-entry.ts <dataDir>/plugin-host-artifacts/provider-acp/de8ac2b8…/host.js provider-acp <dataDir>/plugins/provider-acp/bridge-data
$ node --conditions=source --import tsx packages/provider-bridge-protocol/src/bridge-worker-entry.ts --mcp-stdio; echo "exit=$?"     # exact command the bridge hands Cursor
provider bridge bootstrap usage: <bridgeModulePath> <pluginId> <pluginDataDir> (absolute paths)
exit=1

C. Unit-level repro of B (fails on main, passes with the fix)

File: issue-1612-mcp-server-launch.test.ts (also at plugins/provider-acp/src/bridge/ in my worktree). It spawns the bridge exactly as the agent runtime does (bootstrap → bridge.ts), starts a thread with one dynamic tool against the repo's fake ACP agent, reads the mcpServers config the bridge passed to the agent, runs that command itself and asks it for tools/list. Run: cd plugins/provider-acp && pnpm exec vitest run src/bridge/issue-1612-mcp-server-launch.test.ts.

main (full output):
 × the MCP command the bridge hands the agent actually serves bb's dynamic tools 556ms
AssertionError: expected 'process exited with code 1 before rep…' to be undefined
+ Received:
"process exited with code 1 before replying; stderr: provider bridge bootstrap usage: <bridgeModulePath> <pluginId> <pluginDataDir> (absolute paths); command: …/node --conditions=source --import …/tsx/dist/loader.mjs …/packages/provider-bridge-protocol/src/bridge-worker-entry.ts --mcp-stdio"
 ❯ src/bridge/issue-1612-mcp-server-launch.test.ts:233:21

with the patch (full output):
 Test Files  1 passed (1)
      Tests  1 passed (1)
/**
 * Repro for the regression found while investigating get-bb/bb#1612.
 *
 * Since #1640 the ACP bridge runs under the provider-bridge bootstrap
 * (`node <bridge-worker-entry> <host.js> <pluginId> <dataDir>`). The bridge
 * hands Cursor an MCP server config built from `process.argv[1]`, which is now
 * the bootstrap, not the bridge artifact:
 *
 *   node <bridge-worker-entry> --mcp-stdio
 *
 * The bootstrap rejects that argv ("provider bridge bootstrap usage: ...") and
 * exits 1, so the ACP agent never sees bb's dynamic tools
 * (update_environment_directory, bb_workflow_run, ...).
 *
 * This test spawns the bridge exactly the way the runtime does from source,
 * starts a thread with one dynamic tool against the fake ACP agent, reads the
 * MCP server config the bridge passed to the agent, then runs that command
 * itself and asks it for `tools/list`.
 */
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";

const HERE = dirname(fileURLToPath(import.meta.url));
const FAKE_AGENT_PATH = resolve(HERE, "fake-acp-agent.mjs");
const BRIDGE_MODULE_PATH = resolve(HERE, "bridge.ts");
const WORKER_ENTRY = fileURLToPath(
  import.meta.resolve("@bb/provider-bridge-protocol/bridge-worker-entry"),
);

interface JsonRpc {
  id?: number | string;
  method?: string;
  params?: Record<string, unknown>;
  result?: unknown;
  error?: { message: string };
}

function jsonRpcClient(child: ChildProcess) {
  const pending = new Map<
    number,
    { resolve: (m: JsonRpc) => void; reject: (e: Error) => void }
  >();
  const notifications: JsonRpc[] = [];
  let nextId = 1;
  createInterface({ input: child.stdout! }).on("line", (line) => {
    if (!line.trim()) return;
    let msg: JsonRpc;
    try {
      msg = JSON.parse(line) as JsonRpc;
    } catch {
      return;
    }
    if (typeof msg.id === "number" && pending.has(msg.id)) {
      pending.get(msg.id)!.resolve(msg);
      pending.delete(msg.id);
      return;
    }
    if (msg.method !== undefined) notifications.push(msg);
  });
  child.on("exit", (code) => {
    for (const p of pending.values()) {
      p.reject(new Error(`process exited with code ${code} before replying`));
    }
    pending.clear();
  });
  return {
    notifications,
    request(method: string, params: unknown): Promise<JsonRpc> {
      const id = nextId++;
      return new Promise((resolveP, rejectP) => {
        pending.set(id, { resolve: resolveP, reject: rejectP });
        child.stdin!.write(
          `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
        );
      });
    },
  };
}

async function waitFor<T>(
  probe: () => T | undefined,
  what: string,
  timeoutMs = 20_000,
): Promise<T> {
  const deadline = Date.now() + timeoutMs;
  for (;;) {
    const v = probe();
    if (v !== undefined) return v;
    if (Date.now() > deadline) throw new Error(`Timed out waiting for ${what}`);
    await new Promise((r) => setTimeout(r, 25));
  }
}

const children: ChildProcess[] = [];
let workspaceDir: string;
let dataDir: string;

afterEach(() => {
  for (const c of children.splice(0)) c.kill("SIGKILL");
  rmSync(workspaceDir, { recursive: true, force: true });
  rmSync(dataDir, { recursive: true, force: true });
});

describe("issue #1612 follow-up: dynamic-tool MCP server launched by the ACP bridge", () => {
  it("the MCP command the bridge hands the agent actually serves bb's dynamic tools", async () => {
    workspaceDir = mkdtempSync(join(tmpdir(), "bb-1612-ws-"));
    dataDir = mkdtempSync(join(tmpdir(), "bb-1612-data-"));

    // 1. Spawn the bridge the way the agent runtime does from source.
    const bridge = spawn(
      process.execPath,
      [
        "--conditions=source",
        "--import",
        import.meta.resolve("tsx"),
        WORKER_ENTRY,
        BRIDGE_MODULE_PATH,
        "provider-acp",
        dataDir,
      ],
      { stdio: ["pipe", "pipe", "pipe"], cwd: workspaceDir },
    );
    children.push(bridge);
    let bridgeStderr = "";
    bridge.stderr!.on("data", (d) => (bridgeStderr += String(d)));
    const rpc = jsonRpcClient(bridge);

    const init = await rpc
      .request("initialize", { protocolVersion: 1, client: { name: "bb", version: "1.0.0" } })
      .catch((e: Error) => ({ error: { message: `${e.message}: ${bridgeStderr}` } }));
    expect(init.error, bridgeStderr).toBeUndefined();

    // 2. Start a thread with ONE dynamic tool against the fake ACP agent.
    const start = await rpc.request("thread/start", {
      threadId: "thread-1612",
      cwd: workspaceDir,
      instructionMode: "append",
      options: {
        permissionMode: "full",
        permissionScope: "full",
        approvalReviewer: null,
        permissionEscalation: null,
        providerOptions: {
          acpLaunchSpec: {
            displayName: "Fake ACP",
            command: process.execPath,
            args: [FAKE_AGENT_PATH],
            env: {},
          },
        },
      },
      dynamicTools: [
        {
          name: "update_environment_directory",
          description: "Move this thread to another environment directory.",
          inputSchema: {
            type: "object",
            properties: { path: { type: "string" } },
            required: ["path"],
          },
        },
      ],
    });
    expect(start.error, bridgeStderr).toBeUndefined();
    const providerThreadId = (start.result as { providerThreadId: string })
      .providerThreadId;

    // 3. Ask the fake agent to echo the mcpServers config it received.
    await rpc.request("turn/start", {
      threadId: "thread-1612",
      providerThreadId,
      clientRequestId: "creq_abcdefghjk",
      options: {
        permissionMode: "full",
        permissionScope: "full",
        approvalReviewer: null,
        permissionEscalation: null,
      },
      input: [{ type: "text", text: "echo-mcp-server-config", mentions: [] }],
    });
    const configText = await waitFor(() => {
      for (const n of rpc.notifications) {
        if (n.method !== "thread/event") continue;
        const event = (n.params as { event?: Record<string, unknown> }).event;
        if (event?.type !== "item/agentMessage/delta") continue;
        const delta = String(event.delta ?? "");
        if (delta.startsWith("mcp-server-config:")) {
          return delta.slice("mcp-server-config:".length);
        }
      }
      return undefined;
    }, "mcp-server-config echo");
    const [mcpConfig] = JSON.parse(configText) as {
      name: string;
      command: string;
      args: string[];
      env: { name: string; value: string }[];
    }[];
    expect(mcpConfig.name).toBe("bb-bridge");

    // 4. Run that MCP server command ourselves, like Cursor would, and ask it
    //    for its tools. On main the process is `node <bridge-worker-entry>
    //    --mcp-stdio`, which prints the bootstrap usage and exits 1.
    const mcp = spawn(mcpConfig.command, mcpConfig.args, {
      cwd: workspaceDir,
      stdio: ["pipe", "pipe", "pipe"],
      env: {
        ...process.env,
        ...Object.fromEntries(mcpConfig.env.map((e) => [e.name, e.value])),
      },
    });
    children.push(mcp);
    let mcpStderr = "";
    mcp.stderr!.on("data", (d) => (mcpStderr += String(d)));
    const mcpRpc = jsonRpcClient(mcp);

    let toolNames: string[] | undefined;
    let failure: string | undefined;
    try {
      await mcpRpc.request("initialize", { protocolVersion: "2024-11-05" });
      const list = await mcpRpc.request("tools/list", {});
      toolNames = (list.result as { tools: { name: string }[] }).tools.map(
        (t) => t.name,
      );
    } catch (error) {
      failure = `${error instanceof Error ? error.message : String(error)}; stderr: ${mcpStderr.trim()}; command: ${mcpConfig.command} ${mcpConfig.args.join(" ")}`;
    }
    expect(failure).toBeUndefined();
    expect(toolNames).toEqual(["update_environment_directory"]);
  }, 60_000);
});

D. With the bridge fixed, Grok 4.6 in bb receives the recursive schema and still works

Prerequisite: the workflows plugin must be enabled (Section B, POST /api/v1/plugins/workflows/enable) — otherwise the only dynamic tool is update_environment_directory and the captured env contains 0 recursive refs. Apply the patch, restart the instance (the server rebuilds the plugin host artifact from source on start), spawn a new thread:

$ git apply /tmp/bb-reports/issues/1612/repro/proposed-fix-bridge-mcp-entrypoint.patch
$ pnpm dev:stop && scripts/bb-dev-app current && eval "$(scripts/bb-dev-app env)"; unset BB_THREAD_ID BB_ENVIRONMENT_ID BB_THREAD_STORAGE BB_PROJECT_ID BB_CLI
$ curl -s $BB_SERVER_URL/api/v1/plugins | python3 -c 'import sys,json; [print(p["id"],p["status"],p["enabled"]) for p in json.load(sys.stdin) if p["id"]=="workflows"]'
workflows running True
$ cd /tmp/bb-reports/issues/1612/repro && BB_REPO=<worktree> bash spawn-and-watch.sh proj_zsbfqm6ahz host_g84bkqv3wg cursor-grok-4.6-medium "1612 main+bridgefix v2" dynamic-tools-env-mainfix-v2.txt
  "id": "thr_iy2mnbwk5n",
[procwatch] new bb MCP server alive pid=1867242 cmd=node --conditions=source --import file://<repo>/node_modules/.pnpm/tsx@4.23.1/node_modules/tsx/dist/loader.mjs <dataDir>/plugin-host-artifacts/provider-acp/e64ff2eb…/host.js --mcp-stdio
$ grep -oF '"$ref":"#/$defs/__schema0"' dynamic-tools-env-mainfix-v2.txt | wc -l     # BB_ACP_DYNAMIC_TOOLS env of that MCP process
3
$ grep -o '"name":"[a-z_]*"' dynamic-tools-env-mainfix-v2.txt | sort -u
"name":"bb_workflow_run"
"name":"update_environment_directory"
$ node <worktree>/packages/scripts/dist/commands/run-cli.js thread log thr_iy2mnbwk5n | tail -2
── Assistant ───────────────────────────────────────────────
ok
$ node <worktree>/packages/scripts/dist/commands/run-cli.js thread tell thr_iy2mnbwk5n "List the names of every tool available to you, comma separated, nothing else. Include any tool whose name starts with mcp_."
$ sleep 45; node <worktree>/packages/scripts/dist/commands/run-cli.js thread log thr_iy2mnbwk5n | tail -2
── Assistant ───────────────────────────────────────────────
Shell, Grep, Delete, WebSearch, WebFetch, GenerateImage, ReadLints, EditNotebook, TodoWrite, StrReplace, Write, Read, Glob, Task, AwaitShell, ListMcpResources, FetchMcpResource, SwitchMode, mcp_bb-bridge_bb_workflow_run, mcp_bb-bridge_update_environment_directory

Expected per the issue: the first turn fails with NonRetriableError: Provider Error …. Actual: Grok 4.6 medium receives the recursive $ref schema (3 refs in the env Cursor spawned the server with) and answers ok, then lists both bb tools. Files: dynamic-tools-env-mainfix-v2.txt (the exact tool list Cursor received), out-bb-mainfix-grok46-toollist-v2.txt; first-pass equivalents dynamic-tools-env-mainfix.txt, out-bb-mainfix-grok46-toollist.txt, out-bb-mainfix-grok46-high.txt (grok 4.6 high, first turn ok). Afterwards: pnpm dev:stop && git checkout -- plugins/provider-acp/src/bridge/bridge.ts.

Root cause

1. The issue as filed: recursive $ref from z.json() — shape verified, rejection not reproducible

plugins/workflows/src/server.ts#L47-L74 declares args: z.json() and value: z.json(); the server converts with z.toJSONSchema(parameters, { io: "input" }) (plugin-api.ts#L988-L1003), which for zod 4 emits:

"args": { "default": null, "description": "…", "$ref": "#/$defs/__schema0" },
"$defs": { "__schema0": { "anyOf": [ {"type":"string"}, {"type":"number"}, {"type":"boolean"}, {"type":"null"},
    {"type":"array","items":{"$ref":"#/$defs/__schema0"}},
    {"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"$ref":"#/$defs/__schema0"}} ] } }

That schema rides thread.start → host daemon → ACP bridge → the bridge's MCP server (BB_ACP_DYNAMIC_TOOLS) → cursor-agent → Cursor's backend → xAI. Only the last two hops can produce "Provider Error We're having trouble connecting to the model provider"; bb just relays the text. Today that whole chain accepts the schema (Section A/D). The reporter's isolation table is internally consistent and specific, so I believe it was true on 2026-08-14; the trigger has evidently moved server-side. Confidence in "recursive $ref is what Grok 4.6 rejected" is therefore medium: consistent with the report, not observable now.

2. Regression on main: the ACP bridge launches its MCP server through the bootstrap

bridge.ts#L328-L333:

function resolveBridgeProcessArgsForMcpServer(): string[] {
  const entryPoint = process.argv[1]
    ? resolve(process.argv[1])
    : fileURLToPath(import.meta.url);
  return [...process.execArgv, entryPoint, "--mcp-stdio"];
}

Before #1640 the runtime executed the bridge module directly, so argv[1] was the bridge artifact and re-executing it with --mcp-stdio hit the MCP-server entry guard. Since #1640 the runtime spawns node <bridge-worker-entry> <bridgeModule> <pluginId> <dataDir> for every bridge (provider-registry.ts#L60-L78), so argv[1] is the bootstrap, and the bootstrap validates argv strictly (bridge-worker-entry.ts#L31-L48) and exits 1 on --mcp-stdio. Cursor logs nothing about the dead MCP server (checked ~/.cursor/projects/…/worker.log) and the session proceeds without bb tools. This affects the packaged daemon too (bb-provider-bridge-worker.mjs is likewise argv[1]) and every ACP provider, not just Cursor. The existing bridge test only asserts mcpServers: [{ name: ACP_BRIDGE_MCP_SERVER_NAME }] (bridge.test.ts#L1942-L1946) — the name, never that the command starts — which is why CI is green.

Consequence for this issue: on main, PR #1613 (or any schema change) cannot be validated end-to-end against Cursor until the bridge regression is fixed, because Cursor never receives the workflow tools at all.

Proposed fix (first principles)

  1. Bridge regression (confident, high): in plugins/provider-acp/src/bridge/bridge.ts derive the MCP-server entry from the bridge module itself, never argv[1]: const entryPoint = fileURLToPath(import.meta.url); (patch: proposed-fix-bridge-mcp-entrypoint.patch). Under the bootstrap the artifact is already imported by absolute path, so executing it directly resolves the same way; ELECTRON_RUN_AS_NODE is still restored by resolveBridgeProcessEnvForMcpServer. Add the test in Section C (or extend bridge.test.ts to actually run mcpServers[0].command args and call tools/list). Risk: none I can see; provider-acp's 145 existing tests pass with it (provider-acp-tests-fixed.txt). Layer: daemon-side provider plugin — correct per AGENTS.md; no wire shape changes, so no HOST_DAEMON_PROTOCOL_VERSION bump.
  2. Recursive schemas (medium): #1613 has now merged (c25298f69): the two workflow tools use z.unknown() (good, keep) and the server rejects any recursive $ref at plugin registration. Because the Grok rejection is not observable today, I would soften that second part in a follow-up rather than keep a global hard reject (see #1613 review). If Cursor/Grok regresses again, the least destructive general fix is lossy flattening at the point where the tool list is assembled per provider: replace any subtree that participates in a $ref cycle with {} (keep description/default), which is exactly what z.json() means anyway; that keeps every plugin loading and every provider accepting the list, and can be gated per provider (Sawyer's suggestion) if the flattening measurably hurts other models. Changing the two workflows tools to z.unknown() (as #1613 does) is a fine local hardening either way and cost nothing on codex (Section: PR review, tests).
  3. Misleading error text: the string comes from cursor-agent as agent-message text; bb could pattern-match ^Error: (Non)?RetriableError: chunks in the ACP bridge and surface them as a thread error with a hint ("Cursor reported a provider error; try disabling plugin tools"), but that is heuristic and provider-specific. Next experiment if it recurs: capture the exact ACP session/prompt response and cursor-agent stderr for a failing session so bb can key off a structured signal instead.

PR review

#1613 · Keep workflow tool schemas free of recursive $refs (mattwyckhouse) — MERGED 2026-08-18T05:37Z (c25298f69); post-merge findings, would have been REQUEST CHANGES

Status: merged into main after this report's base commit (gh pr view 1613 --json state,mergedAt,mergeCommitMERGED, 2026-08-18T05:37:17Z, c25298f69). origin/main now has freeformJson = z.unknown() in plugins/workflows/src/server.ts:53 and calls assertNoRecursiveJsonSchemaReferences in apps/server/src/services/plugins/plugin-api.ts:1029. The findings below are therefore follow-up work on main, not a merge gate. Note the ACP bridge regression (Root cause 2) is unchanged on origin/main (bridge.ts:328-333 still uses process.argv[1]).

What it changes (head 0b56687af, includes main; diff saved as pr-1613.diff): (a) plugins/workflows/src/server.ts replaces z.json() with z.unknown() for bb_workflow_run.args and bb_workflow_result.value, moves assertJsonValue into json-value.ts and narrows with toJsonValue() at both call sites; (b) adds assertNoRecursiveJsonSchemaReferences() to packages/plugin-sdk/src/internal/host-policy.ts and calls it from plugin-api.ts registerTool, plugin-service.ts normalizePluginAgentToolParameters (configure() overrides) and the fake plugin host, so any plugin tool with a recursive local $ref is rejected at registration; (c) tests + regenerated bundled types.

Does it address the root cause? Part (a) removes the specific schema the reporter isolated and is harmless: the wire schema becomes "args": {"default": null, "description": …} / "value": {"description": …} with required: ["value"] intact (dump), codex still lists and accepts the tools (out). Part (b) is a product-policy decision that goes further than the evidence supports, and it is exactly what Sawyer's comment on the issue pushed back on.

Findings

  1. [high] Global rejection breaks plugins for every provider to satisfy one provider whose rejection I cannot reproduce. apps/server/src/services/plugins/plugin-api.ts:1029-1032: registerTool now throws for any recursive schema, and a throw inside a plugin's default export fails activation of the whole plugin. z.json() is zod 4's documented "any JSON" idiom (the workflows plugin itself uses it in validation.ts:113), so third-party plugins that use it in a tool schema stop loading after this lands, on codex/claude-code/pi too, with an error message about a JSON-Schema detail. Grok 4.6 accepts the recursive schema today (Sections A/D), so the rule currently protects nobody. Prefer lossy flattening at tool-list assembly (or a per-provider gate) over registration-time rejection; at minimum make it a warning + flatten.
  2. [medium] Same rule applied to configure() parameter overrides drops the plugin's entire per-thread configuration, silently. apps/server/src/services/plugins/plugin-service.ts:777-780; when normalizePluginAgentToolParameters throws, invokeWrapped logs it and the loop continues (plugin-service.ts:2097-2107): the thread gets none of that plugin's tools and none of its dynamic instructions. For workflows that means a worker whose outputSchema trips the check gets neither bb_workflow_result nor the "Required schema: …" instruction and can only time out. Today only a self-referencing $ref: "#" output schema trips it because resultToolParameters wraps the user schema at properties.value so #/$defs/… refs dangle (see Related, latent bug), but the failure mode is the wrong shape: a tool-schema policy violation should degrade that one tool, not the plugin's whole configure() output.
  3. [low] z.unknown() makes bb_workflow_result accept {} at parse time (parse({}) → ok, probe output); the PR handles it by returning an error result from toJsonValue(undefined) ("value is not JSON-compatible"), which is a less helpful message than the old zod "required" issue. Cosmetic.
  4. [low] Layer. The checker lives in the SDK's internal host-policy and is enforced by the server — acceptable for a server-owned tool-list policy per AGENTS.md; no wire shape changes, so no protocol bump needed. Fine.
  5. [info] The recursion detector itself looks correct: DFS with visiting/visited sets over all schema-bearing keywords, local pointer + $anchor/$id fragment resolution, unresolvable refs ignored, DAG re-use not flagged. I could not construct a false positive.
  6. [info] Cannot be validated end-to-end against Cursor on main until the bridge regression (Root cause 2) is fixed: Cursor never receives the workflow tools, flat or not.

Tests run (on the PR code merged with base main): turbo run test typecheck --filter=@get-bb/plugin-sdk --filter=bb-plugin-workflows → 11 + 13 files pass (pr-1613-tests.txt); apps/server/test/services/plugins/plugin-agent-tools.test.ts → 14 pass (pr-1613-server-tests.txt); live codex thread on the PR build lists bb_workflow_run, update_environment_directory. Verdict (post-merge): would have been REQUEST CHANGES; as follow-up on main — (a) is fine as merged; soften (b) from a registration-time hard reject to flatten-with-warning (or provider-gate it) since the Grok rejection it targets no longer reproduces, and re-confirm against Cursor before treating #1612 as closed by this change.

#1687 · Promote Cursor Grok 4.6 in the model picker (yurilaguardia) — REQUEST CHANGES (rebase; unrelated to the bug)

One-line change of primaryModels from cursor-grok-4.5-medium to cursor-grok-4.6-medium in packages/agent-runtime/src/acp/profiles.ts — a file that no longer exists on main (#1640 moved ACP into plugins/provider-acp/; the bundled Cursor policy is now packages/agent-runtime/src/acp-launch-specs.ts:30-38). GitHub reports CONFLICTING. It explicitly does not address #1612 and does not touch the schema path. Content-wise it is reasonable (Grok 4.6 is the current family) and, as the PR notes, follows the precedent of 9daeffb7; but promoting a model whose bb behavior was reported broken three days ago without linking a fix is questionable ordering — after this report, however, 4.6 works. Verdict: REQUEST CHANGES — rebase onto acp-launch-specs.ts; no protocol bump needed (catalog value only). Diff: pr-1687.diff.

#1691 · Fix Cursor always selecting fast mode (georgecollier-nqu) — REQUEST CHANGES (rebase; unrelated to the bug)

Advertises Cursor's _meta.parameterizedModelPicker client capability, maps bb reasoning levels to the ACP effort option and service tier to fast, probes primary models first during catalog discovery, and bumps HOST_DAEMON_PROTOCOL_VERSION 123→124. Every touched file (packages/agent-runtime/src/acp/*) was moved to plugins/provider-acp/src/* by #1640 and main is now at protocol version 130, so the PR is CONFLICTING/DIRTY and cannot be checked out onto main for testing; its green CI predates #1640. It says itself it "is separate from #1612" and it is: the recursive-schema path is untouched. Substantively it looks sound (the capability is only advertised for the Cursor profile, discovery cache key includes it, protocol bump present) and it would remove the "Model cursor-grok-4.6-high has no medium reasoning variant; launching it at its default effort" warning I hit in Section D. Findings: [medium] needs a full rebase into the plugin layout and a fresh protocol bump (131+); [low] the effort/fast mapping has no test against a real cursor-agent in-repo (author verified manually). Verdict: REQUEST CHANGES (rebase, re-run against the fake ACP agent in plugins/provider-acp). Diff: pr-1691.diff.

Related issues

Appendix

All repro artifacts (1612/repro/)

Commands run (chronological, condensed)

gh issue view 1612 --comments
pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build
cursor-agent --version                                   # 2026.08.11-e8db854
node acp-probe.mjs cursor-grok-4.6-medium recursive      # x2 (+ PROBE_PROMPT tool listing), previous run: none/recursive/flat/bb on medium+high
cd plugins/workflows && pnpm exec vitest run pr1613-probe.test.ts / schema-dump.test.ts   (on PR head merge bc84e3098)
cd plugins/provider-acp && pnpm exec vitest run src/bridge/issue-1612-mcp-server-launch.test.ts   # fails on main
git checkout 16ceb3a54; scripts/bb-dev-app current
bb thread spawn … --provider acp-cursor --model cursor-grok-4.6-medium   (thr_3u75qi4ws3) ; bb thread tell … "List the names of every tool…"
node --conditions=source --import tsx packages/provider-bridge-protocol/src/bridge-worker-entry.ts --mcp-stdio   # usage error, exit 1
edit plugins/provider-acp/src/bridge/bridge.ts (import.meta.url); vitest run issue-1612-mcp-server-launch.test.ts   # passes
spawn-and-watch.sh … cursor-grok-4.6-medium (thr_p4snxf45qr) ; bb thread spawn … cursor-grok-4.6-high (thr_etv8ztrtjc)
git checkout bc84e3098 (PR #1613 + main); turbo build; scripts/bb-dev-app current; bb thread spawn --provider codex … (thr_c6yz5egj2z)
pnpm dev:stop; git checkout 16ceb3a54; re-apply bridge fix in worktree
gh pr view/diff 1613 1687 1691; git fetch origin pull/1613/head
# --- revision pass (worktree wf_debcf606-e4a-48, server :25774) ---
gh pr view 1613 --json state,mergedAt,mergeCommit                 # MERGED c25298f69 2026-08-18T05:37:17Z
node acp-probe.mjs cursor-grok-4.6-medium flat ; PROBE_PROMPT="List…" node acp-probe.mjs cursor-grok-4.6-medium flat
scripts/bb-dev-app current; curl POST /api/v1/projects; curl GET /api/v1/plugins (workflows disabled False); toggle-workflows.sh on
spawn-and-watch.sh … cursor-grok-4.6-medium (thr_a58mp94x5m, unpatched: no --mcp-stdio, no mcp_ tools)
apply patch; pnpm dev:stop; scripts/bb-dev-app current; spawn-and-watch.sh … (thr_iy2mnbwk5n: MCP server pid seen, 3 recursive refs, ok, both bb tools listed)
pnpm dev:stop; revert bridge.ts; git show origin/main:… (regression still present; #1613 content present)

Cursor's own log for the workspace shows no MCP failure

$ grep -ci "mcp\|bb-bridge\|bootstrap usage" ~/.cursor/projects/tmp-bb-1612-scratch/worker.log
0

Transient seen once during probing (not the bug; shows the error text arrives as agent prose)

RESULT stopReason= end_turn text= "Error: RetriableError: [unavailable] getaddrinfo EAI_AGAIN agentn.global.api5.cursor.sh" (18229ms)

Verification (independent re-run and what changed in this revision)

An independent verifier followed Sections A–D on their own worktree/instance at 16ceb3a54 (app :14401, server :22401, daemon :30401). Confirmed: raw ACP recursive/bb-tools probes on Grok 4.6 medium/high all return ok and list the MCP tools; on unpatched main no --mcp-stdio process appears and Grok lists no mcp_ tools; the bootstrap rejects --mcp-stdio with exit 1; issue-1612-mcp-server-launch.test.ts fails on main with the reported AssertionError and passes with the patch; with the patch and the workflows plugin enabled, a new thread's MCP server env carried 3 $ref:#/$defs/__schema0 and Grok 4.6 medium answered ok. Deviations they hit, all fixed in this revision: