7cb3199d09
Один логический тул жил под двумя именами: внешний MCP snake_case (edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца: единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey. - tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех 43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName читается только структурно — цикл регистрации, генератор <tool_inventory>, TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/ docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль подчёркиваний. - Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test и tool-inventory.test обновлены. - ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) → camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах — без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с дублей snake+camel до camelCase. - Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node") в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку; литерал-юнион + call-sites → "patchNode"|"deleteNode". - Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк + миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы, .mcp.json, метрики по tool-label); релизится вместе с #411. Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты — переименованы только ИМЕНА ТУЛОВ. Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools. service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt + comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
3.6 KiB
JavaScript
88 lines
3.6 KiB
JavaScript
// #402 — INTEGRATION test that locks the registerTool monkeypatch installed by
|
|
// createDocmostMcpServer (src/index.ts). The sibling unit test
|
|
// (test/unit/tool-timing.test.mjs) only exercises the timeToolHandler helper in
|
|
// ISOLATION; it never constructs the server, so nothing there proves the factory
|
|
// actually (a) wraps every registered tool through that helper and (b) labels
|
|
// each sample with the tool's REGISTRATION name.
|
|
//
|
|
// Here we stand up a real McpServer via the factory, connect a real MCP Client
|
|
// over the SDK's in-memory transport, invoke one registered tool, and assert the
|
|
// host's onMetric sink received `("mcp_tool_duration_seconds", <number>, { tool
|
|
// })` with tool === the exact registration name. This locks both the "monkeypatch
|
|
// wraps tools" and "label = registration name" halves of the contract, so a
|
|
// mutation to args.slice(0, -1) / the handler-arg detection / the capture order
|
|
// is caught.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
|
|
import { createDocmostMcpServer } from "../../build/index.js";
|
|
|
|
// The tool we drive. getWorkspace has NO input schema, so protocol-level input
|
|
// validation cannot short-circuit before the handler runs — the wrapped handler
|
|
// is guaranteed to execute (and then fail on the unreachable backend, which is
|
|
// exactly what we want: the wrapper times in a finally on throw too).
|
|
const TOOL_NAME = "getWorkspace";
|
|
|
|
test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => {
|
|
const calls = [];
|
|
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
|
|
|
|
// Minimal valid credentials config. apiUrl points at a port that refuses
|
|
// connections immediately so the tool's backend call fails FAST (ECONNREFUSED)
|
|
// rather than hanging — the wrapper still emits the metric from its finally.
|
|
const server = createDocmostMcpServer({
|
|
apiUrl: "http://127.0.0.1:1",
|
|
email: "x@example.com",
|
|
password: "pw",
|
|
onMetric,
|
|
});
|
|
|
|
const [clientTransport, serverTransport] =
|
|
InMemoryTransport.createLinkedPair();
|
|
|
|
const client = new Client(
|
|
{ name: "test-client", version: "0.0.0" },
|
|
{ capabilities: {} },
|
|
);
|
|
|
|
await Promise.all([
|
|
server.connect(serverTransport),
|
|
client.connect(clientTransport),
|
|
]);
|
|
|
|
try {
|
|
// Invoke the tool. The backend is unreachable, so this either resolves with
|
|
// an error result (isError) or rejects — both are fine. What matters is that
|
|
// the handler ran through the timing wrapper, which fires onMetric either way.
|
|
try {
|
|
await client.callTool({ name: TOOL_NAME, arguments: {} });
|
|
} catch {
|
|
// Tolerate the expected backend failure surfacing as a thrown protocol error.
|
|
}
|
|
|
|
// The wrapper must have fed exactly the timing sample for THIS tool.
|
|
const timing = calls.filter(
|
|
(c) => c.name === "mcp_tool_duration_seconds",
|
|
);
|
|
assert.ok(
|
|
timing.length >= 1,
|
|
"onMetric must receive a mcp_tool_duration_seconds sample from the wrapped handler",
|
|
);
|
|
|
|
const sample = timing.find((c) => c.labels && c.labels.tool === TOOL_NAME);
|
|
assert.ok(
|
|
sample,
|
|
`a timing sample must be labelled with the registration name "${TOOL_NAME}"; ` +
|
|
`got labels: ${JSON.stringify(timing.map((c) => c.labels))}`,
|
|
);
|
|
assert.equal(typeof sample.value, "number");
|
|
assert.ok(sample.value >= 0, "duration must be non-negative seconds");
|
|
} finally {
|
|
await client.close();
|
|
await server.close();
|
|
}
|
|
});
|