@roadmapperai/mcp 0.9.1 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/package.json +1 -1
  3. package/server.mjs +461 -19
package/README.md CHANGED
@@ -134,6 +134,13 @@ the check with `ROADMAPPER_DISABLE_UPDATE_CHECK=1`.
134
134
 
135
135
  ### Recent changes
136
136
 
137
+ - **0.9.3** — new `link_repo` tool: when `get_active_workspace` reports
138
+ `env_default`/`unresolved` and you're in a git repo, one call persists
139
+ the repo → workspace mapping so future sessions resolve silently (the
140
+ repo slug is derived server-side; your API key pins the workspace).
141
+ - **0.9.2** — `get_active_workspace` returns an explicit status envelope
142
+ (`resolved` / `ambiguous` / `env_default` / `unresolved`) with a `next`
143
+ action, instead of prose; surfaces multi-repo ambiguity.
137
144
  - **0.9.1** — the server now self-reports its real version (was a stale
138
145
  hardcoded string in `serverInfo` and audit logs) and warns at boot
139
146
  when a newer package is published.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roadmapperai/mcp",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Roadmapper AI MCP server — exposes a planning surface (themes, capabilities, tasks, sprints, PRs) to coding agents via stdio JSON-RPC. Pairs with the Roadmapper AI workspace at dashboard.roadmapperai.com.",
