Compare commits

..

6 Commits

Author SHA1 Message Date
agent_coder 36b940fdb8 fix(#294 review F1-F2): test the changed execute wirings + transport-neutral descriptions
- F1: added in-app execute tests for the two wirings that ACTUALLY changed in the
  migration (the contract-parity test only checks advertised schema keys, not
  execute bodies): movePage forwards the newly-added optional `position` to
  client.movePage (and passes undefined position + null parent when omitted); the
  table trio (insert/delete/updateCell) forwards the unified `table` param
  positionally. A field destructured under the wrong name would have silently
  passed undefined to the client (execute is any-cast, tsc won't catch it).
- F2: rewrote the three migrated descriptions that hardcoded snake_case sibling
  tool names (which the in-app camelCase layer exposes under different ids,
  violating the registry's own transport-neutral-prose convention) into neutral
  prose: getPage "use get_page_json" -> "use the lossless page-JSON read tool";
  updatePageJson "get_page_json -> ... -> update_page_json" -> "read the page-JSON
  view -> modify -> write it back", "prefer rename_page" -> "prefer the rename-page
  tool"; exportPageMarkdown "import_page_markdown round-trip" -> "page-Markdown
  import round-trip" (the last was a direct regress — the in-app base said the
  camelCase importPageMarkdown). (stashPage's pre-existing get_page_json mention is
  out of scope, per the reviewer.)

Gate: mcp build 0; ai-chat-tools.service + tool-tiers (catalog-partition) pass,
incl. the 5 new execute-wiring tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 03:10:42 +03:00
agent_coder ce70fab1df refactor(ai-chat): unify share_page into SHARED_TOOL_SPECS (#294, misc family)
Migrates share_page / sharePage into the transport-agnostic spec registry
(schema + description declared once; each transport keeps only its execute/auth):
- sharePage (deferred) -> SHARED_TOOL_SPECS; index.ts uses registerShared(),
  ai-chat uses sharedTool(); removed from INLINE_TOOL_TIERS.

Drift reconciled (documented inline): both inline copies already carried the
"only share when the user explicitly asked" security framing, so the old
"per-transport divergence" note in BOTH layers was STALE — there was no real
behavioral divergence, only wording drift. The canonical description merges the
MCP copy's URL-format + idempotency detail with the in-app copy's reversibility
note and keeps the shared security framing. pageId keeps the MCP copy's stricter
.min(1). The MCP execute keeps its own `searchIndexing ?? true` default
(per-layer, not part of the shared schema).

Intentionally NOT migrated (kept inline — genuinely divergent, as their existing
notes state):
- search / searchPages: the in-app tool is a semantic+keyword hybrid (RRF) with
  in-process access control and a tuned schema (limit 1-20); the MCP `search` is
  a plain REST full-text search (limit up to 100). Different behavior AND schema.
- docmost_transform / transformPage: the in-app tool deliberately omits the
  `deleteComments` schema field (a comment-deletion guardrail) and carries a
  shorter description. Different schema.

Gate: mcp build 0 + node --test 458/458 (page-search excluded — hangs only under
the local re2->RegExp type-shim, its source untouched), server jest 775 incl.
tool-tiers catalog-partition + shared-spec contract parity, server tsc 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:27:50 +03:00
agent_coder b51dae16a6 docs(mcp): mark media tools MCP-only in index.ts (#294, media family)
The media tools — insert_image, replace_image, insert_footnote — are MCP-only
by design: the in-app AI-chat agent exposes no image or footnote tools, so there
is no second layer to unify into SHARED_TOOL_SPECS. A registry spec's
tier/catalogLine are in-app metadata and the catalog-partition test forbids a
spec without a live in-app tool, so forcing them into the registry would break
the invariant. They stay per-transport (inline in index.ts).

No behavior change — documentation only (adds the rationale above each tool so a
future migrator does not re-investigate why these are not shared).

Gate: mcp tsc 0 (comment-only change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:14:39 +03:00
agent_coder 39735afd73 refactor(ai-chat): unify page tools into SHARED_TOOL_SPECS (#294, pages family)
Migrates the three-layer page tools into the transport-agnostic spec registry
(schema + description declared once; each transport keeps only its execute/auth):
- getPage, listPages (core), createPage, movePage, renamePage, deletePage,
  updatePageJson, exportPageMarkdown (deferred) -> SHARED_TOOL_SPECS; index.ts
  uses registerShared(), ai-chat uses sharedTool(); removed from
  INLINE_TOOL_TIERS. Tiers preserved from CORE_TOOL_KEYS (getPage/listPages =
  core, the rest deferred).

delete_page is genuinely three-layer (in-app deletePage exists), so it IS
migrated — not MCP-only. Its H4 guardrail is preserved: the shared schema
exposes ONLY pageId, so no permanentlyDelete/forceDelete flag can reach the
client (still asserted by ai-chat-tools.service.spec.ts).

Descriptions merged (documented inline): each canonical text takes the MCP
copy's richer structural notes plus the in-app copy's reversibility framing.

Schema DRIFT reconciled (documented inline):
- createPage.content: MCP pinned .min(1) but the in-app copy left it unbounded
  and DOCUMENTS an empty body as valid ("may be empty" — creating an empty page
  to fill later is a real use). Kept the looser no-min form: create_page now also
  accepts an empty body (harmless) and no previously-valid in-app input is
  rejected. title/spaceId keep the MCP .min(1) (empty is never valid).
- movePage: MCP exposed an optional `position` (fractional-index) field the
  in-app copy lacked. Unified by KEEPING position — the in-app client already
  accepts an optional position arg, so the in-app execute now forwards it;
  optional, so no previously-valid call is rejected. `parentPageId` is nullable
  on both (real JSON null -> root); the MCP execute keeps its 'null'/'' string
  coercion as a per-layer robustness fallback.
- getPage/renamePage/updatePageJson/exportPageMarkdown/listPages: kept the MCP
  copy's stricter .min(1) on ids where the in-app copy was unbounded.

Per-transport execute logic preserved: getPage's {title,markdown} projection,
updatePageJson's JSON-string normalization, list_pages' default limit/tree, and
move_page's cycle guard + positive-confirmation check all stay in their execute
bodies.

Intentionally NOT touched: updatePageContent (Markdown-based body update; no MCP
equivalent) and getTable (name-convention divergence, see tables family) stay
inline.

Gate: mcp build 0 + node --test 458/458 (page-search excluded — hangs only under
the local re2->RegExp type-shim, its source untouched), server jest 770 incl.
tool-tiers catalog-partition + shared-spec contract parity + deletePage H4
guardrail, server tsc 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:13:41 +03:00
agent_coder eebbe6717c refactor(ai-chat): unify table row/cell tools into SHARED_TOOL_SPECS (#294, tables family)
Migrates the three-layer table WRITE tools into the transport-agnostic spec
registry (schema + description declared once; each transport keeps only its
execute/auth):
- tableInsertRow, tableDeleteRow, tableUpdateCell -> SHARED_TOOL_SPECS;
  index.ts uses registerShared(), ai-chat uses sharedTool(); removed from
  INLINE_TOOL_TIERS (all three are deferred; not in CORE_TOOL_KEYS).

Drift reconciled (documented inline): the four table tools previously carried a
"NOT shared" note in both layers over a single parameter-NAME drift — the MCP
layer named the table reference `table`, the in-app layer `tableRef`. Unified on
the MCP name `table` (renaming the public MCP parameter would break external MCP
clients; the in-app parameter is model-facing/prompt-only and safe to rename).
The in-app execute bodies now destructure `table`. Descriptions took the MCP
copy's richer wording (documents `#<index>`, padding, header-row behavior) plus
the in-app copy's "Reversible via page history" note; both fields keep the MCP
copy's stricter .min(1) (in-app left them unbounded); sibling tool references
phrased transport-neutrally.

Intentionally NOT migrated (kept inline): table_get / getTable. Its MCP tool
name is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
which breaks the snake_case(inAppKey) naming convention the registry enforces
(shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would break
external clients, so it stays per-transport — but its in-app reference param was
still aligned to `table` (was `tableRef`) for consistency with the migrated trio.

Gate: mcp tsc 0 + node --test 458/458 (page-search excluded — hangs only under
the local re2->RegExp type-shim, its source is untouched), server jest 730 incl.
tool-tiers catalog-partition + shared-spec contract parity, server tsc 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 02:06:52 +03:00
agent_coder e348433a39 refactor(ai-chat): unify comment tools into SHARED_TOOL_SPECS (#294, comments family)
Migrates the three-layer comment tools into the single transport-agnostic spec
registry (schema + model-facing description declared once; each transport keeps
only its execute/auth):
- createComment, listComments, resolveComment, checkNewComments — moved to
  SHARED_TOOL_SPECS; index.ts uses registerShared(), ai-chat uses sharedTool();
  removed from INLINE_TOOL_TIERS (tier/catalogLine now on the spec). Tiers
  preserved from CORE_TOOL_KEYS (create/list/resolve = core, check = deferred).

Intentionally NOT migrated (kept MCP-inline): update_comment / delete_comment —
they are MCP-only by design; the in-app AI-chat layer deliberately has no
updateComment/deleteComment (comment edits are irreversible / not
version-tracked), asserted by ai-chat-tools.service.spec.ts. A registry spec's
tier/catalogLine are in-app metadata and the catalog-partition test forbids a
deferred spec without a live in-app tool, so these stay per-transport.

Drift reconciled (documented inline): createComment/listComments/checkNewComments
took the more-maintained/superset description + stricter .min(1) guards.
resolveComment: `resolved` drifted (MCP optional+default(true) vs in-app
required) — kept the MCP superset, so in-app resolveComment now accepts an
omitted `resolved` (defaults to resolve) — a deliberate, backward-compatible
unification (never rejects a previously-valid input).

Gate: mcp build 0 + node --test 480/480, ai-chat 654, tool-tiers (incl. F3
catalog-partition) 16/16, server tsc 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 01:45:43 +03:00
15 changed files with 871 additions and 1893 deletions
@@ -45,7 +45,6 @@ import {
TiptapPdf,
PageBreak,
SearchAndReplace,
MultiCursor,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
@@ -448,10 +447,6 @@ export const mainExtensions = [
};
},
}).configure(),
// Multi-cursor editing (MVP / Variant A): select-all-occurrences + type into
// all at once. Does not depend on collaboration, so it lives in mainExtensions
// (available in both the plain and collaborative editors).
MultiCursor,
Columns,
Column,
AutoJoiner.configure({
@@ -1,6 +1,5 @@
@import "./core.css";
@import "./collaboration.css";
@import "./multi-cursor.css";
@import "./task-list.css";
@import "./placeholder.css";
@import "./drag-handle.css";
@@ -1,60 +0,0 @@
/*
* Multi-cursor (issue #196). Deliberately DISTINCT from the collaboration
* carets (collaboration.css) so a user never confuses their own multi-cursors
* with a co-author's caret: solid accent-blue carets + a translucent blue
* range highlight, versus the thin dark collaboration caret with a name label.
*/
/* A secondary caret rendered as a Decoration.widget at each cursor position. */
.multi-cursor__caret {
position: relative;
display: inline-block;
width: 0;
height: 1em;
vertical-align: text-bottom;
pointer-events: none;
}
.multi-cursor__caret::after {
content: "";
position: absolute;
left: -1px;
top: 0;
bottom: 0;
width: 2px;
background: #2b6cb0;
animation: multi-cursor-blink 1s steps(1) infinite;
}
/* Optional label class reserved for future per-cursor annotations. */
.multi-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 0.7rem;
line-height: normal;
padding: 0.05rem 0.25rem;
border-radius: 3px 3px 3px 0;
background: #2b6cb0;
color: #fff;
white-space: nowrap;
user-select: none;
pointer-events: none;
}
/* Inline highlight for a multi-cursor RANGE (from < to). */
.multi-cursor__selection {
background: rgba(43, 108, 176, 0.28);
border-radius: 2px;
}
@keyframes multi-cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@@ -539,3 +539,115 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
});
});
/**
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
* but two wirings actually CHANGED in the migration and are otherwise untested:
* - movePage now forwards the newly-added optional `position` field to the
* client (client.movePage(pageId, parentPageId, position));
* - the table trio unified its `tableRef` param to `table` and must forward it
* positionally. A field destructured under the wrong name would silently pass
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
*/
describe('AiChatToolsService #294 changed execute wirings', () => {
const calls: Record<string, unknown[][]> = {
movePage: [],
tableInsertRow: [],
tableDeleteRow: [],
tableUpdateCell: [],
};
const fakeClient: Partial<DocmostClientLike> = {
movePage: (...args: unknown[]) => {
calls.movePage.push(args);
return Promise.resolve({ success: true });
},
tableInsertRow: (...args: unknown[]) => {
calls.tableInsertRow.push(args);
return Promise.resolve({ ok: true });
},
tableDeleteRow: (...args: unknown[]) => {
calls.tableDeleteRow.push(args);
return Promise.resolve({ ok: true });
},
tableUpdateCell: (...args: unknown[]) => {
calls.tableUpdateCell.push(args);
return Promise.resolve({ ok: true });
},
};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let service: AiChatToolsService;
beforeEach(() => {
for (const k of Object.keys(calls)) calls[k].length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
} as never,
);
});
afterEach(() => jest.restoreAllMocks());
const buildTools = () =>
service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
);
it('movePage forwards the optional position to the client', async () => {
const tools = await buildTools();
await tools.movePage.execute(
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
{} as never,
);
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
});
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
const tools = await buildTools();
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
expect(calls.movePage).toEqual([['p2', null, undefined]]);
});
it('tableInsertRow forwards the unified `table` param positionally', async () => {
const tools = await buildTools();
await tools.tableInsertRow.execute(
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
{} as never,
);
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
});
it('tableDeleteRow forwards `table` positionally', async () => {
const tools = await buildTools();
await tools.tableDeleteRow.execute(
{ pageId: 'p1', table: '#0', index: 1 } as never,
{} as never,
);
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
});
it('tableUpdateCell forwards `table` positionally', async () => {
const tools = await buildTools();
await tools.tableUpdateCell.execute(
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
{} as never,
);
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
});
});
@@ -316,50 +316,27 @@ export class AiChatToolsService {
execute: async () => resolveCurrentPageResult(openedPage),
}),
getPage: tool({
description:
'Fetch a single page as Markdown by its page id. Returns the page ' +
'title and its Markdown content. Inline <span data-comment-id> tags ' +
'in the markdown are comment highlight anchors (also present for ' +
'RESOLVED threads) — treat them as markup, not page text.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id (or slugId) of the page.'),
}),
execute: async ({ pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = await client.getPage(pageId);
const data = (result?.data ?? {}) as {
title?: string;
content?: string;
};
return {
title: data.title ?? '',
markdown: typeof data.content === 'string' ? data.content : '',
};
},
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's { title, markdown } projection.
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = await client.getPage(pageId);
const data = (result?.data ?? {}) as {
title?: string;
content?: string;
};
return {
title: data.title ?? '',
markdown: typeof data.content === 'string' ? data.content : '',
};
}),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
createPage: tool({
description:
'Create a new page with a Markdown body in a space, optionally under ' +
'a parent page. Returns the new page id and title. Reversible: a page ' +
'can be moved to trash later.',
inputSchema: modelFriendlyInput({
title: z.string().describe('The title of the new page.'),
content: z
.string()
.describe('The page body as Markdown (may be empty).'),
spaceId: z
.string()
.describe('The id of the space to create the page in.'),
parentPageId: z
.string()
.optional()
.describe('Optional parent page id to nest the new page under.'),
}),
execute: async ({ title, content, spaceId, parentPageId }) => {
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
createPage: sharedTool(
sharedToolSpecs.createPage,
async ({ title, content, spaceId, parentPageId }) => {
// createPage(title, content, spaceId, parentPageId?) ->
// { data: filterPage(page, markdown), success }.
const result = await client.createPage(
@@ -375,7 +352,7 @@ export class AiChatToolsService {
};
return { id: data.id ?? data.slugId, title: data.title ?? title };
},
}),
),
updatePageContent: tool({
description:
@@ -399,115 +376,46 @@ export class AiChatToolsService {
},
}),
renamePage: tool({
description:
"Rename a page (change its title only; the body is untouched). " +
'Reversible: rename back at any time.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to rename.'),
title: z.string().describe('The new title.'),
}),
execute: async ({ pageId, title }) => {
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
renamePage: sharedTool(
sharedToolSpecs.renamePage,
async ({ pageId, title }) => {
// renamePage(pageId, title) -> { success, pageId, title }.
await client.renamePage(pageId, title);
return { pageId, title };
},
}),
),
movePage: tool({
description:
'Move a page under a new parent page, or to the space root when no ' +
'parent is given. Reversible: move it back at any time.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to move.'),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
'Target parent page id. Null/omitted moves the page to the ' +
'space root.',
),
}),
execute: async ({ pageId, parentPageId }) => {
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The shared schema adds the optional `position` field this layer lacked
// before; the execute now forwards it (the client already accepted it).
movePage: sharedTool(
sharedToolSpecs.movePage,
async ({ pageId, parentPageId, position }) => {
// movePage(pageId, parentPageId, position?) -> raw move response.
await client.movePage(pageId, parentPageId ?? null);
await client.movePage(pageId, parentPageId ?? null, position);
return { pageId, parentPageId: parentPageId ?? null, moved: true };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
// permanentlyDelete/forceDelete are never part of the input and can never
// be forwarded — the agent physically cannot permanently delete a page.
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
}),
deletePage: tool({
description:
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
'page can be restored from trash). This NEVER permanently deletes.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to move to trash.'),
}),
// GUARDRAIL (§14 H4): the only field ever passed to the client is
// pageId. permanentlyDelete/forceDelete are not part of the schema and
// are never forwarded, so the agent physically cannot permanently
// delete a page through this tool.
execute: async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
},
}),
// INTENTIONAL per-transport divergence (not shared): the description is
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
// keeps its own wording. Kept per-layer.
createComment: tool({
description:
'Add an INLINE comment to a page, or reply to an existing top-level ' +
'comment (one level only — the backend rejects replies to replies). ' +
'The comment is anchored inline to the given exact `selection` text ' +
'(which gets highlighted); page-level comments are NOT supported. A ' +
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
"parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. You may also attach a ' +
'`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
selection: z
.string()
.min(1)
.max(250)
.optional()
.describe(
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
'formatting boundaries). Required for a new top-level comment; ' +
'omit only when replying via parentCommentId.',
),
parentCommentId: z
.string()
.optional()
.describe(
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).',
),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
'must be UNIQUE in the page — expand it with surrounding context ' +
'(still <=250 chars) if it occurs more than once, or the call is ' +
'refused.',
),
}),
execute: async ({
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// This layer keeps only its own execute-side guards (require a selection
// for a top-level comment; reject suggestedText on a reply / without a
// selection) — the schema+description are shared.
createComment: sharedTool(
sharedToolSpecs.createComment,
async ({
pageId,
content,
selection,
@@ -548,26 +456,17 @@ export class AiChatToolsService {
const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId };
},
}),
),
resolveComment: tool({
description:
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
'the resolved flag). Only top-level comments can be resolved.',
inputSchema: modelFriendlyInput({
commentId: z
.string()
.describe('The id of the top-level comment to resolve/reopen.'),
resolved: z
.boolean()
.describe('true to resolve the thread, false to reopen it.'),
}),
execute: async ({ commentId, resolved }) => {
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
resolveComment: sharedTool(
sharedToolSpecs.resolveComment,
async ({ commentId, resolved }) => {
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
await client.resolveComment(commentId, resolved);
return { commentId, resolved };
},
}),
),
// --- READ tools (added) ---
@@ -585,33 +484,12 @@ export class AiChatToolsService {
// hierarchy mode but is worded for the in-app agent; the standalone MCP
// `list_pages` carries its own wording. Kept per-layer so each side tunes
// its own guidance.
listPages: tool({
description:
'List the most recent pages, optionally scoped to a single space. ' +
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
inputSchema: modelFriendlyInput({
spaceId: z
.string()
.optional()
.describe('Optional space id to scope the listing to.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Maximum number of pages (1-100).'),
tree: z
.boolean()
.optional()
.describe(
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
),
}),
execute: async ({ spaceId, limit, tree }) =>
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listPages: sharedTool(
sharedToolSpecs.listPages,
async ({ spaceId, limit, tree }) =>
await client.listPages(spaceId, limit, tree),
}),
),
listSidebarPages: tool({
description:
@@ -656,41 +534,34 @@ export class AiChatToolsService {
}),
),
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
// while this key is `getTable` (verb-first), breaking the
// snake_case(inAppKey) convention the shared registry enforces. Its
// reference parameter is still named `table` (was `tableRef`) so it matches
// the migrated table row/cell tools below.
getTable: tool({
description:
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
'matrix so cells can be addressed for rich edits).',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
table: z
.string()
.describe(
'"#<index>" from getOutline, or a block id of any node inside ' +
'the table.',
'"#<index>" from the page outline, or a block id of any node ' +
'inside the table.',
),
}),
execute: async ({ pageId, tableRef }) =>
await client.getTable(pageId, tableRef),
execute: async ({ pageId, table }) =>
await client.getTable(pageId, table),
}),
listComments: tool({
description:
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
'threads are returned; resolved threads (a resolved top-level ' +
'comment and all its replies) are hidden and their count reported ' +
'as `resolvedThreadsHidden` so you can re-query with ' +
'`includeResolved: true` to see everything. Returns ' +
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
includeResolved: z
.boolean()
.optional()
.describe('default only active threads; true — include resolved'),
}),
execute: async ({ pageId, includeResolved }) =>
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listComments: sharedTool(
sharedToolSpecs.listComments,
async ({ pageId, includeResolved }) =>
await client.listComments(pageId, includeResolved),
}),
),
getComment: tool({
description: 'Fetch a single comment by id (content as Markdown).',
@@ -700,26 +571,12 @@ export class AiChatToolsService {
execute: async ({ commentId }) => await client.getComment(commentId),
}),
checkNewComments: tool({
description:
'Find new comments across a space (optionally scoped to a subtree) ' +
'created after a given timestamp.',
inputSchema: modelFriendlyInput({
spaceId: z.string().describe('The id of the space to scan.'),
since: z
.string()
.describe('An ISO-8601 timestamp; only comments created after it.'),
parentPageId: z
.string()
.optional()
.describe(
'Optional page id to scope the scan to that page and its ' +
'descendants.',
),
}),
execute: async ({ spaceId, since, parentPageId }) =>
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
checkNewComments: sharedTool(
sharedToolSpecs.checkNewComments,
async ({ spaceId, since, parentPageId }) =>
await client.checkNewComments(spaceId, since, parentPageId),
}),
),
listShares: sharedTool(
sharedToolSpecs.listShares,
@@ -749,19 +606,14 @@ export class AiChatToolsService {
await client.diffPageVersions(pageId, from, to),
),
exportPageMarkdown: tool({
description:
'Export a page to a single self-contained Docmost-flavoured ' +
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
'with importPageMarkdown.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to export.'),
}),
execute: async ({ pageId }) => {
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
exportPageMarkdown: sharedTool(
sharedToolSpecs.exportPageMarkdown,
async ({ pageId }) => {
const markdown = await client.exportPageMarkdown(pageId);
return { markdown };
},
}),
),
// --- WRITE tools (added; reversible via page history/trash) ---
@@ -811,28 +663,12 @@ export class AiChatToolsService {
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
),
updatePageJson: tool({
description:
"Replace a page's body with a full ProseMirror document — a full " +
'overwrite — and/or update its title. Minimal example content: ' +
'{"type":"doc","content":[{"type":"paragraph","content":' +
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
'object or a JSON string (both accepted). Omit content for a ' +
'title-only update. Reversible: the previous version is kept in page ' +
'history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to update.'),
content: z
.any()
.optional()
.describe(
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
'object or JSON string); omit for a title-only update.',
),
title: z.string().optional().describe('Optional new title.'),
}),
execute: async ({ pageId, content, title }) => {
// Parity with the standalone MCP server (index.ts update_page_json):
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's content normalization (parity with
// the standalone MCP server, index.ts update_page_json).
updatePageJson: sharedTool(
sharedToolSpecs.updatePageJson,
async ({ pageId, content, title }) => {
// undefined/null pass through as undefined (title-only / no-op); any
// string is JSON.parsed (so an empty string "" throws, matching the
// MCP server); an object is passed through unchanged.
@@ -845,66 +681,29 @@ export class AiChatToolsService {
}
return await client.updatePageJson(pageId, doc, title);
},
}),
),
// NOT in the shared registry: this layer names the table argument
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
// Sharing one buildShape would rename a model-facing parameter on one
// transport, so the table row/cell tools stay per-layer by design.
tableInsertRow: tool({
description:
'Insert a row of plain-text cells into a table. Reversible via ' +
'page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
.describe('"#<index>" from getOutline, or a block id in the table.'),
cells: z.array(z.string()).describe('The cell texts for the row.'),
index: z
.number()
.int()
.optional()
.describe('0-based insert position (omit/out-of-range to append).'),
}),
execute: async ({ pageId, tableRef, cells, index }) =>
await client.tableInsertRow(pageId, tableRef, cells, index),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The table reference parameter was unified to `table` (was `tableRef`).
tableInsertRow: sharedTool(
sharedToolSpecs.tableInsertRow,
async ({ pageId, table, cells, index }) =>
await client.tableInsertRow(pageId, table, cells, index),
),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableDeleteRow: tool({
description:
'Delete a table row at a 0-based index. Reversible via page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
.describe('"#<index>" from getOutline, or a block id in the table.'),
index: z.number().int().describe('0-based row index to delete.'),
}),
execute: async ({ pageId, tableRef, index }) =>
await client.tableDeleteRow(pageId, tableRef, index),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableDeleteRow: sharedTool(
sharedToolSpecs.tableDeleteRow,
async ({ pageId, table, index }) =>
await client.tableDeleteRow(pageId, table, index),
),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableUpdateCell: tool({
description:
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
'Reversible via page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
.describe('"#<index>" from getOutline, or a block id in the table.'),
row: z.number().int().describe('0-based row index.'),
col: z.number().int().describe('0-based column index.'),
text: z.string().describe('The new cell text.'),
}),
execute: async ({ pageId, tableRef, row, col, text }) =>
await client.tableUpdateCell(pageId, tableRef, row, col, text),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableUpdateCell: sharedTool(
sharedToolSpecs.tableUpdateCell,
async ({ pageId, table, row, col, text }) =>
await client.tableUpdateCell(pageId, table, row, col, text),
),
copyPageContent: sharedTool(
sharedToolSpecs.copyPageContent,
@@ -918,25 +717,14 @@ export class AiChatToolsService {
await client.importPageMarkdown(pageId, markdown),
),
// INTENTIONAL per-transport divergence (not shared): adds a security
// confirmation framing ("Only share when the user explicitly asked, since
// this exposes the page to anyone with the link") for the in-app agent; the
// standalone MCP `share_page` keeps the plain public-URL wording.
sharePage: tool({
description:
'Make a page PUBLICLY accessible and return its public URL. ' +
'Reversible via unsharePage. Only share when the user explicitly ' +
'asked, since this exposes the page to anyone with the link.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to share.'),
searchIndexing: z
.boolean()
.optional()
.describe('Allow public search engines to index it (default true).'),
}),
execute: async ({ pageId, searchIndexing }) =>
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// Both layers already carried the security-confirmation framing, so there
// was no real divergence to preserve — only wording drift.
sharePage: sharedTool(
sharedToolSpecs.sharePage,
async ({ pageId, searchIndexing }) =>
await client.sharePage(pageId, searchIndexing),
}),
),
unsharePage: sharedTool(
sharedToolSpecs.unsharePage,
@@ -100,54 +100,26 @@ export const INLINE_TOOL_TIERS: Record<
tier: 'core',
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
},
getPage: {
tier: 'core',
catalogLine: 'getPage — fetch a page as Markdown by its id.',
},
listPages: {
tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.",
},
listComments: {
tier: 'core',
catalogLine: 'listComments — list all comments on a page (including resolved).',
},
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
// (#294); they carry their own tier ('core') + catalogLine there.
// NOTE: createComment, listComments and resolveComment moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
// catalogLine there. getComment stays inline (MCP-only shape divergence is
// n/a — it simply has no shared spec).
getComment: {
tier: 'core',
catalogLine: 'getComment — fetch a single comment by id.',
},
createComment: {
tier: 'core',
catalogLine:
'createComment — add an inline comment (optionally with a suggested edit).',
},
resolveComment: {
tier: 'core',
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
},
// --- deferred inline ---
createPage: {
tier: 'deferred',
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
},
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
// carry their own deferred tier + catalogLine there.
updatePageContent: {
tier: 'deferred',
catalogLine:
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
},
renamePage: {
tier: 'deferred',
catalogLine: "renamePage — change a page's title only (body untouched).",
},
movePage: {
tier: 'deferred',
catalogLine: 'movePage — move a page under a new parent or to the space root.',
},
deletePage: {
tier: 'deferred',
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
},
listSidebarPages: {
tier: 'deferred',
catalogLine:
@@ -157,42 +129,21 @@ export const INLINE_TOOL_TIERS: Record<
tier: 'deferred',
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
},
checkNewComments: {
tier: 'deferred',
catalogLine:
'checkNewComments — find comments in a space created after a timestamp.',
},
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
// snake_case(inAppKey) convention, so it has no shared spec).
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
// it carries its own deferred tier + catalogLine there.
getPageHistory: {
tier: 'deferred',
catalogLine:
'getPageHistory — fetch one page-history version with its ProseMirror content.',
},
exportPageMarkdown: {
tier: 'deferred',
catalogLine:
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
},
updatePageJson: {
tier: 'deferred',
catalogLine:
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
},
tableInsertRow: {
tier: 'deferred',
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
},
tableDeleteRow: {
tier: 'deferred',
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
},
tableUpdateCell: {
tier: 'deferred',
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
},
sharePage: {
tier: 'deferred',
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
},
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
// its own deferred tier + catalogLine there. transformPage stays inline (its
// schema deliberately diverges — it omits the deleteComments field the MCP
// docmost_transform exposes, a comment-deletion guardrail).
transformPage: {
tier: 'deferred',
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
-1
View File
@@ -20,7 +20,6 @@ export * from "./lib/html-embed/html-embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/multi-cursor";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/transclusion";
@@ -1,3 +0,0 @@
import { MultiCursor } from "./multi-cursor";
export * from "./multi-cursor";
export default MultiCursor;
@@ -1,453 +0,0 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Bold } from "@tiptap/extension-bold";
import { Node as PMNode } from "@tiptap/pm/model";
import { MultiCursor, multiCursorPluginKey, MAX_CURSORS } from "./multi-cursor";
import { findOccurrences } from "../search-and-replace/find-occurrences";
const extensions = [Document, Paragraph, Text, Bold, MultiCursor];
function makeEditor(content?: any) {
return new Editor({
extensions,
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
});
}
function doc(...paragraphs: string[]) {
return {
type: "doc",
content: paragraphs.map((text) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
})),
};
}
function paraTexts(d: PMNode): string[] {
const out: string[] = [];
d.forEach((node) => {
if (node.type.name === "paragraph") out.push(node.textContent);
});
return out;
}
function cursors(editor: Editor) {
return multiCursorPluginKey.getState(editor.state)!.cursors;
}
// Simulate typing a character through the real handleTextInput routing (the
// browser path). someMethod-equivalent: dispatch a DOM-ish text input by calling
// the view's input handler directly.
function typeText(editor: Editor, text: string) {
const { from, to } = editor.state.selection;
// props.handleTextInput is what ProseMirror calls on beforeinput/keypress.
const handled = editor.view.someProp(
"handleTextInput",
(fn) => fn(editor.view, from, to, text) || false,
);
if (!handled) {
// Fall back to a normal insertion (no active multi-cursor set).
editor.view.dispatch(editor.state.tr.insertText(text, from, to));
}
}
function pressKey(editor: Editor, key: string) {
editor.view.someProp("handleKeyDown", (fn) =>
fn(editor.view, new KeyboardEvent("keydown", { key })),
);
}
describe("multi-cursor: selectAllOccurrences", () => {
it("finds EVERY occurrence of a repeated word under the cursor", () => {
const editor = makeEditor(doc("foo bar foo baz foo"));
// Cursor inside the first "foo".
editor.commands.setTextSelection(2);
expect(editor.commands.selectAllOccurrences()).toBe(true);
const cs = cursors(editor);
expect(cs.length).toBe(3);
// Every cursor spans a "foo".
for (const c of cs) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
}
editor.destroy();
});
it("uses the current non-empty selection as the term", () => {
const editor = makeEditor(doc("ab abc ab abcd ab"));
// Select the first "ab".
editor.commands.setTextSelection({ from: 1, to: 3 });
expect(editor.state.doc.textBetween(1, 3)).toBe("ab");
editor.commands.selectAllOccurrences();
// Literal substring match (selection is not whole-word), so every "ab"
// including those inside "abc"/"abcd" is matched: 5 total.
const cs = cursors(editor);
expect(cs.length).toBe(5);
editor.destroy();
});
it("whole-word matching from a word cursor does not match substrings", () => {
const editor = makeEditor(doc("cat category cat scatter cat"));
editor.commands.setTextSelection(2); // inside first "cat"
editor.commands.selectAllOccurrences();
// Only the three standalone "cat" words, not "category"/"scatter".
expect(cursors(editor).length).toBe(3);
editor.destroy();
});
});
describe("multi-cursor: mass typing (single transaction)", () => {
it("types text into N carets at once", () => {
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(3);
// Typing replaces each selected "foo" with "X".
typeText(editor, "X");
expect(paraTexts(editor.state.doc)).toEqual(["X X X"]);
// The cursors are now carets right after each inserted "X".
const cs = cursors(editor);
expect(cs.length).toBe(3);
for (const c of cs) expect(c.from).toBe(c.to);
editor.destroy();
});
it("continues typing at the resulting carets (append semantics)", () => {
const editor = makeEditor(doc("a a a"));
editor.commands.setTextSelection(1);
editor.commands.selectAllOccurrences();
typeText(editor, "b"); // each "a" -> "b"
typeText(editor, "c"); // append at each caret -> "bc"
expect(paraTexts(editor.state.doc)).toEqual(["bc bc bc"]);
editor.destroy();
});
it("applies the whole multi-edit in a SINGLE transaction (one undo step)", () => {
// "One Cmd/Ctrl+Z undoes the whole multi-edit" holds iff the N edits land in
// ONE transaction (history groups by transaction). @tiptap/extension-history
// is not a dependency here, so rather than exercise undo we assert the
// property that guarantees it: typing into N cursors is exactly ONE dispatch.
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(3);
const orig = editor.view.dispatch.bind(editor.view);
let dispatches = 0;
editor.view.dispatch = (tr) => {
dispatches += 1;
return orig(tr);
};
typeText(editor, "Z");
editor.view.dispatch = orig;
expect(dispatches).toBe(1); // all three edits share one transaction
expect(paraTexts(editor.state.doc)).toEqual(["Z Z Z"]);
editor.destroy();
});
it("off-by-one guard: reverse-order iteration keeps every position valid", () => {
// If the mass edit iterated FORWARD, inserting at an earlier cursor would
// shift every later cursor and corrupt the result. Different-length
// replacement makes such a bug visible.
const editor = makeEditor(doc("x x x x"));
editor.commands.setTextSelection(1);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(4);
typeText(editor, "LONG");
expect(paraTexts(editor.state.doc)).toEqual(["LONG LONG LONG LONG"]);
editor.destroy();
});
});
describe("multi-cursor: mass Backspace / Delete", () => {
it("Backspace removes one char before each caret", () => {
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
// Collapse selections to carets at the END of each "foo" by typing then
// removing is complex; instead type to convert ranges into carets first.
typeText(editor, "ab"); // each "foo" -> "ab", carets after "ab"
expect(paraTexts(editor.state.doc)).toEqual(["ab ab ab"]);
pressKey(editor, "Backspace"); // remove the trailing "b" at each caret
expect(paraTexts(editor.state.doc)).toEqual(["a a a"]);
editor.destroy();
});
it("Delete removes one char after each caret", () => {
const editor = makeEditor(doc("fooX fooX"));
// Literal (selection) match of "foo" -> both occurrences inside "fooX".
editor.commands.setTextSelection({ from: 1, to: 4 }); // first "foo"
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
typeText(editor, "foo"); // rewrite "foo", carets now sit before each "X"
expect(paraTexts(editor.state.doc)).toEqual(["fooX fooX"]);
pressKey(editor, "Delete"); // remove the "X" after each caret
expect(paraTexts(editor.state.doc)).toEqual(["foo foo"]);
editor.destroy();
});
it("Backspace at a block-start caret is a no-op for that cursor", () => {
const editor = makeEditor(doc("ab", "ab"));
// Select both "ab" then convert to carets at start by replacing with "".
editor.commands.setTextSelection({ from: 1, to: 3 }); // first "ab"
editor.commands.selectAllOccurrences();
// Move carets to block start: type "" is not possible; instead delete range.
pressKey(editor, "Backspace"); // deletes each selected "ab"
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
// Carets are now at each block start; another Backspace must not throw and
// must not merge blocks (still two empty paragraphs).
pressKey(editor, "Backspace");
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
editor.destroy();
});
});
describe("multi-cursor: addNextOccurrence (Cmd/Ctrl+D)", () => {
it("first press selects the current word, next press adds the next", () => {
const editor = makeEditor(doc("go go go"));
editor.commands.setTextSelection(2); // inside first "go"
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(1);
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(2);
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(3);
// Nothing left to add — stays at 3.
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(3);
for (const c of cursors(editor)) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("go");
}
editor.destroy();
});
});
describe("multi-cursor: position remapping", () => {
it("remaps cursors after a LOCAL edit before them", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
// Insert unrelated text at the very start (pos 1), shifting everything +5.
editor.view.dispatch(editor.state.tr.insertText("HELLO", 1));
const after = cursors(editor);
expect(after.length).toBe(before.length);
for (let i = 0; i < after.length; i += 1) {
expect(after[i].from).toBe(before[i].from + 5);
expect(after[i].to).toBe(before[i].to + 5);
// And they still point at "foo".
expect(editor.state.doc.textBetween(after[i].from, after[i].to)).toBe(
"foo",
);
}
editor.destroy();
});
it("remaps cursors after a simulated REMOTE edit (ordinary transaction)", () => {
const editor = makeEditor(doc("foo bar foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
expect(before.length).toBe(2);
// y-prosemirror applies remote changes as ordinary transactions. Emulate a
// remote insertion between the two "foo"s (inside "bar", pos 6) with a tr
// that carries NO multi-cursor meta — exactly like a collaborator's edit.
const tr = editor.state.tr.insertText("ZZ", 6);
editor.view.dispatch(tr);
const after = cursors(editor);
// The first "foo" (before the insertion) is unchanged; the second shifts +2.
expect(after[0].from).toBe(before[0].from);
expect(after[1].from).toBe(before[1].from + 2);
for (const c of after) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
}
editor.destroy();
});
it("a REMOTE delete UNDER a cursor collapses it to a caret (not drop), leaving others intact", () => {
// The riskiest remap path: a collaborator deletes the very text one cursor
// spans. Both edges map with assoc +1 and there is no drop logic, so the
// deleted-over cursor CONTRACT is: it collapses to a zero-width caret at the
// deletion point (from === to) and STAYS in the set — it is not removed.
// Untouched cursors keep spanning their occurrence. Pinning this makes the
// collapse-not-drop choice explicit (review #372 F2).
const editor = makeEditor(doc("foo bar foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
expect(before.length).toBe(2);
// Remote (no multi-cursor meta) delete of the FIRST "foo" range.
const tr = editor.state.tr.delete(before[0].from, before[0].to);
editor.view.dispatch(tr);
const after = cursors(editor);
// Still two cursors — the deleted-over one is NOT dropped.
expect(after.length).toBe(2);
// The first collapsed to a caret at the deletion point.
expect(after[0].from).toBe(after[0].to);
expect(after[0].from).toBe(before[0].from);
// The second still spans "foo" (shifted left by the 3 removed chars).
expect(after[1].from).toBe(before[1].from - 3);
expect(editor.state.doc.textBetween(after[1].from, after[1].to)).toBe("foo");
// Sanity: the document now reads " bar foo".
expect(paraTexts(editor.state.doc)).toEqual([" bar foo"]);
editor.destroy();
});
});
describe("multi-cursor: collapse / exit", () => {
it("exitMultiCursor clears the set", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
editor.commands.exitMultiCursor();
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
it("an arrow key collapses the set", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
pressKey(editor, "ArrowRight");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
});
describe("multi-cursor: collapse on composition / mousedown", () => {
// Invoke a plugin handleDOMEvents handler through the real prop plumbing.
function fireDOM(editor: Editor, name: string): void {
editor.view.someProp("handleDOMEvents", (handlers: any) => {
const h = handlers && handlers[name];
if (h) h(editor.view, new Event(name));
return false;
});
}
it("collapses the set on compositionstart (IME) — MVP does not multi-IME", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
fireDOM(editor, "compositionstart");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
it("collapses the set on a plain mousedown (VS Code behaviour)", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
fireDOM(editor, "mousedown");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
});
describe("multi-cursor: hard cap", () => {
it("never activates more than MAX_CURSORS cursors", () => {
const many = new Array(MAX_CURSORS + 20).fill("w").join(" ");
const editor = makeEditor(doc(many));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(MAX_CURSORS);
editor.destroy();
});
});
describe("multi-cursor: marks are carried across a mass edit", () => {
it("preserves marks spanning each replaced range", () => {
const editor = makeEditor({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "a " },
{ type: "text", marks: [{ type: "bold" }], text: "key" },
{ type: "text", text: " b " },
{ type: "text", marks: [{ type: "bold" }], text: "key" },
],
},
],
});
editor.commands.setTextSelection(3); // inside first bold "key"
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
typeText(editor, "NEW");
// Both replacements keep the bold mark.
let boldRuns = 0;
editor.state.doc.descendants((node) => {
if (
node.isText &&
node.text === "NEW" &&
node.marks.some((m) => m.type.name === "bold")
) {
boldRuns += 1;
}
});
expect(boldRuns).toBe(2);
editor.destroy();
});
});
// The extracted find-occurrences util must return the SAME occurrences that the
// old inline walk produced (and that search-and-replace still relies on).
describe("find-occurrences util", () => {
it("finds all matches of a literal regex across text nodes", () => {
const editor = makeEditor(doc("foo foofoo foo"));
const results = findOccurrences(editor.state.doc, /foo/gu);
// 4 occurrences: two standalone + two inside "foofoo".
expect(results.length).toBe(4);
for (const r of results) {
expect(editor.state.doc.textBetween(r.from, r.to)).toBe("foo");
}
editor.destroy();
});
it("ignores whitespace-only matches and empty regex", () => {
const editor = makeEditor(doc("a b c"));
expect(findOccurrences(editor.state.doc, null as any).length).toBe(0);
// A whitespace regex yields no results (matches are trimmed away).
expect(findOccurrences(editor.state.doc, /\s/gu).length).toBe(0);
editor.destroy();
});
it("finds a match spanning two differently-marked contiguous text nodes", () => {
const editor = makeEditor({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "wo" },
{ type: "text", marks: [{ type: "bold" }], text: "rd" },
],
},
],
});
const results = findOccurrences(editor.state.doc, /word/gu);
expect(results.length).toBe(1);
expect(editor.state.doc.textBetween(results[0].from, results[0].to)).toBe(
"word",
);
editor.destroy();
});
});
@@ -1,545 +0,0 @@
import { Extension, Range } from "@tiptap/core";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
TextSelection,
type EditorState,
} from "@tiptap/pm/state";
import { Mark } from "@tiptap/pm/model";
import { findOccurrences } from "../search-and-replace/find-occurrences";
/**
* Multi-cursor editing — MVP (issue #196, "Variant A").
*
* VS Code-style multi-cursor limited to "select all occurrences of a word (or
* the current selection) and type into all of them at once", built ON TOP OF
* the search-and-replace mass-transaction machinery:
*
* - Cmd/Ctrl+Shift+L (selectAllOccurrences): the word under the cursor (or the
* current non-empty selection) -> ALL its occurrences become active cursors.
* - Cmd/Ctrl+D (addNextOccurrence): add the NEXT occurrence of the term.
* - Typing / Backspace / Delete apply to EVERY active cursor in ONE
* transaction (so a single Cmd/Ctrl+Z undoes the whole multi-edit).
* - Esc (exitMultiCursor): collapse back to a single cursor.
*
* The single-transaction, reverse-order edit mechanic mirrors `replaceAll` in
* search-and-replace.ts: we iterate cursors from the END of the document to the
* START so an earlier edit never invalidates a later position, carrying the
* marks that span each range.
*
* CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (these are "Variant B", deliberately NOT
* built here):
* - Alt+Click arbitrary carets and Alt+drag column selection.
* - Cmd/Ctrl+Alt+Up/Down "add cursor on the adjacent line".
* - Simultaneous IME / composition input into multiple positions — on
* `compositionstart` we collapse back to a single cursor.
* - Cursors spanning different schema nodes in one edit.
*
* NOT out of scope, but worth stating precisely: there is NO schema-aware or
* structural cursor. Occurrences are found by a plain text-node walk
* (`findOccurrences`), so a term that appears inside a table cell, code block or
* callout DOES get a cursor there and IS edited — as plain text, exactly like
* `replaceAll`. There is no special table/code handling; the per-cursor try/catch
* only SKIPS a cursor whose edit would violate the schema (never applied
* half-way), it does not exclude those node types from matching.
*/
interface MultiCursorState {
// Each active cursor: a caret when from === to, a range when from < to.
cursors: Range[];
}
export const multiCursorPluginKey = new PluginKey<MultiCursorState>(
"multiCursor",
);
// Hard safety cap on simultaneously-active cursors — stop adding past it.
export const MAX_CURSORS = 100;
export interface MultiCursorStorage {
// Whether the active term matches whole words only. Set to true when the set
// was seeded from a bare cursor (word under caret), false when seeded from an
// explicit selection (literal substring match, like VS Code). Remembered so
// addNextOccurrence keeps matching the same way as selectAllOccurrences.
wholeWord: boolean;
}
declare module "@tiptap/core" {
interface Storage {
multiCursor: MultiCursorStorage;
}
interface Commands<ReturnType> {
multiCursor: {
/** Select all occurrences of the word/selection as active cursors. */
selectAllOccurrences: () => ReturnType;
/** Add the next occurrence of the current term to the cursor set. */
addNextOccurrence: () => ReturnType;
/** Collapse the multi-cursor set back to a single cursor. */
exitMultiCursor: () => ReturnType;
};
}
}
// ---------------------------------------------------------------------------
// Term helpers
// ---------------------------------------------------------------------------
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// A "word" is a run of letters/numbers/underscore; those get whole-word
// matching (\b…\b) so a term never matches inside a larger word. Anything else
// (punctuation, phrases) is matched literally. Case-sensitive, like VS Code.
function isWordTerm(s: string): boolean {
return /^[\p{L}\p{N}_]+$/u.test(s);
}
// wholeWord uses \b…\b so the term never matches inside a larger word; it only
// applies to word-like terms (a term containing punctuation cannot be
// whole-word-bounded meaningfully). Otherwise the term is matched literally.
function buildTermRegex(term: string, wholeWord: boolean): RegExp {
const esc = escapeRegExp(term);
return wholeWord && isWordTerm(term)
? new RegExp(`\\b${esc}\\b`, "gu")
: new RegExp(esc, "gu");
}
// Word under a position: returns the exact { from, to } range and its text, or
// null if the position is not inside a word in a textblock.
function getWordAt(
state: EditorState,
pos: number,
): { from: number; to: number; text: string } | null {
const $pos = state.doc.resolve(pos);
const parent = $pos.parent;
if (!parent.isTextblock) return null;
const text = parent.textContent;
const offset = $pos.parentOffset;
const start = $pos.start();
const wordRe = /[\p{L}\p{N}_]+/gu;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const s = m.index;
const e = m.index + m[0].length;
if (offset >= s && offset <= e) {
return { from: start + s, to: start + e, text: m[0] };
}
}
return null;
}
// ---------------------------------------------------------------------------
// Plugin-state access
// ---------------------------------------------------------------------------
function getCursors(state: EditorState): Range[] {
const st = multiCursorPluginKey.getState(state);
return st ? st.cursors : [];
}
function setCursors(view: EditorView, cursors: Range[]): void {
view.dispatch(view.state.tr.setMeta(multiCursorPluginKey, cursors));
}
function collapse(view: EditorView): void {
setCursors(view, []);
}
// ---------------------------------------------------------------------------
// The single-transaction, reverse-order mass edit (mirrors replaceAll)
// ---------------------------------------------------------------------------
interface EditOp {
from: number;
to: number;
// Text to insert at `from` after deleting [from, to); "" for a pure delete.
text: string;
}
/**
* Apply one edit per cursor in ONE transaction. Ops are processed from the END
* of the document to the START so an earlier edit never shifts a later position
* (mirrors `replaceAll`). Each cursor is wrapped independently: a schema
* violation SKIPS that one cursor instead of throwing away the whole
* transaction, so the document is never left half-applied.
*
* After building the transaction the new cursor positions are recomputed by
* mapping each op's original anchor through `tr.mapping` (which also remaps any
* concurrent changes), so carets land right after their inserted text.
*/
function dispatchMassEdit(view: EditorView, ops: EditOp[]): boolean {
if (!ops.length) return false;
const { state } = view;
const tr = state.tr;
const schema = state.schema;
// Ascending by `from`; iterate reverse so earlier positions stay valid.
const sorted = [...ops].sort((a, b) => a.from - b.from);
const appliedLen: number[] = new Array(sorted.length).fill(0);
for (let i = sorted.length - 1; i >= 0; i -= 1) {
const { from, to, text } = sorted[i];
try {
let marks: readonly Mark[] = [];
if (text) {
if (to > from) {
// Carry all marks spanning the replaced range.
const set = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mk) => set.add(mk));
}
});
marks = Array.from(set);
} else {
// Caret: continue the marks active at the insertion point.
marks = state.storedMarks || state.doc.resolve(from).marks();
}
}
// ONE atomic step per cursor: replaceWith covers both insert (from === to)
// and replace (to > from); a pure delete (empty text) uses delete. This
// can never leave a cursor half-applied (deleted but not re-inserted) the
// way a separate delete-then-insert pair could if the insert step threw.
if (text) {
tr.replaceWith(from, to, schema.text(text, marks as Mark[]));
} else if (to > from) {
tr.delete(from, to);
}
appliedLen[i] = text.length;
} catch {
// Per-cursor backstop (text-only MVP): drop this cursor's edit, keep the
// rest of the transaction intact.
appliedLen[i] = 0;
}
}
if (!tr.docChanged) return false;
// Recompute cursor carets from the ORIGINAL op anchors through the full map.
const newCursors: Range[] = sorted.map((op, i) => {
const start = tr.mapping.map(op.from, -1);
const caret = start + appliedLen[i];
return { from: caret, to: caret };
});
tr.setMeta(multiCursorPluginKey, newCursors);
// Park the native selection on the last caret so the browser draws exactly
// one real caret; the rest are our decoration widgets.
const last = newCursors[newCursors.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from));
view.dispatch(tr);
return true;
}
function buildDeleteOps(
state: EditorState,
cursors: Range[],
forward: boolean,
): EditOp[] {
return cursors.map((c) => {
// A selected range: Backspace/Delete removes the whole range.
if (c.to > c.from) return { from: c.from, to: c.to, text: "" };
const $pos = state.doc.resolve(c.from);
if (forward) {
// Delete: at the end of a textblock there is nothing to remove (a no-op;
// MVP does not merge blocks across a multi-cursor set).
if ($pos.parentOffset >= $pos.parent.content.size) {
return { from: c.from, to: c.from, text: "" };
}
return { from: c.from, to: c.from + 1, text: "" };
}
// Backspace: at the start of a textblock there is nothing to remove.
if ($pos.parentOffset <= 0) {
return { from: c.from, to: c.from, text: "" };
}
return { from: c.from - 1, to: c.from, text: "" };
});
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export const MultiCursor = Extension.create<unknown, MultiCursorStorage>({
name: "multiCursor",
addStorage() {
return { wholeWord: true };
},
addCommands() {
return {
selectAllOccurrences:
() =>
({ editor, state, tr, dispatch }) => {
let term: string;
// A bare cursor expands to the whole word; an explicit selection is
// matched literally (VS Code semantics).
const wholeWord = state.selection.empty;
if (wholeWord) {
const word = getWordAt(state, state.selection.from);
if (!word) return false;
term = word.text;
} else {
term = state.doc.textBetween(
state.selection.from,
state.selection.to,
);
}
if (!term.trim()) return false;
editor.storage.multiCursor.wholeWord = wholeWord;
const results = findOccurrences(
state.doc,
buildTermRegex(term, wholeWord),
).slice(0, MAX_CURSORS);
if (!results.length) return false;
if (dispatch) {
tr.setMeta(multiCursorPluginKey, results);
const last = results[results.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
dispatch(tr);
}
return true;
},
addNextOccurrence:
() =>
({ editor, state, tr, dispatch }) => {
const existing = getCursors(state);
let cursors: Range[];
if (!existing.length) {
// First press: turn the current word/selection into the one cursor.
let range: Range;
const wholeWord = state.selection.empty;
if (wholeWord) {
const word = getWordAt(state, state.selection.from);
if (!word) return false;
range = { from: word.from, to: word.to };
} else {
range = { from: state.selection.from, to: state.selection.to };
}
editor.storage.multiCursor.wholeWord = wholeWord;
cursors = [range];
} else {
// Subsequent press: add the next unselected occurrence of the term,
// matched the SAME way (whole-word vs literal) the set was seeded.
if (existing.length >= MAX_CURSORS) return true;
const first = existing[0];
const term = state.doc.textBetween(first.from, first.to);
if (!term.trim()) return false;
const results = findOccurrences(
state.doc,
buildTermRegex(term, editor.storage.multiCursor.wholeWord),
);
const keys = new Set(existing.map((c) => `${c.from}:${c.to}`));
const notSelected = results.filter(
(r) => !keys.has(`${r.from}:${r.to}`),
);
if (!notSelected.length) return true; // all occurrences selected
const maxTo = Math.max(...existing.map((c) => c.to));
const next =
notSelected.find((r) => r.from >= maxTo) || notSelected[0];
cursors = [...existing, next];
}
if (dispatch) {
tr.setMeta(multiCursorPluginKey, cursors);
const last = cursors[cursors.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
dispatch(tr);
}
return true;
},
exitMultiCursor:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(multiCursorPluginKey, []);
dispatch(tr);
}
return true;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => {
this.editor.commands.selectAllOccurrences();
// Always consume so the browser's default is prevented.
return true;
},
"Mod-d": () => {
this.editor.commands.addNextOccurrence();
// Consume unconditionally to prevent the browser's Cmd/Ctrl+D bookmark.
return true;
},
Escape: () => {
// Only swallow Escape while a multi-cursor set is active; otherwise let
// Escape keep its other behaviours (e.g. closing dialogs).
if (!getCursors(this.editor.state).length) return false;
return this.editor.commands.exitMultiCursor();
},
};
},
addProseMirrorPlugins() {
return [
new Plugin<MultiCursorState>({
key: multiCursorPluginKey,
state: {
init: () => ({ cursors: [] }),
apply(tr, value): MultiCursorState {
// A command (or a mass edit) can set/clear the cursor set directly.
// Its cursors are already in the post-transaction coordinate space,
// so they take priority over remapping.
const meta = tr.getMeta(multiCursorPluginKey) as
| Range[]
| undefined;
if (meta !== undefined) {
return { cursors: meta.slice(0, MAX_CURSORS) };
}
if (!value.cursors.length) return value;
// Remap surviving cursors across ANY doc change — this covers both
// local edits and REMOTE Yjs edits (y-prosemirror applies remote
// changes as ordinary transactions, so mapping them here keeps every
// multi-cursor correctly positioned without special-casing collab).
if (tr.docChanged) {
// Map both edges with the SAME association (+1) so content
// inserted at a boundary shifts the whole cursor right and a caret
// (from === to) can never invert into a range.
const cursors = value.cursors.map((c) => ({
from: tr.mapping.map(c.from, 1),
to: tr.mapping.map(c.to, 1),
}));
return { cursors };
}
return value;
},
},
props: {
decorations(state) {
const st = multiCursorPluginKey.getState(state);
if (!st || !st.cursors.length) return DecorationSet.empty;
const decorations: Decoration[] = [];
st.cursors.forEach((c, i) => {
if (c.from === c.to) {
decorations.push(
Decoration.widget(
c.from,
() => {
const el = document.createElement("span");
el.className = "multi-cursor__caret";
return el;
},
{ side: 0, key: `mc-caret-${i}` },
),
);
} else {
decorations.push(
Decoration.inline(c.from, c.to, {
class: "multi-cursor__selection",
}),
);
}
});
return DecorationSet.create(state.doc, decorations);
},
handleTextInput(view, _from, _to, text) {
const cursors = getCursors(view.state);
if (!cursors.length) return false;
// Insert `text` at EVERY cursor in one transaction. Returning true
// prevents ProseMirror's own single-position insert at the native
// selection, so there is no double-insert there.
const ops = cursors.map((c) => ({
from: c.from,
to: c.to,
text,
}));
return dispatchMassEdit(view, ops);
},
handleKeyDown(view, event) {
const cursors = getCursors(view.state);
if (!cursors.length) return false;
if (event.key === "Backspace") {
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, false));
return true;
}
if (event.key === "Delete") {
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, true));
return true;
}
// Let modifier combinations (our own shortcuts, copy, etc.) through
// WITHOUT collapsing the set.
if (event.metaKey || event.ctrlKey || event.altKey) return false;
// Navigation / block keys collapse back to a single cursor, then let
// ProseMirror handle the movement on the native selection.
const COLLAPSE_KEYS = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
"Enter",
"Tab",
];
if (COLLAPSE_KEYS.includes(event.key)) {
collapse(view);
return false;
}
return false;
},
handleDOMEvents: {
// A plain click exits multi-cursor (VS Code behaviour).
mousedown: (view) => {
if (getCursors(view.state).length) collapse(view);
return false;
},
// MVP does not drive multi-position IME — collapse on composition.
compositionstart: (view) => {
if (getCursors(view.state).length) collapse(view);
return false;
},
},
},
}),
];
},
});
export default MultiCursor;
@@ -1,69 +0,0 @@
import { Range } from "@tiptap/core";
import { Node as PMNode } from "@tiptap/pm/model";
interface TextNodesWithPosition {
text: string;
pos: number;
}
/**
* Shared "find all occurrences of a term in the doc" primitive.
*
* Walks every text node of the document and returns each regex match as a
* `{ from, to }` range. Contiguous text nodes (which may differ only by marks)
* are concatenated into a single run, so a match that spans e.g. "wo" + bold
* "rd" is still found; runs are split by any non-text node, so a match never
* crosses a node boundary. Whitespace-only matches are ignored.
*
* This is used by BOTH search-and-replace (highlight/replace) and multi-cursor
* (turn occurrences into active cursors) so the two stay behaviourally in sync.
* Extracted verbatim from the original `processSearches` walk.
*/
export function findOccurrences(doc: PMNode, searchTerm: RegExp): Range[] {
const results: Range[] = [];
if (!searchTerm) return results;
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
return results;
}
@@ -1,4 +1,3 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export * from './find-occurrences'
export default SearchAndReplace
@@ -29,7 +29,6 @@ import {
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
import { findOccurrences } from "./find-occurrences";
declare module "@tiptap/core" {
interface Storage {
@@ -77,6 +76,11 @@ declare module "@tiptap/core" {
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
s: string,
disableRegex: boolean,
@@ -100,6 +104,10 @@ function processSearches(
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
@@ -108,8 +116,43 @@ function processSearches(
};
}
// Shared find-all-occurrences primitive (also used by multi-cursor).
const results: Range[] = findOccurrences(doc, searchTerm);
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
+83 -355
View File
@@ -118,56 +118,19 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
server.registerTool(
"list_pages",
{
description:
"List most recent pages in a space ordered by updatedAt (descending). " +
"Returns a bounded list (default 50, max 100) — use search for lookups " +
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
"space's full page hierarchy as a nested tree.",
inputSchema: {
spaceId: z.string().optional(),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Max pages to return (default 50, max 100)"),
tree: z
.boolean()
.optional()
.describe(
"When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
),
},
},
async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
},
);
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
});
// Tool: get_page
server.registerTool(
"get_page",
{
description:
"Get page details with content converted to Markdown. The conversion is " +
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
"lossless representation use get_page_json. Inline <span data-comment-id> " +
"tags in the markdown are comment highlight anchors (also present for " +
"RESOLVED threads) — treat them as markup, not page text.",
inputSchema: {
pageId: z.string().min(1),
},
},
async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
},
);
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
});
// Tool: get_page_json
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
@@ -201,6 +164,10 @@ registerShared(
);
// Tool: table_get
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
// Renaming the public MCP tool would break external clients, so it stays inline.
server.registerTool(
"table_get",
{
@@ -223,25 +190,10 @@ server.registerTool(
);
// Tool: table_insert_row
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool(
"table_insert_row",
{
description:
"Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
"a block id inside it. `cells` = text per column (padded to the table's " +
"column count; error if more cells than columns). `index` = 0-based " +
"insert position (0 inserts before the header); omit to append at the end.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
cells: z.array(z.string()),
index: z.number().int().optional(),
},
},
// Schema + description now live in the shared registry (#294); the `table`
// parameter name is the canonical one (the in-app layer was unified to it).
registerShared(
SHARED_TOOL_SPECS.tableInsertRow,
async ({ pageId, table, cells, index }) => {
const result = await docmostClient.tableInsertRow(
pageId,
@@ -254,22 +206,9 @@ server.registerTool(
);
// Tool: table_delete_row
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_delete_row",
{
description:
"Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
"a block id inside it). Refuses to delete the table's only row. An " +
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
"and the next row becomes the new header.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
index: z.number().int(),
},
},
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableDeleteRow,
async ({ pageId, table, index }) => {
const result = await docmostClient.tableDeleteRow(pageId, table, index);
return jsonContent(result);
@@ -277,24 +216,9 @@ server.registerTool(
);
// Tool: table_update_cell
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_update_cell",
{
description:
"Set the plain-text content of cell [row,col] (0-based) in a table " +
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
"content with a single text paragraph; for rich formatting use patch_node " +
"on the cell's paragraph id from table_get.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
row: z.number().int(),
col: z.number().int(),
text: z.string(),
},
},
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableUpdateCell,
async ({ pageId, table, row, col, text }) => {
const result = await docmostClient.tableUpdateCell(
pageId,
@@ -308,22 +232,9 @@ server.registerTool(
);
// Tool: create_page
server.registerTool(
"create_page",
{
description:
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
"it under a parent; omit it to create at the space root.",
inputSchema: {
title: z.string().min(1).describe("Title of the page"),
content: z.string().min(1).describe("Markdown content"),
spaceId: z.string().min(1),
parentPageId: z
.string()
.optional()
.describe("Optional parent page ID to nest under"),
},
},
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.createPage,
async ({ title, content, spaceId, parentPageId }) => {
const result = await docmostClient.createPage(
title,
@@ -336,32 +247,11 @@ server.registerTool(
);
// Tool: update_page_json
server.registerTool(
"update_page_json",
{
description:
"Replace a page's content with a raw ProseMirror JSON document " +
"(lossless write: preserves the block ids, callouts, tables and " +
"attributes you pass in). Typical flow: get_page_json -> modify the " +
"JSON -> update_page_json. Keep existing node ids intact so heading " +
"anchors and history stay stable. Minimal full-doc example: " +
'{"type":"doc","content":[{"type":"paragraph","content":' +
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
"the title (though prefer rename_page for a title-only change). " +
"Supplying neither content nor title is an error.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to update"),
content: z
.any()
.optional()
.describe(
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
"JSON string). Omit to rename only.",
),
title: z.string().optional().describe("Optional new title"),
},
},
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's content normalization (parse a JSON-string content,
// pass undefined/null through for a title-only/no-op update).
registerShared(
SHARED_TOOL_SPECS.updatePageJson,
async ({ pageId, content, title }) => {
// Only parse/validate the document when it was actually supplied; when it
// is omitted, pass it straight through so the client performs a title-only
@@ -379,26 +269,11 @@ server.registerTool(
);
// Tool: export_page_markdown
server.registerTool(
"export_page_markdown",
{
description:
"Export a page to a single self-contained, lossless Docmost-flavoured " +
"Markdown file (custom extensions): YAML-free meta header, body with " +
"inline comment anchors and diagrams, and a trailing comments-thread " +
"block. Designed for a download -> edit body -> import_page_markdown " +
"round-trip that preserves everything, including comment highlights. " +
"Comment THREADS are preserved in the file but are not re-pushed to the " +
"server on import.",
inputSchema: {
pageId: z.string().min(1),
},
},
async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text" as const, text: md }] };
},
);
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text" as const, text: md }] };
});
// Tool: import_page_markdown
registerShared(
@@ -422,22 +297,11 @@ registerShared(
);
// Tool: rename_page
server.registerTool(
"rename_page",
{
description:
"Rename a page (change its title only) without touching or resending " +
"its content.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to rename"),
title: z.string().min(1).describe("New title"),
},
},
async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
},
);
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
});
// Tool: edit_page_text
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
@@ -516,6 +380,10 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
});
// Tool: insert_image
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
// exposes no image tools (insert/replace), so there is no second layer to unify
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
// catalog-partition test forbids a spec without a live in-app tool (#294).
server.registerTool(
"insert_image",
{
@@ -561,6 +429,7 @@ server.registerTool(
);
// Tool: replace_image
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
server.registerTool(
"replace_image",
{
@@ -603,25 +472,10 @@ server.registerTool(
);
// Tool: share_page
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool(
"share_page",
{
description:
"Make a page publicly accessible (idempotent) and return its public " +
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
"page content to ANYONE with the URL — do it only when explicitly asked.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to share"),
searchIndexing: z
.boolean()
.optional()
.describe("Allow search engines to index the page (default true)"),
},
},
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own `searchIndexing ?? true` default.
registerShared(
SHARED_TOOL_SPECS.sharePage,
async ({ pageId, searchIndexing }) => {
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
return jsonContent(result);
@@ -641,29 +495,11 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
});
// Tool: move_page
server.registerTool(
"move_page",
{
description:
"Move a page under a new parent (nesting) or to the space root.",
inputSchema: {
pageId: z.string().min(1),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
"Target parent page ID. Pass 'null' or empty string to move to root.",
),
position: z
.string()
.min(5)
.optional()
.describe(
"fractional-index position key; min 5 chars; omit to append at the end.",
),
},
},
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
// its positive-confirmation check on the move response.
registerShared(
SHARED_TOOL_SPECS.movePage,
async ({ pageId, parentPageId, position }) => {
const finalParentId =
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
@@ -698,49 +534,22 @@ server.registerTool(
);
// Tool: delete_page
server.registerTool(
"delete_page",
{
description:
"Delete a single page by ID. SOFT delete only: the page is moved to " +
"trash and can be restored; nothing is permanently deleted.",
inputSchema: {
pageId: z.string().min(1),
},
},
async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
],
};
},
);
// Schema + description now live in the shared registry (#294). The shared schema
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
],
};
});
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
// Tool: list_comments
server.registerTool(
"list_comments",
{
description:
"List comments on a page in one call (pagination is handled " +
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
"threads (a resolved top-level comment and all its replies) are hidden " +
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
"with `includeResolved: true` to see everything. Returns " +
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
inputSchema: {
pageId: z.string().describe("ID of the page"),
includeResolved: z
.boolean()
.optional()
.describe(
"default only active threads; true — include resolved",
),
},
},
registerShared(
SHARED_TOOL_SPECS.listComments,
async ({ pageId, includeResolved }) => {
const comments = await docmostClient.listComments(pageId, includeResolved);
return jsonContent(comments);
@@ -748,55 +557,11 @@ server.registerTool(
);
// Tool: create_comment
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool(
"create_comment",
{
description:
"Create a new comment on a page. The comment is ALWAYS inline and is " +
"anchored to (highlights) its `selection` text — there are no page-level " +
"comments. Content is provided as Markdown and automatically converted. " +
"A top-level comment REQUIRES an exact `selection`; if the selection " +
"cannot be found in the page the call fails (no orphan comment is left). " +
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
"selection. You may also attach a `suggestedText` proposing a replacement " +
"for the `selection`; a human applies (or rejects) it from the UI. When " +
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
"page — expand it with surrounding context if it is ambiguous.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
selection: z
.string()
.min(1)
// Enforce the documented 250-char cap to match the description above.
.max(250)
.optional()
.describe(
"EXACT contiguous text from a single paragraph/block to anchor the " +
"comment on (<=250 chars). Required for a top-level comment; omit " +
"only when replying via parentCommentId.",
),
parentCommentId: z
.string()
.optional()
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
"applied by a human via the UI (never auto-applied). REQUIRES a " +
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
"be UNIQUE in the page — expand it with surrounding context (still " +
"<=250 chars) if it occurs more than once, or the call is refused.",
),
},
},
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guards (require a selection for a top-level
// comment; reject suggestedText on a reply / without a selection).
registerShared(
SHARED_TOOL_SPECS.createComment,
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
@@ -872,28 +637,9 @@ server.registerTool(
);
// Tool: resolve_comment
server.registerTool(
"resolve_comment",
{
description:
"Resolve (close) or reopen a comment thread. Only top-level comments can " +
"be resolved — the server rejects resolving a reply. Reversible: pass " +
"resolved=false to reopen. Resolving keeps the thread and its replies " +
"(unlike delete_comment, which permanently removes them).",
inputSchema: {
commentId: z
.string()
.min(1)
.describe("ID of the top-level comment thread to resolve or reopen"),
resolved: z
.boolean()
.optional()
.default(true)
.describe(
"true (default) marks the thread resolved/closed; false reopens it",
),
},
},
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.resolveComment,
async ({ commentId, resolved }) => {
const result = await docmostClient.resolveComment(commentId, resolved);
return jsonContent(result);
@@ -901,30 +647,10 @@ server.registerTool(
);
// Tool: check_new_comments
server.registerTool(
"check_new_comments",
{
description:
"Check for new comments across pages in a space since a given timestamp. " +
"Optionally scope to a page subtree (folder). Returns only comments " +
"created after the specified time.",
inputSchema: {
spaceId: z.string().describe("Space ID to check for new comments"),
since: z
.string()
.min(1)
.describe(
"ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
),
parentPageId: z
.string()
.optional()
.describe(
"Optional root page ID to scope the check to a subtree (folder). " +
"Only pages under this parent will be checked.",
),
},
},
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
registerShared(
SHARED_TOOL_SPECS.checkNewComments,
async ({ spaceId, since, parentPageId }) => {
// Reject an unparseable timestamp up front: otherwise the comparison
// against NaN silently treats every comment as "not new" and the tool
@@ -1053,6 +779,8 @@ server.registerTool(
);
// Tool: insert_footnote
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
// footnote tool, so there is no second layer to unify — stays inline (#294).
server.registerTool(
"insert_footnote",
{
+494
View File
@@ -316,6 +316,34 @@ export const SHARED_TOOL_SPECS = {
// --- share management ---
// Unified from the per-layer inline definitions (#294). Both layers already
// carried the "only share when explicitly asked" security framing (the
// "per-transport divergence" note on the old inline copies was stale), so
// there was no real behavioral divergence to preserve — only wording drift.
sharePage: {
mcpName: 'share_page',
inAppKey: 'sharePage',
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
// in-app copy's reversibility note; keeps the security framing both had.
description:
'Make a page PUBLICLY accessible (idempotent) and return its public URL ' +
'(format: <app>/share/<key>/p/<slugId>). This exposes the page content ' +
'to ANYONE with the URL — only share when the user explicitly asked. ' +
'Reversible: unshare it later to revoke the public URL.',
tier: 'deferred',
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
// Reconciled: MCP's stricter .min(1) on pageId kept; field descriptions from
// the in-app copy. The MCP execute keeps its own `searchIndexing ?? true`
// default (a per-layer concern, not part of the shared schema).
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to share.'),
searchIndexing: z
.boolean()
.optional()
.describe('Allow public search engines to index it (default true).'),
}),
},
unsharePage: {
mcpName: 'unshare_page',
inAppKey: 'unsharePage',
@@ -509,4 +537,470 @@ export const SHARED_TOOL_SPECS = {
pageId: z.string().min(1),
}),
},
// --- page tools (unified from the per-layer inline definitions, #294) ---
//
// Descriptions merge both layers (the MCP copy's richer structural notes + the
// in-app copy's "Reversible via history/trash" framing where it added one).
// Field constraints keep the MCP copy's stricter .min(1) EXCEPT where the
// in-app layer deliberately allowed a looser value (documented per field).
getPage: {
mcpName: 'get_page',
inAppKey: 'getPage',
description:
'Fetch a single page as Markdown by its id. Returns the page title and ' +
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
'table/callout structure are approximated); for a lossless representation ' +
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
'are comment highlight anchors (also present for RESOLVED threads) — ' +
'treat them as markup, not page text.',
tier: 'core',
catalogLine: 'getPage — fetch a page as Markdown by its id.',
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
// "(or slugId)" describe kept.
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
}),
},
listPages: {
mcpName: 'list_pages',
inAppKey: 'listPages',
description:
'List the most recent pages (ordered by updatedAt, descending), ' +
'optionally scoped to a single space. Returns a bounded list (default ' +
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
"(with spaceId) to instead get the space's full page hierarchy as a " +
'nested tree.',
tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.",
buildShape: (z) => ({
spaceId: z
.string()
.optional()
.describe('Optional space id to scope the listing to.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Maximum number of pages (default 50, max 100).'),
tree: z
.boolean()
.optional()
.describe(
"When true, return the space's full page hierarchy as a nested tree " +
'(children arrays) instead of the recent-by-updatedAt flat list. ' +
'Requires spaceId; ignores limit.',
),
}),
},
createPage: {
mcpName: 'create_page',
inAppKey: 'createPage',
description:
'Create a new page with a Markdown body in a space, optionally under a ' +
'parent page (omit parentPageId to create at the space root). Returns ' +
'the new page id and title. Reversible: a page can be moved to trash ' +
'later.',
tier: 'deferred',
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
// ("may be empty") — creating an empty page to fill in later is a real use
// case. The looser (no-min) form is kept, so create_page now also accepts an
// empty body (harmless — it creates an empty page) and no previously-valid
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
// (an empty title or space is never valid).
buildShape: (z) => ({
title: z.string().min(1).describe('The title of the new page.'),
content: z.string().describe('The page body as Markdown (may be empty).'),
spaceId: z.string().min(1).describe('The id of the space to create the page in.'),
parentPageId: z
.string()
.optional()
.describe('Optional parent page id to nest the new page under.'),
}),
},
movePage: {
mcpName: 'move_page',
inAppKey: 'movePage',
description:
'Move a page under a new parent page, or to the space root when no ' +
'parent is given. Reversible: move it back at any time.',
tier: 'deferred',
catalogLine: 'movePage — move a page under a new parent or to the space root.',
// Reconciled schema DRIFT: the MCP copy exposed a `position` field
// (fractional-index ordering) that the in-app copy lacked. Unified by
// KEEPING position (the in-app client already accepts an optional position
// arg, so the in-app execute now forwards it) — it is optional, so no
// previously-valid in-app call is rejected. `parentPageId` is `.nullable()`
// on both, so a real JSON null moves to root on either transport; the MCP
// execute additionally coerces the strings 'null'/'' to null as a robustness
// fallback (kept in its execute body, not in the shared schema).
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to move.'),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
'Target parent page id. Null or omitted moves the page to the space ' +
'root.',
),
position: z
.string()
.min(5)
.optional()
.describe(
'Optional fractional-index position key (min 5 chars); omit to ' +
'append at the end.',
),
}),
},
renamePage: {
mcpName: 'rename_page',
inAppKey: 'renamePage',
description:
'Rename a page (change its title only; the body is untouched, never ' +
'resent). Reversible: rename back at any time.',
tier: 'deferred',
catalogLine: "renamePage — change a page's title only (body untouched).",
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to rename.'),
title: z.string().min(1).describe('The new title.'),
}),
},
deletePage: {
mcpName: 'delete_page',
inAppKey: 'deletePage',
description:
'Move a page to the trash — SOFT delete only: the page can be restored ' +
'from trash and nothing is ever permanently deleted.',
tier: 'deferred',
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
// GUARDRAIL preserved (§14 H4): the schema exposes ONLY pageId, so a
// permanentlyDelete/forceDelete flag can never reach the client through this
// tool (asserted by ai-chat-tools.service.spec.ts).
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to move to trash.'),
}),
},
updatePageJson: {
mcpName: 'update_page_json',
inAppKey: 'updatePageJson',
description:
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
'write: preserves the block ids, callouts, tables and attributes you pass ' +
'in). Typical flow: read the page-JSON view -> modify the JSON -> write it back. ' +
'Keep existing node ids intact so heading anchors and history stay ' +
'stable. Minimal full-doc example: {"type":"doc","content":[{"type":' +
'"paragraph","content":[{"type":"text","text":"Hi"}]}]}. `content` may be ' +
'a JSON object or a JSON string (both accepted), and is OPTIONAL: omit it ' +
'to update only the title (though prefer the rename-page tool for a title-only ' +
'change). Supplying neither content nor title is an error. Reversible: ' +
'the previous version is kept in page history.',
tier: 'deferred',
catalogLine:
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page to update'),
content: z
.any()
.optional()
.describe(
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
'JSON string). Omit to update only the title.',
),
title: z.string().optional().describe('Optional new title'),
}),
},
exportPageMarkdown: {
mcpName: 'export_page_markdown',
inAppKey: 'exportPageMarkdown',
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
description:
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
'Markdown file (custom extensions): YAML-free meta header, body with ' +
'inline comment anchors and diagrams, and a trailing comments-thread ' +
'block. Designed for a download -> edit body -> page-Markdown import ' +
'round-trip that preserves everything, including comment highlights. ' +
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
'server on import.',
tier: 'deferred',
catalogLine:
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to export.'),
}),
},
// --- comment tools (unified from the per-layer inline definitions, #294) ---
//
// create_comment and resolve_comment previously carried a "per-transport
// divergence" note in BOTH layers; #294 unifies their schema + description
// here. Only the four tools that genuinely exist in BOTH layers live in the
// registry: create/list/resolve comment and check_new_comments.
//
// update_comment and delete_comment are intentionally NOT here: they exist
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
// hard comment edit/delete tool (comment edits are irreversible / not
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
// so there is nothing to unify — they stay inline in index.ts.
createComment: {
mcpName: 'create_comment',
inAppKey: 'createComment',
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
// page-level comments, replies inherit the anchor, suggestedText must be
// unique — and adds the "retry with a corrected EXACT selection" and reply-
// to-reply-rejected guidance the MCP copy lacked. Execute-side validation
// (reject suggestedText on a reply, require a selection) stays per-layer.
description:
'Add an INLINE comment to a page, or reply to an existing top-level ' +
'comment (one level only — the backend rejects replies to replies). ' +
'The comment is anchored inline to the given exact `selection` text ' +
'(which gets highlighted); page-level comments are NOT supported. A ' +
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
"parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. You may also attach a ' +
'`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.',
tier: 'core',
catalogLine:
'createComment — add an inline comment (optionally with a suggested edit).',
// Reconciled schema: the field set is identical across both layers; the
// only constraint drift is `content`, which the MCP copy pinned to
// .min(1) while the in-app copy left unbounded — the stricter MCP form is
// kept (an empty comment body is never valid).
buildShape: (z) => ({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().min(1).describe('The comment body as Markdown.'),
selection: z
.string()
.min(1)
.max(250)
.optional()
.describe(
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
'formatting boundaries). Required for a new top-level comment; ' +
'omit only when replying via parentCommentId.',
),
parentCommentId: z
.string()
.optional()
.describe(
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).',
),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
'must be UNIQUE in the page — expand it with surrounding context ' +
'(still <=250 chars) if it occurs more than once, or the call is ' +
'refused.',
),
}),
},
listComments: {
mcpName: 'list_comments',
inAppKey: 'listComments',
// CANONICAL: the two copies are near-identical; the MCP copy is the
// superset (it keeps the "(pagination is handled internally)" note the
// in-app copy dropped), so it is used verbatim.
description:
'List comments on a page in one call (pagination is handled ' +
'internally). By DEFAULT only ACTIVE threads are returned; resolved ' +
'threads (a resolved top-level comment and all its replies) are hidden ' +
'and their count reported as `resolvedThreadsHidden` so you can re-query ' +
'with `includeResolved: true` to see everything. Returns ' +
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
tier: 'core',
catalogLine:
'listComments — list all comments on a page (including resolved).',
buildShape: (z) => ({
pageId: z.string().describe('ID of the page'),
includeResolved: z
.boolean()
.optional()
.describe('default only active threads; true — include resolved'),
}),
},
resolveComment: {
mcpName: 'resolve_comment',
inAppKey: 'resolveComment',
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference
// to `delete_comment` (a sibling tool that does NOT exist in the in-app
// layer) — rephrased transport-neutrally per the registry convention.
description:
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
'pass resolved=false to reopen). Only top-level comments can be ' +
'resolved; the server rejects resolving a reply. Resolving keeps the ' +
'thread and its replies intact (it is not a deletion).',
tier: 'core',
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
// Reconciled schema: `resolved` drifted — the MCP copy made it optional
// with .default(true) (resolve is the common case, documented), the in-app
// copy made it required. The MCP form is kept (a strict superset: it never
// rejects a previously-valid input and adds a sensible default), and
// commentId keeps the MCP copy's stricter .min(1).
buildShape: (z) => ({
commentId: z
.string()
.min(1)
.describe('ID of the top-level comment thread to resolve or reopen'),
resolved: z
.boolean()
.optional()
.default(true)
.describe(
'true (default) marks the thread resolved/closed; false reopens it',
),
}),
},
checkNewComments: {
mcpName: 'check_new_comments',
inAppKey: 'checkNewComments',
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
// execute-side guard that rejects an unparseable `since` timestamp stays in
// its execute body (per-layer logic), not in the shared schema.
description:
'Check for new comments across pages in a space since a given ' +
'timestamp. Optionally scope to a page subtree (folder). Returns only ' +
'comments created after the specified time.',
tier: 'deferred',
catalogLine:
'checkNewComments — find comments in a space created after a timestamp.',
// Reconciled schema: `since` keeps the MCP copy's stricter .min(1) (the
// in-app copy left it unbounded); field descriptions use the MCP copy's
// more detailed wording (it carries an example timestamp).
buildShape: (z) => ({
spaceId: z.string().describe('Space ID to check for new comments'),
since: z
.string()
.min(1)
.describe(
"ISO 8601 timestamp — only return comments created after this time " +
"(e.g. '2026-03-10T00:00:00Z')",
),
parentPageId: z
.string()
.optional()
.describe(
'Optional root page ID to scope the check to a subtree (folder). ' +
'Only pages under this parent will be checked.',
),
}),
},
// --- table tools (unified from the per-layer inline definitions, #294) ---
//
// These tools carried a "NOT shared" note in BOTH layers because of a single
// parameter-NAME drift: the MCP layer named the table reference `table` while
// the in-app layer named it `tableRef`. #294 reconciles that drift by unifying
// on the MCP name `table` — renaming the MCP public parameter would break
// external MCP clients, whereas the in-app parameter is model-facing
// (prompt-only) and safe to rename. The in-app execute bodies now destructure
// `table` instead of `tableRef` (nothing else changes). Descriptions take the
// MCP copy's richer wording (it documented `#<index>`, padding, header-row
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
// tool references are phrased transport-neutrally.
//
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
// so it breaks the snake_case(inAppKey) naming convention the registry enforces
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would
// break external clients, so it stays per-transport (its in-app param was still
// aligned to `table` for consistency with the migrated trio below).
tableInsertRow: {
mcpName: 'table_insert_row',
inAppKey: 'tableInsertRow',
description:
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
'from the page outline, or a block id inside it. `cells` is the text per ' +
"column (padded to the table's column count; an error if more cells than " +
'columns). `index` is the 0-based insert position (0 inserts before the ' +
'header); omit to append at the end. Reversible via page history.',
tier: 'deferred',
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page.'),
table: z
.string()
.min(1)
.describe('"#<index>" from the page outline, or a block id in the table.'),
cells: z.array(z.string()).describe('The cell texts for the row (one per column).'),
index: z
.number()
.int()
.optional()
.describe('0-based insert position (0 inserts before the header); omit to append.'),
}),
},
tableDeleteRow: {
mcpName: 'table_delete_row',
inAppKey: 'tableDeleteRow',
description:
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
'from the page outline, or a block id inside it). Refuses to delete the ' +
"table's only row; an out-of-range `index` throws. Deleting `index` 0 " +
'removes the header row, and the next row becomes the new header. ' +
'Reversible via page history.',
tier: 'deferred',
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page.'),
table: z
.string()
.min(1)
.describe('"#<index>" from the page outline, or a block id in the table.'),
index: z.number().int().describe('0-based row index to delete.'),
}),
},
tableUpdateCell: {
mcpName: 'table_update_cell',
inAppKey: 'tableUpdateCell',
description:
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
'(`table` is `#<index>` from the page outline, or a block id inside it). ' +
"Replaces the cell's content with a single text paragraph; for rich " +
"formatting, patch the cell's paragraph id (obtained from reading the " +
'table) instead. Reversible via page history.',
tier: 'deferred',
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page.'),
table: z
.string()
.min(1)
.describe('"#<index>" from the page outline, or a block id in the table.'),
row: z.number().int().describe('0-based row index.'),
col: z.number().int().describe('0-based column index.'),
text: z.string().describe('The new cell text.'),
}),
},
} satisfies Record<string, SharedToolSpec>;