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>
This commit is contained in:
@@ -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,60 +376,37 @@ 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 };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
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 };
|
||||
},
|
||||
// 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 };
|
||||
}),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
@@ -530,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:
|
||||
@@ -673,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) ---
|
||||
|
||||
@@ -735,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.
|
||||
@@ -769,7 +681,7 @@ export class AiChatToolsService {
|
||||
}
|
||||
return await client.updatePageJson(pageId, doc, title);
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||
|
||||
@@ -100,14 +100,8 @@ 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.",
|
||||
},
|
||||
// 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
|
||||
@@ -118,27 +112,14 @@ export const INLINE_TOOL_TIERS: Record<
|
||||
},
|
||||
|
||||
// --- 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:
|
||||
@@ -159,16 +140,6 @@ export const INLINE_TOOL_TIERS: Record<
|
||||
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.",
|
||||
},
|
||||
sharePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||
|
||||
+44
-168
@@ -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 }) => {
|
||||
@@ -270,22 +233,9 @@ registerShared(
|
||||
);
|
||||
|
||||
// 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,
|
||||
@@ -298,32 +248,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
|
||||
@@ -341,26 +270,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(
|
||||
@@ -384,22 +298,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 }) => {
|
||||
@@ -603,29 +506,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;
|
||||
@@ -660,25 +545,16 @@ 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) ---
|
||||
|
||||
|
||||
@@ -510,6 +510,211 @@ export const SHARED_TOOL_SPECS = {
|
||||
}),
|
||||
},
|
||||
|
||||
// --- 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 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.',
|
||||
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: 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. 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 -> 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.',
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user