UNPKG

@mnemoverse/mcp-memory-server

Version:

Hosted AI agent memory over MCP that learns from outcomes — feedback re-ranks recall — with shared rooms for multi-agent work; one key or OAuth across Claude Code, Cursor, VS Code & ChatGPT, no infra to run

288 lines • 13.9 kB
/** * Tool schemas in JSON Schema 2020-12, the dialect MCP defaults to (#189). * * WHAT WAS WRONG. `@modelcontextprotocol/sdk` 1.x builds the `tools/list` * answer itself. For each registered tool it converts the zod input and output * schemas with `toJsonSchemaCompat(obj, { strictUnions, pipeStrategy })` * (dist/esm/server/mcp.js) and passes no `target`, and that function's target * mapping falls back to `draft-7` when none is given. So every `inputSchema` * and `outputSchema` this package served said * `"$schema": "http://json-schema.org/draft-07/schema#"` (0.15.0 with SDK * 1.30.0 and with 1.32.1, the newest 1.x, checked 2026-10-11). MCP 2025-11-25 * ("JSON Schema Usage") makes 2020-12 the default dialect, and a client whose * validator accepts only 2020-12 refuses such a tool before calling it, with * the message quoted in #189: "JSON Schema declares an unsupported dialect * (draft-07). The default validator supports JSON Schema 2020-12 only". That * message comes from the `AjvJsonSchemaValidator` of * `@modelcontextprotocol/client` 2.0.0-alpha.4 to 2.0.0-beta.5, which * rejected all 22 schemas of 0.15.0 and accepts all 22 served through this * file. The SDK side is modelcontextprotocol/typescript-sdk#2084, open on * 2026-10-11. * * WHAT THIS DOES. It wraps the SDK's own `tools/list` handler. For each listed * tool it converts the registered zod schemas again, with the SDK's own * converter and the same options plus `target: "draft-2020-12"` (the change * #2084 proposes), and then removes the `$schema` key. Everything else on a * listed tool (name, title, description, annotations, execution, `_meta`) is * what the SDK built. For this package's eleven tools the 2020-12 conversion * and the draft-07 one differ in the `$schema` line alone; the bodies are * byte-identical (test/schema-dialect.test.ts compares them). * * WHY NO `$schema` KEY, rather than the 2020-12 URI. Both mean 2020-12 under * the spec: "When a schema does not include a `$schema` field, it defaults to * JSON Schema 2020-12." The difference is in older clients. A draft-07 Ajv * instance in its default configuration (`new Ajv()`) cannot compile a schema * that names the 2020-12 meta-schema (`no schema with key or ref * "https://json-schema.org/draft/2020-12/schema"`), and it compiles the same * schema without the key. Without the key, a 2020-12-only validator reads * 2020-12 and a draft-07 one still works. The test compiles every schema * under both. * * WHICH TOOLS. Every tool on the server, not only the eleven registered by * `registerMemoryTools`: a server that registers its own tools beside these * gets the same dialect for them. A tool whose schema the SDK's converter * cannot write in 2020-12 (a zod 3 schema: its converter has no 2020-12 * target and returns draft-07 whatever it is asked) is left exactly as the SDK * built it, since dropping `$schema` from a draft-07 body would claim a * dialect that body was not written in. * * WHEN TO CALL. Before the first tool is registered on the server. The SDK * installs its `tools/list` handler at the first `registerTool` and never * again, so this function watches for that one installation and wraps it. * `registerMemoryTools` calls it first thing; calling it again on the same * server does nothing. Called after a tool is already registered, it throws, * because it could not do what its name says. * * WHAT IT READS FROM THE SDK THAT IS NOT DOCUMENTED API, AND WHAT HAPPENS * WHEN A LATER SDK CHANGES IT. Three things: the registered tools, * `McpServer._registeredTools` (the map the SDK's own handler lists from; the * hosted connector's host-surface check reads it too), and the converter * modules `server/zod-json-schema-compat.js` and `server/zod-compat.js`, which * the SDK's `./*` export makes importable. None of them is needed for the * server to start or for its tools to work, so none of them is imported * statically: `npx` resolves `^1.32.1` to the newest 1.x, and a static import * of a module that release had moved would stop the process before it * answered `initialize`. The converter modules are loaded with a dynamic * `import()` inside a `try`, and the map is looked up when this function * runs. When any of them is missing, or the SDK has installed `tools/list` * before any tool was registered, the SDK's own `tools/list` answer goes out * unchanged (draft-07, as in 0.15.0) and one line on stderr says why, once * per process. stdout is the MCP channel, so nothing is written there. * test/schema-dialect-fallback.test.ts takes each of these away in turn and * checks that the server still lists its eleven tools. When the SDK passes * the target itself, the bare-SDK test in test/schema-dialect.test.ts fails. * The SDK would then write the 2020-12 URI, which this wrapper leaves out, so * the choice at that point is whether to keep the wrapper for that reason * alone. */ /** The `$schema` URI the SDK's converter writes for a 2020-12 schema. Used to * confirm the conversion really produced 2020-12 before the key is dropped. */ export const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"; /** The MCP method whose handler this file wraps. */ const LIST_METHOD = "tools/list"; /** Servers this function has already dealt with: wrapped, or left to the SDK * with a warning. */ const handled = new WeakSet(); /** * Make `server`'s `tools/list` describe every tool schema in JSON Schema * 2020-12 with no `$schema` key. See the file header for why, for the calling * order it requires, and for what it does when the SDK lacks a part it needs. */ export function serveToolSchemasIn2020_12(server) { if (handled.has(server)) return; const lowLevel = server.server; const registered = registeredTools(server); let listAlreadyInstalled = false; try { lowLevel.assertCanSetRequestHandler(LIST_METHOD); } catch { listAlreadyInstalled = true; } if (listAlreadyInstalled && registered !== undefined && Object.keys(registered).length > 0) { // The caller's mistake, not the SDK's: say so instead of serving draft-07. throw new Error("serveToolSchemasIn2020_12: a tools/list handler is already installed on this server, " + "which means a tool was registered before this call. Call it (or registerMemoryTools, " + "which calls it) before registering any tool."); } handled.add(server); if (registered === undefined) { warnOnce("this SDK has no McpServer._registeredTools to read the tool schemas from"); return; } if (listAlreadyInstalled) { // No tool is registered and the handler is there anyway: this SDK installs // it on its own, so there is no installation left to wrap. warnOnce("this SDK installs its tools/list handler before any tool is registered"); return; } // Started now so that a missing module is reported at startup rather than // at the first tools/list; nothing waits on it here. void loadConverter(); // Whatever `setRequestHandler` resolves to right now: the Server method, or // a consumer's own shadow on this instance (the hosted connector wraps // tools/call this way, src/host-surface.ts). It is put back as it was once // the tools/list handler has gone through, unless a consumer has put its // own shadow over this one in the meantime: that shadow stays where it is, // and this one, still under it, passes every later handler straight on. const hadOwn = Object.prototype.hasOwnProperty.call(lowLevel, "setRequestHandler"); const previous = lowLevel.setRequestHandler; const current = () => lowLevel.setRequestHandler; const restore = () => { if (current() !== shadow) return; if (hadOwn) { lowLevel.setRequestHandler = previous; } else { delete lowLevel.setRequestHandler; } }; let listInstalled = false; const shadow = (schema, handler) => { if (listInstalled || methodOf(schema) !== LIST_METHOD) { previous.call(lowLevel, schema, handler); return; } listInstalled = true; restore(); previous.call(lowLevel, schema, async (request, extra) => { const result = (await handler(request, extra)); const convert = await loadConverter(); return convert === undefined ? result : in2020_12(convert, result, registeredTools(server)); }); }; lowLevel.setRequestHandler = shadow; } let warned = false; /** One line on stderr per process, the first time the SDK's draft-07 answer * has to stand. Never stdout: on stdio that is the MCP channel. */ function warnOnce(reason) { if (warned) return; warned = true; console.error(`Mnemoverse: tool schemas are listed as the MCP SDK builds them (JSON Schema draft-07), ` + `not 2020-12, because ${reason}. The tools work as before; a client that accepts only ` + `JSON Schema 2020-12 may refuse them (#189).`); } let converter; /** The SDK's converter, loaded on first use and kept; undefined, with the * warning written, when this SDK does not have it where 1.30 to 1.32 do. */ function loadConverter() { converter ??= (async () => { try { const [jsonSchemaCompat, zodCompat] = await Promise.all([ import("@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"), import("@modelcontextprotocol/sdk/server/zod-compat.js"), ]); const { toJsonSchemaCompat } = jsonSchemaCompat; const { normalizeObjectSchema } = zodCompat; if (typeof toJsonSchemaCompat !== "function" || typeof normalizeObjectSchema !== "function") { warnOnce("this SDK's converter modules no longer export toJsonSchemaCompat and normalizeObjectSchema"); return undefined; } return { toJsonSchemaCompat, normalizeObjectSchema }; } catch (err) { warnOnce(`this SDK's converter modules could not be loaded (${firstLine(err)})`); return undefined; } })(); return converter; } function firstLine(err) { const message = err instanceof Error ? err.message : String(err); return message.split("\n", 1)[0] ?? message; } /** * The method string of a request schema passed to `setRequestHandler`, or * undefined when it has none. The same reading as the SDK's * `getMethodLiteral`, done here so that recognising `tools/list` does not * depend on loading the SDK's converter modules: the shape of a zod 4 object * (`_zod.def.shape`) or zod 3 object (`.shape`), then the value of its * `method` literal. */ function methodOf(schema) { try { const s = schema; let shape = s?._zod !== undefined ? s._zod.def?.shape : s?.shape; if (typeof shape === "function") shape = shape(); const literal = shape?.method; if (literal === undefined || literal === null) return undefined; const def = literal._zod?.def ?? literal._def; const value = def?.value !== undefined ? def.value : Array.isArray(def?.values) && def.values.length > 0 ? def.values[0] : literal.value; return typeof value === "string" ? value : undefined; } catch { return undefined; } } /** The SDK's registered-tool map, or undefined when this SDK does not have one. */ function registeredTools(server) { const registered = server._registeredTools; return registered !== null && typeof registered === "object" ? registered : undefined; } /** The `tools/list` answer with each tool's schemas in 2020-12, wherever they * can be. A tool whose schemas fail to convert is left as the SDK built it. */ function in2020_12(convert, result, registered) { if (registered === undefined) { warnOnce("this SDK has no McpServer._registeredTools to read the tool schemas from"); return result; } return { ...result, tools: result.tools.map((tool) => { const own = Object.prototype.hasOwnProperty.call(registered, tool.name) ? registered[tool.name] : undefined; if (own === undefined) return tool; try { const next = { ...tool }; const input = schemaIn2020_12(convert, own.inputSchema, "input"); if (input !== undefined) next.inputSchema = input; if (tool.outputSchema !== undefined) { const output = schemaIn2020_12(convert, own.outputSchema, "output"); if (output !== undefined) next.outputSchema = output; } return next; } catch (err) { warnOnce(`the schemas of ${tool.name} could not be converted (${firstLine(err)})`); return tool; } }), }; } /** * One registered zod schema as 2020-12 JSON Schema with no `$schema` key, or * undefined when it cannot be: no schema (the SDK's own empty-object answer * then stands, and it has no `$schema` key), or a converter that did not * produce 2020-12. */ function schemaIn2020_12(convert, schema, io) { const obj = convert.normalizeObjectSchema(schema); if (obj === undefined) return undefined; const json = convert.toJsonSchemaCompat(obj, { strictUnions: true, pipeStrategy: io, target: "draft-2020-12", }); if (json.$schema !== JSON_SCHEMA_2020_12) return undefined; const { $schema: _dialect, ...body } = json; return body; } //# sourceMappingURL=schema-dialect.js.map