@roadmapperai/mcp 0.9.5 → 0.9.6

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/AGENTS.md CHANGED
@@ -131,8 +131,8 @@ you do, every write tool below returns a structured error:
131
131
  ```json
132
132
  {
133
133
  "error": "prerequisite_missing",
134
- "message": "Call get_agents_md first this session, then retry ...",
135
- "fix": "get_agents_md()"
134
+ "message": "Call roadmap({ op: \"get_agents_md\" }) first this session, then retry ...",
135
+ "fix": "roadmap({ op: \"get_agents_md\" })"
136
136
  }
137
137
  ```
138
138
 
@@ -275,15 +275,15 @@ the first such write is **refused** with a structured error:
275
275
  {
276
276
  "error": "repo_unmapped",
277
277
  "message": "\"owner/name\" isn't mapped to a workspace ...",
278
- "fix": "link_repo()",
279
- "alt": "<tool>({ workspaceId: \"<target>\", ... })"
278
+ "fix": "roadmap({ op: \"link_repo\" })",
279
+ "alt": "roadmap({ op: \"<op>\", args: { workspaceId: \"<target>\", ... } })"
280
280
  }
281
281
  ```
282
282
 
283
283
  Resolve it one of two ways, then retry:
284
- - **`link_repo()`** — maps the repo you're in to your key's
285
- workspace, so every future session resolves silently. This is
286
- the right move when the repo *should* feed this workspace.
284
+ - **`roadmap({ op: "link_repo" })`** — maps the repo you're in to
285
+ your key's workspace, so every future session resolves silently.
286
+ This is the right move when the repo *should* feed this workspace.
287
287
  - **Pass `workspaceId` explicitly** on the call — proceeds without
288
288
  mapping the repo. This is the escape hatch when you're working
289
289
  across **several repos in one session** and just want this write
@@ -356,52 +356,69 @@ PRs without submitted acceptance grades; call submit_acceptance_grades
356
356
  for TK-X, TK-Y, TK-Z." Surface these to the user; they're the
357
357
  roadmap's way of asking for the next action.
358
358
 
359
- Three capability tiers, driven by which env vars the operator set:
359
+ Two install shapes:
360
+
361
+ - **Customer (recommended):** `npx -y @roadmapperai/mcp` with
362
+ `ROADMAPPER_BACKEND_URL` + `ROADMAPPER_PUBLISHABLE_KEY` +
363
+ `ROADMAPPER_WORKSPACE_ID`, plus `ROADMAPPER_API_KEY` (an `rmpr_…` key)
364
+ to enable writes. Writes route through the mcp-broker, so no
365
+ service-role key ever lives on the machine. The dashboard's
366
+ Settings → Connect page generates this block pre-filled.
367
+ - **Self-host / operator:** run `node mcp/server.mjs` from a local
368
+ checkout with the `SUPABASE_*` env vars (legacy aliases the server
369
+ still accepts) and the Postgres migrations applied for writes.
370
+
371
+ Capability tiers, by which env vars are set (either shape):
360
372
 
361
373
  | Tier | Required env | What you get |
362
374
  |-------------------|-----------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
363
375
  | Seed-only | none | Read tools against the static `roadmap.json`. |
364
- | Live read | `SUPABASE_URL` + (`SUPABASE_PUBLISHABLE_KEY` or `SUPABASE_ANON_KEY`) + `SUPABASE_WORKSPACE_ID` | Read tools merged with the workspace's edits. |
365
- | Live read + write | All of the above plus `SUPABASE_SERVICE_ROLE_KEY` (and migrations 0005–0045 applied) | Read tools + propose_* / grade / archive_* / unarchive_* / move_* / update_* tools. |
366
-
367
- If a write tool returns an error result mentioning `SUPABASE_SERVICE_ROLE_KEY`,
368
- the operator is on the live-read tier; don't keep retrying — fall
369
- back to telling the human what you'd do and let them apply it.
370
-
371
- Sanity check the install with `node mcp/server.mjs --selftest` — runs
372
- every tool against the local seed and prints a pass/fail summary.
373
-
374
- If the operator chose project-scoped install (`.mcp.json` in the
375
- roadmapper repo root, which is the default `npm run mcp:setup` path),
376
- the MCP only loads when their client is launched from that repo. If
377
- you're an agent running in another codebase and the `roadmapper`
378
- tools aren't visible, ask the operator to either (a) merge the
379
- `mcpServers.roadmapper` block from `roadmap/.mcp.json` into their
380
- user-level client config or (b) point you at the roadmapper repo so
381
- you can run there instead.
382
-
383
- Wire-up (Claude Code, Claude Desktop, or any MCP client):
376
+ | Live read | `ROADMAPPER_BACKEND_URL` + `ROADMAPPER_PUBLISHABLE_KEY` + `ROADMAPPER_WORKSPACE_ID` (or the `SUPABASE_*` equivalents) | Read merged with the workspace's edits. |
377
+ | Live read + write | the above plus `ROADMAPPER_API_KEY` (`rmpr_…`, broker path) — or, self-hosting, `SUPABASE_SERVICE_ROLE_KEY` with migrations applied | Read + propose_* / grade / archive_* / unarchive_* / move_* / update_* ops. |
378
+
379
+ If a write returns an error about a missing key, you're on the read
380
+ tier; don't keep retrying — tell the human what you'd do and let them
381
+ apply it.
382
+
383
+ Sanity check (repo checkout only): `node mcp/server.mjs --selftest`
384
+ runs every check against the bundled seed and prints a pass/fail
385
+ summary. Run it from the **repo root** — an npm/npx install ships no
386
+ seed file, so ~13 seed-dependent checks fail with `ENOENT` there and
387
+ that is EXPECTED, not a broken install; the server still works over
388
+ the wire (reads fall back to a demo seed; live edits come from the
389
+ backend).
390
+
391
+ If the operator chose project-scoped install (`.mcp.json` in the repo,
392
+ the default `npm run mcp:setup` path), the MCP only loads when their
393
+ client is launched from that repo. If you're an agent in another
394
+ codebase and the `roadmapper` tools aren't visible, ask the operator
395
+ to either (a) merge the `mcpServers.roadmapper` block into their
396
+ user-level client config or (b) point you at the roadmapper repo.
397
+
398
+ Wire-up (Claude Code, Claude Desktop, or any MCP client) — customer path:
384
399
 
385
400
  ```jsonc
