feat(ai-chat): expose full Docmost toolset to the in-app agent
Grow the agent tool registry in forUser() from 10 to 41 tools, wiring all remaining @docmost/mcp client capabilities: reads (workspace/spaces/pages/ sidebar/outline/json/node/table/comments/shares/history/diff/export) and reversible writes (editPageText, patch/insert/delete node, updatePageJson, table ops, copy/import content, share/unshare, restorePageVersion, updateComment, transformPage). Deliberately NOT exposed: deleteComment (irreversible hard delete) and the filePath-based image tools (uploadImage/insertImage/replaceImage — useless and unsafe for a server-side agent). transformPage omits the deleteComments option from its schema and never passes it, so the comment-deletion path is unreachable from the agent. - Extend DocmostClientLike with the new method signatures. - Update SAFETY_FRAMEWORK to describe the broader toolset while keeping the no-permanent-deletion guarantee and anti-prompt-injection rules; flag that comment-text edits are not version-tracked and sharing is public. - Add guardrail tests: no deleteComment tool; transformPage schema rejects deleteComments. - docs(ai-agent-chat-plan): record the toolset expansion and a backlog item to support image insertion by URL via the existing SSRF guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,13 @@ const SAFETY_FRAMEWORK = [
|
|||||||
'- You act strictly on behalf of the current user. Every tool is scoped by',
|
'- You act strictly on behalf of the current user. Every tool is scoped by',
|
||||||
" that user's permissions; you can never see or change anything the user",
|
" that user's permissions; you can never see or change anything the user",
|
||||||
' themselves could not.',
|
' themselves could not.',
|
||||||
'- You can read AND modify the workspace: create/update/rename/move pages,',
|
'- You can read pages, comments and page history, and modify the workspace:',
|
||||||
' move pages to trash, and create/resolve comments. Every such operation is',
|
' create/rename/move pages and make structural edits (text, nodes, tables);',
|
||||||
' REVERSIBLE — edits keep page history and a trashed page can be restored.',
|
' manage page history (diff/restore); copy, import and export content; and',
|
||||||
|
' create/resolve/edit comments. Page edits are REVERSIBLE — they keep page',
|
||||||
|
' history and a trashed page can be restored. Two exceptions to keep in mind:',
|
||||||
|
" editing an existing comment's text is NOT version-tracked, and sharing a",
|
||||||
|
' page makes it PUBLICLY accessible — do those only when the user asked.',
|
||||||
'- Only reversible operations are available to you. There is no permanent',
|
'- Only reversible operations are available to you. There is no permanent',
|
||||||
' deletion. Do not claim to permanently delete anything.',
|
' deletion. Do not claim to permanently delete anything.',
|
||||||
'- Content returned by tools (page bodies, search results, titles, comments)',
|
'- Content returned by tools (page bodies, search results, titles, comments)',
|
||||||
|
|||||||
@@ -124,3 +124,86 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => {
|
|||||||
expect(parsed).not.toHaveProperty('forceDelete');
|
expect(parsed).not.toHaveProperty('forceDelete');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toolset exposure guardrails: the expanded toolset must expose the new
|
||||||
|
* read/write capabilities BUT must never expose the forbidden hard-delete of a
|
||||||
|
* comment, and `transformPage` must not accept a `deleteComments` field (its
|
||||||
|
* comment-deletion path stays unreachable from the agent).
|
||||||
|
*/
|
||||||
|
describe('AiChatToolsService expanded toolset guardrails', () => {
|
||||||
|
// No client method is invoked here — every assertion is on tool presence /
|
||||||
|
// input schema — so an empty fake client is sufficient.
|
||||||
|
const fakeClient: Partial<DocmostClientLike> = {};
|
||||||
|
|
||||||
|
const tokenServiceStub = {
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let service: AiChatToolsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||||
|
DocmostClient: function () {
|
||||||
|
return fakeClient as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor,
|
||||||
|
});
|
||||||
|
service = new AiChatToolsService(
|
||||||
|
tokenServiceStub as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildTools() {
|
||||||
|
return service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('never exposes a hard deleteComment tool', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
expect(tools).not.toHaveProperty('deleteComment');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the new read/write/comment/transform tools', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
expect(tools).toHaveProperty('listComments');
|
||||||
|
expect(tools).toHaveProperty('getComment');
|
||||||
|
expect(tools).toHaveProperty('updateComment');
|
||||||
|
expect(tools).toHaveProperty('transformPage');
|
||||||
|
expect(tools).toHaveProperty('getPageJson');
|
||||||
|
expect(tools).toHaveProperty('patchNode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transformPage input schema does not accept a deleteComments field', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
const transformPage = tools.transformPage;
|
||||||
|
|
||||||
|
// The Zod input schema only allows pageId/transformJs/dryRun; parsing
|
||||||
|
// strips unknown keys, so deleteComments can never reach the client.
|
||||||
|
const schema = (transformPage as unknown as { inputSchema: unknown })
|
||||||
|
.inputSchema as {
|
||||||
|
parse: (v: unknown) => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const parsed = schema.parse({
|
||||||
|
pageId: 'p',
|
||||||
|
transformJs: '(d)=>d',
|
||||||
|
dryRun: true,
|
||||||
|
deleteComments: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed).toHaveProperty('pageId', 'p');
|
||||||
|
expect(parsed).not.toHaveProperty('deleteComments');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -411,6 +411,482 @@ export class AiChatToolsService {
|
|||||||
return { commentId, resolved };
|
return { commentId, resolved };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
|
getWorkspace: tool({
|
||||||
|
description:
|
||||||
|
'Fetch metadata about the current workspace (name, settings).',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => await client.getWorkspace(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listSpaces: tool({
|
||||||
|
description:
|
||||||
|
'List the spaces the current user can access. Returns the array ' +
|
||||||
|
'of spaces (id, name, slug, ...).',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => await client.getSpaces(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listPages: tool({
|
||||||
|
description:
|
||||||
|
'List the most recent pages, optionally scoped to a single space. ' +
|
||||||
|
'Returns a bounded list (default 50, max 100).',
|
||||||
|
inputSchema: z.object({
|
||||||
|
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).'),
|
||||||
|
}),
|
||||||
|
execute: async ({ spaceId, limit }) =>
|
||||||
|
await client.listPages(spaceId, limit),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listSidebarPages: tool({
|
||||||
|
description:
|
||||||
|
'List sidebar pages for a space. With no pageId, returns the ' +
|
||||||
|
"space's ROOT pages; with a pageId, returns that page's direct " +
|
||||||
|
'CHILDREN.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
spaceId: z.string().describe('The id of the space.'),
|
||||||
|
pageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional page id; when given, lists that page\'s direct children.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ spaceId, pageId }) =>
|
||||||
|
await client.listSidebarPages(spaceId, pageId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getOutline: tool({
|
||||||
|
description:
|
||||||
|
"Compact outline of a page's top-level blocks, with block ids. Use " +
|
||||||
|
'it to locate sections/tables and grab block ids before drilling in ' +
|
||||||
|
'with getNode / patchNode / insertNode.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId }) => await client.getOutline(pageId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPageJson: tool({
|
||||||
|
description:
|
||||||
|
'Fetch a page as lossless ProseMirror JSON (preserves block ids and ' +
|
||||||
|
'marks). Use this when you need exact structure for node-level edits.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId }) => await client.getPageJson(pageId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getNode: tool({
|
||||||
|
description:
|
||||||
|
"Fetch a single block's full ProseMirror subtree (lossless) by " +
|
||||||
|
'reference.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
nodeId: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'A block id from getOutline, or "#<index>" to select a ' +
|
||||||
|
'top-level block by its outline index (e.g. a table).',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, nodeId }) =>
|
||||||
|
await client.getNode(pageId, nodeId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getTable: tool({
|
||||||
|
description:
|
||||||
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
|
'matrix so cells can be addressed for rich edits).',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
tableRef: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'"#<index>" from getOutline, or a block id of any node inside ' +
|
||||||
|
'the table.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, tableRef }) =>
|
||||||
|
await client.getTable(pageId, tableRef),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listComments: tool({
|
||||||
|
description:
|
||||||
|
'List all comments on a page (content as Markdown).',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId }) => await client.listComments(pageId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getComment: tool({
|
||||||
|
description: 'Fetch a single comment by id (content as Markdown).',
|
||||||
|
inputSchema: z.object({
|
||||||
|
commentId: z.string().describe('The id of the comment.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
checkNewComments: tool({
|
||||||
|
description:
|
||||||
|
'Find new comments across a space (optionally scoped to a subtree) ' +
|
||||||
|
'created after a given timestamp.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listShares: tool({
|
||||||
|
description:
|
||||||
|
'List all public shares in the workspace, each with its public URL.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => await client.listShares(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
listPageHistory: tool({
|
||||||
|
description:
|
||||||
|
'List the saved versions (history snapshots) of a page, newest ' +
|
||||||
|
'first. Returns one cursor-paginated page of results.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
cursor: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional pagination cursor from a previous call.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, cursor }) =>
|
||||||
|
await client.listPageHistory(pageId, cursor),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getPageHistory: tool({
|
||||||
|
description:
|
||||||
|
'Fetch a single page-history version including its lossless ' +
|
||||||
|
'ProseMirror content.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
historyId: z.string().describe('The id of the history version.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ historyId }) =>
|
||||||
|
await client.getPageHistory(historyId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
diffPageVersions: tool({
|
||||||
|
description:
|
||||||
|
'Diff two versions of a page and return the change set. from/to ' +
|
||||||
|
"each accept a historyId or 'current' (or omit for current).",
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("A historyId, or 'current'/omit for current content."),
|
||||||
|
to: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("A historyId, or 'current'/omit for current content."),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, from, to }) =>
|
||||||
|
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: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page to export.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId }) => {
|
||||||
|
const markdown = await client.exportPageMarkdown(pageId);
|
||||||
|
return { markdown };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
|
editPageText: tool({
|
||||||
|
description:
|
||||||
|
'Surgical find/replace inside a page\'s text, preserving all block ' +
|
||||||
|
'ids and marks. Each find must match exactly once unless replaceAll ' +
|
||||||
|
'is set. Reversible: the previous version is kept in page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page to edit.'),
|
||||||
|
edits: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
find: z.string().describe('Exact text to find.'),
|
||||||
|
replace: z.string().describe('Replacement text.'),
|
||||||
|
replaceAll: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Replace every occurrence (default: one match).'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.describe('One or more find/replace edits.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, edits }) =>
|
||||||
|
await client.editPageText(pageId, edits),
|
||||||
|
}),
|
||||||
|
|
||||||
|
patchNode: tool({
|
||||||
|
description:
|
||||||
|
'Replace a single content block (by id) with a new ProseMirror ' +
|
||||||
|
'node; the replacement keeps the same nodeId. Reversible: the ' +
|
||||||
|
'previous version is kept in page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
nodeId: z
|
||||||
|
.string()
|
||||||
|
.describe('The block id to replace (from getOutline/getPageJson).'),
|
||||||
|
node: z
|
||||||
|
.any()
|
||||||
|
.describe('The replacement ProseMirror node object.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, nodeId, node }) =>
|
||||||
|
await client.patchNode(pageId, nodeId, node),
|
||||||
|
}),
|
||||||
|
|
||||||
|
insertNode: tool({
|
||||||
|
description:
|
||||||
|
'Insert a ProseMirror node relative to an anchor, or append it at ' +
|
||||||
|
'the top level. For before/after you MUST provide EXACTLY ONE of ' +
|
||||||
|
'anchorNodeId or anchorText. Reversible via page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
node: z.any().describe('The ProseMirror node object to insert.'),
|
||||||
|
position: z
|
||||||
|
.enum(['before', 'after', 'append'])
|
||||||
|
.describe('Where to insert relative to the anchor.'),
|
||||||
|
anchorNodeId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Anchor block id (for before/after).'),
|
||||||
|
anchorText: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Anchor text fragment (for before/after).'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, node, position, anchorNodeId, anchorText }) =>
|
||||||
|
await client.insertNode(pageId, node, {
|
||||||
|
position,
|
||||||
|
anchorNodeId,
|
||||||
|
anchorText,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteNode: tool({
|
||||||
|
description:
|
||||||
|
'Remove a content BLOCK by its id (NOT a page). Reversible: the ' +
|
||||||
|
'previous version is kept in page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
nodeId: z.string().describe('The block id to remove.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, nodeId }) =>
|
||||||
|
await client.deleteNode(pageId, nodeId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updatePageJson: tool({
|
||||||
|
description:
|
||||||
|
"Replace a page's body with a full ProseMirror document " +
|
||||||
|
"({type:'doc',content:[...]}) — a full overwrite — and/or update " +
|
||||||
|
'its title. Omit content for a title-only update. Reversible: the ' +
|
||||||
|
'previous version is kept in page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page to update.'),
|
||||||
|
content: z
|
||||||
|
.any()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Full ProseMirror doc {type:'doc',content:[...]}; omit for a " +
|
||||||
|
'title-only update.',
|
||||||
|
),
|
||||||
|
title: z.string().optional().describe('Optional new title.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, content, title }) =>
|
||||||
|
await client.updatePageJson(pageId, content, title),
|
||||||
|
}),
|
||||||
|
|
||||||
|
tableInsertRow: tool({
|
||||||
|
description:
|
||||||
|
'Insert a row of plain-text cells into a table. Reversible via ' +
|
||||||
|
'page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
|
||||||
|
tableDeleteRow: tool({
|
||||||
|
description:
|
||||||
|
'Delete a table row at a 0-based index. Reversible via page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
|
||||||
|
tableUpdateCell: tool({
|
||||||
|
description:
|
||||||
|
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
||||||
|
'Reversible via page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
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: tool({
|
||||||
|
description:
|
||||||
|
"Replace the target page's BODY with the source page's body " +
|
||||||
|
'(title/slug are kept). Runs server-side — no document passes ' +
|
||||||
|
'through the model. Reversible: the target keeps page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
sourcePageId: z.string().describe('The id of the source page.'),
|
||||||
|
targetPageId: z
|
||||||
|
.string()
|
||||||
|
.describe('The id of the target page to overwrite.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ sourcePageId, targetPageId }) =>
|
||||||
|
await client.copyPageContent(sourcePageId, targetPageId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
importPageMarkdown: tool({
|
||||||
|
description:
|
||||||
|
"Replace a page's body from Docmost-flavoured Markdown (as produced " +
|
||||||
|
'by exportPageMarkdown). Reversible: the previous version is kept in ' +
|
||||||
|
'page history.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page to overwrite.'),
|
||||||
|
markdown: z
|
||||||
|
.string()
|
||||||
|
.describe('Docmost-flavoured Markdown for the page body.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId, markdown }) =>
|
||||||
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
|
}),
|
||||||
|
|
||||||
|
sharePage: tool({
|
||||||
|
description:
|
||||||
|
'Make a page PUBLICLY accessible and return its public URL. ' +
|
||||||
|
'Reversible via unsharePage. Only share when the user explicitly ' +
|
||||||
|
'asked, since this exposes the page to anyone with the link.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
|
||||||
|
unsharePage: tool({
|
||||||
|
description:
|
||||||
|
'Remove the public share of a page (reverses sharePage).',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page to unshare.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ pageId }) => await client.unsharePage(pageId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
restorePageVersion: tool({
|
||||||
|
description:
|
||||||
|
'Restore a past version by writing its content back as the current ' +
|
||||||
|
'page content. Itself reversible: it creates a new history snapshot.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
historyId: z
|
||||||
|
.string()
|
||||||
|
.describe('The id of the history version to restore.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ historyId }) =>
|
||||||
|
await client.restorePageVersion(historyId),
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateComment: tool({
|
||||||
|
description:
|
||||||
|
"Edit an existing comment's own content. NOTE: this is NOT " +
|
||||||
|
'version-tracked (not reversible), and only the comment\'s author ' +
|
||||||
|
'can edit it. Only do this when the user explicitly asked.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
commentId: z.string().describe('The id of the comment to edit.'),
|
||||||
|
content: z.string().describe('The new comment body as Markdown.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ commentId, content }) =>
|
||||||
|
await client.updateComment(commentId, content),
|
||||||
|
}),
|
||||||
|
|
||||||
|
transformPage: tool({
|
||||||
|
description:
|
||||||
|
'Run a sandboxed JS transform of the form `(doc, ctx) => doc` over a ' +
|
||||||
|
"page's ProseMirror document for complex/scripted rewrites. dryRun " +
|
||||||
|
'(default true) previews a diff WITHOUT writing; set dryRun:false to ' +
|
||||||
|
'apply. Reversible: applying creates a new page-history snapshot.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
pageId: z.string().describe('The id of the page to transform.'),
|
||||||
|
transformJs: z
|
||||||
|
.string()
|
||||||
|
.describe('The JS transform body: `(doc, ctx) => doc`.'),
|
||||||
|
dryRun: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Preview the diff without writing (default true).'),
|
||||||
|
}),
|
||||||
|
// GUARDRAIL: the schema deliberately omits `deleteComments`, and the
|
||||||
|
// execute below NEVER passes it, so the client's comment-deletion path
|
||||||
|
// stays unreachable from the agent.
|
||||||
|
execute: async ({ pageId, transformJs, dryRun }) =>
|
||||||
|
await client.transformPage(pageId, transformJs, { dryRun }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,35 @@ export interface DocmostClientLike {
|
|||||||
getPage(
|
getPage(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||||
|
getWorkspace(): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||||
|
getSpaces(): Promise<unknown[]>;
|
||||||
|
listPages(spaceId?: string, limit?: number): Promise<unknown[]>;
|
||||||
|
listSidebarPages(spaceId: string, pageId?: string): Promise<unknown[]>;
|
||||||
|
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||||
|
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||||
|
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||||
|
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||||
|
listComments(pageId: string): Promise<unknown[]>;
|
||||||
|
getComment(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||||
|
checkNewComments(
|
||||||
|
spaceId: string,
|
||||||
|
since: string,
|
||||||
|
parentPageId?: string,
|
||||||
|
): Promise<unknown>;
|
||||||
|
listShares(): Promise<unknown[]>;
|
||||||
|
listPageHistory(
|
||||||
|
pageId: string,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<{ items: unknown[]; nextCursor: string | null }>;
|
||||||
|
getPageHistory(historyId: string): Promise<Record<string, unknown>>;
|
||||||
|
diffPageVersions(
|
||||||
|
pageId: string,
|
||||||
|
from?: string,
|
||||||
|
to?: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
exportPageMarkdown(pageId: string): Promise<string>;
|
||||||
// --- write (page) ---
|
// --- write (page) ---
|
||||||
createPage(
|
createPage(
|
||||||
title: string,
|
title: string,
|
||||||
@@ -43,6 +72,72 @@ export interface DocmostClientLike {
|
|||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
// SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent.
|
// SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent.
|
||||||
deletePage(pageId: string): Promise<unknown>;
|
deletePage(pageId: string): Promise<unknown>;
|
||||||
|
editPageText(
|
||||||
|
pageId: string,
|
||||||
|
edits: Array<{ find: string; replace: string; replaceAll?: boolean }>,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
patchNode(
|
||||||
|
pageId: string,
|
||||||
|
nodeId: string,
|
||||||
|
node: unknown,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
insertNode(
|
||||||
|
pageId: string,
|
||||||
|
node: unknown,
|
||||||
|
opts: {
|
||||||
|
position: 'before' | 'after' | 'append';
|
||||||
|
anchorNodeId?: string;
|
||||||
|
anchorText?: string;
|
||||||
|
},
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
deleteNode(
|
||||||
|
pageId: string,
|
||||||
|
nodeId: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
updatePageJson(
|
||||||
|
pageId: string,
|
||||||
|
doc?: unknown,
|
||||||
|
title?: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
tableInsertRow(
|
||||||
|
pageId: string,
|
||||||
|
tableRef: string,
|
||||||
|
cells: string[],
|
||||||
|
index?: number,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
tableDeleteRow(
|
||||||
|
pageId: string,
|
||||||
|
tableRef: string,
|
||||||
|
index: number,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
tableUpdateCell(
|
||||||
|
pageId: string,
|
||||||
|
tableRef: string,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
text: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
copyPageContent(
|
||||||
|
sourcePageId: string,
|
||||||
|
targetPageId: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
importPageMarkdown(
|
||||||
|
pageId: string,
|
||||||
|
fullMarkdown: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
sharePage(
|
||||||
|
pageId: string,
|
||||||
|
searchIndexing?: boolean,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
unsharePage(pageId: string): Promise<Record<string, unknown>>;
|
||||||
|
restorePageVersion(historyId: string): Promise<Record<string, unknown>>;
|
||||||
|
// The opts type declares deleteComments? to match the real client signature,
|
||||||
|
// but the agent tool NEVER sets it (comment deletion stays unreachable).
|
||||||
|
transformPage(
|
||||||
|
pageId: string,
|
||||||
|
transformJs: string,
|
||||||
|
opts?: { dryRun?: boolean; deleteComments?: boolean },
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
// --- write (comment) ---
|
// --- write (comment) ---
|
||||||
createComment(
|
createComment(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
@@ -55,6 +150,12 @@ export interface DocmostClientLike {
|
|||||||
commentId: string,
|
commentId: string,
|
||||||
resolved: boolean,
|
resolved: boolean,
|
||||||
): Promise<Record<string, unknown>>;
|
): Promise<Record<string, unknown>>;
|
||||||
|
// Edits a comment's own content. NOT version-tracked (not reversible); the
|
||||||
|
// server only lets the comment's author edit it.
|
||||||
|
updateComment(
|
||||||
|
commentId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocmostClientConfig = {
|
export type DocmostClientConfig = {
|
||||||
|
|||||||
@@ -671,3 +671,48 @@ API AI SDK v6 + мост стрима (H3/M4/M5), снять аудит как
|
|||||||
Все блокеры имеют конкретный механизм; непроверенные швы подтверждены. План **готов к старту
|
Все блокеры имеют конкретный механизм; непроверенные швы подтверждены. План **готов к старту
|
||||||
этапа A**. Самый рискованный кусок — C2 (provenance-collab) — реализовать первым сквозным
|
этапа A**. Самый рискованный кусок — C2 (provenance-collab) — реализовать первым сквозным
|
||||||
вертикальным срезом «правка агентом → бейдж в истории», чтобы снять интеграционный риск рано.
|
вертикальным срезом «правка агентом → бейдж в истории», чтобы снять интеграционный риск рано.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Бэклог: расширение тулсета агента (2026-06-17)
|
||||||
|
|
||||||
|
> Решения сессии по составу инструментов агента в `ai-chat/tools/ai-chat-tools.service.ts`.
|
||||||
|
|
||||||
|
**Сделано.** Раньше агенту были доступны только 10 тулов (поиск, чтение страницы, грубый
|
||||||
|
CRUD страниц + create/resolve комментариев). Прокидываем в адаптер **все** оставшиеся
|
||||||
|
возможности клиента `@docmost/mcp` (`packages/mcp/src/client.ts`), КРОМЕ удаления
|
||||||
|
комментариев. Добавлены:
|
||||||
|
|
||||||
|
- **чтение:** `getWorkspace`, `listSpaces`, `listPages`, `listSidebarPages`, `getOutline`,
|
||||||
|
`getPageJson`, `getNode`, `getTable`, `listComments`, `getComment`, `checkNewComments`,
|
||||||
|
`listShares`, `listPageHistory`, `getPageHistory`, `diffPageVersions`, `exportPageMarkdown`;
|
||||||
|
- **обратимая запись:** `editPageText`, `patchNode`, `insertNode`, `deleteNode`,
|
||||||
|
`updatePageJson`, `tableInsertRow`, `tableDeleteRow`, `tableUpdateCell`, `copyPageContent`,
|
||||||
|
`importPageMarkdown`, `sharePage`, `unsharePage`, `restorePageVersion`, `updateComment`,
|
||||||
|
`transformPage`.
|
||||||
|
|
||||||
|
**Сознательно НЕ прокидываем:**
|
||||||
|
|
||||||
|
- `deleteComment` — hard delete комментария, необратимо (запрошено явно: «кроме удаления
|
||||||
|
комментариев»). По той же причине у `transformPage` НЕ экспонируем опцию `deleteComments`
|
||||||
|
(захардкожен `false`).
|
||||||
|
- `uploadImage` / `insertImage` / `replaceImage` — принимают **локальный путь на ФС сервера**
|
||||||
|
(`filePath`, НЕ URL). Для серверного агента это бесполезно (он не может положить файл на
|
||||||
|
хост) и потенциально опасно — по сути примитив чтения локальных файлов хоста.
|
||||||
|
|
||||||
|
**ХОТИМ СДЕЛАТЬ (TODO): вставка картинок по URL.** Научить агента вставлять изображение
|
||||||
|
**по URL**: агент передаёт ссылку → сервер скачивает картинку через уже имеющийся
|
||||||
|
`external-mcp/ssrf-guard.ts` (защита от SSRF / приватных адресов) → грузит во вложения
|
||||||
|
(`POST /files/upload`) и вставляет image-узел. Это безопасная и реально работающая замена
|
||||||
|
filePath-тулам. Требует доработки клиента (новый метод `insertImageFromUrl`, либо опция `url`
|
||||||
|
у существующих image-методов) или обёртки в адаптере с загрузкой во временный буфер.
|
||||||
|
|
||||||
|
**Замечания (учесть при ревью/эксплуатации):**
|
||||||
|
|
||||||
|
- `updateComment` редактирует контент комментария БЕЗ истории версий — **необратимо**;
|
||||||
|
отступление от инварианта D2/D3 «агенту доступно только обратимое». Включено по явному
|
||||||
|
запросу (исключили лишь удаление). Серверная проверка прав остаётся: правится только свой
|
||||||
|
комментарий (`creatorId === authUser.id`).
|
||||||
|
- `sharePage` делает страницу **публично доступной**; возвращаемый `publicUrl` строится от
|
||||||
|
`apiUrl` адаптера (loopback `127.0.0.1`), поэтому для внешней ссылки нужен публичный хост
|
||||||
|
(`MCP_DOCMOST_API_URL`).
|
||||||
|
|||||||
Reference in New Issue
Block a user