@roadmapperai/mcp 0.9.0 → 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 +22 -0
  2. package/package.json +1 -1
  3. package/server.mjs +544 -20
package/README.md CHANGED
@@ -126,6 +126,28 @@ This package follows semver. Breaking changes to tool shapes get a
126
126
  major-version bump and a deprecation window. Add `--package=@roadmapperai/mcp@0.x.y`
127
127
  to your `npx` invocation if you want to pin.
128
128
 
129
+ Because the install is version-pinned (the dashboard hands you a config
130
+ pinned to an exact version), the server checks the npm registry at boot
131
+ and logs a one-line nudge to stderr if a newer version is published —
132
+ re-copy the install command from **Settings → MCP** to upgrade. Disable
133
+ the check with `ROADMAPPER_DISABLE_UPDATE_CHECK=1`.
134
+
135
+ ### Recent changes
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.
144
+ - **0.9.1** — the server now self-reports its real version (was a stale
145
+ hardcoded string in `serverInfo` and audit logs) and warns at boot
146
+ when a newer package is published.
147
+ - **0.9.0** — entity reads on the customer path now route through the
148
+ broker, fixing an empty roadmap for every customer; new triage + gap
149
+ tools; light-by-default reads.
150
+
129
151
  ## Support
130
152
 
131
153
  - Bugs / feature requests: contact@roadmapperai.com
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roadmapperai/mcp",
3
- "version": "0.9.0",
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
@@ -95,7 +95,22 @@ const REPO_AGENTS_PATH = join(REPO, "AGENTS.md");
95
95
 
96
96
  const PROTOCOL_VERSION = "2024-11-05";
97
97
  const SERVER_NAME = "roadmapper";
98
- const SERVER_VERSION = "0.6.0";
98
+ // Read the real version from the bundled package.json (it's in the npm
99
+ // `files` allow-list) so SERVER_VERSION never drifts from the published
100
+ // package. A hardcoded constant rotted to "0.6.0" while the package was
101
+ // at 0.9.0 — which silently mis-stamped every audit-log row's
102
+ // server_version. scripts/check-mcp-version.mjs guards the dashboard's
103
+ // LATEST_MCP_VERSION against package.json; this closes the same gap on
104
+ // the server side. Falls back to "0.0.0" only if the file is somehow
105
+ // unreadable (never expected in a real install).
106
+ const SERVER_VERSION = (() => {
107
+ try {
108
+ const pkg = JSON.parse(readFileSync(join(HERE, "package.json"), "utf-8"));
109
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
110
+ } catch {
111
+ return "0.0.0";
112
+ }
113
+ })();
99
114
 
100
115
  // Must match src/types.ts EFFORT_DAYS — AI-era calibration.
101
116
  // Fractional values (XS=0.25, S=0.5) get rounded up when used to
@@ -110,6 +125,69 @@ function log(...args) {
110
125
  console.error("[roadmapper-mcp]", ...args);
111
126
  }
112
127
 
