Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce70fab1df | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| eebbe6717c | |||
| e348433a39 |
@@ -316,50 +316,27 @@ export class AiChatToolsService {
|
|||||||
execute: async () => resolveCurrentPageResult(openedPage),
|
execute: async () => resolveCurrentPageResult(openedPage),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's { title, markdown } projection.
|
||||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||||
'in the markdown are comment highlight anchors (also present for ' +
|
const result = await client.getPage(pageId);
|
||||||
'RESOLVED threads) — treat them as markup, not page text.',
|
const data = (result?.data ?? {}) as {
|
||||||
inputSchema: modelFriendlyInput({
|
title?: string;
|
||||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
content?: string;
|
||||||
}),
|
};
|
||||||
execute: async ({ pageId }) => {
|
return {
|
||||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
title: data.title ?? '',
|
||||||
const result = await client.getPage(pageId);
|
markdown: typeof data.content === 'string' ? data.content : '',
|
||||||
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) ---
|
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||||
|
|
||||||
createPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
createPage: sharedTool(
|
||||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
sharedToolSpecs.createPage,
|
||||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
'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 }) => {
|
|
||||||
// createPage(title, content, spaceId, parentPageId?) ->
|
// createPage(title, content, spaceId, parentPageId?) ->
|
||||||
// { data: filterPage(page, markdown), success }.
|
// { data: filterPage(page, markdown), success }.
|
||||||
const result = await client.createPage(
|
const result = await client.createPage(
|
||||||
@@ -375,7 +352,7 @@ export class AiChatToolsService {
|
|||||||
};
|
};
|
||||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
updatePageContent: tool({
|
updatePageContent: tool({
|
||||||
description:
|
description:
|
||||||
@@ -399,115 +376,46 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
renamePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
renamePage: sharedTool(
|
||||||
"Rename a page (change its title only; the body is untouched). " +
|
sharedToolSpecs.renamePage,
|
||||||
'Reversible: rename back at any time.',
|
async ({ pageId, title }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to rename.'),
|
|
||||||
title: z.string().describe('The new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, title }) => {
|
|
||||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||||
await client.renamePage(pageId, title);
|
await client.renamePage(pageId, title);
|
||||||
return { pageId, title };
|
return { pageId, title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
movePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The shared schema adds the optional `position` field this layer lacked
|
||||||
'Move a page under a new parent page, or to the space root when no ' +
|
// before; the execute now forwards it (the client already accepted it).
|
||||||
'parent is given. Reversible: move it back at any time.',
|
movePage: sharedTool(
|
||||||
inputSchema: modelFriendlyInput({
|
sharedToolSpecs.movePage,
|
||||||
pageId: z.string().describe('The id of the page to move.'),
|
async ({ pageId, parentPageId, position }) => {
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Target parent page id. Null/omitted moves the page to the ' +
|
|
||||||
'space root.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, parentPageId }) => {
|
|
||||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
// 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 };
|
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({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// This layer keeps only its own execute-side guards (require a selection
|
||||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
// for a top-level comment; reject suggestedText on a reply / without a
|
||||||
'page can be restored from trash). This NEVER permanently deletes.',
|
// selection) — the schema+description are shared.
|
||||||
inputSchema: modelFriendlyInput({
|
createComment: sharedTool(
|
||||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
sharedToolSpecs.createComment,
|
||||||
}),
|
async ({
|
||||||
// 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 ({
|
|
||||||
pageId,
|
pageId,
|
||||||
content,
|
content,
|
||||||
selection,
|
selection,
|
||||||
@@ -548,26 +456,17 @@ export class AiChatToolsService {
|
|||||||
const data = (result?.data ?? {}) as { id?: string };
|
const data = (result?.data ?? {}) as { id?: string };
|
||||||
return { commentId: data.id, pageId };
|
return { commentId: data.id, pageId };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
resolveComment: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
resolveComment: sharedTool(
|
||||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
sharedToolSpecs.resolveComment,
|
||||||
'the resolved flag). Only top-level comments can be resolved.',
|
async ({ commentId, 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 }) => {
|
|
||||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||||
await client.resolveComment(commentId, resolved);
|
await client.resolveComment(commentId, resolved);
|
||||||
return { commentId, resolved };
|
return { commentId, resolved };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- READ tools (added) ---
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
@@ -585,33 +484,12 @@ export class AiChatToolsService {
|
|||||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
// 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
|
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||||
// its own guidance.
|
// its own guidance.
|
||||||
listPages: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listPages: sharedTool(
|
||||||
'List the most recent pages, optionally scoped to a single space. ' +
|
sharedToolSpecs.listPages,
|
||||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
async ({ spaceId, limit, tree }) =>
|
||||||
"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 }) =>
|
|
||||||
await client.listPages(spaceId, limit, tree),
|
await client.listPages(spaceId, limit, tree),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listSidebarPages: tool({
|
listSidebarPages: tool({
|
||||||
description:
|
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({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
'matrix so cells can be addressed for rich edits).',
|
'matrix so cells can be addressed for rich edits).',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
tableRef: z
|
table: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
'"#<index>" from the page outline, or a block id of any node ' +
|
||||||
'the table.',
|
'inside the table.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId, tableRef }) =>
|
execute: async ({ pageId, table }) =>
|
||||||
await client.getTable(pageId, tableRef),
|
await client.getTable(pageId, table),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listComments: sharedTool(
|
||||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
sharedToolSpecs.listComments,
|
||||||
'threads are returned; resolved threads (a resolved top-level ' +
|
async ({ pageId, includeResolved }) =>
|
||||||
'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 }) =>
|
|
||||||
await client.listComments(pageId, includeResolved),
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
description: 'Fetch a single comment by id (content as Markdown).',
|
description: 'Fetch a single comment by id (content as Markdown).',
|
||||||
@@ -700,26 +571,12 @@ export class AiChatToolsService {
|
|||||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
checkNewComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
checkNewComments: sharedTool(
|
||||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
sharedToolSpecs.checkNewComments,
|
||||||
'created after a given timestamp.',
|
async ({ spaceId, since, parentPageId }) =>
|
||||||
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 }) =>
|
|
||||||
await client.checkNewComments(spaceId, since, parentPageId),
|
await client.checkNewComments(spaceId, since, parentPageId),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listShares: sharedTool(
|
listShares: sharedTool(
|
||||||
sharedToolSpecs.listShares,
|
sharedToolSpecs.listShares,
|
||||||
@@ -749,19 +606,14 @@ export class AiChatToolsService {
|
|||||||
await client.diffPageVersions(pageId, from, to),
|
await client.diffPageVersions(pageId, from, to),
|
||||||
),
|
),
|
||||||
|
|
||||||
exportPageMarkdown: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
exportPageMarkdown: sharedTool(
|
||||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
sharedToolSpecs.exportPageMarkdown,
|
||||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
async ({ pageId }) => {
|
||||||
'with importPageMarkdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to export.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
const markdown = await client.exportPageMarkdown(pageId);
|
const markdown = await client.exportPageMarkdown(pageId);
|
||||||
return { markdown };
|
return { markdown };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
@@ -811,28 +663,12 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
updatePageJson: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's content normalization (parity with
|
||||||
"Replace a page's body with a full ProseMirror document — a full " +
|
// the standalone MCP server, index.ts update_page_json).
|
||||||
'overwrite — and/or update its title. Minimal example content: ' +
|
updatePageJson: sharedTool(
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
sharedToolSpecs.updatePageJson,
|
||||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
async ({ pageId, content, title }) => {
|
||||||
'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):
|
|
||||||
// undefined/null pass through as undefined (title-only / no-op); any
|
// undefined/null pass through as undefined (title-only / no-op); any
|
||||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||||
// MCP server); an object is passed through unchanged.
|
// MCP server); an object is passed through unchanged.
|
||||||
@@ -845,66 +681,29 @@ export class AiChatToolsService {
|
|||||||
}
|
}
|
||||||
return await client.updatePageJson(pageId, doc, title);
|
return await client.updatePageJson(pageId, doc, title);
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// NOT in the shared registry: this layer names the table argument
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||||
// Sharing one buildShape would rename a model-facing parameter on one
|
tableInsertRow: sharedTool(
|
||||||
// transport, so the table row/cell tools stay per-layer by design.
|
sharedToolSpecs.tableInsertRow,
|
||||||
tableInsertRow: tool({
|
async ({ pageId, table, cells, index }) =>
|
||||||
description:
|
await client.tableInsertRow(pageId, table, cells, index),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableDeleteRow: sharedTool(
|
||||||
tableDeleteRow: tool({
|
sharedToolSpecs.tableDeleteRow,
|
||||||
description:
|
async ({ pageId, table, index }) =>
|
||||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
await client.tableDeleteRow(pageId, table, index),
|
||||||
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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableUpdateCell: sharedTool(
|
||||||
tableUpdateCell: tool({
|
sharedToolSpecs.tableUpdateCell,
|
||||||
description:
|
async ({ pageId, table, row, col, text }) =>
|
||||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
copyPageContent: sharedTool(
|
copyPageContent: sharedTool(
|
||||||
sharedToolSpecs.copyPageContent,
|
sharedToolSpecs.copyPageContent,
|
||||||
@@ -918,25 +717,14 @@ export class AiChatToolsService {
|
|||||||
await client.importPageMarkdown(pageId, markdown),
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
),
|
),
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// confirmation framing ("Only share when the user explicitly asked, since
|
// Both layers already carried the security-confirmation framing, so there
|
||||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
// was no real divergence to preserve — only wording drift.
|
||||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
sharePage: sharedTool(
|
||||||
sharePage: tool({
|
sharedToolSpecs.sharePage,
|
||||||
description:
|
async ({ pageId, searchIndexing }) =>
|
||||||
'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 }) =>
|
|
||||||
await client.sharePage(pageId, searchIndexing),
|
await client.sharePage(pageId, searchIndexing),
|
||||||
}),
|
),
|
||||||
|
|
||||||
unsharePage: sharedTool(
|
unsharePage: sharedTool(
|
||||||
sharedToolSpecs.unsharePage,
|
sharedToolSpecs.unsharePage,
|
||||||
|
|||||||
@@ -100,54 +100,26 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
},
|
},
|
||||||
getPage: {
|
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
tier: 'core',
|
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
// NOTE: createComment, listComments and resolveComment moved to
|
||||||
},
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||||
listPages: {
|
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||||
tier: 'core',
|
// n/a — it simply has no shared spec).
|
||||||
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).',
|
|
||||||
},
|
|
||||||
getComment: {
|
getComment: {
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getComment — fetch a single comment by id.',
|
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 ---
|
// --- deferred inline ---
|
||||||
createPage: {
|
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
|
||||||
tier: 'deferred',
|
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
|
||||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
// carry their own deferred tier + catalogLine there.
|
||||||
},
|
|
||||||
updatePageContent: {
|
updatePageContent: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
"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: {
|
listSidebarPages: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -157,42 +129,21 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
},
|
},
|
||||||
checkNewComments: {
|
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||||
tier: 'deferred',
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||||
catalogLine:
|
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
||||||
'checkNewComments — find comments in a space created after a timestamp.',
|
// 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: {
|
getPageHistory: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
},
|
},
|
||||||
exportPageMarkdown: {
|
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||||
tier: 'deferred',
|
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||||
catalogLine:
|
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
// docmost_transform exposes, a comment-deletion guardrail).
|
||||||
},
|
|
||||||
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.',
|
|
||||||
},
|
|
||||||
transformPage: {
|
transformPage: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
|||||||
+83
-355
@@ -118,56 +118,19 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
// 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.
|
// 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.
|
// Kept per-layer so each side can tune its own guidance.
|
||||||
server.registerTool(
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
|
||||||
"list_pages",
|
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
|
||||||
{
|
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
|
||||||
description:
|
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
||||||
"List most recent pages in a space ordered by updatedAt (descending). " +
|
return jsonContent(result);
|
||||||
"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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_page
|
// Tool: get_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"get_page",
|
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
|
||||||
{
|
const page = await docmostClient.getPage(pageId);
|
||||||
description:
|
return jsonContent(page);
|
||||||
"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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_page_json
|
// Tool: get_page_json
|
||||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||||
@@ -201,6 +164,10 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_get
|
// 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(
|
server.registerTool(
|
||||||
"table_get",
|
"table_get",
|
||||||
{
|
{
|
||||||
@@ -223,25 +190,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_insert_row
|
// Tool: table_insert_row
|
||||||
// NOT in the shared registry: this transport names the table argument `table`,
|
// Schema + description now live in the shared registry (#294); the `table`
|
||||||
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
|
// parameter name is the canonical one (the in-app layer was unified to it).
|
||||||
// one buildShape would rename a public MCP parameter, so the table row/cell
|
registerShared(
|
||||||
// tools stay per-transport by design.
|
SHARED_TOOL_SPECS.tableInsertRow,
|
||||||
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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, cells, index }) => {
|
async ({ pageId, table, cells, index }) => {
|
||||||
const result = await docmostClient.tableInsertRow(
|
const result = await docmostClient.tableInsertRow(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -254,22 +206,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_delete_row
|
// Tool: table_delete_row
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
// Schema + description now live in the shared registry (#294).
|
||||||
// divergence as table_insert_row.
|
registerShared(
|
||||||
server.registerTool(
|
SHARED_TOOL_SPECS.tableDeleteRow,
|
||||||
"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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, index }) => {
|
async ({ pageId, table, index }) => {
|
||||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -277,24 +216,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_update_cell
|
// Tool: table_update_cell
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
// Schema + description now live in the shared registry (#294).
|
||||||
// divergence as table_insert_row.
|
registerShared(
|
||||||
server.registerTool(
|
SHARED_TOOL_SPECS.tableUpdateCell,
|
||||||
"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(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, row, col, text }) => {
|
async ({ pageId, table, row, col, text }) => {
|
||||||
const result = await docmostClient.tableUpdateCell(
|
const result = await docmostClient.tableUpdateCell(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -308,22 +232,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: create_page
|
// Tool: create_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"create_page",
|
registerShared(
|
||||||
{
|
SHARED_TOOL_SPECS.createPage,
|
||||||
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"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ title, content, spaceId, parentPageId }) => {
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
const result = await docmostClient.createPage(
|
const result = await docmostClient.createPage(
|
||||||
title,
|
title,
|
||||||
@@ -336,32 +247,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: update_page_json
|
// Tool: update_page_json
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"update_page_json",
|
// keeps this transport's content normalization (parse a JSON-string content,
|
||||||
{
|
// pass undefined/null through for a title-only/no-op update).
|
||||||
description:
|
registerShared(
|
||||||
"Replace a page's content with a raw ProseMirror JSON document " +
|
SHARED_TOOL_SPECS.updatePageJson,
|
||||||
"(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"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, content, title }) => {
|
async ({ pageId, content, title }) => {
|
||||||
// Only parse/validate the document when it was actually supplied; when it
|
// 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
|
// is omitted, pass it straight through so the client performs a title-only
|
||||||
@@ -379,26 +269,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: export_page_markdown
|
// Tool: export_page_markdown
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"export_page_markdown",
|
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
|
||||||
{
|
const md = await docmostClient.exportPageMarkdown(pageId);
|
||||||
description:
|
return { content: [{ type: "text" as const, text: md }] };
|
||||||
"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 }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: import_page_markdown
|
// Tool: import_page_markdown
|
||||||
registerShared(
|
registerShared(
|
||||||
@@ -422,22 +297,11 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: rename_page
|
// Tool: rename_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"rename_page",
|
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
|
||||||
{
|
const result = await docmostClient.renamePage(pageId, title);
|
||||||
description:
|
return jsonContent(result);
|
||||||
"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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: edit_page_text
|
// Tool: edit_page_text
|
||||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||||
@@ -516,6 +380,10 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: insert_image
|
// 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(
|
server.registerTool(
|
||||||
"insert_image",
|
"insert_image",
|
||||||
{
|
{
|
||||||
@@ -561,6 +429,7 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: replace_image
|
// Tool: replace_image
|
||||||
|
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"replace_image",
|
"replace_image",
|
||||||
{
|
{
|
||||||
@@ -603,25 +472,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: share_page
|
// Tool: share_page
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
// security-confirmation framing ("only share when the user explicitly asked,
|
// keeps this transport's own `searchIndexing ?? true` default.
|
||||||
// since this exposes the page to anyone with the link") tuned for the in-app
|
registerShared(
|
||||||
// agent; this transport keeps the plain public-URL wording.
|
SHARED_TOOL_SPECS.sharePage,
|
||||||
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)"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, searchIndexing }) => {
|
async ({ pageId, searchIndexing }) => {
|
||||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -641,29 +495,11 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: move_page
|
// Tool: move_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"move_page",
|
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
|
||||||
{
|
// its positive-confirmation check on the move response.
|
||||||
description:
|
registerShared(
|
||||||
"Move a page under a new parent (nesting) or to the space root.",
|
SHARED_TOOL_SPECS.movePage,
|
||||||
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.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, parentPageId, position }) => {
|
async ({ pageId, parentPageId, position }) => {
|
||||||
const finalParentId =
|
const finalParentId =
|
||||||
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
||||||
@@ -698,49 +534,22 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: delete_page
|
// Tool: delete_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The shared schema
|
||||||
"delete_page",
|
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
|
||||||
{
|
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
|
||||||
description:
|
await docmostClient.deletePage(pageId);
|
||||||
"Delete a single page by ID. SOFT delete only: the page is moved to " +
|
return {
|
||||||
"trash and can be restored; nothing is permanently deleted.",
|
content: [
|
||||||
inputSchema: {
|
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
||||||
pageId: z.string().min(1),
|
],
|
||||||
},
|
};
|
||||||
},
|
});
|
||||||
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) ---
|
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
||||||
|
|
||||||
// Tool: list_comments
|
// Tool: list_comments
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"list_comments",
|
SHARED_TOOL_SPECS.listComments,
|
||||||
{
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, includeResolved }) => {
|
async ({ pageId, includeResolved }) => {
|
||||||
const comments = await docmostClient.listComments(pageId, includeResolved);
|
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||||
return jsonContent(comments);
|
return jsonContent(comments);
|
||||||
@@ -748,55 +557,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: create_comment
|
// Tool: create_comment
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
// keeps this transport's own guards (require a selection for a top-level
|
||||||
// and "Reversible via the comment UI"); this transport keeps its own wording.
|
// comment; reject suggestedText on a reply / without a selection).
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"create_comment",
|
SHARED_TOOL_SPECS.createComment,
|
||||||
{
|
|
||||||
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.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -872,28 +637,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: resolve_comment
|
// Tool: resolve_comment
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"resolve_comment",
|
registerShared(
|
||||||
{
|
SHARED_TOOL_SPECS.resolveComment,
|
||||||
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",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ commentId, resolved }) => {
|
async ({ commentId, resolved }) => {
|
||||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
const result = await docmostClient.resolveComment(commentId, resolved);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -901,30 +647,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: check_new_comments
|
// Tool: check_new_comments
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"check_new_comments",
|
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
|
||||||
{
|
registerShared(
|
||||||
description:
|
SHARED_TOOL_SPECS.checkNewComments,
|
||||||
"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.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ spaceId, since, parentPageId }) => {
|
async ({ spaceId, since, parentPageId }) => {
|
||||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
// Reject an unparseable timestamp up front: otherwise the comparison
|
||||||
// against NaN silently treats every comment as "not new" and the tool
|
// against NaN silently treats every comment as "not new" and the tool
|
||||||
@@ -1053,6 +779,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: insert_footnote
|
// 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(
|
server.registerTool(
|
||||||
"insert_footnote",
|
"insert_footnote",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -316,6 +316,34 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
|
|
||||||
// --- share management ---
|
// --- 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: {
|
unsharePage: {
|
||||||
mcpName: 'unshare_page',
|
mcpName: 'unshare_page',
|
||||||
inAppKey: 'unsharePage',
|
inAppKey: 'unsharePage',
|
||||||
@@ -509,4 +537,470 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
pageId: z.string().min(1),
|
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 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
|
||||||
|
// 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>;
|
} satisfies Record<string, SharedToolSpec>;
|
||||||
|
|||||||
Reference in New Issue
Block a user