Implements two architecture follow-ups from the multi-aspect review.
1. Shared, zod-agnostic tool-spec registry (packages/mcp/src/tool-specs.ts)
for the 14 AI tools whose name + schema + model-facing description are
genuinely identical across the standalone MCP server and the in-app
AI-SDK chat. Both layers consume it (registerShared in index.ts;
sharedTool in ai-chat-tools.service.ts) and keep their own execute/auth.
- Zod-agnostic builders (z) => ZodRawShape bridge the zod v3 (mcp) vs
zod v4 (server) split; the registry imports no zod.
- Folds in the documented edit_page_text drift-bug fix: the stale
"strip-and-retry tolerated" claim is gone; canonical wording states a
formatting-only change is refused into failed[].
- Sibling-tool references in shared descriptions are transport-neutral so
one description is correct for both snake_case (MCP) and camelCase
(in-app) tool names.
- Loader fail-fast guard for a stale @docmost/mcp build.
- The ~17 intentionally-divergent tools (security guardrails, tuned UX)
stay per-layer, untouched.
- Rebuilt committed mcp artifacts (also regenerates a previously stale
build/lib/docmost-schema.js to match its already-committed source).
2. Formalize apps/server/test/integration/db.ts as the canonical
integration-test seed factory (module doc + a shortId helper); the
hand-written minimal seeders are kept on purpose, decoupled from the
app service-layer side effects.
Verified: server tsc + lint clean, mcp build clean; mcp unit tests 261 pass,
ai-chat-tools.service 16 pass, public-share-chat-tools 8 pass, ai-chat suite
224 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
3.7 KiB
JavaScript
91 lines
3.7 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { z } from "zod";
|
|
|
|
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
|
|
|
|
// The shared registry is consumed by BOTH the zod-v3 MCP server and the zod-v4
|
|
// in-app AI-SDK service, so every spec must carry the cross-layer wiring
|
|
// (mcpName + inAppKey) and its builders must produce the right field set when
|
|
// called with a real zod namespace.
|
|
|
|
test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () => {
|
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
|
assert.equal(typeof spec.mcpName, "string");
|
|
assert.ok(spec.mcpName.length > 0, `${key}: empty mcpName`);
|
|
assert.equal(typeof spec.inAppKey, "string");
|
|
assert.ok(spec.inAppKey.length > 0, `${key}: empty inAppKey`);
|
|
assert.equal(typeof spec.description, "string");
|
|
assert.ok(spec.description.length > 0, `${key}: empty description`);
|
|
// The registry is keyed by inAppKey — keep the two in sync.
|
|
assert.equal(spec.inAppKey, key, `${key}: registry key must equal inAppKey`);
|
|
}
|
|
});
|
|
|
|
test("mcpName uses snake_case and inAppKey uses camelCase", () => {
|
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
|
assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`);
|
|
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
|
|
}
|
|
});
|
|
|
|
test("mcpName and inAppKey are each unique across the registry", () => {
|
|
const mcpNames = new Set();
|
|
const inAppKeys = new Set();
|
|
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
|
|
assert.ok(!mcpNames.has(spec.mcpName), `duplicate mcpName: ${spec.mcpName}`);
|
|
assert.ok(!inAppKeys.has(spec.inAppKey), `duplicate inAppKey: ${spec.inAppKey}`);
|
|
mcpNames.add(spec.mcpName);
|
|
inAppKeys.add(spec.inAppKey);
|
|
}
|
|
});
|
|
|
|
test("buildShape (when present) returns a usable ZodRawShape with a real zod", () => {
|
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
|
if (!spec.buildShape) continue;
|
|
const shape = spec.buildShape(z);
|
|
assert.equal(typeof shape, "object");
|
|
// Each field must be a real zod type so z.object(shape) compiles a schema.
|
|
for (const [field, zt] of Object.entries(shape)) {
|
|
assert.ok(
|
|
zt && typeof zt.parse === "function",
|
|
`${key}.${field}: not a zod type`,
|
|
);
|
|
}
|
|
// The compiled object schema must parse a minimal valid input.
|
|
assert.doesNotThrow(() => z.object(shape));
|
|
}
|
|
});
|
|
|
|
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
|
|
const spec = SHARED_TOOL_SPECS.editPageText;
|
|
assert.equal(spec.mcpName, "edit_page_text");
|
|
const shape = spec.buildShape(z);
|
|
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
|
|
// A valid edits batch parses.
|
|
const schema = z.object(shape);
|
|
const parsed = schema.parse({
|
|
pageId: "p1",
|
|
edits: [{ find: "teh", replace: "the" }],
|
|
});
|
|
assert.equal(parsed.pageId, "p1");
|
|
assert.equal(parsed.edits.length, 1);
|
|
// The canonical description must NOT carry the stale MCP strip-and-retry claim.
|
|
assert.ok(
|
|
!/strip-and-retry/i.test(spec.description),
|
|
"editPageText description still claims strip-and-retry",
|
|
);
|
|
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
|
|
});
|
|
|
|
test("getNode builder produces exactly { pageId, nodeId }", () => {
|
|
const shape = SHARED_TOOL_SPECS.getNode.buildShape(z);
|
|
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
|
|
});
|
|
|
|
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
|
|
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
|
|
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
|
|
}
|
|
});
|