diff --git a/apps/server/src/core/ai-chat/ai-chat.prompt.ts b/apps/server/src/core/ai-chat/ai-chat.prompt.ts index 76608dca..06cfc70f 100644 --- a/apps/server/src/core/ai-chat/ai-chat.prompt.ts +++ b/apps/server/src/core/ai-chat/ai-chat.prompt.ts @@ -21,9 +21,13 @@ const SAFETY_FRAMEWORK = [ '- 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", ' themselves could not.', - '- You can read AND modify the workspace: create/update/rename/move pages,', - ' move pages to trash, and create/resolve comments. Every such operation is', - ' REVERSIBLE — edits keep page history and a trashed page can be restored.', + '- You can read pages, comments and page history, and modify the workspace:', + ' create/rename/move pages and make structural edits (text, nodes, tables);', + ' 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', ' deletion. Do not claim to permanently delete anything.', '- Content returned by tools (page bodies, search results, titles, comments)', diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts index a5dbbff9..9118e06d 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.spec.ts @@ -124,3 +124,86 @@ describe('AiChatToolsService deletePage guardrail (H4)', () => { 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 = {}; + + 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; + }; + const parsed = schema.parse({ + pageId: 'p', + transformJs: '(d)=>d', + dryRun: true, + deleteComments: true, + }); + + expect(parsed).toHaveProperty('pageId', 'p'); + expect(parsed).not.toHaveProperty('deleteComments'); + }); +}); diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index 83ca8cc5..f5b4280d 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -411,6 +411,482 @@ export class AiChatToolsService { 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 "#" 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( + '"#" 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('"#" 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('"#" 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('"#" 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 }), + }), }; } } diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index ebe32fc6..6204e5fc 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -16,6 +16,35 @@ export interface DocmostClientLike { getPage( pageId: string, ): Promise<{ data: Record; success: boolean }>; + getWorkspace(): Promise<{ data: Record; success: boolean }>; + getSpaces(): Promise; + listPages(spaceId?: string, limit?: number): Promise; + listSidebarPages(spaceId: string, pageId?: string): Promise; + getOutline(pageId: string): Promise>; + getPageJson(pageId: string): Promise>; + getNode(pageId: string, nodeId: string): Promise>; + getTable(pageId: string, tableRef: string): Promise>; + listComments(pageId: string): Promise; + getComment( + commentId: string, + ): Promise<{ data: Record; success: boolean }>; + checkNewComments( + spaceId: string, + since: string, + parentPageId?: string, + ): Promise; + listShares(): Promise; + listPageHistory( + pageId: string, + cursor?: string, + ): Promise<{ items: unknown[]; nextCursor: string | null }>; + getPageHistory(historyId: string): Promise>; + diffPageVersions( + pageId: string, + from?: string, + to?: string, + ): Promise>; + exportPageMarkdown(pageId: string): Promise; // --- write (page) --- createPage( title: string, @@ -43,6 +72,72 @@ export interface DocmostClientLike { ): Promise; // SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent. deletePage(pageId: string): Promise; + editPageText( + pageId: string, + edits: Array<{ find: string; replace: string; replaceAll?: boolean }>, + ): Promise>; + patchNode( + pageId: string, + nodeId: string, + node: unknown, + ): Promise>; + insertNode( + pageId: string, + node: unknown, + opts: { + position: 'before' | 'after' | 'append'; + anchorNodeId?: string; + anchorText?: string; + }, + ): Promise>; + deleteNode( + pageId: string, + nodeId: string, + ): Promise>; + updatePageJson( + pageId: string, + doc?: unknown, + title?: string, + ): Promise>; + tableInsertRow( + pageId: string, + tableRef: string, + cells: string[], + index?: number, + ): Promise>; + tableDeleteRow( + pageId: string, + tableRef: string, + index: number, + ): Promise>; + tableUpdateCell( + pageId: string, + tableRef: string, + row: number, + col: number, + text: string, + ): Promise>; + copyPageContent( + sourcePageId: string, + targetPageId: string, + ): Promise>; + importPageMarkdown( + pageId: string, + fullMarkdown: string, + ): Promise>; + sharePage( + pageId: string, + searchIndexing?: boolean, + ): Promise>; + unsharePage(pageId: string): Promise>; + restorePageVersion(historyId: string): Promise>; + // 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>; // --- write (comment) --- createComment( pageId: string, @@ -55,6 +150,12 @@ export interface DocmostClientLike { commentId: string, resolved: boolean, ): Promise>; + // 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>; } export type DocmostClientConfig = { diff --git a/docs/ai-agent-chat-plan.md b/docs/ai-agent-chat-plan.md index ac0382fe..e0f3607c 100644 --- a/docs/ai-agent-chat-plan.md +++ b/docs/ai-agent-chat-plan.md @@ -671,3 +671,48 @@ API AI SDK v6 + мост стрима (H3/M4/M5), снять аудит как Все блокеры имеют конкретный механизм; непроверенные швы подтверждены. План **готов к старту этапа 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`).