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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ All notable changes to MeMesh are documented here.

### 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".
- 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", except `payload`, whose null is a value (#553).
- The MCP `message` tool accepts `payload: null` on `send`, as its schema and the HTTP API already did; it was refused with `payload: Invalid input` (#553).

## [4.10.11] — 2026-09-29

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: e38a65e55d9a8be9760a92d20e69a2f71d0a5bd6c26cddb004fa3c26f84364e5
Source map SHA-256: 6f92117584bd8615b1b57545acd20bf6e77467e1c395342242f6dbe8df3e3615
Bundle SHA-256: d81a77176966492c52bec0e8923da9a38ba9bc2665b9bf96221a22fd724e6a66
Source map SHA-256: bc916581b5eb47f3726e3523d9978f450962a4071421ce5ad0253a8669acb56f

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

Expand Down
3 changes: 2 additions & 1 deletion dist/mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -32445,11 +32445,12 @@ function stripNullProps(value) {
return value;
const out = {};
for (const [k, v] of Object.entries(value)) {
if (v !== null)
if (v !== null || NULL_IS_DATA.has(k))
out[k] = v;
}
return out;
}
var NULL_IS_DATA = /* @__PURE__ */ new Set(["payload"]);
function parseOrFail(schema, args) {
const raw = args === void 0 || args === null ? {} : args;
const strictPass = schema.safeParse(raw);
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.

3 changes: 2 additions & 1 deletion 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.

15 changes: 12 additions & 3 deletions src/transports/mcp/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,9 @@ function formatIssue(issue: z.ZodIssue): string {
* where Claude Code and Codex omit the key entirely. Zod's `.optional()`
* accepts the missing key but rejects the explicit null, so the exact same
* 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.
* this boundary a null-valued property means "left blank", so it is dropped
* before validation — except where null is itself the value (NULL_IS_DATA
* below: a message `payload`, #553).
*
* 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
Expand All @@ -480,11 +481,19 @@ function stripNullProps(value: unknown): unknown {
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;
if (v !== null || NULL_IS_DATA.has(k)) out[k] = v;
}
return out;
}

/**
* A top-level parameter whose null is a value, not a blank: a message
* `payload` is any JSON value, null included, as the schema and the HTTP
* path accept (#553). Only `send` declares it; on every other action the
* unknown-key check above has already refused it.
*/
const NULL_IS_DATA: ReadonlySet<string> = new Set(['payload']);

function parseOrFail<T>(schema: z.ZodType<T>, args: unknown):
| { ok: true; data: T }
| { ok: false; message: string; result: ToolResult } {
Expand Down
56 changes: 56 additions & 0 deletions tests/transports/mcp-null-payload-stdio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* #553 over the real stdio server: the packaged MCP bundle accepts a message
* `payload: null` on `send`, returns it on `fetch`, and still refuses a send
* with no payload and a payload on a non-send action.
*/
import { it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { removeTempDir } from '../helpers/temp-dir.js';

it('#553 the packaged MCP server sends and fetches a null payload', async () => {
const server = fileURLToPath(new URL('../../dist/mcp/server.js', import.meta.url));
const runtime = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-mcp-null-payload-')));
const env = {
HOME: runtime, USERPROFILE: runtime, MEMESH_DIR: runtime,
MEMESH_DB_PATH: path.join(runtime, 'memory.db'),
MEMESH_AUTO_UPDATE: '0',
PATH: path.dirname(process.execPath),
};
const client = new Client({ name: 'null-payload-test', version: '1' });
const transport = new StdioClientTransport({ command: process.execPath, args: [server], cwd: runtime, env, stderr: 'pipe' });
const call = async (args: Record<string, unknown>) => {
const response = await client.callTool({ name: 'message', arguments: args }, undefined, { timeout: 30000 });
const content = response.content as Array<{ type: string; text?: string }>;
return { isError: response.isError === true, text: content[0]?.text ?? '' };
};
const base = { project: 'p553', sender: 'agent-a', recipient: 'agent-b', content_type: 'application/json' };
try {
await client.connect(transport);

const sent = await call({ action: 'send', ...base, idempotency_key: 'k-null', payload: null });
expect(sent.isError, sent.text).toBe(false);
const { message_id } = JSON.parse(sent.text) as { message_id: string };

const fetched = await call({ action: 'fetch', project: 'p553', recipient: 'agent-b', message_id });
expect(fetched.isError, fetched.text).toBe(false);
const body = JSON.parse(fetched.text) as Record<string, unknown>;
expect(body).toHaveProperty('payload');
expect(body.payload).toBeNull();

const missing = await call({ action: 'send', ...base, idempotency_key: 'k-missing' });
expect(missing.isError).toBe(true);
expect(missing.text).toContain('payload');

const onFetch = await call({ action: 'fetch', project: 'p553', recipient: 'agent-b', message_id, payload: null });
expect(onFetch.isError).toBe(true);
expect(onFetch.text).toContain('payload');
} finally {
await client.close();
removeTempDir(runtime);
}
}, 60000);
32 changes: 32 additions & 0 deletions tests/transports/mcp-payload-nulls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,38 @@ describe('Feature: #517 the MCP message tool stores a JSON payload exactly as se
expect(await fetch(mcpId)).toEqual(await fetch(viaShared.message_id));
});

it('#553 sends a null payload, as the schema allows, and stores what the shared send stores', async () => {
const sent = await handleTool('message', {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-e',
idempotency_key: 'k-553-mcp', content_type: 'application/json', payload: null,
}, 'claude-code');
expect(sent.isError, textOf(sent)).toBeUndefined();
const { message_id } = JSON.parse(textOf(sent)) as { message_id: string };
const viaShared = await executeAgentMessageAction(getDatabase(), {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-e',
idempotency_key: 'k-553-shared', content_type: 'application/json', payload: null,
}, { transport: 'http', sourceHost: 'http' }) as { message_id: string };

for (const id of [message_id, viaShared.message_id]) {
const fetched = await handleTool('message', {
action: 'fetch', project: PROJECT, recipient: 'agent-e', message_id: id,
}, 'claude-code');
expect(fetched.isError).toBeUndefined();
const body = JSON.parse(textOf(fetched)) as Record<string, unknown>;
expect(body).toHaveProperty('payload');
expect(body.payload).toBeNull();
}
});

it('#553 a send that omits the payload is still refused', async () => {
const sent = await handleTool('message', {
action: 'send', project: PROJECT, sender: 'agent-a', recipient: 'agent-f',
idempotency_key: 'k-553-missing', content_type: 'application/json',
}, 'claude-code');
expect(sent.isError).toBe(true);
expect(textOf(sent)).toContain('payload');
});

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',
Expand Down
Loading