128
+ /**
129
+ * Compare two semver-ish strings ("1.2.3"). Returns 1 if a>b, -1 if
130
+ * a<b, 0 if equal. Compares the numeric major.minor.patch only — any
131
+ * prerelease/build suffix is ignored, which is fine for "is there a
132
+ * newer published release?" The probe below tolerates a 0 or negative
133
+ * result by staying silent, so an unexpected suffix never produces a
134
+ * false "upgrade available" nag.
135
+ */
136
+ function compareVersions(a, b) {
137
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
138
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
139
+ for (let i = 0; i < 3; i++) {
140
+ const da = pa[i] ?? 0;
141
+ const db = pb[i] ?? 0;
142
+ if (da > db) return 1;
143
+ if (da < db) return -1;
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ /**
149
+ * Best-effort startup staleness check. Fetches the `latest` dist-tag
150
+ * for @roadmapperai/mcp from the npm registry and, if the running
151
+ * version is behind, logs a one-line nudge to stderr. Entirely
152
+ * advisory — the server is pinned to a specific version in the
153
+ * client's MCP config (see the dashboard install snippets), so there
154
+ * is no auto-update; the user upgrades by re-copying the install
155
+ * command from Settings → MCP. This is the only signal a stranded,
156
+ * outdated install ever gets, so failures must stay silent (a flaky
157
+ * network or offline install must never spam the log or delay boot).
158
+ * Disable entirely with ROADMAPPER_DISABLE_UPDATE_CHECK=1.
159
+ */
160
+ async function checkForUpdate() {
161
+ if (process.env.ROADMAPPER_DISABLE_UPDATE_CHECK === "1") return;
162
+ if (SERVER_VERSION === "0.0.0") return; // version unreadable; nothing to compare
163
+ try {
164
+ const ctrl = new AbortController();
165
+ const timer = setTimeout(() => ctrl.abort(), 2500);
166
+ let latest;
167
+ try {
168
+ const res = await fetch(
169
+ "https://registry.npmjs.org/@roadmapperai/mcp/latest",
170
+ { headers: { Accept: "application/json" }, signal: ctrl.signal }
171
+ );
172
+ if (!res.ok) return;
173
+ const body = await res.json();
174
+ latest = body && typeof body.version === "string" ? body.version : null;
175
+ } finally {
176
+ clearTimeout(timer);
177
+ }
178
+ if (!latest) return;
179
+ if (compareVersions(latest, SERVER_VERSION) > 0) {
180
+ log(
181
+ `update available: v${SERVER_VERSION} installed, v${latest} published. ` +
182
+ `This install is pinned — re-copy the install command from ` +
183
+ `Settings → MCP in the Roadmapper dashboard to upgrade.`
184
+ );
185
+ }
186
+ } catch {
187
+ // Offline, blocked, slow, or aborted — stay silent by design.
188
+ }
189
+ }
190
+
113
191
  function send(message) {
114
192
  process.stdout.write(JSON.stringify(message) + "\n");
115
193
  }
@@ -509,6 +587,9 @@ function __setSnapshotWorkspaceForTest(value) {
509
587
  let _clientRoots = []; // array of absolute dir paths from the client
510
588
  let _rootWorkspace = undefined; // undefined=unresolved, null=resolved-but-none, string=workspaceId
511
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].
512
593
  let _clientSupportsRoots = false; // set from initialize params.capabilities.roots
513
594
  const ROOTS_LIST_REQUEST_ID = "roadmapper-roots-list"; // our id for the roots/list request we send
514
595
 
@@ -534,6 +615,7 @@ function setClientRoots(roots) {
534
615
  // Invalidate the cached resolution so the next access re-derives it.
535
616
  _rootWorkspace = undefined;
536
617
  _rootWorkspaceRepo = null;
618
+ _rootWorkspaceMatches = [];
537
619
  }
538
620
 
539
621
  /**
@@ -541,7 +623,21 @@ function setClientRoots(roots) {
541
623
  * to find the repo root implicitly via `git -C <dir>`. Returns null if
542
624
  * the dir isn't a git repo, has no origin, or git isn't available.
543
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
+
544
635
  async function repoSlugForDir(dir) {
636
+ if (_repoSlugOverride !== undefined) {
637
+ return typeof _repoSlugOverride === "string"
638
+ ? _repoSlugOverride
639
+ : (_repoSlugOverride && _repoSlugOverride[dir]) || null;
640
+ }
545
641
  try {
546
642
  // Async so a slow/hanging git call never blocks the stdin event loop
547
643
  // (this runs while handling the client's roots/list reply). 2s cap.
@@ -620,6 +716,7 @@ async function resolveRootWorkspace() {
620
716
  `Pass workspaceId explicitly on calls to target a specific one.`
621
717
  );
622
718
  }
719
+ _rootWorkspaceMatches = matches;
623
720
  if (matches.length > 0) {
624
721
  _rootWorkspace = matches[0].ws;
625
722
  _rootWorkspaceRepo = matches[0].slug;
@@ -630,6 +727,24 @@ async function resolveRootWorkspace() {
630
727
  return null;
631
728
  }
632
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
+
633
748
  /** Cached root-derived workspace id (sync read). null if none/unresolved. */
634
749
  function rootWorkspaceId() {
635
750
  return _rootWorkspace ?? null;
@@ -637,9 +752,11 @@ function rootWorkspaceId() {
637
752
 
638
753
  // Test hook: seed the root-resolution cache without touching the client
639
754
  // protocol or the network.
640
- function __setRootWorkspaceForTest(id, repo = null) {
755
+ function __setRootWorkspaceForTest(id, repo = null, matches = null) {
641
756
  _rootWorkspace = id;
642
757
  _rootWorkspaceRepo = repo;
758
+ _rootWorkspaceMatches =
759
+ matches ?? (id ? [{ ws: id, slug: repo }] : []);
643
760
  }
644
761
 
645
762
  /**
@@ -964,13 +1081,29 @@ function compactResult(obj) {
964
1081
  * concurrent agent writes safe — see migration 0006 for the
965
1082
  * function bodies.
966
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
+
967
1093
  async function rpcCall(fn, body) {
968
1094
  const { url, writeKey, apiKey, brokerUrl } = supabaseConfig();
969
- // body must already carry p_workspace_id — the per-tool resolver
970
- // injects it before calling rpcCall so the override path works.
971
- 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) {
972
1105
  throw new Error(
973
- "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)."
974
1107
  );
975
1108
  }
976
1109
 
@@ -1007,7 +1140,10 @@ async function rpcCall(fn, body) {
1007
1140
  "Write tools require either ROADMAPPER_API_KEY (customer path) or ROADMAPPER_ADMIN_KEY (operator path)."
1008
1141
  );
1009
1142
  }
1010
- 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}`, {
1011
1147
  method: "POST",
1012
1148
  headers: {
1013
1149
  apikey: writeKey,
@@ -1020,7 +1156,7 @@ async function rpcCall(fn, body) {
1020
1156
  if (!res.ok) {
1021
1157
  const txt = await res.text();
1022
1158
  throw new Error(
1023
- `rpc ${fn} failed: ${res.status} ${txt.slice(0, 300)}`
1159
+ `rpc ${rpcName} failed: ${res.status} ${txt.slice(0, 300)}`
1024
1160
  );
1025
1161
  }
1026
1162
  return res.json();
@@ -1559,7 +1695,8 @@ const TOOLS = [
1559
1695
  {
1560
1696
  name: "get_active_workspace",
1561
1697
  description:
1562
- "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" +
1563
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" +
1564
1701
  "PREREQUISITE: none — read-only.\n" +
1565
1702
  "ANTI-PATTERN: don't use it to inspect roadmap contents — that's get_roadmap_snapshot. This only answers 'where am I pointed'.\n" +
@@ -1576,6 +1713,21 @@ const TOOLS = [
1576
1713
  additionalProperties: false,
1577
1714
  },
1578
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
+ },
1579
1731
  {
1580
1732
  name: "propose_task",
1581
1733
  description:
@@ -2218,21 +2370,111 @@ async function callTool(name, args) {
2218
2370
  if (name === "get_active_workspace") {
2219
2371
  const { id, source } = resolveWorkspaceWithSource(args?.workspaceId);
2220
2372
  const { url } = supabaseConfig();
2221
- let note;
2222
- if (source === "env") {
2223
- note =
2224
- "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.";
2225
- } else if (source === "none") {
2226
- note =
2227
- "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
+ };
2228
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
+ };
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
+
2229
2459
  return textResult(
2230
2460
  JSON.stringify(
2231
2461
  {
2232
2462
  workspaceId: id,
2233
- resolvedFrom: source, // "arg" | "snapshot" | "env" | "none"
2234
- 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,
2235
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
2236
2478
  ...(note ? { note } : {}),
2237
2479
  },
2238
2480
  null,
@@ -2241,6 +2483,97 @@ async function callTool(name, args) {
2241
2483
  );
2242
2484
  }
2243
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
+
2244
2577
  // Post-Piece-6c, the entity tables ARE the canonical projection
2245
2578
  // — no edits blob, no seed-overlay merge. Fall back to the
2246
2579
  // bundled seed only when the DB is unreachable (offline / dev).
@@ -4465,8 +4798,16 @@ async function runSelftest() {
4465
4798
  const out = JSON.parse(r?.result?.content?.[0]?.text ?? "{}");
4466
4799
  return (
4467
4800
  typeof out.resolvedFrom === "string" &&
4468
- ["arg", "snapshot", "env", "none"].includes(out.resolvedFrom) &&
4469
- ["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"))
4470
4811
  );
4471
4812
  } catch {
4472
4813
  return false;
@@ -5864,6 +6205,42 @@ async function runSelftest() {
5864
6205
  pass: (r) =>
5865
6206
  r?.result?.id === "ws-from-snapshot" && r?.result?.source === "snapshot",
5866
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
+ },
5867
6244
  {
5868
6245
  // setClientRoots parses both file:// URIs and bare paths and
5869
6246
  // invalidates the cached resolution.
@@ -5880,6 +6257,149 @@ async function runSelftest() {
5880
6257
  // returns null until resolveRootWorkspace() runs. So "cleared" must be null.
5881
6258
  pass: (r) => r?.result?.cleared === null,
5882
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
+ },
5883
6403
  ];
5884
6404
 
5885
6405
  let passed = 0;
@@ -5976,4 +6496,8 @@ if (process.argv.includes("--selftest")) {
5976
6496
  const snapTail = snap ? `, snapshot-workspace=${snap}` : "";
5977
6497
  log(`ready (mode=${mode}${tail}${snapTail})`);
5978
6498
  })();
6499
+
6500
+ // Advisory: nudge the operator if a newer package is published. Runs
6501
+ // detached from boot — never blocks startup, never throws.
6502
+ checkForUpdate();
5979
6503
  }