Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4af21494af |
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useScrollPosition } from "./use-scroll-position";
|
||||
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
@@ -372,3 +372,23 @@ describe("useScrollPosition", () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSavedReadingPosition", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("returns false when nothing is saved for the page", () => {
|
||||
expect(hasSavedReadingPosition("none")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the saved value is 0 (page stays at the top)", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
|
||||
expect(hasSavedReadingPosition("zero")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when a positive position is saved", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
|
||||
expect(hasSavedReadingPosition("deep")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,17 @@ function writeStorage(pageId: string, scrollY: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a positive reading position is saved for this page — i.e. the page
|
||||
* will be scrolled away from the top on load. Used by the title editor to avoid
|
||||
* auto-focusing (and thus placing the caret in) the now-off-screen title.
|
||||
* Returns false when nothing is saved or storage is unavailable.
|
||||
*/
|
||||
export function hasSavedReadingPosition(pageId: string): boolean {
|
||||
const y = readStorage(pageId);
|
||||
return typeof y === "number" && y > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists and restores the window scroll position per page so a reader keeps
|
||||
* their place across a reload (F5) or reopening the document.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTitleAutofocus } from "./use-title-autofocus";
|
||||
|
||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||
|
||||
function fakeEditor(overrides = {}) {
|
||||
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
|
||||
}
|
||||
|
||||
describe("useTitleAutofocus", () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips auto-focus when a saved reading position exists", () => {
|
||||
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
|
||||
const editor = fakeEditor();
|
||||
renderHook(() => useTitleAutofocus(editor, "saved"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
|
||||
const editor = fakeEditor();
|
||||
renderHook(() => useTitleAutofocus(editor, "fresh"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
|
||||
});
|
||||
|
||||
it("does not focus before initialization", () => {
|
||||
const editor = fakeEditor({ isInitialized: false });
|
||||
renderHook(() => useTitleAutofocus(editor, "fresh2"));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the pending focus on unmount", () => {
|
||||
const editor = fakeEditor();
|
||||
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
|
||||
unmount();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { hasSavedReadingPosition } from "./use-scroll-position";
|
||||
|
||||
// Delay before auto-focusing the title on load — guards a tiptap init race
|
||||
// ("Cannot access view['hasFocus']" if focused too early).
|
||||
const TITLE_AUTOFOCUS_DELAY_MS = 300;
|
||||
|
||||
/**
|
||||
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
|
||||
* will be restored (then the viewport scrolls away from the top, and focusing the
|
||||
* top-of-page title would drop the caret off-screen). When it does focus, it uses
|
||||
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
|
||||
* (tiptap's focus scrolls the focused node into view by default, which otherwise
|
||||
* yanks the window to the top and fights scroll-position restoration).
|
||||
*
|
||||
* Extracted from TitleEditor so this exact decision is unit-testable.
|
||||
*
|
||||
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
|
||||
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
|
||||
* per page. It is read synchronously on first render, before any scroll-save
|
||||
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
|
||||
* synchronous capture of `initialTargetRef`.
|
||||
*/
|
||||
export function useTitleAutofocus(
|
||||
titleEditor: Editor | null,
|
||||
pageId: string,
|
||||
): void {
|
||||
const hasSavedScrollRef = useRef<boolean | null>(null);
|
||||
if (hasSavedScrollRef.current === null) {
|
||||
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSavedScrollRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
// guard against "Cannot access view['hasFocus']" before init
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end", { scrollIntoView: false });
|
||||
}, TITLE_AUTOFOCUS_DELAY_MS);
|
||||
// Clear the pending focus if the editor changes or the component unmounts
|
||||
// (also fixes the previously-uncancelled timer).
|
||||
return () => clearTimeout(timer);
|
||||
}, [titleEditor]);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { platformModifierKey } from "@/lib";
|
||||
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
||||
|
||||
export interface TitleEditorProps {
|
||||
pageId: string;
|
||||
@@ -167,13 +168,7 @@ export function TitleEditor({
|
||||
}
|
||||
}, [pageId, title, titleEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 300);
|
||||
}, [titleEditor]);
|
||||
useTitleAutofocus(titleEditor, pageId);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -173,11 +173,6 @@ export class AiChatToolsService {
|
||||
});
|
||||
|
||||
return {
|
||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
||||
// in-app search runs a semantic + keyword hybrid (RRF) with in-process
|
||||
// access control and a tuned schema (limit 1-20); the standalone MCP
|
||||
// `search` is a plain REST full-text search (limit up to 100). Different
|
||||
// behaviour AND schema, so kept per-layer.
|
||||
searchPages: tool({
|
||||
description:
|
||||
'Search the wiki for pages relevant to a query. Combines exact ' +
|
||||
@@ -437,10 +432,6 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
// 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 ' +
|
||||
@@ -528,10 +519,6 @@ export class AiChatToolsService {
|
||||
async () => await client.getSpaces(),
|
||||
),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): keeps the `tree:true`
|
||||
// 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. ' +
|
||||
@@ -705,25 +692,85 @@ export class AiChatToolsService {
|
||||
async ({ pageId }) => await client.stashPage(pageId),
|
||||
),
|
||||
|
||||
// Schema + description from the shared registry (identical across both
|
||||
// transports). The execute body keeps its OWN parseNodeArg normalization:
|
||||
// the model sometimes serializes the node as a JSON string, and we parse it
|
||||
// before the client's typeof-object guard rejects it (parity with the
|
||||
// standalone MCP server, index.ts patch_node).
|
||||
patchNode: sharedTool(
|
||||
sharedToolSpecs.patchNode,
|
||||
async ({ pageId, nodeId, node }) => {
|
||||
patchNode: tool({
|
||||
description:
|
||||
'Replace a single content block (by id) with a new ProseMirror ' +
|
||||
'node; the replacement keeps the same nodeId. Example node: a ' +
|
||||
'paragraph {"type":"paragraph","content":[{"type":"text","text":"Hello"}]} ' +
|
||||
'or a heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
|
||||
'may be a JSON object or a JSON string (both accepted). Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
nodeId: z
|
||||
.string()
|
||||
.describe('The block id to replace (from getOutline/getPageJson).'),
|
||||
node: z
|
||||
.any()
|
||||
.describe(
|
||||
'The replacement ProseMirror node, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, nodeId, node }) => {
|
||||
// Parity with the standalone MCP server (index.ts patch_node): the
|
||||
// model sometimes serializes the node as a JSON string. Parse it
|
||||
// before the client's typeof-object guard rejects it.
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.patchNode(pageId, nodeId, parsedNode);
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
// Shared registry schema + description; execute retains parseNodeArg on the
|
||||
// incoming node (parity with the standalone MCP server, index.ts
|
||||
// insert_node).
|
||||
insertNode: sharedTool(
|
||||
sharedToolSpecs.insertNode,
|
||||
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
insertNode: tool({
|
||||
description:
|
||||
'Insert a ProseMirror node relative to an anchor, or append it at ' +
|
||||
'the top level. For before/after you MUST provide EXACTLY ONE of ' +
|
||||
'anchorNodeId or anchorText. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node arg ' +
|
||||
'may be a JSON object or a JSON string (both accepted). Reversible ' +
|
||||
'via page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
node: z
|
||||
.any()
|
||||
.describe(
|
||||
'The ProseMirror node to insert, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.',
|
||||
),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
.describe('Where to insert relative to the anchor.'),
|
||||
anchorNodeId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Anchor block id (for before/after).'),
|
||||
anchorText: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Anchor text fragment (for before/after), matched against the ' +
|
||||
"block's literal rendered plain text (no markdown). " +
|
||||
'Markdown/emoji are tolerated as a fallback; prefer plain text ' +
|
||||
'or anchorNodeId.',
|
||||
),
|
||||
}),
|
||||
execute: async ({
|
||||
pageId,
|
||||
node,
|
||||
position,
|
||||
anchorNodeId,
|
||||
anchorText,
|
||||
}) => {
|
||||
// Parity with the standalone MCP server (index.ts insert_node): the
|
||||
// model sometimes serializes the node as a JSON string. Parse it
|
||||
// before the client's typeof-object guard rejects it.
|
||||
const parsedNode = parseNodeArg(node);
|
||||
return await client.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
@@ -731,7 +778,7 @@ export class AiChatToolsService {
|
||||
anchorText,
|
||||
});
|
||||
},
|
||||
),
|
||||
}),
|
||||
|
||||
deleteNode: sharedTool(
|
||||
sharedToolSpecs.deleteNode,
|
||||
@@ -774,10 +821,6 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
// 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 ' +
|
||||
@@ -798,8 +841,6 @@ export class AiChatToolsService {
|
||||
await client.tableInsertRow(pageId, tableRef, 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.',
|
||||
@@ -814,8 +855,6 @@ export class AiChatToolsService {
|
||||
await client.tableDeleteRow(pageId, tableRef, 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). ' +
|
||||
@@ -845,10 +884,6 @@ 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. ' +
|
||||
@@ -875,10 +910,6 @@ export class AiChatToolsService {
|
||||
async ({ historyId }) => await client.restorePageVersion(historyId),
|
||||
),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
|
||||
// `deleteComments` schema field (comment-deletion guardrail) and carries a
|
||||
// much shorter description; the standalone MCP `docmost_transform` exposes
|
||||
// the full helper catalogue. Different schema, so kept per-layer.
|
||||
transformPage: tool({
|
||||
description:
|
||||
'Run a sandboxed JS transform of the form `(doc, ctx) => doc` over a ' +
|
||||
|
||||
@@ -113,15 +113,9 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
|
||||
const expectedKeys = Object.keys(shape).sort();
|
||||
expect(actualKeys).toEqual(expectedKeys);
|
||||
|
||||
// A field that was NOT wrapped in `.optional()` must surface as required in
|
||||
// the advertised schema. We test for the ZodOptional wrapper rather than
|
||||
// `isOptional()`: `z.any()`/`z.unknown()` accept `undefined` and so report
|
||||
// `isOptional() === true`, yet z.toJSONSchema still lists them under
|
||||
// `required` (they carry no `.optional()`). Matching on the wrapper is what
|
||||
// the emitted JSON schema actually does, so it stays correct for the
|
||||
// registry's `node: z.any()` fields (patchNode/insertNode).
|
||||
// A non-.optional() field must surface as required in the advertised schema.
|
||||
const expectedRequired = Object.entries(shape)
|
||||
.filter(([, field]) => !(field instanceof z.ZodOptional))
|
||||
.filter(([, field]) => !(field as z.ZodTypeAny).isOptional?.())
|
||||
.map(([k]) => k)
|
||||
.sort();
|
||||
expect((json.required ?? []).slice().sort()).toEqual(expectedRequired);
|
||||
|
||||
+52
-34
@@ -76,10 +76,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(spaces);
|
||||
});
|
||||
// Tool: list_pages
|
||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
||||
// 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 " +
|
||||
@@ -147,10 +143,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(result);
|
||||
});
|
||||
// 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 " +
|
||||
@@ -167,8 +159,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(result);
|
||||
});
|
||||
// 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 " +
|
||||
@@ -184,8 +174,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(result);
|
||||
});
|
||||
// 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 " +
|
||||
@@ -329,17 +317,62 @@ export function createDocmostMcpServer(config) {
|
||||
},
|
||||
};
|
||||
});
|
||||
// Tool: patch_node — schema + description from the shared registry (identical
|
||||
// across both transports). The execute body keeps its own parseNodeArg
|
||||
// normalization (the model sometimes serializes `node` as a JSON string).
|
||||
registerShared(SHARED_TOOL_SPECS.patchNode, async ({ pageId, nodeId, node }) => {
|
||||
// Tool: patch_node
|
||||
server.registerTool("patch_node", {
|
||||
description: "Replaces a single block identified by its attrs.id WITHOUT resending the " +
|
||||
"whole document. Get the block id from get_page_json, then pass a " +
|
||||
"ProseMirror node to put in its place. Example node: a paragraph " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
|
||||
"update_page_json for one-block structural edits.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
nodeId: z.string().min(1),
|
||||
node: z
|
||||
.any()
|
||||
.describe("ProseMirror node to put in place of the node with this id, e.g. " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
"JSON object or JSON string both accepted."),
|
||||
},
|
||||
}, async ({ pageId, nodeId, node }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
return jsonContent(result);
|
||||
});
|
||||
// Tool: insert_node — schema + description from the shared registry. As with
|
||||
// patch_node, the execute body retains parseNodeArg on the incoming node.
|
||||
registerShared(SHARED_TOOL_SPECS.insertNode, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
// Tool: insert_node
|
||||
server.registerTool("insert_node", {
|
||||
description: "Insert a block before/after another block (by attrs.id or anchor text) " +
|
||||
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
|
||||
"resending the whole document. Can also insert table structure: to add a " +
|
||||
"tableRow, pass a tableRow node with position before/after and anchor " +
|
||||
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
|
||||
"anchorText matching the table; to add a tableCell/tableHeader, use " +
|
||||
"anchorNodeId of a block inside the target row (anchorText only resolves " +
|
||||
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
|
||||
"against the block's literal rendered plain text (no markdown); " +
|
||||
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
|
||||
"anchorNodeId. Note: append is top-level " +
|
||||
"only and rejects structural table nodes. Example node: a paragraph " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
"JSON object or a JSON string (both accepted).",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
node: z
|
||||
.any()
|
||||
.describe("ProseMirror node to insert, e.g. " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
"JSON object or JSON string both accepted."),
|
||||
position: z.enum(["before", "after", "append"]),
|
||||
anchorNodeId: z.string().optional(),
|
||||
anchorText: z.string().optional(),
|
||||
},
|
||||
}, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
@@ -420,10 +453,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(result);
|
||||
});
|
||||
// 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>.",
|
||||
@@ -510,9 +539,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(comments);
|
||||
});
|
||||
// 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 " +
|
||||
@@ -626,10 +652,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(result);
|
||||
});
|
||||
// Tool: search
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
||||
server.registerTool("search", {
|
||||
description: "Search for pages and content. Results are bounded by `limit` " +
|
||||
"(default applied by the client, max 100).",
|
||||
@@ -650,10 +672,6 @@ export function createDocmostMcpServer(config) {
|
||||
return jsonContent(result);
|
||||
});
|
||||
// Tool: docmost_transform
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
|
||||
// deliberately omits the `deleteComments` schema field (comment-deletion
|
||||
// guardrail) and carries a much shorter description; this transport exposes the
|
||||
// full helper catalogue. Different schema, so kept per-layer.
|
||||
server.registerTool("docmost_transform", {
|
||||
description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
|
||||
"against its LIVE ProseMirror document, with a diff preview and page " +
|
||||
|
||||
@@ -80,86 +80,6 @@ export const SHARED_TOOL_SPECS = {
|
||||
nodeId: z.string().min(1),
|
||||
}),
|
||||
},
|
||||
// --- single-block structural write (patch / insert) ---
|
||||
//
|
||||
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
|
||||
// the whole document" + "cheaper/safer than a full-document replace" guidance
|
||||
// AND the in-app copy's "keeps the same node id" + "Reversible via page
|
||||
// history" framing — nothing either side conveyed is dropped. Sibling tools are
|
||||
// named in transport-neutral prose ("the page-JSON view", "a full-document
|
||||
// replace") to match the rest of the registry, since the two layers expose
|
||||
// those siblings under different (snake_case vs camelCase) identifiers.
|
||||
patchNode: {
|
||||
mcpName: 'patch_node',
|
||||
inAppKey: 'patchNode',
|
||||
description: 'Replace a single content block identified by its attrs.id with a new ' +
|
||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
||||
'keeps the same node id. Get the block id from the page-JSON view, then ' +
|
||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
||||
nodeId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('attrs.id of the block to replace (from the page outline or ' +
|
||||
'page-JSON view)'),
|
||||
node: z
|
||||
.any()
|
||||
.describe('ProseMirror node to put in place of the node with this id, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.'),
|
||||
}),
|
||||
},
|
||||
insertNode: {
|
||||
mcpName: 'insert_node',
|
||||
inAppKey: 'insertNode',
|
||||
description: 'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||
'page-JSON view. Avoids resending the whole document. Can also insert ' +
|
||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
||||
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
|
||||
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
|
||||
"`anchorText` is matched against the block's literal rendered plain text " +
|
||||
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
|
||||
'text or anchorNodeId. Note: append is top-level only and rejects ' +
|
||||
'structural table nodes. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
node: z
|
||||
.any()
|
||||
.describe('ProseMirror node to insert, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.'),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
.describe('Where to insert relative to the anchor.'),
|
||||
anchorNodeId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Anchor block id (for before/after).'),
|
||||
anchorText: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Anchor text fragment (for before/after), matched against the " +
|
||||
"block's literal rendered plain text (no markdown). Markdown/emoji " +
|
||||
'are tolerated as a fallback; prefer plain text or anchorNodeId.'),
|
||||
}),
|
||||
},
|
||||
// --- share management ---
|
||||
unsharePage: {
|
||||
mcpName: 'unshare_page',
|
||||
|
||||
+62
-36
@@ -105,10 +105,6 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
||||
});
|
||||
|
||||
// Tool: list_pages
|
||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
||||
// 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",
|
||||
{
|
||||
@@ -199,10 +195,6 @@ 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",
|
||||
{
|
||||
@@ -230,8 +222,6 @@ 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",
|
||||
{
|
||||
@@ -253,8 +243,6 @@ 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",
|
||||
{
|
||||
@@ -457,11 +445,32 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: patch_node — schema + description from the shared registry (identical
|
||||
// across both transports). The execute body keeps its own parseNodeArg
|
||||
// normalization (the model sometimes serializes `node` as a JSON string).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.patchNode,
|
||||
// Tool: patch_node
|
||||
server.registerTool(
|
||||
"patch_node",
|
||||
{
|
||||
description:
|
||||
"Replaces a single block identified by its attrs.id WITHOUT resending the " +
|
||||
"whole document. Get the block id from get_page_json, then pass a " +
|
||||
"ProseMirror node to put in its place. Example node: a paragraph " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
"JSON object or a JSON string (both accepted). Cheaper and safer than " +
|
||||
"update_page_json for one-block structural edits.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
nodeId: z.string().min(1),
|
||||
node: z
|
||||
.any()
|
||||
.describe(
|
||||
"ProseMirror node to put in place of the node with this id, e.g. " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
"JSON object or JSON string both accepted.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ pageId, nodeId, node }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
@@ -469,10 +478,42 @@ registerShared(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: insert_node — schema + description from the shared registry. As with
|
||||
// patch_node, the execute body retains parseNodeArg on the incoming node.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.insertNode,
|
||||
// Tool: insert_node
|
||||
server.registerTool(
|
||||
"insert_node",
|
||||
{
|
||||
description:
|
||||
"Insert a block before/after another block (by attrs.id or anchor text) " +
|
||||
"or append at the end. Get anchor block ids from get_page_json. Avoids " +
|
||||
"resending the whole document. Can also insert table structure: to add a " +
|
||||
"tableRow, pass a tableRow node with position before/after and anchor " +
|
||||
"INSIDE the target table — anchorNodeId of any block/cell in it, or " +
|
||||
"anchorText matching the table; to add a tableCell/tableHeader, use " +
|
||||
"anchorNodeId of a block inside the target row (anchorText only resolves " +
|
||||
"top-level blocks, so it cannot target a row). `anchorText` is matched " +
|
||||
"against the block's literal rendered plain text (no markdown); " +
|
||||
"markdown/emoji are tolerated as a fallback; prefer plain text or " +
|
||||
"anchorNodeId. Note: append is top-level " +
|
||||
"only and rejects structural table nodes. Example node: a paragraph " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
"JSON object or a JSON string (both accepted).",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
node: z
|
||||
.any()
|
||||
.describe(
|
||||
"ProseMirror node to insert, e.g. " +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
"JSON object or JSON string both accepted.",
|
||||
),
|
||||
position: z.enum(["before", "after", "append"]),
|
||||
anchorNodeId: z.string().optional(),
|
||||
anchorText: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
@@ -578,10 +619,6 @@ 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",
|
||||
{
|
||||
@@ -709,9 +746,6 @@ 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",
|
||||
{
|
||||
@@ -877,10 +911,6 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: search
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
||||
server.registerTool(
|
||||
"search",
|
||||
{
|
||||
@@ -907,10 +937,6 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: docmost_transform
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
|
||||
// deliberately omits the `deleteComments` schema field (comment-deletion
|
||||
// guardrail) and carries a much shorter description; this transport exposes the
|
||||
// full helper catalogue. Different schema, so kept per-layer.
|
||||
server.registerTool(
|
||||
"docmost_transform",
|
||||
{
|
||||
|
||||
@@ -119,98 +119,6 @@ export const SHARED_TOOL_SPECS = {
|
||||
}),
|
||||
},
|
||||
|
||||
// --- single-block structural write (patch / insert) ---
|
||||
//
|
||||
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
|
||||
// the whole document" + "cheaper/safer than a full-document replace" guidance
|
||||
// AND the in-app copy's "keeps the same node id" + "Reversible via page
|
||||
// history" framing — nothing either side conveyed is dropped. Sibling tools are
|
||||
// named in transport-neutral prose ("the page-JSON view", "a full-document
|
||||
// replace") to match the rest of the registry, since the two layers expose
|
||||
// those siblings under different (snake_case vs camelCase) identifiers.
|
||||
patchNode: {
|
||||
mcpName: 'patch_node',
|
||||
inAppKey: 'patchNode',
|
||||
description:
|
||||
'Replace a single content block identified by its attrs.id with a new ' +
|
||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
||||
'keeps the same node id. Get the block id from the page-JSON view, then ' +
|
||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
||||
nodeId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'attrs.id of the block to replace (from the page outline or ' +
|
||||
'page-JSON view)',
|
||||
),
|
||||
node: z
|
||||
.any()
|
||||
.describe(
|
||||
'ProseMirror node to put in place of the node with this id, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
insertNode: {
|
||||
mcpName: 'insert_node',
|
||||
inAppKey: 'insertNode',
|
||||
description:
|
||||
'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||
'page-JSON view. Avoids resending the whole document. Can also insert ' +
|
||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
||||
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
|
||||
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
|
||||
"`anchorText` is matched against the block's literal rendered plain text " +
|
||||
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
|
||||
'text or anchorNodeId. Note: append is top-level only and rejects ' +
|
||||
'structural table nodes. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
node: z
|
||||
.any()
|
||||
.describe(
|
||||
'ProseMirror node to insert, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.',
|
||||
),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
.describe('Where to insert relative to the anchor.'),
|
||||
anchorNodeId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Anchor block id (for before/after).'),
|
||||
anchorText: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Anchor text fragment (for before/after), matched against the " +
|
||||
"block's literal rendered plain text (no markdown). Markdown/emoji " +
|
||||
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
// --- share management ---
|
||||
|
||||
unsharePage: {
|
||||
|
||||
@@ -83,63 +83,6 @@ test("getNode builder produces exactly { pageId, nodeId }", () => {
|
||||
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
|
||||
});
|
||||
|
||||
test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => {
|
||||
const spec = SHARED_TOOL_SPECS.patchNode;
|
||||
assert.ok(spec, "patchNode spec missing");
|
||||
assert.equal(spec.mcpName, "patch_node");
|
||||
assert.equal(spec.inAppKey, "patchNode");
|
||||
|
||||
// The canonical description must carry the key guidance from BOTH originals:
|
||||
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note.
|
||||
// - in-app-only: "keeps the same node id" + the "Reversible ... page history"
|
||||
// framing the MCP copy lacked.
|
||||
assert.match(spec.description, /WITHOUT resending the whole document/);
|
||||
assert.match(spec.description, /Cheaper and safer/);
|
||||
assert.match(spec.description, /keeps the same node id/i);
|
||||
assert.match(spec.description, /Reversible/i);
|
||||
assert.match(spec.description, /page history/i);
|
||||
|
||||
const shape = spec.buildShape(z);
|
||||
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]);
|
||||
// A minimal valid input parses (node accepts an arbitrary object via z.any()).
|
||||
const parsed = z.object(shape).parse({
|
||||
pageId: "p1",
|
||||
nodeId: "n1",
|
||||
node: { type: "paragraph" },
|
||||
});
|
||||
assert.equal(parsed.pageId, "p1");
|
||||
assert.equal(parsed.nodeId, "n1");
|
||||
});
|
||||
|
||||
test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => {
|
||||
const spec = SHARED_TOOL_SPECS.insertNode;
|
||||
assert.ok(spec, "insertNode spec missing");
|
||||
assert.equal(spec.mcpName, "insert_node");
|
||||
assert.equal(spec.inAppKey, "insertNode");
|
||||
|
||||
// Canonical description must keep BOTH sides' nuance:
|
||||
// - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible".
|
||||
// - MCP-only: the table-structure (tableRow/tableCell) insertion guidance.
|
||||
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
|
||||
assert.match(spec.description, /tableRow/);
|
||||
assert.match(spec.description, /append is top-level only/);
|
||||
assert.match(spec.description, /Reversible via page history/);
|
||||
|
||||
const shape = spec.buildShape(z);
|
||||
assert.deepEqual(
|
||||
Object.keys(shape).sort(),
|
||||
["anchorNodeId", "anchorText", "node", "pageId", "position"],
|
||||
);
|
||||
// before/after/append are the only accepted positions; anchors are optional.
|
||||
const schema = z.object(shape);
|
||||
assert.doesNotThrow(() =>
|
||||
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
|
||||
);
|
||||
assert.throws(() =>
|
||||
schema.parse({ pageId: "p1", node: {}, position: "sideways" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
|
||||
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
|
||||
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
|
||||
|
||||
Reference in New Issue
Block a user