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:
vvzvlad
2026-06-17 05:14:45 +03:00
parent cfcaa419cb
commit 6ec91c8a2c
5 changed files with 712 additions and 3 deletions

View File

@@ -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)',

View File

@@ -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<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');
});
});

View File

@@ -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 "#<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 }),
}),
};
}
}

View File

@@ -16,6 +16,35 @@ export interface DocmostClientLike {
getPage(
pageId: string,
): 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) ---
createPage(
title: string,
@@ -43,6 +72,72 @@ export interface DocmostClientLike {
): Promise<unknown>;
// SOFT delete only (POST /pages/delete with { pageId }). NEVER permanent.
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) ---
createComment(
pageId: string,
@@ -55,6 +150,12 @@ export interface DocmostClientLike {
commentId: string,
resolved: boolean,
): 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 = {

View File

@@ -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`).