refactor(ai-chat): drift-guard the DocmostClientLike hand-mirror (#193)
Issue #193's tool-half has two open items. The shared, zod-agnostic tool-spec
registry (SHARED_TOOL_SPECS) for the identical tools is already merged
(f3fa15e7) and consumed by both layers, so that subset is done. The remaining
items are: (a) deriving the layer-3 hand-mirror `DocmostClientLike` from the
real client type, and (b) folding more tools into the registry. Both were
deferred as risky, and that deferral still holds (verified, see below) — so
this change ships the safest concrete increment instead of forcing the risk.
What this adds (behaviour-neutral, test-only + a doc comment):
- packages/mcp/test/unit/client-host-contract.test.mjs: pins the layer-3
contract from the ESM side, where the real DocmostClient is importable. It
asserts every method the in-app `DocmostClientLike` mirror declares exists as
a function on a real DocmostClient instance (constructor is side-effect-free).
A rename/removal in client.ts now fails this test instead of silently shipping
a runtime "x is not a function" into an agent tool call. Negative-case
verified (a bogus method name is detected).
- docmost-client.loader.ts: replaces the vague mirror comment with a pointer to
the guard test and a concrete, empirically-grounded staged plan for the full
type-derivation. Verified blockers kept it deferred: @docmost/mcp emits no
.d.ts (no `declaration`, no `types` export) and the server has no path mapping
for it, so there is no type to import today; and the real methods' inferred
CONCRETE return types conflict with the in-app adapter's loose
Record<string,unknown> + `as`-cast result handling (deriving the exact type
breaks the build / forces pervasive double-casts and full-surface test stubs).
Out of scope (noted in the issue): the PM<->Markdown converter unification.
Verified: server tsc clean; mcp tsc clean; mcp tests 369 pass (367 + 2 new);
ai-chat tools specs 51 pass. No behaviour change; committed mcp build untouched
(no mcp src changed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = <real DocmostClient type>`
|
||||
* (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<string,unknown>` 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<DocmostClientLike>` 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<DocmostClient, ...>` 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 ---
|
||||
|
||||
100
packages/mcp/test/unit/client-host-contract.test.mjs
Normal file
100
packages/mcp/test/unit/client-host-contract.test.mjs
Normal file
@@ -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<string,unknown>` + `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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user