refactor(ai-chat): dedupe node-arg JSON normalization into a shared helper

First, safe step of docs/backlog/ai-chat-tool-definitions-duplicated.md: the
"node may be a JSON object OR a JSON string" quirk was hand-copied at 6 tool
sites. Extract it into a single parseNodeArg() helper per package and call it at
every site. Behavior-preserving — each site's throw message is byte-identical
(patch/insert: 'node was a string but not valid JSON'; update_page_json: 'content
was a string but not valid JSON'); no tool name/description/schema changed.

Two helper copies (packages/mcp/src/lib/parse-node-arg.ts and
apps/server/src/core/ai-chat/tools/parse-node-arg.ts) are intentional: the
ESM-only @docmost/mcp cannot be imported by the CommonJS server (it is loaded at
runtime via the Function('import()') trick), so runtime code cannot cross that
boundary by a normal import. Each copy is now the single source within its
package (6 inline copies -> 2 helpers). packages/mcp/build rebuilt in sync.

Tests: parse-node-arg.spec.ts (server, Jest) + parse-node-arg.test.mjs (mcp,
node:test) — object passthrough, valid-string parse, invalid-string throw with
the right message. Server tsc clean; mcp suite 254 pass; agent structural-edit
path verified live in-browser (agent inserted a node, persisted to the doc).

Deferred (documented for the record, since the backlog doc is removed with this
commit): the FULL transport-agnostic tool-spec registry (one name+schema+
description per tool shared by both transports) and deriving DocmostClientLike
from the real client type. Both are blocked by the current architecture, not by
effort: (1) @docmost/mcp ships no type declarations and is ESM-only, so a
type-only derivation needs declaration emission + tsconfig path wiring, and the
real client's precise return types break the in-app tool test stubs (attempted,
reverted to keep tsc green); (2) the two transports intentionally DIVERGE in tool
NAMES (snake_case x38 vs camelCase x41), membership (in-app adds getCurrentPage/
listSidebarPages, omits delete_comment/image tools) and model-facing
DESCRIPTIONS, so a unified registry would change behavior on BOTH the agent and
external MCP clients and needs its own verification pass. This is forward-looking
debt (the code is correct today), to be done incrementally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-21 06:51:09 +03:00
parent e5bc82c7f1
commit f9757fda12
9 changed files with 140 additions and 191 deletions

View File

@@ -12,6 +12,7 @@ import {
loadDocmostMcp,
type DocmostClientLike,
} from './docmost-client.loader';
import { parseNodeArg } from './parse-node-arg';
/**
* Per-user, per-request adapter that exposes Docmost READ operations to the
@@ -711,14 +712,7 @@ export class AiChatToolsService {
// Parity with the standalone MCP server (index.ts patch_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
let parsedNode = node;
if (typeof node === 'string') {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error('node was a string but not valid JSON');
}
}
const parsedNode = parseNodeArg(node);
return await client.patchNode(pageId, nodeId, parsedNode);
},
}),
@@ -770,14 +764,7 @@ export class AiChatToolsService {
// Parity with the standalone MCP server (index.ts insert_node): the
// model sometimes serializes the node as a JSON string. Parse it
// before the client's typeof-object guard rejects it.
let parsedNode = node;
if (typeof node === 'string') {
try {
parsedNode = JSON.parse(node);
} catch {
throw new Error('node was a string but not valid JSON');
}
}
const parsedNode = parseNodeArg(node);
return await client.insertNode(pageId, parsedNode, {
position,
anchorNodeId,
@@ -826,14 +813,9 @@ export class AiChatToolsService {
let doc;
if (content === undefined || content === null) {
doc = undefined;
} else if (typeof content === 'string') {
try {
doc = JSON.parse(content);
} catch {
throw new Error('content was a string but not valid JSON');
}
} else {
doc = content;
// String -> JSON.parse (throwing on invalid); object passes through.
doc = parseNodeArg(content, 'content was a string but not valid JSON');
}
return await client.updatePageJson(pageId, doc, title);
},

View File

@@ -0,0 +1,37 @@
import { parseNodeArg } from './parse-node-arg';
/**
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
*/
describe('parseNodeArg', () => {
it('passes an object through unchanged', () => {
const obj = { type: 'paragraph', content: [] };
expect(parseNodeArg(obj)).toBe(obj);
});
it('passes undefined/null through unchanged', () => {
expect(parseNodeArg(undefined)).toBeUndefined();
expect(parseNodeArg(null)).toBeNull();
});
it('parses a valid JSON string into an object', () => {
expect(parseNodeArg('{"type":"paragraph"}')).toEqual({
type: 'paragraph',
});
});
it('throws the default message on an invalid JSON string', () => {
expect(() => parseNodeArg('{not json')).toThrow(
'node was a string but not valid JSON',
);
});
it('throws a custom message on an invalid JSON string', () => {
expect(() =>
parseNodeArg('{not json', 'content was a string but not valid JSON'),
).toThrow('content was a string but not valid JSON');
});
});

View File

@@ -0,0 +1,24 @@
// The model sometimes serializes a ProseMirror node arg as a JSON string
// instead of an object. Normalize: parse a string to an object (throwing on
// invalid JSON), pass an object through unchanged. Shared by patchNode /
// insertNode (and the analogous updatePageJson content parsing).
//
// This mirrors `packages/mcp/src/lib/parse-node-arg.ts` byte-for-byte. We
// cannot import that helper here: `@docmost/mcp` is ESM-only and this server
// compiles with module:commonjs, so it is loaded at runtime via the
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
// runtime code across that ESM/CJS boundary by a normal import is impossible,
// hence the mirrored copy.
export function parseNodeArg(
node: unknown,
errMsg = 'node was a string but not valid JSON',
): unknown {
if (typeof node === 'string') {
try {
return JSON.parse(node);
} catch {
throw new Error(errMsg);
}
}
return node;
}