@roadmapperai/mcp 0.4.0 → 0.6.0

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 (2) hide show
  1. package/package.json +1 -1
  2. package/server.mjs +146 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roadmapperai/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
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
@@ -160,6 +160,136 @@ async function readAgentsMdForWorkspace() {
160
160
  return readAgentsMd();
161
161
  }
162
162
 
163
+ /**
164
+ * Per-workspace label cache for tool descriptions.
165
+ *
166
+ * When a workspace renames "Theme" → "Initiative" (Settings →
167
+ * Customize → Labels), the dashboard already shows the new label
168
+ * everywhere. The MCP server also surfaces level names — inside
169
+ * tool descriptions the agent reads. This cache holds the
170
+ * resolved labels for the session so the descriptions reflect
171
+ * the customer's vocabulary the moment the agent connects.
172
+ *
173
+ * Loaded lazily: first tools/list call kicks off the fetch
174
+ * (async, doesn't block the response — first agent sees defaults,
175
+ * subsequent calls see the customized labels). Cache is per
176
+ * process lifetime; restart the MCP server to pick up label
177
+ * changes. (Customers rarely change labels; a restart is fine.)
178
+ */
179
+ const DEFAULT_TOOL_LABELS = Object.freeze({
180
+ theme: "theme",
181
+ themes: "themes",
182
+ capability: "capability",
183
+ capabilities: "capabilities",
184
+ sprint: "sprint",
185
+ sprints: "sprints",
186
+ task: "task",
187
+ tasks: "tasks",
188
+ });
189
+ let labelsCache = null;
190
+ let labelsLoadStarted = false;
191
+
192
+ function pluralizeForTools(s) {
193
+ if (!s) return s;
194
+ if (s.length > 1 && s.endsWith("s")) return s;
195
+ const m = s.match(/([^aeiouAEIOU])y$/);
196
+ if (m) return s.slice(0, -1) + "ies";
197
+ if (/(ch|sh|x|z)$/i.test(s)) return s + "es";
198
+ return s + "s";
199
+ }
200
+
201
+ function normalizeFetchedLabels(raw) {
202
+ if (!raw || typeof raw !== "object") return DEFAULT_TOOL_LABELS;
203
+ const pick = (v, def) => {
204
+ const t = (v ?? "").trim();
205
+ return t.length === 0 ? def : t.toLowerCase();
206
+ };
207
+ const themeS = pick(raw.theme, "theme");
208
+ const capS = pick(raw.capability, "capability");
209
+ const sprintS = pick(raw.sprint, "sprint");
210
+ const taskS = pick(raw.task, "task");
211
+ return {
212
+ theme: themeS,
213
+ themes: pluralizeForTools(themeS),
214
+ capability: capS,
215
+ capabilities: pluralizeForTools(capS),
216
+ sprint: sprintS,
217
+ sprints: pluralizeForTools(sprintS),
218
+ task: taskS,
219
+ tasks: pluralizeForTools(taskS),
220
+ };
221
+ }
222
+
223
+ async function fetchWorkspaceLabels() {
224
+ const { apiKey, brokerUrl } = supabaseConfig();
225
+ if (!apiKey || !brokerUrl) return null;
226
+ try {
227
+ const res = await fetch(brokerUrl, {
228
+ method: "POST",
229
+ headers: {
230
+ Authorization: `Bearer ${apiKey}`,
231
+ "content-type": "application/json",
232
+ Accept: "application/json",
233
+ },
234
+ body: JSON.stringify({ rpc: "get_workspace_labels", body: {} }),
235
+ });
236
+ if (!res.ok) return null;
237
+ const parsed = await res.json();
238
+ return normalizeFetchedLabels(parsed);
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ /** Trigger label load once; safe to call repeatedly. */
245
+ function startLabelLoad() {
246
+ if (labelsLoadStarted) return;
247
+ labelsLoadStarted = true;
248
+ fetchWorkspaceLabels().then((l) => {
249
+ if (l) labelsCache = l;
250
+ });
251
+ }
252
+
253
+ /** Resolve labels currently in effect; defaults during load. */
254
+ function currentLabels() {
255
+ return labelsCache ?? DEFAULT_TOOL_LABELS;
256
+ }
257
+
258
+ /**
259
+ * Substitute level-name labels into a tool description.
260
+ *
261
+ * Two layers:
262
+ * 1. Explicit tokens — {theme}, {themes}, {capability}, etc. —
263
+ * always get replaced with the customer's label (or the
264
+ * default when nothing is customized).
265
+ * 2. Bare-word substitution — when a label differs from its
266
+ * default, every word-bounded occurrence of the default in
267
+ * the description is rewritten to the customized label.
268
+ * Preserves leading-cap for sentence starts ("Theme" →
269
+ * "Initiative", "theme" → "initiative"). Word boundaries
270
+ * keep compound identifiers (themeId, capabilityId) intact.
271
+ *
272
+ * Net: a customer who renames Theme → Initiative sees every tool
273
+ * description talk about "initiatives" instead of "themes", with
274
+ * zero manual changes to this file. A workspace that doesn't
275
+ * customize anything reads unchanged descriptions.
276
+ */
277
+ function tplDescription(text, labels) {
278
+ let out = text.replace(
279
+ /\{(theme|themes|capability|capabilities|sprint|sprints|task|tasks)\}/g,
280
+ (_, k) => labels[k] ?? k
281
+ );
282
+ for (const [k, v] of Object.entries(labels)) {
283
+ const def = DEFAULT_TOOL_LABELS[k];
284
+ if (!def || v === def) continue;
285
+ out = out.replace(new RegExp(`\\b${def}\\b`, "g"), v);
286
+ const DefCap = def[0].toUpperCase() + def.slice(1);
287
+ const VCap = v[0].toUpperCase() + v.slice(1);
288
+ out = out.replace(new RegExp(`\\b${DefCap}\\b`, "g"), VCap);
289
+ }
290
+ return out;
291
+ }
292
+
163
293
  /**
164
294
  * Resolve a config value from a primary `ROADMAPPER_*` env var,
165
295
  * falling back to a legacy `SUPABASE_*` alias when the primary
@@ -960,7 +1090,8 @@ const TOOLS = [
960
1090
  "USE WHEN: the user asks to plan features, design new work, sketch a roadmap, file a TODO that should persist beyond this conversation, or break a capability into deliverables.\n" +
961
1091
  "PREREQUISITE: get_agents_md once this session (the server enforces this and returns an error with a `fix` field if missing). Call suggest_capability_for first to find the right parent capability — do not invent a new one.\n" +
962
1092
  "ANTI-PATTERN: do not call to track in-progress work within a single conversation — use the harness TodoWrite tool. Do not call to log a bug discovered during implementation — file in the issue tracker, not roadmapper. Do not call when you don't know which capability the task belongs under; resolve that first.\n" +
963
- "EXAMPLE: propose_task({ capabilityId: 'CAP-XXX', title: 'Drag-and-drop block reorder', acceptance: ['Block can be dragged with mouse + keyboard', 'Order persists across reloads'], idempotencyKey: 'session-1-task-3' })\n\n" +
1093
+ "REQUIRED FIELDS: capabilityId, title, effort. Always size the task XS (≤2h) / S (≤1d) / M (~1-3d) / L (~1-2w) / XL (>2w). Effort drives capability % roll-up weighting; do not omit.\n" +
1094
+ "EXAMPLE: propose_task({ capabilityId: 'CAP-XXX', title: 'Drag-and-drop block reorder', effort: 'M', acceptance: ['Block can be dragged with mouse + keyboard', 'Order persists across reloads'], idempotencyKey: 'session-1-task-3' })\n\n" +
964
1095
  "Requires SUPABASE_SERVICE_ROLE_KEY. Pass idempotencyKey so retries don't duplicate. Pass dryRun: true to validate without writing. Pass workspaceId to target a workspace other than the env default.",
965
1096
  inputSchema: {
966
1097
  type: "object",
@@ -988,7 +1119,7 @@ const TOOLS = [
988
1119
  dryRun: { type: "boolean" },
989
1120
  workspaceId: { type: "string" },
990
1121
  },
991
- required: ["capabilityId", "title"],
1122
+ required: ["capabilityId", "title", "effort"],
992
1123
  additionalProperties: false,
993
1124
  },
994
1125
  },
@@ -3123,7 +3254,19 @@ async function handle(request) {
3123
3254
  };
3124
3255
  }
3125
3256
  if (method === "tools/list") {
3126
- return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
3257
+ // Kick off the per-workspace label fetch (idempotent). The
3258
+ // first call serves descriptions with defaults; subsequent
3259
+ // calls in the same session see the customized labels once
3260
+ // the broker responds (typically <300ms). Most MCP clients
3261
+ // call tools/list once at connect and once after a reconnect,
3262
+ // so the timing usually works out.
3263
+ startLabelLoad();
3264
+ const labels = currentLabels();
3265
+ const tools = TOOLS.map((t) => ({
3266
+ ...t,
3267
+ description: tplDescription(t.description, labels),
3268
+ }));
3269
+ return { jsonrpc: "2.0", id, result: { tools } };
3127
3270
  }
3128
3271
  if (method === "tools/call") {
3129
3272
  const result = await callTool(params?.name, params?.arguments ?? {});