@composer-app/mcp 0.0.4-beta.0 → 0.0.4-beta.2

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.
@@ -4371,22 +4371,22 @@ function buildFlatMap(fragment) {
4371
4371
  walk(node);
4372
4372
  }
4373
4373
  if (idx < topLevel.length - 1) {
4374
- flat += "\n";
4374
+ flat += "\n\n";
4375
4375
  }
4376
4376
  });
4377
4377
  return { flat, map, blockFlatStarts };
4378
4378
  }
4379
- function findNthOccurrence(flat, needle, n) {
4380
- if (needle.length === 0) return null;
4381
- let searchFrom = 0;
4382
- let found2 = -1;
4383
- for (let i = 0; i < n; i++) {
4384
- const idx = flat.indexOf(needle, searchFrom);
4385
- if (idx < 0) return null;
4386
- found2 = idx;
4387
- searchFrom = idx + 1;
4379
+ function findAllOccurrences(haystack, needle) {
4380
+ if (needle.length === 0) return [];
4381
+ const out = [];
4382
+ let from2 = 0;
4383
+ while (true) {
4384
+ const idx = haystack.indexOf(needle, from2);
4385
+ if (idx < 0) break;
4386
+ out.push(idx);
4387
+ from2 = idx + 1;
4388
4388
  }
4389
- return found2;
4389
+ return out;
4390
4390
  }
4391
4391
  function lookupFlatIndex(map, flatIndex) {
4392
4392
  for (const entry of map) {
@@ -4484,13 +4484,11 @@ function resolveServerAnchor(doc, spec) {
4484
4484
  return {
4485
4485
  ok: false,
4486
4486
  error: "section_not_found",
4487
- currentSectionText: ""
4487
+ currentSectionText: "",
4488
+ currentSectionFlat: ""
4488
4489
  };
4489
4490
  }
4490
4491
  const currentSectionText = getSection(doc, spec.headingId);
4491
- if (!currentSectionText.includes(spec.textToFind)) {
4492
- return { ok: false, error: "text_not_found", currentSectionText };
4493
- }
4494
4492
  const fragment = doc.getXmlFragment("default");
4495
4493
  const { flat, map, blockFlatStarts } = buildFlatMap(fragment);
4496
4494
  const range = getSectionBlockRange(doc, spec.headingId);
@@ -4498,25 +4496,68 @@ function resolveServerAnchor(doc, spec) {
4498
4496
  return {
4499
4497
  ok: false,
4500
4498
  error: "section_not_found",
4501
- currentSectionText
4499
+ currentSectionText,
4500
+ currentSectionFlat: ""
4502
4501
  };
4503
4502
  }
4504
4503
  const sectionFlatStart = blockFlatStarts[range.start] ?? 0;
4505
4504
  const sectionFlatEnd = range.end < blockFlatStarts.length ? blockFlatStarts[range.end] : flat.length;
4506
4505
  const sectionFlat = flat.slice(sectionFlatStart, sectionFlatEnd);
4507
- const sectionRelStart = findNthOccurrence(sectionFlat, spec.textToFind, occurrence);
4508
- if (sectionRelStart === null) {
4509
- return { ok: false, error: "text_not_found", currentSectionText };
4506
+ const allMatches = findAllOccurrences(sectionFlat, spec.textToFind);
4507
+ if (allMatches.length === 0) {
4508
+ return {
4509
+ ok: false,
4510
+ error: "text_not_found",
4511
+ currentSectionText,
4512
+ currentSectionFlat: sectionFlat
4513
+ };
4514
+ }
4515
+ const topLevel = fragment.toArray();
4516
+ const isHeadingBlockAt = (absFlatIndex) => {
4517
+ let blockIdx = 0;
4518
+ for (let i = 0; i < blockFlatStarts.length; i++) {
4519
+ if (blockFlatStarts[i] <= absFlatIndex) blockIdx = i;
4520
+ else break;
4521
+ }
4522
+ const block = topLevel[blockIdx];
4523
+ return block instanceof Y4.XmlElement && block.nodeName === "heading";
4524
+ };
4525
+ const ranked = allMatches.map((sectionRelStart) => ({
4526
+ sectionRelStart,
4527
+ isHeading: isHeadingBlockAt(sectionFlatStart + sectionRelStart)
4528
+ }));
4529
+ ranked.sort((a, b) => {
4530
+ if (a.isHeading !== b.isHeading) return a.isHeading ? 1 : -1;
4531
+ return a.sectionRelStart - b.sectionRelStart;
4532
+ });
4533
+ const pick = ranked[occurrence - 1];
4534
+ if (!pick) {
4535
+ return {
4536
+ ok: false,
4537
+ error: "text_not_found",
4538
+ currentSectionText,
4539
+ currentSectionFlat: sectionFlat
4540
+ };
4510
4541
  }
4511
- const flatStart = sectionFlatStart + sectionRelStart;
4542
+ const flatStart = sectionFlatStart + pick.sectionRelStart;
4512
4543
  const flatEnd = flatStart + spec.textToFind.length;
4513
4544
  const startEntry = lookupFlatIndex(map, flatStart);
4514
4545
  if (!startEntry) {
4515
- return { ok: false, error: "text_not_found", currentSectionText };
4546
+ return {
4547
+ ok: false,
4548
+ error: "text_not_found",
4549
+ currentSectionText,
4550
+ currentSectionFlat: sectionFlat
4551
+ };
4516
4552
  }
4517
4553
  const lastCharEntry = lookupFlatIndexEnd(map, flatEnd - 1);
4518
4554
  if (!lastCharEntry) {
4519
- return { ok: false, error: "text_not_found", currentSectionText };
4555
+ return {
4556
+ ok: false,
4557
+ error: "text_not_found",
4558
+ currentSectionText,
4559
+ currentSectionFlat: sectionFlat
4560
+ };
4520
4561
  }
4521
4562
  const fromRelPos = Y4.createRelativePositionFromTypeIndex(
4522
4563
  startEntry.xmlText,
@@ -4543,15 +4584,8 @@ var COMPOSER_MCP_VERSION = pkg.version;
4543
4584
  // src/roomState.ts
4544
4585
  var HAD_UPGRADE_403 = /* @__PURE__ */ Symbol("composer.upgrade403");
4545
4586
  var TerminalDetectingWS = class extends WebSocket {
4546
- constructor(address, protocols, options) {
4547
- const existingHeaders = options?.headers ?? {};
4548
- super(address, protocols, {
4549
- ...options,
4550
- headers: {
4551
- ...existingHeaders,
4552
- "X-Composer-Client": COMPOSER_MCP_VERSION
4553
- }
4554
- });
4587
+ constructor(...args) {
4588
+ super(...args);
4555
4589
  this.on("unexpected-response", (_req, res) => {
4556
4590
  if (res.statusCode === 403) {
4557
4591
  this[HAD_UPGRADE_403] = true;
@@ -4649,6 +4683,7 @@ var RoomState = class _RoomState {
4649
4683
  this.provider = new YProvider(opts.serverHost, opts.roomId, this.doc, {
4650
4684
  party: "composer-room",
4651
4685
  connect: false,
4686
+ params: { client: `mcp-${COMPOSER_MCP_VERSION}` },
4652
4687
  WebSocketPolyfill: TerminalDetectingWS
4653
4688
  });
4654
4689
  }
@@ -5582,7 +5617,7 @@ var TOOL_DEFS = [
5582
5617
  },
5583
5618
  {
5584
5619
  name: "composer_add_comment",
5585
- description: "Post a new top-level comment anchored to a text span anywhere in the doc. Anchor is { headingId, textToFind, occurrence? }. Use this to flag something the user didn't ask about \u2014 cross-referencing related sections, raising a concern elsewhere in the doc, or seeding a thread on a new span. Use `composer_reply_comment` instead when continuing an existing thread. Accepts optional `state` (ack-first flow: post with `state: \"thinking\"` to start the live indicator immediately) and `mentions` (array of target userIds \u2014 the invoker's userId from the mention event payload, for the `@invoker` backlink). Returns { id } on success or an isError result if the anchor cannot be resolved.",
5620
+ description: "Post a new top-level comment anchored to a text span anywhere in the doc. Anchor is { headingId, textToFind, occurrence? }. Use this to flag something the user didn't ask about \u2014 cross-referencing related sections, raising a concern elsewhere in the doc, or seeding a thread on a new span. Use `composer_reply_comment` instead when continuing an existing thread. Accepts optional `state` (ack-first flow: post with `state: \"thinking\"` to start the live indicator immediately) and `mentions` (array of target userIds \u2014 the invoker's userId from the mention event payload, for the `@invoker` backlink).\n**`textToFind` matches the doc's stored text, not the markdown source.** Inline marks (`**bold**`, `*italic*`, `` `code` ``, `_emphasis_`, `~~strike~~`) are stored as Y.Marks \u2014 write plain text, no markers. Top-level blocks join with `\\n\\n`; list items have NO separator (three bullets `- a / - b / - c` are stored as `abc`). Copy from `Section as the matcher sees it` in any `text_not_found` error if unsure. Returns { id } on success or an isError result if the anchor cannot be resolved.",
5586
5621
  inputSchema: {
5587
5622
  type: "object",
5588
5623
  properties: {
@@ -5636,7 +5671,7 @@ var TOOL_DEFS = [
5636
5671
  },
5637
5672
  {
5638
5673
  name: "composer_add_suggestion",
5639
- description: "Post a text replacement suggestion. A suggestion can target ANY span anywhere in the doc \u2014 not just the span of the thread that triggered you. Pick exactly one of:\n - `fromThreadId` \u2014 inherit the source thread's exact stored anchor. Right when the user's request is scoped to what they selected (the common case: 'rewrite this', 'make this shorter').\n - `anchor` \u2014 specify a span yourself via `{ headingId, textToFind, occurrence? }`. Use this when the user's request targets different text ('also update the intro', 'the bullet list in Section 3 is stale') OR for proactive suggestions with no source thread.\n**Anchor = what gets deleted.** Your `textToFind` is literally cut when the user accepts and `replacementText` is inserted in its place. Anchor the whole unit you're changing (full sentence including terminal punctuation; full list item text; full paragraph), match your replacementText's shape (inline for mid-paragraph edits, full markdown block for block replacements), end replacement at the same boundary as the anchor, and include any formatting you want preserved in the replacement itself \u2014 the anchor's bold / link / heading level is gone on accept. A too-narrow or mid-token anchor leaves broken spacing or smashed-together words. See SKILL.md 'Pick the right span' for the full rubric.\n**Ripple coverage is your responsibility.** If the change requires edits nearby or elsewhere (enumeration counts, cross-references, subject/verb agreement, restated facts), call this tool MULTIPLE times in the same turn \u2014 one suggestion per span \u2014 so accepting leaves the doc correct. If you're unsure whether ripples exist elsewhere in the doc, call `composer_get_full_doc` first. Returns { id } on success or an isError result if the anchor cannot be resolved.",
5674
+ description: "Post a text replacement suggestion. A suggestion can target ANY span anywhere in the doc \u2014 not just the span of the thread that triggered you. Pick exactly one of:\n - `fromThreadId` \u2014 inherit the source thread's exact stored anchor. Right when the user's request is scoped to what they selected (the common case: 'rewrite this', 'make this shorter').\n - `anchor` \u2014 specify a span yourself via `{ headingId, textToFind, occurrence? }`. Use this when the user's request targets different text ('also update the intro', 'the bullet list in Section 3 is stale') OR for proactive suggestions with no source thread.\n**Anchor = what gets deleted.** Your `textToFind` is literally cut when the user accepts and `replacementText` is inserted in its place. Anchor the whole unit you're changing (full sentence including terminal punctuation; full list item text; full paragraph), match your replacementText's shape (inline for mid-paragraph edits, full markdown block for block replacements), end replacement at the same boundary as the anchor, and include any formatting you want preserved in the replacement itself \u2014 the anchor's bold / link / heading level is gone on accept. A too-narrow or mid-token anchor leaves broken spacing or smashed-together words. See SKILL.md 'Pick the right span' for the full rubric.\n**`textToFind` matches the doc's stored text, not the markdown source.** Three rules govern the shape:\n 1. **Inline marks are stripped.** `**bold**`, `*italic*`, `` `code` ``, `_emphasis_`, `~~strike~~` are stored as Y.Marks, not as literal characters. Write the plain text \u2014 `the file back` not `` `the file back` ``.\n 2. **Top-level blocks separate with `\\n\\n`.** Heading + body, or two sibling paragraphs, are joined by a blank line. Soft line breaks WITHIN a single paragraph are preserved as single `\\n`.\n 3. **List items have NO separator.** Three bullets `- a / - b / - c` are stored as `abc` (smashed). Same for ordered list items.\n When in doubt, copy from the `Section as the matcher sees it` block in any `text_not_found` error.\n**Ripple coverage is your responsibility.** If the change requires edits nearby or elsewhere (enumeration counts, cross-references, subject/verb agreement, restated facts), call this tool MULTIPLE times in the same turn \u2014 one suggestion per span \u2014 so accepting leaves the doc correct. If you're unsure whether ripples exist elsewhere in the doc, call `composer_get_full_doc` first. Returns { id } on success or an isError result if the anchor cannot be resolved.",
5640
5675
  inputSchema: {
5641
5676
  type: "object",
5642
5677
  properties: {
@@ -5793,6 +5828,12 @@ function asString(value, field) {
5793
5828
  }
5794
5829
  return value;
5795
5830
  }
5831
+ function asStringAllowEmpty(value, field) {
5832
+ if (typeof value !== "string") {
5833
+ throw new Error(`${field} must be a string`);
5834
+ }
5835
+ return value;
5836
+ }
5796
5837
  function asOptionalString(value, field) {
5797
5838
  if (value === void 0) return void 0;
5798
5839
  if (typeof value !== "string") {
@@ -6145,8 +6186,11 @@ function performAddComment(state, a) {
6145
6186
  const resolved = resolveServerAnchor(state.doc, anchor);
6146
6187
  if (!resolved.ok) {
6147
6188
  return errorResult(
6148
- `anchor ${resolved.error}. Current section:
6149
- ${resolved.currentSectionText}`
6189
+ `anchor ${resolved.error}. Section as rendered (markdown form, with markers):
6190
+ ${resolved.currentSectionText}
6191
+
6192
+ Section as the matcher sees it (use this exact form for textToFind \u2014 inline marks like \`code\`, **bold**, *italic* are stored as marks not characters, so they're absent here; soft line breaks are preserved; list items have no separators):
6193
+ ${resolved.currentSectionFlat}`
6150
6194
  );
6151
6195
  }
6152
6196
  const id = nanoid4();
@@ -6249,7 +6293,7 @@ function handleReplyComment(args) {
6249
6293
  return performReplyComment(state, a);
6250
6294
  }
6251
6295
  function performAddSuggestion(state, a) {
6252
- const replacementText = asString(a.replacementText, "replacementText");
6296
+ const replacementText = asStringAllowEmpty(a.replacementText, "replacementText");
6253
6297
  const fromThreadId = asOptionalString(a.fromThreadId, "fromThreadId");
6254
6298
  const agentState = asOptionalAgentState(a.state, "state");
6255
6299
  let anchorFrom;
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  logError,
5
5
  startMcpHttpServer,
6
6
  startMcpServer
7
- } from "./chunk-WZIAULKX.js";
7
+ } from "./chunk-D62LPVSX.js";
8
8
 
9
9
  // src/setup.ts
10
10
  import * as fs from "fs/promises";
package/dist/mcp.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  startMcpHttpServer,
16
16
  startMcpServer,
17
17
  teardownAllRooms
18
- } from "./chunk-WZIAULKX.js";
18
+ } from "./chunk-D62LPVSX.js";
19
19
  export {
20
20
  __test_clearRooms,
21
21
  __test_dispatch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@composer-app/mcp",
3
- "version": "0.0.4-beta.0",
3
+ "version": "0.0.4-beta.2",
4
4
  "description": "Composer MCP",
5
5
  "license": "MIT",
6
6
  "author": "Josh Philpott",