@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.
- package/README.md +7 -0
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
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(
|
|
4547
|
-
|
|
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;
|