@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.
- package/README.md +22 -0
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
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
|
-
|
|
2234
|
-
|
|
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(
|
|
4469
|
-
|
|
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
|
}
|