Files
gitmost/packages/mcp/test/mock/tool-timing-server.test.mjs
T
agent_coder 7cb3199d09 refactor(mcp)!: BREAKING — все имена MCP-тулов snake_case → camelCase (унификация с in-app) (#412)
Один логический тул жил под двумя именами: внешний 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>
2026-07-10 19:10:38 +03:00

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();
}
});