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:
@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { DocmostClient } from "./client.js";
|
||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
||||
// directly — for the credentials variant OR the per-user getToken variant.
|
||||
@@ -245,16 +246,9 @@ export function createDocmostMcpServer(config) {
|
||||
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");
|
||||
}
|
||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
||||
return jsonContent(result);
|
||||
@@ -379,18 +373,7 @@ export function createDocmostMcpServer(config) {
|
||||
"JSON object or JSON string both accepted."),
|
||||
},
|
||||
}, async ({ pageId, nodeId, node }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
}
|
||||
catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
}
|
||||
else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
return jsonContent(result);
|
||||
});
|
||||
@@ -425,18 +408,7 @@ export function createDocmostMcpServer(config) {
|
||||
anchorText: z.string().optional(),
|
||||
},
|
||||
}, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
}
|
||||
catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
}
|
||||
else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
|
||||
15
packages/mcp/build/lib/parse-node-arg.js
Normal file
15
packages/mcp/build/lib/parse-node-arg.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// 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 patch_node /
|
||||
// insert_node (and the analogous update_page_json content parsing).
|
||||
export function parseNodeArg(node, errMsg = "node was a string but not valid JSON") {
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
return JSON.parse(node);
|
||||
}
|
||||
catch {
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||
|
||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
||||
@@ -354,14 +355,9 @@ server.registerTool(
|
||||
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");
|
||||
}
|
||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
||||
return jsonContent(result);
|
||||
@@ -529,16 +525,7 @@ server.registerTool(
|
||||
},
|
||||
},
|
||||
async ({ pageId, nodeId, node }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
} else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
||||
return jsonContent(result);
|
||||
},
|
||||
@@ -581,16 +568,7 @@ server.registerTool(
|
||||
},
|
||||
},
|
||||
async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
||||
let parsedNode;
|
||||
if (typeof node === "string") {
|
||||
try {
|
||||
parsedNode = JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error("node was a string but not valid JSON");
|
||||
}
|
||||
} else {
|
||||
parsedNode = node;
|
||||
}
|
||||
const parsedNode = parseNodeArg(node);
|
||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
||||
position,
|
||||
anchorNodeId,
|
||||
|
||||
17
packages/mcp/src/lib/parse-node-arg.ts
Normal file
17
packages/mcp/src/lib/parse-node-arg.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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 patch_node /
|
||||
// insert_node (and the analogous update_page_json content parsing).
|
||||
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;
|
||||
}
|
||||
32
packages/mcp/test/unit/parse-node-arg.test.mjs
Normal file
32
packages/mcp/test/unit/parse-node-arg.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseNodeArg } from "../../build/lib/parse-node-arg.js";
|
||||
|
||||
test("parseNodeArg passes an object through unchanged", () => {
|
||||
const obj = { type: "paragraph", content: [] };
|
||||
assert.strictEqual(parseNodeArg(obj), obj);
|
||||
});
|
||||
|
||||
test("parseNodeArg passes undefined/null through unchanged", () => {
|
||||
assert.strictEqual(parseNodeArg(undefined), undefined);
|
||||
assert.strictEqual(parseNodeArg(null), null);
|
||||
});
|
||||
|
||||
test("parseNodeArg parses a valid JSON string", () => {
|
||||
const parsed = parseNodeArg('{"type":"paragraph"}');
|
||||
assert.deepStrictEqual(parsed, { type: "paragraph" });
|
||||
});
|
||||
|
||||
test("parseNodeArg throws the default message on invalid JSON string", () => {
|
||||
assert.throws(() => parseNodeArg("{not json"), {
|
||||
message: "node was a string but not valid JSON",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseNodeArg throws a custom message on invalid JSON string", () => {
|
||||
assert.throws(
|
||||
() => parseNodeArg("{not json", "content was a string but not valid JSON"),
|
||||
{ message: "content was a string but not valid JSON" },
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user