@limeadelabs/clarabit-mcp 2.2.0 → 2.3.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/dist/index.js +210 -1
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -660,6 +660,178 @@ function registerPageTools(server2, client2) {
660
660
 
661
661
  // src/tools/page-comments.ts
662
662
  import { z as z8 } from "zod";
663
+
664
+ // src/tools/anchor.ts
665
+ var PREVIEW = 40;
666
+ function computeAnchor(body, versionNumber, input) {
667
+ if (input.prefix !== void 0 && input.text !== void 0 && input.suffix !== void 0) {
668
+ return anchorWithContext(body, versionNumber, input.prefix, input.text, input.suffix);
669
+ }
670
+ if (input.heading !== void 0 && input.text !== void 0) {
671
+ return anchorInSection(body, versionNumber, input.heading, input.text);
672
+ }
673
+ if (input.exact_text !== void 0) {
674
+ return anchorExact(body, versionNumber, input.exact_text);
675
+ }
676
+ return {
677
+ error: "invalid_input",
678
+ hint: "Provide one of: exact_text, prefix+text+suffix, or heading+text"
679
+ };
680
+ }
681
+ function anchorExact(body, version, text) {
682
+ if (text.length === 0) {
683
+ return { error: "invalid_input", hint: "exact_text must be non-empty" };
684
+ }
685
+ const indices = findAll(body, text);
686
+ if (indices.length === 0) {
687
+ return { error: "no_match", hint: `exact_text not found in page body` };
688
+ }
689
+ if (indices.length > 1) {
690
+ return {
691
+ error: "ambiguous_anchor",
692
+ match_count: indices.length,
693
+ matches: indices.slice(0, 10).map((start) => ({
694
+ anchor_start: start,
695
+ anchor_end: start + text.length,
696
+ preview_before: body.slice(Math.max(0, start - PREVIEW), start),
697
+ preview_after: body.slice(
698
+ start + text.length,
699
+ Math.min(body.length, start + text.length + PREVIEW)
700
+ )
701
+ })),
702
+ hint: "Provide prefix/suffix or heading context to disambiguate."
703
+ };
704
+ }
705
+ return success(body, text, indices[0], version, "medium");
706
+ }
707
+ function anchorWithContext(body, version, prefix, text, suffix) {
708
+ if (text.length === 0) {
709
+ return { error: "invalid_input", hint: "text must be non-empty" };
710
+ }
711
+ const composite = prefix + text + suffix;
712
+ const indices = findAll(body, composite);
713
+ if (indices.length === 0) {
714
+ return {
715
+ error: "no_match",
716
+ hint: "prefix+text+suffix not found verbatim \u2014 try a shorter / different context"
717
+ };
718
+ }
719
+ if (indices.length > 1) {
720
+ return {
721
+ error: "ambiguous_anchor",
722
+ match_count: indices.length,
723
+ matches: indices.slice(0, 10).map((start2) => {
724
+ const ts = start2 + prefix.length;
725
+ return {
726
+ anchor_start: ts,
727
+ anchor_end: ts + text.length,
728
+ preview_before: body.slice(Math.max(0, ts - PREVIEW), ts),
729
+ preview_after: body.slice(ts + text.length, Math.min(body.length, ts + text.length + PREVIEW))
730
+ };
731
+ }),
732
+ hint: "prefix+text+suffix still has multiple matches; widen the surrounding context."
733
+ };
734
+ }
735
+ const start = indices[0] + prefix.length;
736
+ return success(body, text, start, version, "high");
737
+ }
738
+ function anchorInSection(body, version, heading, text) {
739
+ if (text.length === 0) {
740
+ return { error: "invalid_input", hint: "text must be non-empty" };
741
+ }
742
+ const sections = findSections(body, heading);
743
+ if (sections.length === 0) {
744
+ return { error: "no_match", hint: `heading "${heading}" not found in page body` };
745
+ }
746
+ if (sections.length > 1) {
747
+ return {
748
+ error: "ambiguous_anchor",
749
+ match_count: sections.length,
750
+ matches: sections.slice(0, 10).map((s) => ({
751
+ anchor_start: s.headingStart,
752
+ anchor_end: s.headingEnd,
753
+ preview_before: body.slice(Math.max(0, s.headingStart - PREVIEW), s.headingStart),
754
+ preview_after: body.slice(s.headingEnd, Math.min(body.length, s.headingEnd + PREVIEW))
755
+ })),
756
+ hint: `heading "${heading}" matches ${sections.length} sections; pass the exact heading title to disambiguate.`
757
+ };
758
+ }
759
+ const section = sections[0];
760
+ const sectionBody = body.slice(section.start, section.end);
761
+ const indicesInSection = findAll(sectionBody, text);
762
+ if (indicesInSection.length === 0) {
763
+ return { error: "no_match", hint: `text not found under heading "${heading}"` };
764
+ }
765
+ if (indicesInSection.length > 1) {
766
+ return {
767
+ error: "ambiguous_anchor",
768
+ match_count: indicesInSection.length,
769
+ matches: indicesInSection.slice(0, 10).map((local) => {
770
+ const start2 = section.start + local;
771
+ return {
772
+ anchor_start: start2,
773
+ anchor_end: start2 + text.length,
774
+ preview_before: body.slice(Math.max(0, start2 - PREVIEW), start2),
775
+ preview_after: body.slice(start2 + text.length, Math.min(body.length, start2 + text.length + PREVIEW))
776
+ };
777
+ }),
778
+ hint: `text appears multiple times under heading "${heading}"; use prefix+text+suffix instead.`
779
+ };
780
+ }
781
+ const start = section.start + indicesInSection[0];
782
+ return success(body, text, start, version, "high");
783
+ }
784
+ function findAll(body, needle) {
785
+ const out = [];
786
+ let i = body.indexOf(needle);
787
+ while (i !== -1) {
788
+ out.push(i);
789
+ i = body.indexOf(needle, i + 1);
790
+ }
791
+ return out;
792
+ }
793
+ function findSections(body, heading) {
794
+ const headingRe = /^(#{1,6})\s+(.+?)\s*$/gm;
795
+ const wanted = heading.replace(/^#+\s*/, "").trim();
796
+ const headings = [];
797
+ let match;
798
+ while ((match = headingRe.exec(body)) !== null) {
799
+ headings.push({
800
+ level: match[1].length,
801
+ title: match[2].trim(),
802
+ headingStart: match.index,
803
+ bodyStart: match.index + match[0].length
804
+ });
805
+ }
806
+ const exact = headings.filter((h) => h.title === wanted);
807
+ const candidates = exact.length > 0 ? exact : headings.filter((h) => h.title.includes(wanted));
808
+ return candidates.map((h) => {
809
+ const myIndex = headings.indexOf(h);
810
+ let end = body.length;
811
+ for (let j = myIndex + 1; j < headings.length; j++) {
812
+ if (headings[j].level <= h.level) {
813
+ end = headings[j].headingStart;
814
+ break;
815
+ }
816
+ }
817
+ return { start: h.bodyStart, end, headingStart: h.headingStart, headingEnd: h.bodyStart };
818
+ });
819
+ }
820
+ function success(body, text, start, version, confidence) {
821
+ const end = start + text.length;
822
+ return {
823
+ anchor_text: text,
824
+ anchor_start: start,
825
+ anchor_end: end,
826
+ anchor_version: version,
827
+ match_count: 1,
828
+ confidence,
829
+ preview_before: body.slice(Math.max(0, start - PREVIEW), start),
830
+ preview_after: body.slice(end, Math.min(body.length, end + PREVIEW))
831
+ };
832
+ }
833
+
834
+ // src/tools/page-comments.ts
663
835
  function registerPageCommentTools(server2, client2) {
664
836
  server2.tool(
665
837
  "cla_list_page_comments",
@@ -679,9 +851,46 @@ function registerPageCommentTools(server2, client2) {
679
851
  }
680
852
  }
681
853
  );
854
+ server2.tool(
855
+ "cla_prepare_page_comment_anchor",
856
+ "Compute validated offsets for an anchored page comment without manually counting characters. Pass one locator: exact_text (must occur exactly once in the page body), or prefix+text+suffix to disambiguate repeated text via surrounding context, or heading+text to restrict the search to a markdown section. Returns anchor_text + anchor_start + anchor_end + anchor_version ready to feed into cla_create_page_comment. Returns a structured ambiguous_anchor error (with the list of matches and their preview context) when the locator still resolves to more than one position \u2014 never silently guesses.",
857
+ {
858
+ page_id: z8.coerce.number().describe("Page ID to anchor the comment on"),
859
+ project_id: z8.coerce.number().optional().describe("Project ID (optional \u2014 inferred via the flat page route)"),
860
+ exact_text: z8.string().optional().describe("Unique substring to anchor to (errors with ambiguous_anchor if not unique)"),
861
+ prefix: z8.string().optional().describe("Text immediately before the anchor (use with text + suffix to disambiguate)"),
862
+ text: z8.string().optional().describe("The selection to anchor (use with prefix+suffix, or with heading)"),
863
+ suffix: z8.string().optional().describe("Text immediately after the anchor (use with prefix + text)"),
864
+ heading: z8.string().optional().describe("Markdown heading whose section to search within (use with text)")
865
+ },
866
+ async (params) => {
867
+ try {
868
+ const pageResponse = params.project_id === void 0 ? await client2.getPageFlat(params.page_id) : await client2.getPage(params.project_id, params.page_id);
869
+ const page = pageResponse.page;
870
+ const body = page.body ?? "";
871
+ if (typeof page.version_number !== "number") {
872
+ const compat = {
873
+ error: "invalid_input",
874
+ hint: "page response is missing version_number \u2014 upgrade the Clarabit server (or pass anchor_text + offsets manually to cla_create_page_comment)"
875
+ };
876
+ return { content: [{ type: "text", text: JSON.stringify(compat, null, 2) }] };
877
+ }
878
+ const result = computeAnchor(body, page.version_number, {
879
+ exact_text: params.exact_text,
880
+ prefix: params.prefix,
881
+ text: params.text,
882
+ suffix: params.suffix,
883
+ heading: params.heading
884
+ });
885
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
886
+ } catch (error) {
887
+ return handleError(error, client2.timeoutMs);
888
+ }
889
+ }
890
+ );
682
891
  server2.tool(
683
892
  "cla_create_page_comment",
684
- "Post a comment on a page. Optionally thread under another comment via parent_comment_id, or anchor to a text selection via anchor_text/anchor_start/anchor_end.",
893
+ "Post a comment on a page. Optionally thread under another comment via parent_comment_id, or anchor to a text selection via anchor_text/anchor_start/anchor_end (+ optionally anchor_version to record the body revision the anchor was computed against). For anchored comments, prefer calling cla_prepare_page_comment_anchor first to get validated offsets \u2014 it handles ambiguous repeated text instead of silently anchoring to the first match.",
685
894
  {
686
895
  project_id: z8.coerce.number().optional().describe("Project ID (optional \u2014 inferred if omitted)"),
687
896
  page_id: z8.coerce.number().describe("Page ID to comment on"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limeadelabs/clarabit-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Clarabit MCP server for Claude Code — AI-native project management integration",
5
5
  "type": "module",
6
6
  "exports": {