refactor(mcp): гарды остальных зеркал реестра — проза, ярлыки, зонды, счётчик (#494)
Коммит 2. Каждое ручное зеркало получает настоящий гард/деривацию/parity-тест вместо комментария «mirror this»: - ROUTING_PROSE → ОБРАТНЫЙ гард (server-instructions.ts): прямой уже покрыт генерируемым <tool_inventory> (каждый зарегистрированный тул в списке); теперь `unregisteredProseToolMentions` краснеет, если проза ссылается на несуществующий/переименованный тул (camelCase-токены прозы ⊆ реестр, минус явный список не-тул-терминов PROSE_NON_TOOL_TERMS). Раньше мёртвая ссылка в прозе не краснела. Мутационный тест: `getPageContentz` ловится. - LABELS экспорта чата (chat-markdown.util.ts) → parity-тест: каждый ключ-ярлык обязан быть реальным in-app тулом (иначе переименованный тул молча сваливается на generic «Ran tool <name>»), и оба языка (en/ru) размечают ОДИН набор тулов. - зонд comment-signal ×2 (оба хоста) → общий `createListCommentsProbe` в packages/mcp: index.ts и ai-chat-tools.service.ts (через loader) строят tracker.probe из ОДНОЙ фабрики — тела больше не могут разойтись (например, один считает resolved-комментарии, другой нет). Проброшен через loader-границу как опциональный (отсутствует на устаревшем билде → сигнал выключен). - countAnchorMatches (comment-anchor.ts) → делегирует решение exact-wins/strip-fallback единственному резолверу resolveAnchorSelection вместо параллельной копии; поведение идентично (rawCanAnchor ⟺ rawCount>0), parity-тест по корпусу краснеет при расхождении count↔resolve. - normalize+sha256 ×2 (gen-registry-stamp.mjs + docmost-client.loader.ts): зеркало УЖЕ закрыто cross-impl parity-тестом (CROSS_IMPL_TREE/EXPECTED проверяется с обеих сторон) — критерий issue «либо parity-тест» уже выполнен; извлечение общего модуля через границу пакета/билд-шага регрессионно-опасно для load-bearing integrity-проверки (#486), поэтому оставлено как есть. Тесты: mcp node --test unit+mock зелёные (844); затронутые server-specs (chat-markdown, comment-signal-inapp, loader, service, tiers, contract, cap) зелёные (351). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { buildChatMarkdown, normalizeLang } from './chat-markdown.util';
|
||||
import {
|
||||
buildChatMarkdown,
|
||||
normalizeLang,
|
||||
labelledToolNames,
|
||||
} from './chat-markdown.util';
|
||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||
import { SHARED_TOOL_SPECS } from '../../../../../packages/mcp/src/tool-specs';
|
||||
|
||||
/**
|
||||
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
|
||||
@@ -455,3 +460,43 @@ describe('buildChatMarkdown (server) — structure', () => {
|
||||
expect(md).toContain('````');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #494 — REVERSE drift-guard for the export's friendly tool labels. A label keyed
|
||||
* by a tool name that no longer exists silently degrades to the generic
|
||||
* "Ran tool <name>" line; nothing reddened before. This asserts every labelled
|
||||
* name is a real in-app tool and that both languages label the same set.
|
||||
*/
|
||||
describe('tool-label parity (#494)', () => {
|
||||
// In-app tool names come from the shared registry (inAppKey, excluding
|
||||
// mcpOnly specs) PLUS the inline in-app-only tools that carry a friendly label.
|
||||
// The only labelled inline tool is the hybrid semantic search.
|
||||
const INLINE_INAPP_LABELLED = new Set(['searchPages']);
|
||||
|
||||
function validInAppToolNames(): Set<string> {
|
||||
const names = new Set<string>(INLINE_INAPP_LABELLED);
|
||||
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
|
||||
if ((spec as { mcpOnly?: boolean }).mcpOnly) continue;
|
||||
names.add((spec as { inAppKey: string }).inAppKey);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
it('en and ru label the SAME set of tools', () => {
|
||||
expect(labelledToolNames('en').sort()).toEqual(
|
||||
labelledToolNames('ru').sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('every labelled tool name is a real in-app tool', () => {
|
||||
const valid = validInAppToolNames();
|
||||
const dead = labelledToolNames('en').filter((n) => !valid.has(n));
|
||||
expect(dead).toEqual([]);
|
||||
});
|
||||
|
||||
it('the guard REDDENS for an unknown label key (mutation check)', () => {
|
||||
const valid = validInAppToolNames();
|
||||
// A hypothetical renamed-away label must be caught.
|
||||
expect(valid.has('getPageRenamedAway')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,17 @@ function toolLabel(name: string, lang: ExportLang): string {
|
||||
return LABELS[lang].tools[name] ?? LABELS[lang].ranTool(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool names that carry a hand-written friendly export label, per language.
|
||||
* Exported for the drift-guard (#494): a label keyed by a tool name that no
|
||||
* longer exists is a DEAD entry (the tool was renamed and now silently falls back
|
||||
* to the generic `ranTool(name)` line). The guard asserts every key here is a
|
||||
* real in-app tool AND that the two languages label the SAME set of tools.
|
||||
*/
|
||||
export function labelledToolNames(lang: ExportLang): string[] {
|
||||
return Object.keys(LABELS[lang].tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||
|
||||
@@ -426,6 +426,7 @@ export class AiChatToolsService {
|
||||
const {
|
||||
sharedToolSpecs,
|
||||
createCommentSignalTracker,
|
||||
createListCommentsProbe,
|
||||
searchShapes,
|
||||
getGuideSection,
|
||||
} = await loadDocmostMcp();
|
||||
@@ -768,35 +769,21 @@ export class AiChatToolsService {
|
||||
// wrapper below) so the race governs the whole call. The client carries the
|
||||
// per-call composite signal via setToolAbortSignal.
|
||||
const capMs = inAppToolCallCapMs();
|
||||
if (!createCommentSignalTracker) {
|
||||
// The signal needs BOTH the tracker factory AND the shared count-source probe
|
||||
// factory (#494). Either being absent (a stale @docmost/mcp build or a mocked
|
||||
// loader) => signal disabled, tool results byte-identical.
|
||||
if (!createCommentSignalTracker || !createListCommentsProbe) {
|
||||
return wrapInAppToolsWithCap(tools, client, capMs);
|
||||
}
|
||||
|
||||
// Shared probe (#494): the SAME factory the standalone MCP host uses, so the
|
||||
// in-app probe body is no longer a hand-mirror that could drift (counting the
|
||||
// full feed newer than the watermark, labelling a hit with the light page
|
||||
// title). `client` supplies the loopback listComments/getPageRaw reads.
|
||||
const tracker = createCommentSignalTracker({
|
||||
probe: async (pageId: string, sinceMs: number) => {
|
||||
const { items } = await client.listComments(pageId, true);
|
||||
const count = (items as Array<{ createdAt?: string }>).filter((c) => {
|
||||
const created = c?.createdAt ? new Date(c.createdAt).getTime() : NaN;
|
||||
return Number.isFinite(created) && created > sinceMs;
|
||||
}).length;
|
||||
let title: string | undefined;
|
||||
if (count > 0) {
|
||||
// Title labels the signal; untrusted, defanged by the shared builder.
|
||||
// Fetched only on a hit so the no-signal path never pays for it. Uses
|
||||
// the LIGHT raw page info (title only) — mirroring the standalone MCP
|
||||
// probe's getPageRaw — instead of the heavy getPage (which also renders
|
||||
// Markdown + subpages) just to read one field.
|
||||
try {
|
||||
const res = (await client.getPageRaw(pageId)) as {
|
||||
title?: string;
|
||||
} | null;
|
||||
title = res?.title ?? undefined;
|
||||
} catch {
|
||||
// Title is optional — omit it when the page can't be fetched.
|
||||
}
|
||||
}
|
||||
return { count, title };
|
||||
},
|
||||
probe: createListCommentsProbe(
|
||||
client as unknown as Parameters<typeof createListCommentsProbe>[0],
|
||||
),
|
||||
});
|
||||
|
||||
return wrapInAppToolsWithCap(
|
||||
|
||||
@@ -21,7 +21,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
|
||||
// The REAL shared tracker factory, imported from source (same cross-boundary
|
||||
// approach the tool-specs spec uses) so the in-app wiring is exercised against
|
||||
// exactly the watermark/debounce/injection-safe logic the package ships.
|
||||
import { createCommentSignalTracker } from '../../../../../../packages/mcp/src/comment-signal';
|
||||
import {
|
||||
createCommentSignalTracker,
|
||||
createListCommentsProbe,
|
||||
} from '../../../../../../packages/mcp/src/comment-signal';
|
||||
// The REAL client-side citation extractor: proves that the passive signal does
|
||||
// NOT strip a tool's citations (the #417 in-app regression this spec guards).
|
||||
import { toolCitations } from '../../../../../../apps/client/src/features/ai-chat/utils/tool-parts';
|
||||
@@ -284,9 +287,13 @@ describe('AiChatToolsService forUser + comment signal (real tracker)', () => {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor,
|
||||
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
|
||||
// Wire the REAL factory so the in-app path is exercised end to end.
|
||||
// Wire the REAL factories so the in-app path is exercised end to end —
|
||||
// including the shared count-source probe (#494) the service now builds the
|
||||
// tracker's `probe` from.
|
||||
createCommentSignalTracker:
|
||||
createCommentSignalTracker as unknown as loader.CommentSignalTrackerFactory,
|
||||
createListCommentsProbe:
|
||||
createListCommentsProbe as unknown as loader.CreateListCommentsProbeFn,
|
||||
// Pure no-network draw.io helpers (#424) — required on the loader return;
|
||||
// this comment-signal test doesn't exercise them, so no-op stubs suffice.
|
||||
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
|
||||
|
||||
@@ -150,6 +150,27 @@ export type CommentSignalTrackerFactory = (options: {
|
||||
debounceMs?: number;
|
||||
}) => CommentSignalTrackerLike;
|
||||
|
||||
/**
|
||||
* Local mirror of `@docmost/mcp`'s `createListCommentsProbe` (#494): the SHARED
|
||||
* count-source probe both hosts use, so the in-app probe body is no longer a
|
||||
* hand-copy of the standalone MCP one. Given a client with the light comment feed
|
||||
* + raw-page-title reads, it returns the tracker's `probe` (count comments newer
|
||||
* than the watermark, label a hit with the page title). Loosely typed at this
|
||||
* cross-package boundary, like the rest of this loader.
|
||||
*/
|
||||
export type CreateListCommentsProbeFn = (client: {
|
||||
listComments(
|
||||
pageId: string,
|
||||
includeResolved: boolean,
|
||||
): Promise<{ items: Array<{ createdAt?: string | null }> }>;
|
||||
getPageRaw(
|
||||
pageId: string,
|
||||
): Promise<{ title?: string | null } | null | undefined>;
|
||||
}) => (
|
||||
pageId: string,
|
||||
sinceMs: number,
|
||||
) => Promise<CommentSignalProbeResultLike>;
|
||||
|
||||
// Pure, no-network draw.io helpers (#424). These are plain functions on the
|
||||
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
|
||||
// directly to wire drawioShapes / drawioGuide, mirroring the MCP server.
|
||||
@@ -170,6 +191,10 @@ interface DocmostMcpModule {
|
||||
// loader in unit tests. The in-app layer treats an absent factory as "signal
|
||||
// disabled" — a pure no-op that leaves tool results byte-identical.
|
||||
createCommentSignalTracker?: CommentSignalTrackerFactory;
|
||||
// Optional (#494): the shared count-source probe factory. Absent on a pre-#494
|
||||
// build or a mocked loader; the in-app layer only builds a probe when the
|
||||
// signal factory above is also present.
|
||||
createListCommentsProbe?: CreateListCommentsProbeFn;
|
||||
// Optional (#447): a deterministic hash of the tool-specs registry content,
|
||||
// generated into build/ by the package's build. Absent on a pre-#447 build (or
|
||||
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it
|
||||
@@ -284,6 +309,7 @@ export async function loadDocmostMcp(): Promise<{
|
||||
DocmostClient: DocmostClientCtor;
|
||||
sharedToolSpecs: Record<string, SharedToolSpec>;
|
||||
createCommentSignalTracker?: CommentSignalTrackerFactory;
|
||||
createListCommentsProbe?: CreateListCommentsProbeFn;
|
||||
searchShapes: SearchShapesFn;
|
||||
getGuideSection: GetGuideSectionFn;
|
||||
}> {
|
||||
@@ -331,6 +357,9 @@ export async function loadDocmostMcp(): Promise<{
|
||||
// Optional: forwarded when present so the in-app layer can build the passive
|
||||
// comment signal (#417); undefined on a stale build => signal disabled.
|
||||
createCommentSignalTracker: mod.createCommentSignalTracker,
|
||||
// Optional (#494): the shared count-source probe factory; undefined on a
|
||||
// stale build => the in-app layer falls back to no signal.
|
||||
createListCommentsProbe: mod.createListCommentsProbe,
|
||||
// Pure no-network draw.io helpers (#424); not client methods.
|
||||
searchShapes: mod.searchShapes,
|
||||
getGuideSection: mod.getGuideSection,
|
||||
|
||||
Reference in New Issue
Block a user