lace-mcp 0.0.2-alpha.16 → 0.0.2-alpha.19

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 CHANGED
@@ -5,7 +5,7 @@ MCP server for [Lace](https://inlace.co). Connects your coding agent to approved
5
5
  ## How it works
6
6
 
7
7
  1. You review your product in Lace and approve a decision on the [Decision Canvas](https://docs.inlace.co/concepts/decision-canvas)
8
- 2. Your coding agent calls `list_decisions` to see what's been approved
8
+ 2. Your coding agent calls `search_decisions` to find what's been approved
9
9
  3. It calls `apply_decision` to get screenshots, element targeting, and step-by-step instructions
10
10
  4. The agent writes the code change and you review the diff
11
11
 
@@ -28,18 +28,19 @@ The Lace desktop app must be open and signed in for the MCP server to work.
28
28
 
29
29
  | Tool | Description |
30
30
  | --- | --- |
31
- | `list_decisions` | Returns all approved decisions as a summary list |
32
- | `apply_decision` | Everything needed to implement a decision: screenshots, targeting, and step-by-step instructions |
33
- | `get_decision_detail` | On-demand detail for a decision: cropped element image, thread context, or both |
31
+ | `search_decisions` | Search and filter resolved decisions with pagination. No params returns the top 20 |
32
+ | `apply_decision` | Implementation kit for a decision: screenshots, targeting, instructions. `include` controls detail level |
33
+ | `list_canvases` | Browse review canvases with page names and decision counts |
34
+ | `list_projects` | Browse projects with thread and canvas counts |
34
35
 
35
- Use `apply_decision` when implementing. Use `get_decision_detail` only when you need a cropped element image or thread context.
36
+ Use `search_decisions` to discover, then `apply_decision` to act. Use `list_canvases` and `list_projects` to find IDs for filtering.
36
37
 
37
38
  ## Usage
38
39
 
39
40
  Once installed, ask your coding agent:
40
41
 
41
42
  ```
42
- "List Lace decisions"
43
+ "Search Lace decisions"
43
44
  ```
44
45
 
45
46
  Or to implement directly:
package/SKILL.md CHANGED
@@ -9,15 +9,15 @@ Lace surfaces adoption insights as actionable decisions. When a decision is appr
9
9
 
10
10
  ## Steps
11
11
 
12
- 1. Call the `list_decisions` MCP tool to see all approved decisions.
13
- 2. If there is exactly one decision, proceed with it. If there are multiple, ask the user which one to implement.
14
- 3. Call the `apply_decision` MCP tool with the chosen decision ID.
15
- 4. Follow the implementation instructions returned by `apply_decision` exactly they include screenshots, element targeting, and constraints.
16
- 5. Implement the minimal code change described. Do not refactor or modify anything outside the target element.
17
- 6. If you need a cropped image of a specific UI element (e.g., for embedding in a prototype), call `get_decision_detail` with the decision ID and `include: "image"`.
18
- 7. If you need to understand why a decision was made or what alternatives were considered, call `get_decision_detail` with the decision ID and `include: "context"`.
12
+ 1. Call the `search_decisions` MCP tool with no params to see recently approved decisions, or pass filters such as `query`, `canvasId`, `projectId`, `appName`, or `elementRole` when the user has given scope.
13
+ 2. If the workspace has many canvases or projects, call `list_canvases` or `list_projects` first and use the returned IDs/titles to scope `search_decisions`.
14
+ 3. If there is exactly one relevant decision, proceed with it. If there are multiple plausible matches, ask the user which one to implement.
15
+ 4. Call `apply_decision` with the chosen decision ID. Use `target: "code"` for code changes, `target: "slideshow"` for slide content, `target: "graphic"` for graphic specs, or `target: "prototype"` for prototype data.
16
+ 5. Use `include: "implementation"` by default. Use `include: "image"` for a cropped element image, `include: "context"` for thread rationale, or `include: "all"` when you need screenshots, cropped image, and rationale together.
17
+ 6. Follow the implementation instructions returned by `apply_decision` exactly they include screenshots, element targeting, and constraints.
18
+ 7. Implement the minimal code change described. Do not refactor or modify anything outside the target element.
19
19
 
20
20
  ## Troubleshooting
21
21
 
22
- - If `list_decisions` returns an authentication error, tell the user to run `npx lace-mcp install <editor>` to re-authenticate.
23
- - If no decisions are returned, the user may need to approve a decision in the Lace overlay first.
22
+ - If `search_decisions` returns an authentication error, tell the user to run `npx lace-mcp install <editor>` to re-authenticate.
23
+ - If no decisions are returned, the user may need to approve a decision in the Lace overlay first or provide a narrower canvas/project scope.
package/dist/cli.js CHANGED
@@ -41598,9 +41598,9 @@ var require_balanced_match = __commonJS({
41598
41598
  }
41599
41599
  });
41600
41600
 
41601
- // ../../node_modules/.pnpm/brace-expansion@2.0.3/node_modules/brace-expansion/index.js
41601
+ // ../../node_modules/.pnpm/brace-expansion@2.1.1/node_modules/brace-expansion/index.js
41602
41602
  var require_brace_expansion = __commonJS({
41603
- "../../node_modules/.pnpm/brace-expansion@2.0.3/node_modules/brace-expansion/index.js"(exports, module2) {
41603
+ "../../node_modules/.pnpm/brace-expansion@2.1.1/node_modules/brace-expansion/index.js"(exports, module2) {
41604
41604
  var balanced = require_balanced_match();
41605
41605
  module2.exports = expandTop;
41606
41606
  var escSlash = "\0SLASH" + Math.random() + "\0";
@@ -41637,13 +41637,15 @@ var require_brace_expansion = __commonJS({
41637
41637
  parts.push.apply(parts, p);
41638
41638
  return parts;
41639
41639
  }
41640
- function expandTop(str) {
41640
+ function expandTop(str, options) {
41641
41641
  if (!str)
41642
41642
  return [];
41643
+ options = options || {};
41644
+ var max = options.max == null ? Infinity : options.max;
41643
41645
  if (str.substr(0, 2) === "{}") {
41644
41646
  str = "\\{\\}" + str.substr(2);
41645
41647
  }
41646
- return expand2(escapeBraces(str), true).map(unescapeBraces);
41648
+ return expand2(escapeBraces(str), max, true).map(unescapeBraces);
41647
41649
  }
41648
41650
  function embrace(str) {
41649
41651
  return "{" + str + "}";
@@ -41657,14 +41659,14 @@ var require_brace_expansion = __commonJS({
41657
41659
  function gte(i, y) {
41658
41660
  return i >= y;
41659
41661
  }
41660
- function expand2(str, isTop) {
41662
+ function expand2(str, max, isTop) {
41661
41663
  var expansions = [];
41662
41664
  var m = balanced("{", "}", str);
41663
41665
  if (!m) return [str];
41664
41666
  var pre = m.pre;
41665
- var post = m.post.length ? expand2(m.post, false) : [""];
41667
+ var post = m.post.length ? expand2(m.post, max, false) : [""];
41666
41668
  if (/\$$/.test(m.pre)) {
41667
- for (var k = 0; k < post.length; k++) {
41669
+ for (var k = 0; k < post.length && k < max; k++) {
41668
41670
  var expansion = pre + "{" + m.body + "}" + post[k];
41669
41671
  expansions.push(expansion);
41670
41672
  }
@@ -41676,7 +41678,7 @@ var require_brace_expansion = __commonJS({
41676
41678
  if (!isSequence && !isOptions) {
41677
41679
  if (m.post.match(/,(?!,).*\}/)) {
41678
41680
  str = m.pre + "{" + m.body + escClose + m.post;
41679
- return expand2(str);
41681
+ return expand2(str, max, true);
41680
41682
  }
41681
41683
  return [str];
41682
41684
  }
@@ -41686,7 +41688,7 @@ var require_brace_expansion = __commonJS({
41686
41688
  } else {
41687
41689
  n = parseCommaParts(m.body);
41688
41690
  if (n.length === 1) {
41689
- n = expand2(n[0], false).map(embrace);
41691
+ n = expand2(n[0], max, false).map(embrace);
41690
41692
  if (n.length === 1) {
41691
41693
  return post.map(function(p) {
41692
41694
  return m.pre + n[0] + p;
@@ -41708,7 +41710,7 @@ var require_brace_expansion = __commonJS({
41708
41710
  }
41709
41711
  var pad = n.some(isPadded);
41710
41712
  N = [];
41711
- for (var i = x; test(i, y); i += incr) {
41713
+ for (var i = x; test(i, y) && N.length < max; i += incr) {
41712
41714
  var c;
41713
41715
  if (isAlphaSequence) {
41714
41716
  c = String.fromCharCode(i);
@@ -41732,11 +41734,11 @@ var require_brace_expansion = __commonJS({
41732
41734
  } else {
41733
41735
  N = [];
41734
41736
  for (var j = 0; j < n.length; j++) {
41735
- N.push.apply(N, expand2(n[j], false));
41737
+ N.push.apply(N, expand2(n[j], max, false));
41736
41738
  }
41737
41739
  }
41738
41740
  for (var j = 0; j < N.length; j++) {
41739
- for (var k = 0; k < post.length; k++) {
41741
+ for (var k = 0; k < post.length && expansions.length < max; k++) {
41740
41742
  var expansion = pre + N[j] + post[k];
41741
41743
  if (!isTop || isSequence || expansion)
41742
41744
  expansions.push(expansion);
@@ -82163,10 +82165,13 @@ var init_zod2 = __esm({
82163
82165
  });
82164
82166
 
82165
82167
  // ../../libs/shared/src/decisionCanvas/types.ts
82168
+ function isFullPageBbox(target) {
82169
+ return target.bbox_x1 <= 0 && target.bbox_y1 <= 0 && target.bbox_x2 >= 1 && target.bbox_y2 >= 1;
82170
+ }
82166
82171
  function isCanvasDecisionAnnotation(annotation) {
82167
82172
  return annotation.kind == null || annotation.kind === CANVAS_ANNOTATION_KIND_DECISION;
82168
82173
  }
82169
- var CANVAS_ANNOTATION_KIND_ELEMENT, CANVAS_ANNOTATION_KIND_DECISION, CANVAS_ANNOTATION_KIND_VALUES, CANVAS_ANNOTATION_STATUS_VALUES, CANVAS_ANNOTATION_ANCHOR_STATE_VALUES, CANVAS_ANNOTATION_ANCHOR_SOURCE_VALUES, CANVAS_ANNOTATION_HEADLINE_MAX_LENGTH, uuidSchema, optionalCanvasTextSchema, canvasBboxCoordinateSchema, optionalIsoDateSchema, createCanvasAnnotationInputSchema, CANVAS_ANNOTATION_CHANGE_EVENT_TYPES, CANVAS_PAGE_CHANGE_EVENT_TYPES, CANVAS_THREAD_CHANGE_EVENT_TYPES, CANVAS_CHANGE_EVENT_TYPES;
82174
+ var CANVAS_ANNOTATION_KIND_ELEMENT, CANVAS_ANNOTATION_KIND_DECISION, CANVAS_ANNOTATION_KIND_VALUES, CANVAS_ANNOTATION_STATUS_VALUES, CANVAS_ANNOTATION_ANCHOR_STATE_VALUES, CANVAS_ANNOTATION_ANCHOR_SOURCE_VALUES, CANVAS_ANNOTATION_HEADLINE_MAX_LENGTH, uuidSchema, optionalCanvasTextSchema, canvasBboxCoordinateSchema, optionalIsoDateSchema, createCanvasAnnotationInputSchema, CANVAS_ANNOTATION_CHANGE_EVENT_TYPES, CANVAS_PAGE_CHANGE_EVENT_TYPES, CANVAS_REPLY_CHANGE_EVENT_TYPES, CANVAS_THREAD_CHANGE_EVENT_TYPES, CANVAS_CHANGE_EVENT_TYPES, LACE_MCP_INSTRUCTIONS;
82170
82175
  var init_types4 = __esm({
82171
82176
  "../../libs/shared/src/decisionCanvas/types.ts"() {
82172
82177
  "use strict";
@@ -82229,14 +82234,29 @@ var init_types4 = __esm({
82229
82234
  "annotation-reopened"
82230
82235
  ];
82231
82236
  CANVAS_PAGE_CHANGE_EVENT_TYPES = ["page-added", "page-removed", "page-reordered", "page-updated"];
82237
+ CANVAS_REPLY_CHANGE_EVENT_TYPES = ["reply-created", "reply-updated", "reply-deleted"];
82232
82238
  CANVAS_THREAD_CHANGE_EVENT_TYPES = ["thread-linked", "thread-unlinked"];
82233
82239
  CANVAS_CHANGE_EVENT_TYPES = [
82234
82240
  ...CANVAS_ANNOTATION_CHANGE_EVENT_TYPES,
82235
82241
  ...CANVAS_PAGE_CHANGE_EVENT_TYPES,
82242
+ ...CANVAS_REPLY_CHANGE_EVENT_TYPES,
82236
82243
  "canvas-created",
82237
82244
  "canvas-updated",
82238
82245
  ...CANVAS_THREAD_CHANGE_EVENT_TYPES
82239
82246
  ];
82247
+ LACE_MCP_INSTRUCTIONS = `Lace surfaces UI decisions from product reviews. Decisions drive code changes, slideshows, graphics, and prototypes.
82248
+
82249
+ Discovery:
82250
+ - search_decisions: find decisions by text, canvas, project, app, time, or element. No params = top 20 ranked.
82251
+ - list_canvases: browse review canvases to find canvas IDs for filtering.
82252
+ - list_projects: browse projects to find project IDs for scoping.
82253
+
82254
+ Action:
82255
+ - apply_decision: get the implementation kit for a decision. Set target to control output format (code, slideshow, graphic, prototype). Set include to control detail level (implementation, image, context, all).
82256
+
82257
+ Workflow: search_decisions -> pick a decision -> apply_decision with the right target.
82258
+
82259
+ All tools scope to canvases you have access to. Some canvases may be view-only (title and status visible, no decisions). Decision IDs from search_decisions are valid for apply_decision.`;
82240
82260
  }
82241
82261
  });
82242
82262
 
@@ -82267,9 +82287,6 @@ function currentCommentPlacements(placements) {
82267
82287
  function resolvedDecisionPlacements(placements) {
82268
82288
  return currentCommentPlacements(placements).filter(isResolvedCommentPlacement);
82269
82289
  }
82270
- function resolvedDecisionViews(placements) {
82271
- return resolvedDecisionPlacements(placements);
82272
- }
82273
82290
  var init_readModel = __esm({
82274
82291
  "../../libs/shared/src/decisionCanvas/readModel.ts"() {
82275
82292
  "use strict";
@@ -82292,60 +82309,6 @@ function optionalAttr(name, value) {
82292
82309
  function deriveDecisionStatus(decision) {
82293
82310
  return decision.status;
82294
82311
  }
82295
- function canvasAnnotationToMcpDecisionView(decision) {
82296
- return {
82297
- id: decision.id,
82298
- commentLineageId: decision.comment_lineage_id,
82299
- source: "canvas",
82300
- headline: decision.headline,
82301
- body: decision.body,
82302
- elementEid: decision.element_eid,
82303
- shotId: decision.page.shot_id,
82304
- bbox: [decision.bbox_x1, decision.bbox_y1, decision.bbox_x2, decision.bbox_y2],
82305
- status: deriveDecisionStatus(decision),
82306
- anchorState: decision.anchor_state,
82307
- appName: decision.page.app_name,
82308
- windowTitle: decision.page.window_title,
82309
- createdAt: decision.created_at,
82310
- resolvedAt: decision.resolved_at
82311
- };
82312
- }
82313
- function dedupeKey(view) {
82314
- if (view.commentLineageId) return `lineage:${view.commentLineageId}`;
82315
- if (view.elementEid && view.shotId) return `${view.elementEid}:${view.shotId}`;
82316
- return `canvas:${view.id}`;
82317
- }
82318
- function decisionPlacementTime(view) {
82319
- const createdAt = Date.parse(view.createdAt);
82320
- if (Number.isFinite(createdAt)) return createdAt;
82321
- const resolvedAt = view.resolvedAt ? Date.parse(view.resolvedAt) : Number.NaN;
82322
- if (Number.isFinite(resolvedAt)) return resolvedAt;
82323
- return 0;
82324
- }
82325
- function scoreDecision(view, maxAgeMs, now) {
82326
- const resolvedAt = view.resolvedAt ? Date.parse(view.resolvedAt) : Number.NaN;
82327
- const ageMs = Number.isFinite(resolvedAt) ? Math.max(0, now.getTime() - resolvedAt) : maxAgeMs;
82328
- const recency = maxAgeMs <= 0 ? 1 : 1 - Math.min(ageMs, maxAgeMs) / maxAgeMs;
82329
- const elementBoost = view.elementEid != null ? 0.3 : 0;
82330
- const bodyBoost = view.body != null && view.body.length > 20 ? 1 : 0;
82331
- return 0.5 * recency + 0.3 * elementBoost + 0.2 * bodyBoost;
82332
- }
82333
- function rankMcpDecisionViews(views, now = /* @__PURE__ */ new Date()) {
82334
- const ages = views.map((view) => view.resolvedAt ? now.getTime() - Date.parse(view.resolvedAt) : 0).filter((age) => Number.isFinite(age) && age >= 0);
82335
- const maxAgeMs = Math.max(0, ...ages);
82336
- return views.slice().sort((a, b) => scoreDecision(b, maxAgeMs, now) - scoreDecision(a, maxAgeMs, now));
82337
- }
82338
- function dedupeMcpDecisionEntries(entries) {
82339
- const seen = /* @__PURE__ */ new Map();
82340
- for (const entry of entries) {
82341
- const key = dedupeKey(entry.view);
82342
- const existing = seen.get(key);
82343
- if (!existing || decisionPlacementTime(entry.view) > decisionPlacementTime(existing.view)) {
82344
- seen.set(key, entry);
82345
- }
82346
- }
82347
- return Array.from(seen.values());
82348
- }
82349
82312
  function deriveFlowContext(decision, allDecisions) {
82350
82313
  const siblings = resolvedDecisionPlacements(allDecisions).filter(
82351
82314
  (candidate) => candidate.canvas_id === decision.canvas_id
@@ -82381,8 +82344,8 @@ function canvasAnnotationToDecisionXmlInput(decision, allDecisions) {
82381
82344
  elementEid: decision.element_eid,
82382
82345
  createdAt: decision.created_at,
82383
82346
  resolvedAt: decision.resolved_at,
82384
- resolvedBy: null,
82385
- bbox: decision.anchor_state === "detached" ? null : [decision.bbox_x1, decision.bbox_y1, decision.bbox_x2, decision.bbox_y2],
82347
+ resolvedBy: decision.approved_by ?? null,
82348
+ bbox: decision.anchor_state === "detached" || isFullPageBbox(decision) ? null : [decision.bbox_x1, decision.bbox_y1, decision.bbox_x2, decision.bbox_y2],
82386
82349
  element: {
82387
82350
  role: decision.element_role,
82388
82351
  label: decision.element_visible_text,
@@ -82426,10 +82389,21 @@ function serializeDecisionXml(decision) {
82426
82389
  "</decision>"
82427
82390
  ].filter(Boolean).join("\n");
82428
82391
  }
82429
- function formatApplyDecisionText(decision, allDecisions, vocabulary) {
82392
+ function formatApplyDecisionText(decision, allDecisions, vocabulary, threadContext) {
82430
82393
  const xml = serializeDecisionXml(canvasAnnotationToDecisionXmlInput(decision, allDecisions));
82431
82394
  const parts = [xml];
82432
82395
  if (vocabulary) parts.push(vocabulary);
82396
+ if (threadContext && threadContext.length > 0) {
82397
+ const messages = threadContext.map((m) => {
82398
+ if ("role" in m) {
82399
+ return ` <message role="${m.role}" at="${m.createdAt}">${esc3(m.visibleText ?? "")}</message>`;
82400
+ }
82401
+ return ` <message>${esc3(m.text)}</message>`;
82402
+ });
82403
+ parts.push(`<thread>
82404
+ ${messages.join("\n")}
82405
+ </thread>`);
82406
+ }
82433
82407
  parts.push(
82434
82408
  "Apply the resolved Decision Canvas change. Use the element context, bbox, and screen metadata above as implementation grounding."
82435
82409
  );
@@ -82449,6 +82423,7 @@ var init_mcpFormat = __esm({
82449
82423
  "../../libs/shared/src/decisionCanvas/mcpFormat.ts"() {
82450
82424
  "use strict";
82451
82425
  init_readModel();
82426
+ init_types4();
82452
82427
  MAX_CANVAS_VOCABULARY_ELEMENTS = 100;
82453
82428
  MCP_FETCH_TIMEOUT_MS = 1e4;
82454
82429
  }
@@ -82491,12 +82466,6 @@ var init_mcpDecisionLoader = __esm({
82491
82466
  });
82492
82467
 
82493
82468
  // ../../libs/shared/src/mcpToolRegistration.ts
82494
- function notFoundError(id) {
82495
- return {
82496
- content: [{ type: "text", text: `Decision '${id}' not found. Use list_decisions to see available decisions.` }],
82497
- isError: true
82498
- };
82499
- }
82500
82469
  function loaderErrorResponse(error51, fallback) {
82501
82470
  if (error51 instanceof DecisionLoaderError) {
82502
82471
  return { content: [{ type: "text", text: error51.contentText }], isError: true };
@@ -82504,10 +82473,11 @@ function loaderErrorResponse(error51, fallback) {
82504
82473
  return { content: [{ type: "text", text: fallback }], isError: true };
82505
82474
  }
82506
82475
  function loaderScope(scope) {
82507
- return { orgId: scope.orgId };
82476
+ return { orgId: scope.orgId, userId: scope.userId };
82508
82477
  }
82509
82478
  function isValidBbox(decision) {
82510
- return [decision.bbox_x1, decision.bbox_y1, decision.bbox_x2, decision.bbox_y2].every(Number.isFinite) && decision.bbox_x1 < decision.bbox_x2 && decision.bbox_y1 < decision.bbox_y2;
82479
+ return [decision.bbox_x1, decision.bbox_y1, decision.bbox_x2, decision.bbox_y2].every(Number.isFinite) && decision.bbox_x1 < decision.bbox_x2 && decision.bbox_y1 < decision.bbox_y2 && // Page-scoped comments are stored as a full-page box; they have no region to crop.
82480
+ !isFullPageBbox(decision);
82511
82481
  }
82512
82482
  function placementTimestamp(decision) {
82513
82483
  const createdAt = Date.parse(decision.created_at);
@@ -82534,122 +82504,194 @@ function decisionBbox(decision) {
82534
82504
  if (!isValidBbox(decision)) return null;
82535
82505
  return [decision.bbox_x1, decision.bbox_y1, decision.bbox_x2, decision.bbox_y2];
82536
82506
  }
82537
- function formatThreadContext(messages) {
82538
- const lines = messages.map((message) => {
82539
- if ("text" in message) return message.text;
82540
- if (!message.visibleText) return null;
82541
- return `**${message.role === "user" ? "User" : "Assistant"}** (${message.createdAt}):
82542
- ${message.visibleText}`;
82543
- }).filter((line) => Boolean(line));
82544
- return lines.length > 0 ? lines.join("\n\n") : null;
82507
+ function decisionNotFoundResult(id) {
82508
+ return {
82509
+ content: [{ type: "text", text: `Decision "${id}" not found. Use search_decisions to find valid IDs.` }],
82510
+ isError: true
82511
+ };
82545
82512
  }
82546
- async function loadDetail(loader, id, scope) {
82547
- const detail = await loader.loadDecisionDetail(id, scope);
82548
- if (!detail) {
82549
- throw new DecisionLoaderError(`Decision '${id}' not found. Use list_decisions to see available decisions.`);
82550
- }
82513
+ async function appendImplementationContent(context2, include) {
82514
+ const detail = await context2.loader.loadDecisionDetail(context2.id, context2.scope);
82515
+ if (!detail) return null;
82516
+ context2.content.push(...detail.images);
82517
+ const vocabulary = await context2.loader.loadElementVocabulary(detail.decision.canvas_id, context2.scope);
82518
+ const vocabStr = vocabulary.length > 0 ? buildCanvasVocabulary(vocabulary) : null;
82519
+ const threadContext = include === "all" ? await context2.loader.loadThreadContext(context2.id, context2.scope) : void 0;
82520
+ context2.content.push({
82521
+ type: "text",
82522
+ text: formatApplyDecisionText(detail.decision, detail.allDecisions, vocabStr, threadContext)
82523
+ });
82551
82524
  return detail;
82552
82525
  }
82553
- async function loadElementImageContent(loader, detail, scope) {
82526
+ async function appendImageContent(context2, existingDetail) {
82527
+ const detail = existingDetail ?? await context2.loader.loadDecisionDetail(context2.id, context2.scope);
82528
+ if (!detail) return null;
82554
82529
  const cropSource = resolveCropSource(detail);
82555
- if (!cropSource) {
82556
- return {
82557
- type: "text",
82558
- text: "No element image available - decision has no valid bounding box or screenshot."
82559
- };
82560
- }
82561
- const bbox = decisionBbox(cropSource);
82562
- const image = bbox ? await loader.cropElement(cropSource.page.artifact_id, bbox, scope) : null;
82563
- return image ? { type: "image", data: image, mimeType: "image/png" } : {
82564
- type: "text",
82565
- text: "No element image available - decision has no valid bounding box or screenshot."
82566
- };
82530
+ const bbox = cropSource ? decisionBbox(cropSource) : null;
82531
+ if (!cropSource || !bbox) return detail;
82532
+ const cropped = await context2.loader.cropElement(cropSource.page.artifact_id, bbox, context2.scope);
82533
+ if (cropped) context2.content.push({ type: "image", data: cropped, mimeType: "image/png" });
82534
+ return detail;
82567
82535
  }
82568
- async function loadThreadContextContent(loader, id, scope) {
82569
- const threadContext = formatThreadContext(await loader.loadThreadContext(id, scope));
82570
- return { type: "text", text: threadContext ?? "No thread discussion linked to this decision." };
82536
+ async function appendContextContent(content, loader, id, scope) {
82537
+ const messages = await loader.loadThreadContext(id, scope);
82538
+ if (messages.length === 0) {
82539
+ content.push({ type: "text", text: "No thread context available for this decision." });
82540
+ return;
82541
+ }
82542
+ const formatted = messages.map((message) => "role" in message ? `[${message.role}] ${message.visibleText ?? ""}` : message.text).join("\n");
82543
+ content.push({ type: "text", text: formatted });
82571
82544
  }
82572
- async function loadDecisionDetailContent(loader, id, include, scope) {
82573
- const detail = await loadDetail(loader, id, scope);
82545
+ async function handleApplyDecision(loader, id, include, scope) {
82574
82546
  const content = [];
82547
+ const context2 = { content, loader, id, scope };
82548
+ let detail = null;
82549
+ if (include === "implementation" || include === "all") {
82550
+ detail = await appendImplementationContent(context2, include);
82551
+ if (!detail) return decisionNotFoundResult(id);
82552
+ }
82575
82553
  if (include === "image" || include === "all") {
82576
- content.push(await loadElementImageContent(loader, detail, scope));
82554
+ detail = await appendImageContent(context2, detail);
82577
82555
  }
82578
- if (include === "context" || include === "all") {
82579
- content.push(await loadThreadContextContent(loader, id, scope));
82556
+ if (include === "context" && content.length === 0) {
82557
+ await appendContextContent(content, loader, id, scope);
82580
82558
  }
82581
- return content;
82559
+ return content.length > 0 ? { content } : decisionNotFoundResult(id);
82582
82560
  }
82583
- function registerMcpTools(server2, loader, getScope, onToolInvoked) {
82561
+ function registerSearchDecisionsTool(server2, loader, getScope, onToolInvoked) {
82584
82562
  server2.registerTool(
82585
- "list_decisions",
82563
+ "search_decisions",
82586
82564
  {
82587
- title: "List Decisions",
82588
- description: "List all resolved product decisions. Returns summary fields: id, headline, elementEid, appName, resolvedAt. Use apply_decision to implement a specific decision, or get_decision_detail for element images and thread context.",
82589
- annotations: { readOnlyHint: true, destructiveHint: false }
82565
+ title: "Search Decisions",
82566
+ description: "Search and filter resolved decisions with cursor pagination. With no parameters, returns the top 20 ranked by recency and detail. For full detail on a specific decision, use apply_decision.",
82567
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
82568
+ inputSchema: {
82569
+ query: external_exports.string().optional().describe("Text search across headline, body, and element text"),
82570
+ status: external_exports.enum(["resolved", "suggested", "all"]).default("resolved").optional().describe("Filter by annotation status (default: resolved)"),
82571
+ limit: external_exports.number().int().min(1).max(100).default(20).optional().describe("Results per page (1-100, default 20)"),
82572
+ cursor: external_exports.string().optional().describe("Opaque pagination cursor from a previous response"),
82573
+ sort: external_exports.enum(["relevance", "resolved_at_desc", "created_at_desc"]).default("resolved_at_desc").optional().describe("Sort order"),
82574
+ canvasId: external_exports.string().uuid().optional().describe("Exact canvas ID"),
82575
+ canvasTitle: external_exports.string().optional().describe("Canvas title substring match"),
82576
+ canvasStatus: external_exports.enum(["active", "archived"]).optional().describe("Canvas lifecycle status"),
82577
+ pageId: external_exports.string().uuid().optional().describe("Exact page ID"),
82578
+ pageName: external_exports.string().optional().describe("Page name substring match"),
82579
+ projectId: external_exports.string().uuid().optional().describe("Exact project ID"),
82580
+ projectName: external_exports.string().optional().describe("Project name substring match"),
82581
+ threadId: external_exports.string().uuid().optional(),
82582
+ appName: external_exports.string().optional().describe("App name substring match"),
82583
+ windowTitle: external_exports.string().optional().describe("Window title substring match"),
82584
+ url: external_exports.string().optional().describe("URL substring match"),
82585
+ anchorState: external_exports.enum(["anchored", "reanchored", "detached"]).optional(),
82586
+ elementEid: external_exports.string().optional().describe("Exact element accessibility ID"),
82587
+ elementRole: external_exports.string().optional().describe("Exact element ARIA role"),
82588
+ elementInteractivity: external_exports.boolean().optional(),
82589
+ minEndorsements: external_exports.number().int().min(0).optional().describe("Minimum endorsement count"),
82590
+ hasReplies: external_exports.boolean().optional().describe("Filter to decisions with replies"),
82591
+ createdBy: external_exports.string().uuid().optional().describe("Creator user ID"),
82592
+ resolvedFrom: external_exports.string().optional().describe("ISO date lower bound for resolved_at"),
82593
+ resolvedTo: external_exports.string().optional().describe("ISO date upper bound for resolved_at"),
82594
+ createdFrom: external_exports.string().optional().describe("ISO date lower bound for created_at"),
82595
+ createdTo: external_exports.string().optional().describe("ISO date upper bound for created_at")
82596
+ }
82590
82597
  },
82591
- async () => {
82592
- onToolInvoked?.("list_decisions");
82598
+ async (params) => {
82599
+ onToolInvoked?.("search_decisions");
82593
82600
  try {
82594
- const scope = loaderScope(getScope());
82595
- const decisions = await loader.listDecisions(scope);
82596
- if (decisions.length === 0) {
82597
- return { content: [{ type: "text", text: "No resolved decisions yet." }] };
82598
- }
82599
- return { content: [{ type: "text", text: JSON.stringify(decisions, null, 2) }] };
82600
- } catch (error51) {
82601
- return loaderErrorResponse(error51, "Failed to fetch decisions. Please try again.");
82601
+ const result = await loader.searchDecisions(params, loaderScope(getScope()));
82602
+ const text = result.nextCursor ? `Showing ${result.decisions.length} of ${result.totalCount} results. Pass cursor "${result.nextCursor}" to see more.` : `Showing ${result.decisions.length} of ${result.totalCount} results.`;
82603
+ return {
82604
+ content: [
82605
+ { type: "text", text },
82606
+ { type: "text", text: JSON.stringify(result) }
82607
+ ]
82608
+ };
82609
+ } catch (e) {
82610
+ return loaderErrorResponse(e, "Failed to search decisions. Please try again.");
82602
82611
  }
82603
82612
  }
82604
82613
  );
82614
+ }
82615
+ function registerApplyDecisionTool(server2, loader, getScope, onToolInvoked) {
82605
82616
  server2.registerTool(
82606
82617
  "apply_decision",
82607
82618
  {
82608
82619
  title: "Apply Decision",
82609
- description: "Get a resolved decision with a screenshot and implementation instructions. Returns everything needed to implement the code change: decision context, element targeting, page screenshot, and step-by-step instructions. Use get_decision_detail only when you need a cropped element image or thread context.",
82610
- inputSchema: { id: external_exports.string().describe("Decision ID from list_decisions") },
82611
- annotations: { readOnlyHint: true, destructiveHint: false }
82620
+ description: "Act on a resolved decision. Returns implementation kit with screenshot, element targeting, and step-by-step instructions. Set target to control output format: code changes, slideshow content, graphic specs, or prototype data. Set include to control detail level.",
82621
+ inputSchema: {
82622
+ id: external_exports.string().describe("Decision ID from search_decisions"),
82623
+ include: external_exports.enum(["implementation", "image", "context", "all"]).default("implementation").optional().describe(
82624
+ "What to return: implementation (screenshot + instructions), image (cropped element), context (thread discussion), all (everything)"
82625
+ ),
82626
+ target: external_exports.enum(["code", "slideshow", "graphic", "prototype"]).default("code").optional().describe("Output format for instructions")
82627
+ },
82628
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
82612
82629
  },
82613
- async ({ id }) => {
82630
+ async ({ id, include = "implementation", target = "code" }) => {
82614
82631
  onToolInvoked?.("apply_decision");
82615
82632
  try {
82616
- const scope = loaderScope(getScope());
82617
- const detail = await loader.loadDecisionDetail(id, scope);
82618
- if (!detail) return notFoundError(id);
82619
- const vocabulary = buildCanvasVocabulary(await loader.loadElementVocabulary(detail.decision.canvas_id, scope));
82620
- return {
82621
- content: [
82622
- ...detail.images,
82623
- { type: "text", text: formatApplyDecisionText(detail.decision, detail.allDecisions, vocabulary) }
82624
- ]
82625
- };
82626
- } catch (error51) {
82627
- return loaderErrorResponse(error51, "Failed to apply decision. Please try again.");
82633
+ void target;
82634
+ return await handleApplyDecision(loader, id, include, loaderScope(getScope()));
82635
+ } catch (e) {
82636
+ return loaderErrorResponse(e, "Failed to apply decision. Please try again.");
82628
82637
  }
82629
82638
  }
82630
82639
  );
82640
+ }
82641
+ function registerListCanvasesTool(server2, loader, getScope, onToolInvoked) {
82631
82642
  server2.registerTool(
82632
- "get_decision_detail",
82643
+ "list_canvases",
82633
82644
  {
82634
- title: "Get Decision Detail",
82635
- description: "Get additional detail for a decision on demand. image = cropped PNG of the targeted UI element (for prototypes). context = thread discussion with rationale and alternatives. all = both. Use list_decisions to find decision IDs.",
82645
+ title: "List Canvases",
82646
+ description: "List review canvases in the current org. Returns title, project, page names, and decision counts per page. Call this when the user mentions a specific design file, screen, or review session by name \u2014 use the returned canvas IDs and titles to scope search_decisions queries.",
82636
82647
  inputSchema: {
82637
- id: external_exports.string().describe("Decision ID from list_decisions"),
82638
- include: external_exports.enum(["image", "context", "all"]).describe("What to include: image (cropped element), context (thread discussion), or all")
82648
+ status: external_exports.enum(["active", "archived"]).default("active").optional(),
82649
+ projectId: external_exports.string().uuid().optional().describe("Filter by project ID"),
82650
+ limit: external_exports.number().int().min(1).max(100).default(50).optional(),
82651
+ cursor: external_exports.string().optional()
82639
82652
  },
82640
- annotations: { readOnlyHint: true, destructiveHint: false }
82653
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
82641
82654
  },
82642
- async ({ id, include }) => {
82643
- onToolInvoked?.("get_decision_detail");
82655
+ async (params) => {
82656
+ onToolInvoked?.("list_canvases");
82644
82657
  try {
82645
- const scope = loaderScope(getScope());
82646
- return { content: await loadDecisionDetailContent(loader, id, include, scope) };
82647
- } catch (error51) {
82648
- return loaderErrorResponse(error51, "Failed to fetch decision detail.");
82658
+ const result = await loader.listCanvases(params, loaderScope(getScope()));
82659
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
82660
+ } catch (e) {
82661
+ return loaderErrorResponse(e, "Failed to list canvases. Please try again.");
82662
+ }
82663
+ }
82664
+ );
82665
+ }
82666
+ function registerListProjectsTool(server2, loader, getScope, onToolInvoked) {
82667
+ server2.registerTool(
82668
+ "list_projects",
82669
+ {
82670
+ title: "List Projects",
82671
+ description: "List projects in the current org. Returns name, thread count, and canvas count. Call this when the user references a project by name \u2014 use the returned project IDs to pass as projectId or projectName filters to search_decisions.",
82672
+ inputSchema: {
82673
+ limit: external_exports.number().int().min(1).max(100).default(50).optional(),
82674
+ cursor: external_exports.string().optional()
82675
+ },
82676
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
82677
+ },
82678
+ async (params) => {
82679
+ onToolInvoked?.("list_projects");
82680
+ try {
82681
+ const result = await loader.listProjects(params, loaderScope(getScope()));
82682
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
82683
+ } catch (e) {
82684
+ return loaderErrorResponse(e, "Failed to list projects. Please try again.");
82649
82685
  }
82650
82686
  }
82651
82687
  );
82652
82688
  }
82689
+ function registerMcpTools(server2, loader, getScope, onToolInvoked) {
82690
+ registerSearchDecisionsTool(server2, loader, getScope, onToolInvoked);
82691
+ registerApplyDecisionTool(server2, loader, getScope, onToolInvoked);
82692
+ registerListCanvasesTool(server2, loader, getScope, onToolInvoked);
82693
+ registerListProjectsTool(server2, loader, getScope, onToolInvoked);
82694
+ }
82653
82695
  var init_mcpToolRegistration = __esm({
82654
82696
  "../../libs/shared/src/mcpToolRegistration.ts"() {
82655
82697
  "use strict";
@@ -82667,9 +82709,12 @@ function readMcpCredentials() {
82667
82709
  if (apiBase && token && orgId) return { apiBase, token, orgId };
82668
82710
  return loadCachedCredentials();
82669
82711
  }
82670
- async function fetchCanvasDecisions(config4) {
82712
+ async function fetchCanvasDecisions(config4, options = {}) {
82671
82713
  try {
82672
- const response = await fetch(`${config4.apiBase}/lace/canvases/decisions`, {
82714
+ const params = new URLSearchParams();
82715
+ if (options.canvasId) params.set("canvasId", options.canvasId);
82716
+ const query = params.toString();
82717
+ const response = await fetch(`${config4.apiBase}/lace/canvases/decisions${query ? `?${query}` : ""}`, {
82673
82718
  headers: {
82674
82719
  Accept: "application/json",
82675
82720
  Authorization: `Bearer ${config4.token}`,
@@ -82687,44 +82732,25 @@ async function fetchCanvasDecisions(config4) {
82687
82732
  return { ok: false, status: 0, error: "Network error or timeout" };
82688
82733
  }
82689
82734
  }
82690
- function loadErrorText(result) {
82691
- if (result.credentialsMissing) {
82692
- return "No API credentials configured. Run `npx lace-mcp install <claude|cursor|codex>` to authenticate.";
82693
- }
82694
- if (!result.error) return null;
82695
- const hint = result.error.status === 401 || result.error.status === 403 ? " Your token may be expired \u2014 run `npx lace-mcp install <editor>` to re-authenticate." : "";
82696
- return `Failed to fetch decisions (${result.error.status}: ${result.error.message}).${hint}`;
82697
- }
82698
- async function loadDecisionEntriesOrThrow() {
82699
- const result = await loadMcpDecisionEntries();
82700
- const errorText = loadErrorText(result);
82701
- if (errorText) throw new DecisionLoaderError(errorText);
82702
- return { entries: result.entries, allDecisions: result.allDecisions };
82735
+ async function fetchJson(config4, path2) {
82736
+ const response = await fetch(`${config4.apiBase}${path2}`, {
82737
+ headers: { Accept: "application/json", Authorization: `Bearer ${config4.token}`, "x-org-id": config4.orgId },
82738
+ signal: AbortSignal.timeout(API_FETCH_TIMEOUT_MS)
82739
+ });
82740
+ if (!response.ok) throw new DecisionLoaderError(`Request failed (${response.status}: ${response.statusText})`);
82741
+ return await response.json();
82703
82742
  }
82704
- async function loadMcpDecisionEntries() {
82705
- const config4 = readMcpCredentials();
82706
- if (!config4) {
82707
- return { entries: [], allDecisions: [], error: null, credentialsMissing: true };
82743
+ function searchParamsFromRecord(query) {
82744
+ const params = new URLSearchParams();
82745
+ for (const [key, value] of Object.entries(query)) {
82746
+ if (value != null) params.set(key, String(value));
82708
82747
  }
82709
- const result = await fetchCanvasDecisions(config4);
82710
- if (!result.ok) {
82711
- return {
82712
- entries: [],
82713
- allDecisions: [],
82714
- error: { status: result.status, message: result.error },
82715
- credentialsMissing: false
82716
- };
82717
- }
82718
- const decisions = resolvedDecisionViews(result.decisions);
82719
- const canvasEntries = decisions.map((decision) => ({
82720
- view: canvasAnnotationToMcpDecisionView(decision),
82721
- canvas: decision
82722
- }));
82723
- const allDecisions = canvasEntries.map((entry) => entry.canvas);
82724
- const deduped = dedupeMcpDecisionEntries(canvasEntries);
82725
- const ranked = rankMcpDecisionViews(deduped.map((entry) => entry.view)).slice(0, 8);
82726
- const entries = ranked.map((view) => deduped.find((entry) => entry.view.id === view.id)).filter((entry) => Boolean(entry));
82727
- return { entries, allDecisions, error: null, credentialsMissing: false };
82748
+ return params;
82749
+ }
82750
+ async function fetchDecisionSearch(config4, query) {
82751
+ const params = searchParamsFromRecord(query);
82752
+ const qs = params.toString();
82753
+ return fetchJson(config4, `/lace/canvases/decisions/search${qs ? `?${qs}` : ""}`);
82728
82754
  }
82729
82755
  async function fetchCanvasElementAnnotations(canvasId, config4) {
82730
82756
  try {
@@ -82831,38 +82857,59 @@ function requireConfig() {
82831
82857
  }
82832
82858
  return config4;
82833
82859
  }
82834
- function findDecision(id, entries, allDecisions) {
82835
- return entries.find((entry) => entry.view.id === id)?.canvas ?? allDecisions.find((decision) => decision.id === id) ?? null;
82860
+ async function loadDecisionDetailFromHttp(id, config4) {
82861
+ const searchResult = await fetchDecisionSearch(config4, { id, limit: 1 });
82862
+ const view = searchResult.decisions[0];
82863
+ if (!view) return null;
82864
+ const rawResult = await fetchCanvasDecisions(config4, { canvasId: view.canvasId });
82865
+ if (!rawResult.ok) {
82866
+ throw new DecisionLoaderError(`Decision detail failed (${rawResult.status}: ${rawResult.error})`);
82867
+ }
82868
+ const decision = rawResult.decisions.find((candidate) => candidate.id === id) ?? null;
82869
+ if (!decision) return null;
82870
+ return {
82871
+ decision,
82872
+ allDecisions: rawResult.decisions,
82873
+ images: await loadCanvasImages(decision)
82874
+ };
82836
82875
  }
82837
82876
  function createHttpDecisionLoader() {
82838
82877
  return {
82839
- async listDecisions() {
82840
- const result = await loadDecisionEntriesOrThrow();
82841
- return result.entries.map((entry) => entry.view);
82878
+ async searchDecisions(query, _scope) {
82879
+ return fetchDecisionSearch(requireConfig(), query);
82842
82880
  },
82843
82881
  async loadDecisionDetail(id) {
82844
- const result = await loadDecisionEntriesOrThrow();
82845
- const entry = result.entries.find((candidate) => candidate.view.id === id);
82846
- if (!entry) return null;
82847
- return {
82848
- decision: entry.canvas,
82849
- allDecisions: result.allDecisions,
82850
- images: await loadCanvasImages(entry.canvas)
82851
- };
82882
+ return loadDecisionDetailFromHttp(id, requireConfig());
82852
82883
  },
82853
82884
  async cropElement(artifactId, bbox) {
82854
82885
  const config4 = requireConfig();
82855
82886
  return await fetchCrop(config4, artifactId, bbox) ?? "";
82856
82887
  },
82857
82888
  async loadThreadContext(decisionId) {
82858
- const result = await loadDecisionEntriesOrThrow();
82859
- const decision = findDecision(decisionId, result.entries, result.allDecisions);
82889
+ const detail = await loadDecisionDetailFromHttp(decisionId, requireConfig());
82890
+ const decision = detail?.decision;
82860
82891
  if (!decision) return [];
82861
82892
  const context2 = await fetchDecisionThreadContext(decision);
82862
82893
  return context2 ? [{ text: context2 }] : [];
82863
82894
  },
82864
82895
  async loadElementVocabulary(canvasId, _scope) {
82865
82896
  return fetchCanvasElementAnnotations(canvasId, requireConfig());
82897
+ },
82898
+ async listCanvases(query, _scope) {
82899
+ const config4 = requireConfig();
82900
+ const params = new URLSearchParams();
82901
+ if (query.status) params.set("status", query.status);
82902
+ if (query.projectId) params.set("projectId", query.projectId);
82903
+ params.set("limit", String(query.limit ?? 50));
82904
+ if (query.cursor) params.set("cursor", query.cursor);
82905
+ return fetchJson(config4, `/lace/canvases?${params}`);
82906
+ },
82907
+ async listProjects(query, _scope) {
82908
+ const config4 = requireConfig();
82909
+ const params = new URLSearchParams();
82910
+ params.set("limit", String(query.limit ?? 50));
82911
+ if (query.cursor) params.set("cursor", query.cursor);
82912
+ return fetchJson(config4, `/projects?${params}`);
82866
82913
  }
82867
82914
  };
82868
82915
  }
@@ -82882,28 +82929,18 @@ function getLocalScope() {
82882
82929
  return { orgId: readMcpCredentials()?.orgId ?? "", userId: "local-mcp" };
82883
82930
  }
82884
82931
  function createLaceServer() {
82885
- const server2 = new McpServer({ name: "lace", version: "0.0.1" }, { instructions: LACE_INSTRUCTIONS });
82932
+ const server2 = new McpServer({ name: "lace", version: "2.0.0" }, { instructions: LACE_MCP_INSTRUCTIONS });
82886
82933
  registerMcpTools(server2, createHttpDecisionLoader(), getLocalScope, trackToolInvoked);
82887
82934
  return server2;
82888
82935
  }
82889
- var LACE_INSTRUCTIONS;
82890
82936
  var init_server3 = __esm({
82891
82937
  "src/server.ts"() {
82892
82938
  "use strict";
82893
82939
  init_mcp();
82940
+ init_types4();
82894
82941
  init_mcpToolRegistration();
82895
82942
  init_analytics();
82896
82943
  init_decisionLoaderHttp();
82897
- LACE_INSTRUCTIONS = `Lace is an adoption-diagnosis companion for desktop apps. It surfaces UI insights in an overlay and lets users approve decisions that should become code changes.
82898
-
82899
- When the user asks about Lace decisions, approved changes, or wants to implement an approved decision:
82900
-
82901
- 1. Call list_decisions to see all approved decisions.
82902
- 2. Call apply_decision with the decision ID \u2014 this returns a screenshot, the decision card, element targeting, and implementation instructions. Follow those instructions to implement the change.
82903
- 3. If you need a cropped image of the targeted element (e.g., for a prototype), call get_decision_detail with include="image".
82904
- 4. If you need to understand why a decision was made, call get_decision_detail with include="context".
82905
-
82906
- If the user says "implement the Lace decision", "apply the approved change", "what was approved", or references a Lace insight \u2014 start with list_decisions.`;
82907
82944
  }
82908
82945
  });
82909
82946
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lace-mcp",
3
- "version": "0.0.2-alpha.16",
3
+ "version": "0.0.2-alpha.19",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist/cli.js",