386
401
  {
387
402
  "mcpServers": {
388
403
  "roadmapper": {
389
- "command": "node",
390
- "args": ["/absolute/path/to/roadmap/mcp/server.mjs"],
404
+ "command": "npx",
405
+ "args": ["-y", "@roadmapperai/mcp"],
391
406
  "env": {
392
- "SUPABASE_URL": "https://<id>.supabase.co",
393
- "SUPABASE_PUBLISHABLE_KEY": "sb_publishable_...",
394
- "SUPABASE_WORKSPACE_ID": "<workspace id>",
395
- "SUPABASE_SERVICE_ROLE_KEY": "<only if write tools wanted>"
407
+ "ROADMAPPER_BACKEND_URL": "https://<id>.supabase.co",
408
+ "ROADMAPPER_PUBLISHABLE_KEY": "sb_publishable_...",
409
+ "ROADMAPPER_WORKSPACE_ID": "<workspace id>",
410
+ "ROADMAPPER_API_KEY": "<rmpr_… — only if write ops wanted>"
396
411
  }
397
412
  }
398
413
  }
399
414
  }
400
415
  ```
401
416
 
402
- The `env` block is optional drop it to serve the seed only. See
403
- [README.md](/README.md#mcp-server) for the config-file path per
404
- client and the full env-var matrix.
417
+ Self-hosting from a checkout instead? Use `"command": "node"` with the
418
+ absolute path to `mcp/server.mjs`; the `SUPABASE_*` env names are
419
+ accepted as aliases. The `env` block is optional — drop it to serve the
420
+ seed only. See [README.md](/README.md#mcp-server) for the config-file
421
+ path per client and the full env-var matrix.
405
422
 
406
423
  ## The snapshot file in connected repos
407
424
 
package/README.md CHANGED
@@ -150,6 +150,15 @@ the check with `ROADMAPPER_DISABLE_UPDATE_CHECK=1`.
150
150
 
151
151
  ### Recent changes
152
152
 
153
+ - **0.9.6** — hardening pass on top of the 0.9.5 collapse. `submit_acceptance_grades`
154
+ now rejects a negative/non-integer/out-of-range `index`, a non-array `grades`,
155
+ and a status that isn't `pass`/`fail` (the per-op schema is no longer on the
156
+ wire for clients to enforce, so the handler validates). A task can no longer be
157
+ moved to `in_progress` with an empty acceptance list on the MCP write path.
158
+ `propose_theme` with `dryRun` now previews a near-duplicate (with a warning)
159
+ instead of hard-blocking. Server instructions no longer tell agents to proceed
160
+ only on `status:"resolved"` (the normal env install resolves to `env_default`).
161
+ Docs reconciled to the dispatch surface + customer (npx) install path.
153
162
  - **0.9.5** — **tool-surface collapse for token efficiency.** `tools/list` now
154
163
  advertises three dispatch tools (`roadmap_search` / `roadmap_describe` /
155
164
  `roadmap`) instead of ~34, cutting the always-loaded tool definitions ~97%
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roadmapperai/mcp",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
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
@@ -511,7 +511,7 @@ PER-SESSION WORKFLOW
511
511
  1. Orient first: get_roadmap_snapshot (or list_themes / list_capabilities). This also satisfies the discovery gates below.
512
512
  2. Writing requires the rubric: every workspace-mutating tool (propose_*/update_*/archive_*/unarchive_*/move_*/record_outcome_reading/link_pr/submit_acceptance_grades) refuses until you call get_agents_md once this session (reading roadmapper://rubric also counts).
513
513
  3. Reuse before creating: suggest_capability_for({description}) to find an existing home; only propose a new capability if nothing fits. suggest_theme_for / list_themes before proposing a theme.
514
- 4. Before your first write, call get_active_workspace and proceed only if its status is "resolved"; for any other status follow the \`next\` action it returns (e.g. link_repo) so writes don't land in the wrong workspace.
514
+ 4. Before your first write, call get_active_workspace. Proceed if status is "resolved", or if it's "env_default" and the named workspace is the one you intend (the common, correct case for an env-configured install — its \`next.detail\` says how to confirm). Stop and follow the \`next\` action only for "ambiguous" or "unresolved" (e.g. link_repo), so writes don't land in the wrong workspace.
515
515
  5. dryRun:true validates any write without committing. Reference everything by stable ID, never by name.
516
516
 
517
517
  MODEL (don't conflate the layers)
@@ -3800,7 +3800,9 @@ async function proposeTheme(args, projected, wsId) {
3800
3800
  nearest = t;
3801
3801
  }
3802
3802
  }
3803
- if (nearest && nearestScore >= THEME_SPRAWL_BLOCK && args.force !== true) {
3803
+ // dryRun is exempt (like the autonomy gate below) so a validate-only call
3804
+ // always returns a preview; the overlap is surfaced as a warning instead.
3805
+ if (nearest && nearestScore >= THEME_SPRAWL_BLOCK && args.force !== true && !args.dryRun) {
3804
3806
  return textResult(
3805
3807
  JSON.stringify(
3806
3808
  {
@@ -3860,13 +3862,19 @@ async function proposeTheme(args, projected, wsId) {
3860
3862
  };
3861
3863
 
3862
3864
  if (args.dryRun) {
3865
+ const warnings =
3866
+ nearest && nearestScore >= THEME_SPRAWL_BLOCK && args.force !== true
3867
+ ? [
3868
+ `Overlaps existing theme ${nearest.id} (${nearest.name}) at ${nearestScore.toFixed(2)} (block bar ${THEME_SPRAWL_BLOCK}). A real call would be refused as too_similar unless force:true — prefer filing under it via ${opCall("propose_capability", `{ pillarId: "${nearest.id}", ... }`)}.`,
3869
+ ]
3870
+ : [];
3863
3871
  return textResult(
3864
3872
  JSON.stringify(
3865
3873
  {
3866
3874
  ok: true,
3867
3875
  dryRun: true,
3868
3876
  wouldCreate: theme,
3869
- warnings: [],
3877
+ warnings,
3870
3878
  message: `Would create theme ${id} (${name}). No record written.`,
3871
3879
  },
3872
3880
  null,
@@ -4643,6 +4651,28 @@ async function updateEntity(kind, args, wsId, projected) {
4643
4651
  );
4644
4652
  }
4645
4653
 
4654
+ // Acceptance gate (MCP-path stopgap). The SQL gate added in migration 0096
4655
+ // ("a task can't transition to in_progress without >=1 acceptance criterion")
4656
+ // landed on a different update_entity overload than the one this JS path
4657
+ // calls (an overload collision — the durable fix is a SQL migration that
4658
+ // reconciles them). Enforce it here too so the rule holds on the MCP path:
4659
+ // we have the merged `current` and the patch, so we know the post-update
4660
+ // acceptance and the real status transition.
4661
+ if (
4662
+ kind === "task" &&
4663
+ effectivePatch.status === "in_progress" &&
4664
+ current.status !== "in_progress"
4665
+ ) {
4666
+ const accAfter = Array.isArray(cleanedPatch.acceptance)
4667
+ ? cleanedPatch.acceptance
4668
+ : current.acceptance;
4669
+ if (!Array.isArray(accAfter) || accAfter.length === 0) {
4670
+ return errorResult(
4671
+ "Cannot move a task to in_progress without at least one acceptance criterion — add acceptance in the same patch (an empty acceptance list is a stop signal)."
4672
+ );
4673
+ }
4674
+ }
4675
+
4646
4676
  try {
4647
4677
  const result = await rpcCall("update_entity", {
4648
4678
  p_workspace_id: wsId,
@@ -5010,10 +5040,27 @@ async function submitAcceptanceGrades(args, projected, wsId) {
5010
5040
  return errorResult(
5011
5041
  `Task ${task.id} has no acceptance criteria to grade. Add some first.`
5012
5042
  );
5043
+ // Validate every grade BEFORE the RPC. The per-op inputSchema
5044
+ // (index integer >= 0, status enum) is advisory only — nothing enforces it
5045
+ // server-side, and since the tool-surface collapse the schema isn't even on
5046
+ // the wire for a client to check. So guard here: a non-array drops to an
5047
+ // opaque -32603 from the for-of, and a negative/float index reaches the SQL
5048
+ // jsonb_set with Postgres negative-index-from-end semantics, silently
5049
+ // overwriting an UNRELATED criterion's grade.
5050
+ if (!Array.isArray(args.grades) || args.grades.length === 0)
5051
+ return errorResult(
5052
+ "grades must be a non-empty array of { index, status, note? } objects."
5053
+ );
5013
5054
  for (const g of args.grades) {
5014
- if (g.index >= max)
5055
+ if (!g || typeof g !== "object" || Array.isArray(g))
5056
+ return errorResult("each grade must be an object { index, status, note? }.");
5057
+ if (!Number.isInteger(g.index) || g.index < 0 || g.index >= max)
5015
5058
  return errorResult(
5016
- `Grade index ${g.index} is out of range (task has ${max} criteria).`
5059
+ `Grade index ${JSON.stringify(g.index)} is invalid must be an integer in 0..${max - 1} (task has ${max} criteria).`
5060
+ );
5061
+ if (g.status !== "pass" && g.status !== "fail")
5062
+ return errorResult(
5063
+ `Grade status for index ${g.index} must be "pass" or "fail" (got ${JSON.stringify(g.status)}).`
5017
5064
  );
5018
5065
  }
5019
5066
 
@@ -7193,6 +7240,88 @@ async function runSelftest() {
7193
7240
  }),
7194
7241
  pass: (r) => r?.result?.isError === true,
7195
7242
  },
7243
+ {
7244
+ // submit_acceptance_grades: a negative index must be rejected up front —
7245
+ // otherwise it reaches the SQL jsonb_set and (negative-index-from-end)
7246
+ // overwrites an UNRELATED criterion's grade. Direct call so the test
7247
+ // targets the validator, not the rubric/seed gates.
7248
+ name: "submit_acceptance_grades rejects a negative index",
7249
+ fn: () =>
7250
+ submitAcceptanceGrades(
7251
+ { taskId: "TK-G", grades: [{ index: -1, status: "pass" }] },
7252
+ { tasks: [{ id: "TK-G", acceptance: ["a", "b"] }], capabilities: [], themes: [] },
7253
+ "ws-test"
7254
+ ),
7255
+ pass: (r) =>
7256
+ r?.isError === true && (r?.content?.[0]?.text ?? "").includes("invalid"),
7257
+ },
7258
+ {
7259
+ name: "submit_acceptance_grades rejects a non-array grades arg",
7260
+ fn: () =>
7261
+ submitAcceptanceGrades(
7262
+ { taskId: "TK-G", grades: "not-an-array" },
7263
+ { tasks: [{ id: "TK-G", acceptance: ["a", "b"] }], capabilities: [], themes: [] },
7264
+ "ws-test"
7265
+ ),
7266
+ pass: (r) =>
7267
+ r?.isError === true &&
7268
+ (r?.content?.[0]?.text ?? "").includes("non-empty array"),
7269
+ },
7270
+ {
7271
+ name: "submit_acceptance_grades rejects a status that isn't pass/fail",
7272
+ fn: () =>
7273
+ submitAcceptanceGrades(
7274
+ { taskId: "TK-G", grades: [{ index: 0, status: "maybe" }] },
7275
+ { tasks: [{ id: "TK-G", acceptance: ["a", "b"] }], capabilities: [], themes: [] },
7276
+ "ws-test"
7277
+ ),
7278
+ pass: (r) =>
7279
+ r?.isError === true && (r?.content?.[0]?.text ?? "").includes("pass"),
7280
+ },
7281
+ {
7282
+ // MCP-path stopgap for the 0096 SQL-overload gate: a task can't move to
7283
+ // in_progress with an empty acceptance list. Direct call with a synthetic
7284
+ // empty-acceptance task (the gate fires before any RPC).
7285
+ name: "update_task in_progress gate blocks a task with no acceptance",
7286
+ fn: () =>
7287
+ updateEntity(
7288
+ "task",
7289
+ { taskId: "TK-EMPTYACC", patch: { status: "in_progress" }, reason: "starting" },
7290
+ "ws-test",
7291
+ {
7292
+ tasks: [{ id: "TK-EMPTYACC", status: "planned", acceptance: [] }],
7293
+ capabilities: [],
7294
+ themes: [],
7295
+ }
7296
+ ),
7297
+ pass: (r) =>
7298
+ r?.isError === true && (r?.content?.[0]?.text ?? "").includes("acceptance"),
7299
+ },
7300
+ {
7301
+ // propose_theme dryRun must PREVIEW a near-duplicate (with a warning),
7302
+ // not hard-block — identical tokens force jaccard >= block bar.
7303
+ name: "propose_theme dryRun previews a near-duplicate with a warning",
7304
+ fn: () =>
7305
+ proposeTheme(
7306
+ { name: "Duplicate Pillar Name", description: "identical tokens here", dryRun: true },
7307
+ {
7308
+ themes: [{ id: "TH-DUP", name: "Duplicate Pillar Name", description: "identical tokens here" }],
7309
+ capabilities: [],
7310
+ tasks: [],
7311
+ settings: {},
7312
+ },
7313
+ "ws-test"
7314
+ ),
7315
+ pass: (r) => {
7316
+ if (r?.isError) return false;
7317
+ try {
7318
+ const b = JSON.parse(r?.content?.[0]?.text ?? "{}");
7319
+ return b.dryRun === true && Array.isArray(b.warnings) && b.warnings.length > 0;
7320
+ } catch {
7321
+ return false;
7322
+ }
7323
+ },
7324
+ },
7196
7325
  {
7197
7326
  // The three update ops are reachable + describable via the dispatch
7198
7327
  // surface (not advertised by name in tools/list anymore).