Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to MeMesh are documented here.

## [Unreleased]

### Fixed

- A message sent through the MCP `message` tool keeps every `null` in its JSON payload. Before, the MCP boundary removed each null-valued key at any depth of the payload while `send` still reported success, so the recipient got different data than the CLI or HTTP would have stored (#517). A null-valued top-level tool parameter still means "left blank".

## [4.10.11] — 2026-09-29

Includes the changes documented under 4.10.10 and 4.10.9 below; neither was published to npm.
Expand Down
4 changes: 2 additions & 2 deletions dist/mcp/THIRD_PARTY_NOTICES.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Third-party software bundled in dist/mcp/server.js

Bundle SHA-256: fbcc0803d7b1f6c11c84fb5364a616483f9524800b3458818879b1c9f7655fd9
Source map SHA-256: cccaaa6daefa62382baaf9c276172cb41face4237bca66eac4b748c0573dff68
Bundle SHA-256: e38a65e55d9a8be9760a92d20e69a2f71d0a5bd6c26cddb004fa3c26f84364e5
Source map SHA-256: 6f92117584bd8615b1b57545acd20bf6e77467e1c395342242f6dbe8df3e3615

Generated by scripts/build-mcp-bundle.mjs from the esbuild input closure.

Expand Down
17 changes: 7 additions & 10 deletions dist/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -32441,17 +32441,14 @@ function formatIssue(issue2) {
return path11 ? `${path11}: ${issue2.message}` : issue2.message;
}
function stripNullProps(value) {
if (Array.isArray(value))
return value.map(stripNullProps);
if (value !== null && typeof value === "object") {
const out = {};
for (const [k, v] of Object.entries(value)) {
if (v !== null)
out[k] = stripNullProps(v);
}
return out;
if (value === null || typeof value !== "object" || Array.isArray(value))
return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
if (v !== null)
out[k] = v;
}
return value;
return out;
}
function parseOrFail(schema, args) {
const raw = args === void 0 || args === null ? {} : args;
Expand Down
2 changes: 1 addition & 1 deletion dist/mcp/server.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/transports/mcp/handlers.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 7 additions & 10 deletions dist/transports/mcp/handlers.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/transports/mcp/handlers.js.map

Large diffs are not rendered by default.

24 changes: 13 additions & 11 deletions src/transports/mcp/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,19 +468,21 @@ function formatIssue(issue: z.ZodIssue): string {
* recall that succeeds from Codex fails from Gemini with a type error. At
* this boundary a null-valued property can only mean "left blank" — no
* memesh tool uses null as a sentinel — so it is dropped before validation.
* Array ELEMENTS are left alone: a null inside `observations` is malformed
* data and must still be rejected, not silently swallowed.
*
* Only the tool's own parameters (the top level) are touched. What a
* parameter CARRIES is data and is passed on as sent: a message payload, an
* import bundle or a work-package result may hold nulls on purpose, and
* recursing into them rewrote a message payload while `send` reported
* success (#517). A null element inside `observations` likewise reaches the
* schema and is rejected, not silently swallowed.
*/
function stripNullProps(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stripNullProps);
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (v !== null) out[k] = stripNullProps(v);
}
return out;
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (v !== null) out[k] = v;
}
return value;
return out;
}

function parseOrFail<T>(schema: z.ZodType<T>, args: unknown):
Expand All @@ -490,7 +492,7 @@ function parseOrFail<T>(schema: z.ZodType<T>, args: unknown):

// Unknown keys are rejected BEFORE any null-stripping.
//
// `stripNullProps` deletes every null-valued property, and it used to run
// `stripNullProps` deletes every null-valued top-level property, and it used to run
// first — so `.strict()` never saw a key whose value happened to be null.
// `forget({name, observations: null})` (plural: the word `remember` uses)
// therefore lost the key entirely, fell through to the archive-the-entity
Expand Down
67 changes: 67 additions & 0 deletions tests/transports/mcp-payload-nulls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* #517: the MCP boundary drops a null-valued tool PARAMETER (a client that
* fills blank optional parameters with null means "left blank"), but it must
* never rewrite the data a parameter carries. A message payload, an import
* bundle's metadata and a work-package result are stored as sent.
*/
import { describe, expect, it } from 'vitest';
import { handleTool } from '../../src/transports/mcp/handlers.js';
import { executeAgentMessageAction } from '../../src/transports/agent-messaging.js';
import { getDatabase } from '../../src/db.js';
import { useTestDatabase } from '../helpers/db-fixture.js';

useTestDatabase('memesh-mcp-payload-nulls-');

const PROJECT = 'proj-517';
const PAYLOAD = { a: null, b: 1, nested: { c: null, d: 2 }, arr: [null, 3] };

// The result is the first content block. The first MCP call of a process can
// carry an update notice as a SECOND block (handlers.ts), which is not JSON.
function textOf(result: { content: Array<{ text?: string }> }): string {
return result.content[0]?.text ?? '';
}

describe('Feature: #517 the MCP message tool stores a JSON payload exactly as sent', () => {
it('keeps null values at every depth of the payload', async () => {
const sent = await handleTool('message', {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-b',
idempotency_key: 'k-517', content_type: 'application/json', payload: PAYLOAD,
}, 'claude-code');
expect(sent.isError).toBeUndefined();
const { message_id } = JSON.parse(textOf(sent)) as { message_id: string };

const fetched = await handleTool('message', {
action: 'fetch', project: PROJECT, recipient: 'agent-b', message_id,
}, 'claude-code');
expect(fetched.isError).toBeUndefined();
expect(JSON.parse(textOf(fetched)).payload).toEqual(PAYLOAD);
});

it('stores the same payload the CLI/HTTP path stores', async () => {
const viaMcp = await handleTool('message', {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-c',
idempotency_key: 'k-517-mcp', content_type: 'application/json', payload: PAYLOAD,
}, 'claude-code');
const viaShared = await executeAgentMessageAction(getDatabase(), {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-c',
idempotency_key: 'k-517-shared', content_type: 'application/json', payload: PAYLOAD,
}, { transport: 'http', sourceHost: 'http' }) as { message_id: string };

const fetch = async (message_id: string) => JSON.parse(textOf(await handleTool('message', {
action: 'fetch', project: PROJECT, recipient: 'agent-c', message_id,
}, 'claude-code'))).payload;
const mcpId = (JSON.parse(textOf(viaMcp)) as { message_id: string }).message_id;
expect(await fetch(mcpId)).toEqual(await fetch(viaShared.message_id));
});

it('still treats a null-valued top-level optional parameter as left blank', async () => {
const sent = await handleTool('message', {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-d',
idempotency_key: 'k-517-blank', payload: 'hello', correlation_id: null, reply_to: null,
}, 'claude-code');
expect(sent.isError).toBeUndefined();
const body = JSON.parse(textOf(sent)) as { correlation_id: string | null; reply_to: string | null };
expect(body.correlation_id).toBeNull();
expect(body.reply_to).toBeNull();
});
});
Loading