diff --git a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts index 5b740cfe..e27bfb9a 100644 --- a/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts +++ b/apps/server/src/core/ai-chat/tools/docmost-client.loader.ts @@ -5,6 +5,34 @@ import { pathToFileURL } from 'node:url'; * ESM-only `@docmost/mcp` package. We only need the constructor + the read/write * methods used by the per-user tool adapter; the full client surface lives in * `packages/mcp/src/client.ts`. Signatures here mirror that file exactly. + * + * DRIFT GUARD: the method NAMES below are runtime-checked against the real + * `DocmostClient` by `packages/mcp/test/unit/client-host-contract.test.mjs` + * (which can import the ESM class directly). If you rename/remove a method here + * or in client.ts, that test fails — so a stale mirror cannot silently ship a + * runtime "x is not a function" into an agent tool call. Keep the two in sync. + * + * STAGED PLAN — full derivation `DocmostClientLike = ` + * (issue #193, layer 3) is intentionally NOT done; it stays a hand-mirror for + * now because of two verified blockers across the ESM(mcp)/CJS(server) boundary: + * 1. `@docmost/mcp` emits NO declaration files (its tsconfig has no + * `declaration`, package.json has no `types`/types-export) and the server + * tsconfig has no path mapping for it — the server only loads it via the + * runtime `import()` trick below, so there is no type to import today. + * 2. The real client methods have inferred, CONCRETE return types; the in-app + * tool adapter reads results through loose `Record` returns + * + `as` casts (e.g. `(result?.data ?? {}) as { title?: string }`). + * Deriving the exact type would make those casts non-overlapping ("may be a + * mistake") and break the build, and `Partial` test stubs + * would have to satisfy the full concrete surface. + * To do it safely later (incrementally): (a) turn on `declaration: true` in + * packages/mcp/tsconfig.json + add a `types` export condition and commit the + * emitted `.d.ts`; (b) `import type { DocmostClient } from '@docmost/mcp'` here + * and replace this interface with a `Pick` of the consumed + * methods; (c) audit every `as` cast in ai-chat-tools.service.ts against the now + * concrete return types (double-cast through `unknown` only where genuinely + * needed); (d) keep the runtime guard test as a belt-and-braces check. Until + * then the guard test above is the cheap, behaviour-neutral protection. */ export interface DocmostClientLike { // --- read --- diff --git a/packages/mcp/test/unit/client-host-contract.test.mjs b/packages/mcp/test/unit/client-host-contract.test.mjs new file mode 100644 index 00000000..c6ca8978 --- /dev/null +++ b/packages/mcp/test/unit/client-host-contract.test.mjs @@ -0,0 +1,100 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { DocmostClient } from "../../build/index.js"; + +// Drift guard for the THIRD hand-written layer of the AI tool set (issue #193, +// layer 3): the in-app server hand-mirrors the DocmostClient method signatures +// it consumes as the `DocmostClientLike` interface in +// apps/server/src/core/ai-chat/tools/docmost-client.loader.ts ("Signatures here +// mirror that file exactly"). That mirror lives across the ESM(mcp)/CJS(server) +// boundary and the package ships NO .d.ts, so the server typecheck cannot verify +// the names against the real class — a rename/removal in client.ts would surface +// only as a runtime "x is not a function" inside an agent tool call. +// +// This test pins the contract from the mcp side (ESM, where the real class is +// directly importable): every method the embedding host depends on MUST exist as +// a function on a real DocmostClient instance. If you rename/remove a client +// method, this fails here AND you must update DocmostClientLike to match. +// +// Keep HOST_CONTRACT_METHODS in sync with the methods declared in the server's +// DocmostClientLike interface (the in-app per-user tool adapter only — it is the +// superset of what either transport calls). Full type-derivation of +// DocmostClientLike from this class is deferred (see the staged plan in +// docmost-client.loader.ts): the package emits no declarations and the real +// (inferred, concrete) return types conflict with the host's loose +// `Record` + `as`-cast result handling. +const HOST_CONTRACT_METHODS = [ + // read + "search", + "getPage", + "getWorkspace", + "getSpaces", + "listPages", + "listSidebarPages", + "getOutline", + "getPageJson", + "getNode", + "getTable", + "listComments", + "getComment", + "checkNewComments", + "listShares", + "listPageHistory", + "getPageHistory", + "diffPageVersions", + "exportPageMarkdown", + // write (page) + "createPage", + "updatePage", + "renamePage", + "movePage", + "deletePage", + "editPageText", + "patchNode", + "insertNode", + "deleteNode", + "updatePageJson", + "tableInsertRow", + "tableDeleteRow", + "tableUpdateCell", + "copyPageContent", + "importPageMarkdown", + "sharePage", + "unsharePage", + "restorePageVersion", + "transformPage", + // write (comment) + "createComment", + "resolveComment", +]; + +test("DocmostClient implements every method the in-app DocmostClientLike mirror declares", () => { + // The constructor is side-effect-free (no network/login on construction): it + // only stores config and creates an axios instance, so it is safe to build a + // throwaway instance here with a dummy token provider. + const client = new DocmostClient({ + apiUrl: "http://127.0.0.1:1/api", + getToken: async () => "test-token", + }); + + const missing = HOST_CONTRACT_METHODS.filter( + (name) => typeof client[name] !== "function", + ); + + assert.deepEqual( + missing, + [], + `DocmostClient is missing host-contract method(s): ${missing.join(", ")}. ` + + `Update packages/mcp/src/client.ts and/or the server's DocmostClientLike ` + + `interface (apps/server/src/core/ai-chat/tools/docmost-client.loader.ts) ` + + `so the hand-mirrored signatures stay in sync.`, + ); +}); + +test("HOST_CONTRACT_METHODS has no duplicates", () => { + assert.equal( + new Set(HOST_CONTRACT_METHODS).size, + HOST_CONTRACT_METHODS.length, + ); +});