5
5
  "keywords": [
6
6
  "mcp",
package/server.mjs CHANGED
@@ -587,6 +587,9 @@ function __setSnapshotWorkspaceForTest(value) {
587
587
  let _clientRoots = []; // array of absolute dir paths from the client
588
588
  let _rootWorkspace = undefined; // undefined=unresolved, null=resolved-but-none, string=workspaceId
589
589
  let _rootWorkspaceRepo = null; // the owner/repo that resolved (for diagnostics)
590
+ let _rootWorkspaceMatches = []; // [{ ws, slug }] for EVERY mapped open root — kept
591
+ // (not just the first) so get_active_workspace can report the ambiguous case
592
+ // instead of silently committing to matches[0].
590
593
  let _clientSupportsRoots = false; // set from initialize params.capabilities.roots
591
594
  const ROOTS_LIST_REQUEST_ID = "roadmapper-roots-list"; // our id for the roots/list request we send
592
595
 
@@ -612,6 +615,7 @@ function setClientRoots(roots) {
612
615
  // Invalidate the cached resolution so the next access re-derives it.
613
616
  _rootWorkspace = undefined;
614
617
  _rootWorkspaceRepo = null;
618
+ _rootWorkspaceMatches = [];
615
619
  }
616
620
 
617
621
  /**
@@ -619,7 +623,21 @@ function setClientRoots(roots) {
619
623
  * to find the repo root implicitly via `git -C <dir>`. Returns null if
620
624
  * the dir isn't a git repo, has no origin, or git isn't available.
621
625
  */
626
+ // Test seam: when set, repoSlugForDir returns this without shelling out
627
+ // to git. Lets the selftest exercise link_repo deterministically (the
628
+ // git call is environment-dependent and slow). Map of dir → slug, or a
629
+ // bare string applied to any dir. null/undefined = real git resolution.
630
+ let _repoSlugOverride = undefined;
631
+ function __setRepoSlugForTest(v) {
632
+ _repoSlugOverride = v;
633
+ }
634
+
622
635
  async function repoSlugForDir(dir) {
636
+ if (_repoSlugOverride !== undefined) {
637
+ return typeof _repoSlugOverride === "string"
638
+ ? _repoSlugOverride
639
+ : (_repoSlugOverride && _repoSlugOverride[dir]) || null;
640
+ }
623
641
  try {
624
642
  // Async so a slow/hanging git call never blocks the stdin event loop
625
643
  // (this runs while handling the client's roots/list reply). 2s cap.
@@ -698,6 +716,7 @@ async function resolveRootWorkspace() {
698
716
  `Pass workspaceId explicitly on calls to target a specific one.`
699
717
  );
700
718
  }
719
+ _rootWorkspaceMatches = matches;
701
720
  if (matches.length > 0) {
702
721
  _rootWorkspace = matches[0].ws;
703
722
  _rootWorkspaceRepo = matches[0].slug;
@@ -708,6 +727,24 @@ async function resolveRootWorkspace() {
708
727
  return null;
709
728
  }
710
729
 
730
+ /**
731
+ * Distinct workspaces the currently-open roots map to, as
732
+ * `[{ workspaceId, repo }]`. Length > 1 means the resolution is
733
+ * ambiguous (two mapped repos open at once) and the caller picked the
734
+ * first — get_active_workspace surfaces this so the agent can pass an
735
+ * explicit workspaceId instead of trusting the silent first-match.
736
+ */
737
+ function rootWorkspaceCandidates() {
738
+ const seen = new Set();
739
+ const out = [];
740
+ for (const m of _rootWorkspaceMatches) {
741
+ if (seen.has(m.ws)) continue;
742
+ seen.add(m.ws);
743
+ out.push({ workspaceId: m.ws, repo: m.slug });
744
+ }
745
+ return out;
746
+ }
747
+
711
748
  /** Cached root-derived workspace id (sync read). null if none/unresolved. */
712
749
  function rootWorkspaceId() {
713
750
  return _rootWorkspace ?? null;
@@ -715,9 +752,11 @@ function rootWorkspaceId() {
715
752
 
716
753
  // Test hook: seed the root-resolution cache without touching the client
717
754
  // protocol or the network.
718
- function __setRootWorkspaceForTest(id, repo = null) {
755
+ function __setRootWorkspaceForTest(id, repo = null, matches = null) {
719
756
  _rootWorkspace = id;
720
757
  _rootWorkspaceRepo = repo;
758
+ _rootWorkspaceMatches =
759
+ matches ?? (id ? [{ ws: id, slug: repo }] : []);
721
760
  }
722
761
 
723
762
  /**
@@ -1042,13 +1081,29 @@ function compactResult(obj) {
1042
1081
  * concurrent agent writes safe — see migration 0006 for the
1043
1082
  * function bodies.
1044
1083
  */
1084
+ // A few write tools forward to a differently-named SECURITY DEFINER RPC
1085
+ // (the *_for_api_key convention). The broker has the matching map
1086
+ // (WRITE_RPC_ALIASES); we mirror it here so the operator path (direct
1087
+ // PostgREST, no broker) hits the same function name. Tools not listed
1088
+ // forward to an RPC of the same name (the common case).
1089
+ const RPC_ALIASES = {
1090
+ link_repo: "link_repo_for_api_key",
1091
+ };
1092
+
1045
1093
  async function rpcCall(fn, body) {
1046
1094
  const { url, writeKey, apiKey, brokerUrl } = supabaseConfig();
1047
- // body must already carry p_workspace_id — the per-tool resolver
1048
- // injects it before calling rpcCall so the override path works.
1049
- if (!url || !body?.p_workspace_id) {
1095
+ if (!url) {
1096
+ throw new Error("Write tools require ROADMAPPER_BACKEND_URL in env.");
1097
+ }
1098
+ // The body must carry p_workspace_id EXCEPT on the broker path, where
1099
+ // the broker injects the validated workspace from the rmpr_ key (see
1100
+ // link_repo, which deliberately omits it so the key's workspace wins
1101
+ // rather than tripping the broker's cross-workspace guard). On the
1102
+ // operator path there's no broker to supply it, so it's required.
1103
+ const onBrokerPath = Boolean(apiKey && brokerUrl);
1104
+ if (!onBrokerPath && !body?.p_workspace_id) {
1050
1105
  throw new Error(
1051
- "Write tools require ROADMAPPER_BACKEND_URL in env and a resolvable workspaceId (either ROADMAPPER_WORKSPACE_ID env or workspaceId arg)."
1106
+ "Write tools require a resolvable workspaceId (either ROADMAPPER_WORKSPACE_ID env or workspaceId arg)."
1052
1107
  );
1053
1108
  }
1054
1109
 
@@ -1085,7 +1140,10 @@ async function rpcCall(fn, body) {
1085
1140
  "Write tools require either ROADMAPPER_API_KEY (customer path) or ROADMAPPER_ADMIN_KEY (operator path)."
1086
1141
  );
1087
1142
  }
1088
- const res = await fetch(`${url}/rest/v1/rpc/${fn}`, {
1143
+ // Resolve the alias for the direct PostgREST call (the broker does this
1144
+ // server-side for the customer path; the operator path goes direct).
1145
+ const rpcName = RPC_ALIASES[fn] ?? fn;
1146
+ const res = await fetch(`${url}/rest/v1/rpc/${rpcName}`, {
1089
1147
  method: "POST",
1090
1148
  headers: {
1091
1149
  apikey: writeKey,
@@ -1098,7 +1156,7 @@ async function rpcCall(fn, body) {
1098
1156
  if (!res.ok) {
1099
1157
  const txt = await res.text();
1100
1158
  throw new Error(
1101
- `rpc ${fn} failed: ${res.status} ${txt.slice(0, 300)}`
1159
+ `rpc ${rpcName} failed: ${res.status} ${txt.slice(0, 300)}`
1102
1160
  );
1103
1161
  }
1104
1162
  return res.json();
@@ -1637,7 +1695,8 @@ const TOOLS = [
1637
1695
  {
1638
1696
  name: "get_active_workspace",
1639
1697
  description:
1640
- "Report the workspace this server will act on RIGHT NOW and HOW it was resolved — arg / .roadmapper snapshot / env default — plus whether writes are enabled and via which path (broker vs operator). Cheap: no roadmap data, no DB read.\n\n" +
1698
+ "Report the workspace this server will act on RIGHT NOW and HOW it was resolved — arg / repo (git origin → repo_workspace_map) / .roadmapper snapshot / env default — plus whether writes are enabled and via which path (broker vs operator). Cheap: no roadmap data, no DB read.\n\n" +
1699
+ "RETURNS a `status` (resolved | ambiguous | env_default | unresolved), `writesEnabled`, and a `next` action object (or null). Act only on status \"resolved\"; for any other status `next` carries the exact step to fix it (and `candidates` when a pick is needed) — surface it to the user as a one-tap choice rather than guessing.\n" +
1641
1700
  "USE WHEN: you're unsure which workspace is active; before the FIRST mutating call in a session; after changing directories. Especially important when the agent was launched outside a connected repo checkout, where the env default (often the seed workspace) silently wins.\n" +
1642
1701
  "PREREQUISITE: none — read-only.\n" +
1643
1702
  "ANTI-PATTERN: don't use it to inspect roadmap contents — that's get_roadmap_snapshot. This only answers 'where am I pointed'.\n" +
@@ -1654,6 +1713,21 @@ const TOOLS = [
1654
1713
  additionalProperties: false,
1655
1714
  },
1656
1715
  },
1716
+ {
1717
+ name: "link_repo",
1718
+ description:
1719
+ "Persist a mapping from the CURRENT git repo to your workspace, so future sessions in this repo resolve the workspace silently (no env default, no workspaceId arg). Your API key already pins ONE workspace, so this is a one-tap confirm — it links whatever repo you're in to that workspace.\n\n" +
1720
+ "USE WHEN: get_active_workspace returns status \"env_default\" or \"unresolved\" while you ARE in a git repo, and you want writes to land in the right workspace going forward. This is the `next.onChoice` action those statuses point at.\n" +
1721
+ "PREREQUISITE: the client must have shared a root (workspace folder) whose git origin resolves to an owner/name slug, and write auth must be configured (ROADMAPPER_API_KEY or operator key). The repo slug is derived server-side from your roots — you do NOT pass it.\n" +
1722
+ "ANTI-PATTERN: don't call to switch an already-resolved workspace (it can't — your key pins one workspace); don't call outside a git repo (returns an actionable error). If the repo is already mapped to a DIFFERENT workspace, the call returns a conflict rather than stealing it.\n" +
1723
+ "RETURNS status \"linked\" (mapping saved; resolution re-runs so the next call resolves from it), \"conflict\" (repo already maps elsewhere — surfaces the existing workspace), or an error result (no repo / no auth).\n" +
1724
+ "EXAMPLE: link_repo()",
1725
+ inputSchema: {
1726
+ type: "object",
1727
+ properties: {},
1728
+ additionalProperties: false,
1729
+ },
1730
+ },
1657
1731
  {
1658
1732
  name: "propose_task",
1659
1733
  description:
@@ -2296,21 +2370,111 @@ async function callTool(name, args) {
2296
2370
  if (name === "get_active_workspace") {
2297
2371
  const { id, source } = resolveWorkspaceWithSource(args?.workspaceId);
2298
2372
  const { url } = supabaseConfig();
2299
- let note;
2300
- if (source === "env") {
2301
- note =
2302
- "Resolved from the MCP install's env default — NOT from the current directory. If you meant a specific repo's workspace, launch from that checkout (connected repos carry .roadmapper/snapshot.json) or pass workspaceId explicitly.";
2303
- } else if (source === "none") {
2304
- note =
2305
- "No workspace resolved. Set ROADMAPPER_WORKSPACE_ID in env, run from a connected repo checkout, or pass workspaceId on the call.";
2373
+ const mode = writeMode(); // "broker" | "operator" | "read-only"
2374
+ const candidates = rootWorkspaceCandidates();
2375
+ const ambiguous = source === "repo" && candidates.length > 1;
2376
+
2377
+ // Map resolution into ONE explicit status. Every state has a defined
2378
+ // outcome and a `next` action the agent can act on directly — no prose
2379
+ // the LLM has to interpret, no state where the answer is "I silently
2380
+ // guessed". This is the contract a caller checks before its first write:
2381
+ // act only on "resolved"; anything else carries the steps to fix it.
2382
+ // resolved — arg / repo map / snapshot pinned it; safe to proceed
2383
+ // ambiguous — several open repos map to different workspaces
2384
+ // env_default — fell through to the install default (the #1 footgun)
2385
+ // unresolved — nothing named a workspace at all
2386
+ let status;
2387
+ if (ambiguous) status = "ambiguous";
2388
+ else if (source === "arg" || source === "repo" || source === "snapshot")
2389
+ status = "resolved";
2390
+ else if (source === "env") status = "env_default";
2391
+ else status = "unresolved";
2392
+
2393
+ // `next`: the single recommended action, machine-shaped so the agent
2394
+ // (or its harness) can turn it into a one-tap prompt. null when nothing
2395
+ // is needed. `prompt` is phrased for a human; `candidates` (when set) is
2396
+ // the pre-resolved pick list so the agent never has to ask open-ended.
2397
+ let next = null;
2398
+ if (status === "ambiguous") {
2399
+ next = {
2400
+ action: "pass_workspace_id",
2401
+ prompt:
2402
+ "Several open repos map to different workspaces. Which one do you mean?",
2403
+ candidates,
2404
+ detail:
2405
+ "Resolution picked the first match. Pass workspaceId explicitly on the call to target a specific workspace.",
2406
+ };
2407
+ } else if (status === "env_default") {
2408
+ next = {
2409
+ action: "confirm_or_relocate",
2410
+ prompt: `Act on the install default workspace "${id}"?`,
2411
+ detail:
2412
+ "Resolved from the MCP install's env default — NOT the current directory. If that's correct, proceed. Otherwise launch from the connected repo checkout (connected repos resolve via git origin → repo_workspace_map, or carry .roadmapper/snapshot.json) or pass workspaceId explicitly.",
2413
+ // If the client shared a git root and writes are enabled, the
2414
+ // agent can persist this repo → workspace in one tap so future
2415
+ // sessions resolve silently (link_repo derives the slug itself).
2416
+ ...(_clientRoots.length > 0 && mode !== "read-only"
2417
+ ? {
2418
+ onChoice: { tool: "link_repo", args: {} },
2419
+ onChoicePrompt: `Link this repo to "${id}" so it resolves automatically next time?`,
2420
+ }
2421
+ : {}),
2422
+ };
2423
+ } else if (status === "unresolved") {
2424
+ next = {
2425
+ action: "configure_workspace",
2426
+ prompt: "No workspace is resolvable. How should I target one?",
2427
+ detail:
2428
+ "Set ROADMAPPER_WORKSPACE_ID in env, run from a connected repo checkout, or pass workspaceId on the call.",
2429
+ ...(_clientRoots.length > 0 && mode !== "read-only"
2430
+ ? {
2431
+ onChoice: { tool: "link_repo", args: {} },
2432
+ onChoicePrompt:
2433
+ "Link the repo you're in to your workspace so it resolves automatically next time?",
2434
+ }
2435
+ : {}),
2436
+ };
2437
+ }
2438
+
2439
+ // Auth/write gate, reported separately from workspace resolution: a
2440
+ // read-only install can resolve a workspace fine but still can't write.
2441
+ // Surfacing it here lets the agent prompt for credentials BEFORE
2442
+ // attempting a mutator, rather than after it's refused.
2443
+ const writesEnabled = mode !== "read-only";
2444
+ if (!writesEnabled) {
2445
+ next = next ?? {
2446
+ action: "set_credentials",
2447
+ prompt: "Writes are disabled. Connect credentials to enable them?",
2448
+ detail:
2449
+ "Set ROADMAPPER_API_KEY (rmpr_ token from dashboard → Settings → MCP activity) to enable writes through the broker. The key pins exactly one workspace.",
2450
+ };
2306
2451
  }
2452
+
2453
+ // Back-compat prose for older callers that read `note`.
2454
+ const note =
2455
+ next && (status === "env_default" || status === "unresolved")
2456
+ ? next.detail
2457
+ : undefined;
2458
+
2307
2459
  return textResult(
2308
2460
  JSON.stringify(
2309
2461
  {
2310
2462
  workspaceId: id,
2311
- resolvedFrom: source, // "arg" | "snapshot" | "env" | "none"
2312
- writeMode: writeMode(), // "broker" | "operator" | "read-only"
2463
+ // "arg" | "repo" | "snapshot" | "env" | "none"
2464
+ resolvedFrom: source,
2465
+ status, // "resolved" | "ambiguous" | "env_default" | "unresolved"
2466
+ writeMode: mode, // "broker" | "operator" | "read-only"
2467
+ writesEnabled,
2313
2468
  backendConfigured: Boolean(url),
2469
+ // Only report the repo that actually resolved the workspace —
2470
+ // _rootWorkspaceRepo can hold a stale root match when an arg /
2471
+ // snapshot / env value is what won.
2472
+ repo: source === "repo" ? _rootWorkspaceRepo || null : null,
2473
+ // Candidates only make sense when resolution was ambiguous;
2474
+ // emitting them on a cleanly-resolved response reads as a
2475
+ // contradiction to the agent consuming the envelope.
2476
+ ...(status === "ambiguous" ? { candidates } : {}),
2477
+ next, // recommended action (or null), pre-shaped for a prompt
2314
2478
  ...(note ? { note } : {}),
2315
2479
  },
2316
2480
  null,
@@ -2319,6 +2483,97 @@ async function callTool(name, args) {
2319
2483
  );
2320
2484
  }
2321
2485
 
2486
+ // link_repo persists "this repo → my key's workspace" so future
2487
+ // sessions resolve silently. Plumbing, not a roadmap write — exempt
2488
+ // from the rubric/discovery gates (it's not in MUTATOR_TOOLS), and it
2489
+ // returns before the projection read since it touches no roadmap data.
2490
+ // The repo slug is derived server-side from the client's roots; the
2491
+ // agent supplies nothing.
2492
+ if (name === "link_repo") {
2493
+ if (writeMode() === "read-only") {
2494
+ return textResult(
2495
+ JSON.stringify({
2496
+ status: "no_auth",
2497
+ error:
2498
+ "Writes are disabled. Set ROADMAPPER_API_KEY (rmpr_ token from dashboard → Settings → MCP activity) to enable linking.",
2499
+ })
2500
+ );
2501
+ }
2502
+ // Find the first open root whose git origin resolves to a slug.
2503
+ let slug = null;
2504
+ for (const dir of _clientRoots) {
2505
+ slug = await repoSlugForDir(dir);
2506
+ if (slug) break;
2507
+ }
2508
+ if (!slug) {
2509
+ return textResult(
2510
+ JSON.stringify({
2511
+ status: "no_repo",
2512
+ error:
2513
+ "Not in a git repo with an 'origin' remote (no resolvable owner/name slug from the client's roots). Open the repo you want to link as a workspace folder and retry.",
2514
+ })
2515
+ );
2516
+ }
2517
+ try {
2518
+ // Workspace-id handling differs by path — link_repo is the one tool
2519
+ // where it MUST:
2520
+ // • Broker (customer rmpr_) path: do NOT send p_workspace_id. The
2521
+ // broker's cross-workspace guard 403s when the body's
2522
+ // p_workspace_id != the key's validated workspace — and link_repo
2523
+ // is invoked exactly when resolution is env_default/unresolved,
2524
+ // i.e. when the MCP's resolved wsId is a guess that usually does
2525
+ // NOT match the key. Omitting it lets the broker inject the
2526
+ // validated workspace (the key pins it — that's the whole point).
2527
+ // • Operator path: no broker to inject, so pass the resolved wsId
2528
+ // to satisfy rpcCall's required-field guard + target the right
2529
+ // workspace. rpcCall throws (→ "error" result) if it's unresolved.
2530
+ const body =
2531
+ writeMode() === "broker"
2532
+ ? { p_repo: slug }
2533
+ : { p_workspace_id: wsId, p_repo: slug };
2534
+ const res = await rpcCall("link_repo", body);
2535
+ const result = Array.isArray(res) ? res[0] : res;
2536
+ if (result?.status === "linked") {
2537
+ // Force the next resolution to re-read repo_workspace_map so the
2538
+ // freshly-linked mapping wins immediately (no stale cache).
2539
+ _rootWorkspace = undefined;
2540
+ _rootWorkspaceMatches = [];
2541
+ _rootWorkspaceRepo = null;
2542
+ return textResult(
2543
+ JSON.stringify({
2544
+ status: "linked",
2545
+ repo: slug,
2546
+ workspaceId: result.workspace_id ?? wsId,
2547
+ detail:
2548
+ "Mapping saved. This repo now resolves to your workspace on future calls.",
2549
+ })
2550
+ );
2551
+ }
2552
+ if (result?.status === "conflict") {
2553
+ return textResult(
2554
+ JSON.stringify({
2555
+ status: "conflict",
2556
+ repo: slug,
2557
+ existingWorkspace: result.existing_workspace ?? null,
2558
+ detail:
2559
+ "This repo is already linked to a different workspace. It was not changed. Pass workspaceId explicitly if you need to act on a specific workspace.",
2560
+ })
2561
+ );
2562
+ }
2563
+ return textResult(
2564
+ JSON.stringify({ status: "unknown", repo: slug, raw: result ?? null })
2565
+ );
2566
+ } catch (e) {
2567
+ return textResult(
2568
+ JSON.stringify({
2569
+ status: "error",
2570
+ repo: slug,
2571
+ error: e instanceof Error ? e.message : String(e),
2572
+ })
2573
+ );
2574
+ }
2575
+ }
2576
+
2322
2577
  // Post-Piece-6c, the entity tables ARE the canonical projection
2323
2578
  // — no edits blob, no seed-overlay merge. Fall back to the
2324
2579
  // bundled seed only when the DB is unreachable (offline / dev).
@@ -4543,8 +4798,16 @@ async function runSelftest() {
4543
4798
  const out = JSON.parse(r?.result?.content?.[0]?.text ?? "{}");
4544
4799
  return (
4545
4800
  typeof out.resolvedFrom === "string" &&
4546
- ["arg", "snapshot", "env", "none"].includes(out.resolvedFrom) &&
4547
- ["broker", "operator", "read-only"].includes(out.writeMode)
4801
+ ["arg", "repo", "snapshot", "env", "none"].includes(
4802
+ out.resolvedFrom
4803
+ ) &&
4804
+ ["broker", "operator", "read-only"].includes(out.writeMode) &&
4805
+ ["resolved", "ambiguous", "env_default", "unresolved"].includes(
4806
+ out.status
4807
+ ) &&
4808
+ // `next` is either null (resolved) or a shaped action object.
4809
+ (out.next === null ||
4810
+ (out.next && typeof out.next.action === "string"))
4548
4811
  );
4549
4812
  } catch {
4550
4813
  return false;
@@ -5942,6 +6205,42 @@ async function runSelftest() {
5942
6205
  pass: (r) =>
5943
6206
  r?.result?.id === "ws-from-snapshot" && r?.result?.source === "snapshot",
5944
6207
  },
6208
+ {
6209
+ // Two open repos mapping to DIFFERENT workspaces must not resolve
6210
+ // silently to the first — get_active_workspace reports status
6211
+ // "ambiguous" and hands back the candidate list so the agent can
6212
+ // prompt for an explicit workspaceId instead of guessing.
6213
+ name: "get_active_workspace surfaces ambiguous multi-repo resolution",
6214
+ fn: () => {
6215
+ try {
6216
+ __setRootWorkspaceForTest("ws-a", "owner/repo-a", [
6217
+ { ws: "ws-a", slug: "owner/repo-a" },
6218
+ { ws: "ws-b", slug: "owner/repo-b" },
6219
+ ]);
6220
+ return handle({
6221
+ id: 23,
6222
+ method: "tools/call",
6223
+ params: { name: "get_active_workspace", arguments: {} },
6224
+ });
6225
+ } finally {
6226
+ __setRootWorkspaceForTest(undefined);
6227
+ }
6228
+ },
6229
+ pass: (r) => {
6230
+ try {
6231
+ const out = JSON.parse(r?.result?.content?.[0]?.text ?? "{}");
6232
+ return (
6233
+ out.status === "ambiguous" &&
6234
+ out.next?.action === "pass_workspace_id" &&
6235
+ Array.isArray(out.candidates) &&
6236
+ out.candidates.length === 2 &&
6237
+ out.candidates.some((c) => c.workspaceId === "ws-b")
6238
+ );
6239
+ } catch {
6240
+ return false;
6241
+ }
6242
+ },
6243
+ },
5945
6244
  {
5946
6245
  // setClientRoots parses both file:// URIs and bare paths and
5947
6246
  // invalidates the cached resolution.
@@ -5958,6 +6257,149 @@ async function runSelftest() {
5958
6257
  // returns null until resolveRootWorkspace() runs. So "cleared" must be null.
5959
6258
  pass: (r) => r?.result?.cleared === null,
5960
6259
  },
6260
+ {
6261
+ // link_repo with no resolvable repo slug returns an actionable
6262
+ // no_repo error rather than calling the broker.
6263
+ name: "link_repo returns no_repo when not in a git repo",
6264
+ fn: async () => {
6265
+ const savedKey = process.env.ROADMAPPER_API_KEY;
6266
+ const savedUrl = process.env.ROADMAPPER_BACKEND_URL;
6267
+ try {
6268
+ process.env.ROADMAPPER_API_KEY = "rmpr_selftest";
6269
+ process.env.ROADMAPPER_BACKEND_URL = "https://selftest.local";
6270
+ _clientRoots = ["/tmp/x"];
6271
+ __setRepoSlugForTest(null); // no slug resolves
6272
+ return await handle({
6273
+ id: 91,
6274
+ method: "tools/call",
6275
+ params: { name: "link_repo", arguments: {} },
6276
+ });
6277
+ } finally {
6278
+ __setRepoSlugForTest(undefined);
6279
+ _clientRoots = [];
6280
+ if (savedKey === undefined) delete process.env.ROADMAPPER_API_KEY;
6281
+ else process.env.ROADMAPPER_API_KEY = savedKey;
6282
+ if (savedUrl === undefined) delete process.env.ROADMAPPER_BACKEND_URL;
6283
+ else process.env.ROADMAPPER_BACKEND_URL = savedUrl;
6284
+ }
6285
+ },
6286
+ pass: (r) => {
6287
+ try {
6288
+ return JSON.parse(r?.result?.content?.[0]?.text ?? "{}").status === "no_repo";
6289
+ } catch {
6290
+ return false;
6291
+ }
6292
+ },
6293
+ },
6294
+ {
6295
+ // link_repo with a resolvable slug POSTs { p_repo } through the
6296
+ // broker and, on {status:"linked"}, invalidates the resolution
6297
+ // cache. Stub fetch to assert the body shape + the linked result.
6298
+ name: "link_repo posts slug to broker and reports linked",
6299
+ fn: async () => {
6300
+ const savedFetch = globalThis.fetch;
6301
+ const savedKey = process.env.ROADMAPPER_API_KEY;
6302
+ const savedUrl = process.env.ROADMAPPER_BACKEND_URL;
6303
+ let sentBody = null;
6304
+ try {
6305
+ process.env.ROADMAPPER_API_KEY = "rmpr_selftest";
6306
+ process.env.ROADMAPPER_BACKEND_URL = "https://selftest.local";
6307
+ _clientRoots = ["/tmp/proj"];
6308
+ __setRepoSlugForTest("acme/widget");
6309
+ __setRootWorkspaceForTest("stale-ws"); // must be invalidated on link
6310
+ globalThis.fetch = async (_u, opts) => {
6311
+ sentBody = JSON.parse(opts.body);
6312
+ return {
6313
+ ok: true,
6314
+ json: async () => ({ status: "linked", workspace_id: "ws-1", repo: "acme/widget" }),
6315
+ text: async () => "",
6316
+ };
6317
+ };
6318
+ const r = await handle({
6319
+ id: 92,
6320
+ method: "tools/call",
6321
+ params: { name: "link_repo", arguments: {} },
6322
+ });
6323
+ return { r, sentBody, cacheCleared: _rootWorkspace };
6324
+ } finally {
6325
+ globalThis.fetch = savedFetch;
6326
+ __setRepoSlugForTest(undefined);
6327
+ __setRootWorkspaceForTest(undefined);
6328
+ _clientRoots = [];
6329
+ if (savedKey === undefined) delete process.env.ROADMAPPER_API_KEY;
6330
+ else process.env.ROADMAPPER_API_KEY = savedKey;
6331
+ if (savedUrl === undefined) delete process.env.ROADMAPPER_BACKEND_URL;
6332
+ else process.env.ROADMAPPER_BACKEND_URL = savedUrl;
6333
+ }
6334
+ },
6335
+ pass: (r) => {
6336
+ try {
6337
+ const out = JSON.parse(r?.r?.result?.content?.[0]?.text ?? "{}");
6338
+ return (
6339
+ out.status === "linked" &&
6340
+ out.repo === "acme/widget" &&
6341
+ // broker body carries the derived slug as p_repo
6342
+ r?.sentBody?.rpc === "link_repo" &&
6343
+ r?.sentBody?.body?.p_repo === "acme/widget" &&
6344
+ // CRITICAL: on the broker path we must NOT send p_workspace_id —
6345
+ // the broker injects the key's validated workspace, and sending
6346
+ // a (likely mismatched) wsId would trip its cross-workspace
6347
+ // guard and 403. This assertion locks in that fix.
6348
+ r?.sentBody?.body?.p_workspace_id === undefined &&
6349
+ // resolution cache invalidated so the new mapping wins next call
6350
+ r?.cacheCleared === undefined
6351
+ );
6352
+ } catch {
6353
+ return false;
6354
+ }
6355
+ },
6356
+ },
6357
+ {
6358
+ // A repo already mapped to a different workspace returns the
6359
+ // conflict passthrough (not a silent steal).
6360
+ name: "link_repo surfaces conflict when repo maps elsewhere",
6361
+ fn: async () => {
6362
+ const savedFetch = globalThis.fetch;
6363
+ const savedKey = process.env.ROADMAPPER_API_KEY;
6364
+ const savedUrl = process.env.ROADMAPPER_BACKEND_URL;
6365
+ const savedWs = process.env.ROADMAPPER_WORKSPACE_ID;
6366
+ try {
6367
+ process.env.ROADMAPPER_API_KEY = "rmpr_selftest";
6368
+ process.env.ROADMAPPER_BACKEND_URL = "https://selftest.local";
6369
+ process.env.ROADMAPPER_WORKSPACE_ID = "ws-mine";
6370
+ _clientRoots = ["/tmp/proj"];
6371
+ __setRepoSlugForTest("acme/taken");
6372
+ globalThis.fetch = async () => ({
6373
+ ok: true,
6374
+ json: async () => ({ status: "conflict", existing_workspace: "ws-other" }),
6375
+ text: async () => "",
6376
+ });
6377
+ return await handle({
6378
+ id: 93,
6379
+ method: "tools/call",
6380
+ params: { name: "link_repo", arguments: {} },
6381
+ });
6382
+ } finally {
6383
+ globalThis.fetch = savedFetch;
6384
+ __setRepoSlugForTest(undefined);
6385
+ _clientRoots = [];
6386
+ if (savedKey === undefined) delete process.env.ROADMAPPER_API_KEY;
6387
+ else process.env.ROADMAPPER_API_KEY = savedKey;
6388
+ if (savedUrl === undefined) delete process.env.ROADMAPPER_BACKEND_URL;
6389
+ else process.env.ROADMAPPER_BACKEND_URL = savedUrl;
6390
+ if (savedWs === undefined) delete process.env.ROADMAPPER_WORKSPACE_ID;
6391
+ else process.env.ROADMAPPER_WORKSPACE_ID = savedWs;
6392
+ }
6393
+ },
6394
+ pass: (r) => {
6395
+ try {
6396
+ const out = JSON.parse(r?.result?.content?.[0]?.text ?? "{}");
6397
+ return out.status === "conflict" && out.existingWorkspace === "ws-other";
6398
+ } catch {
6399
+ return false;
6400
+ }
6401
+ },
6402
+ },
5961
6403
  ];
5962
6404
 
5963
6405
  let passed = 0;