Files
ECC/tests/scripts/memory-mcp.test.js
T
7b76082b13 fix(mcp): accept reserved _meta field in tools/call params (#2670)
* fix(mcp): accept reserved _meta field in tools/call params

The memory MCP server rejected any tools/call whose params contained a key
other than name/arguments, returning -32602 "Unknown or missing memory tool."

MCP clients (e.g. Claude Code) attach the spec-reserved `_meta` field
(such as progressToken) to request params, so every tool call from a
compliant client failed and the entire memory MCP surface was unreachable —
even though initialize/tools-list and the `ecc memory` CLI kept working.

Per the MCP base protocol, `_meta` is reserved for request metadata and
must be accepted. Add it to the params key allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(mcp): validate _meta shape and cover tools/call param allowlist

Address CodeRabbit review on #2670:
- Validate params._meta when present: accept metadata objects, reject null,
  arrays, and scalar values (reuses isRecord). Keeps _meta optional and
  preserves existing name/arguments/unexpected-key rejection.
- Add regression tests: accept _meta with progressToken, reject malformed
  _meta values, and continue rejecting unrelated top-level params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 00:28:09 -04:00

688 lines
21 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { PassThrough } = require('stream');
const { pathToFileURL } = require('url');
const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs');
const {
MAX_RESULTS,
resolveVaultRoots,
saveMemory,
} = require('../../scripts/lib/memory-vault');
let passed = 0;
let failed = 0;
async function test(name, fn) {
try {
await fn();
console.log(` PASS ${name}`);
passed += 1;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` ${error.stack || error.message}`);
failed += 1;
}
}
function createFixture(extraEnv = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-'));
const projectRoot = path.join(root, 'project');
const homeDir = path.join(root, 'home');
fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
return {
root,
projectRoot,
env: Object.fromEntries(
Object.entries({
...process.env,
HOME: homeDir,
USERPROFILE: homeDir,
ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
ECC_MEMORY_HARNESS: 'claude',
...extraEnv,
}).filter(([, value]) => typeof value === 'string')
),
};
}
function parseTextResult(result) {
const text = result.content?.find(item => item.type === 'text')?.text;
assert.ok(text, 'MCP result should contain text');
return JSON.parse(text);
}
async function withClient(fn, options = {}) {
const fixture = createFixture(options.env);
const child = spawn(process.execPath, [options.server || SERVER], {
cwd: fixture.projectRoot,
env: fixture.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
const pending = new Map();
let nextId = 1;
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => {
stdout += chunk.toString('utf8');
let newlineIndex = stdout.indexOf('\n');
while (newlineIndex >= 0) {
const line = stdout.slice(0, newlineIndex);
stdout = stdout.slice(newlineIndex + 1);
if (line.trim()) {
const message = JSON.parse(line);
const waiter = pending.get(message.id);
if (waiter) {
pending.delete(message.id);
if (message.error) {
waiter.reject(new Error(`${message.error.code}: ${message.error.message}`));
} else {
waiter.resolve(message.result);
}
}
}
newlineIndex = stdout.indexOf('\n');
}
});
child.stderr.on('data', chunk => {
stderr += chunk.toString('utf8');
});
function send(message) {
child.stdin.write(`${JSON.stringify(message)}\n`);
}
function request(method, params = {}) {
const id = nextId;
nextId += 1;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`Timed out waiting for ${method}. stderr: ${stderr}`));
}, 5000);
pending.set(id, {
resolve: value => {
clearTimeout(timeout);
resolve(value);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
send({ jsonrpc: '2.0', id, method, params });
});
}
const initialized = await request('initialize', {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
});
assert.strictEqual(initialized.protocolVersion, '2025-11-25');
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
const client = {
listTools: () => request('tools/list'),
callTool: ({ name, arguments: toolArguments }) => request(
'tools/call',
{ name, arguments: toolArguments }
),
callToolRaw: params => request('tools/call', params),
};
try {
await fn(client, fixture);
} finally {
child.stdin.end();
await new Promise(resolve => {
if (child.exitCode !== null) {
resolve();
return;
}
const timeout = setTimeout(() => {
child.kill();
resolve();
}, 2000);
child.once('exit', () => {
clearTimeout(timeout);
resolve();
});
});
fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
async function main() {
console.log('\n=== Testing ECC memory MCP server ===\n');
await test('registers the bounded read/write/search/doctor tool surface', async () => {
await withClient(async client => {
const tools = await client.listTools();
assert.deepStrictEqual(
tools.tools.map(tool => tool.name).sort(),
['memory_doctor', 'memory_read', 'memory_save', 'memory_search']
);
const save = tools.tools.find(tool => tool.name === 'memory_save');
const search = tools.tools.find(tool => tool.name === 'memory_search');
assert.ok(save.description.includes('unreviewed'));
assert.ok(!JSON.stringify(save.inputSchema).includes('trust'));
assert.ok(!JSON.stringify(save.inputSchema).includes('sourceHarness'));
assert.ok(!JSON.stringify(search.inputSchema).includes('targetHarness'));
assert.strictEqual(save.inputSchema.properties.body.minLength, 1);
});
});
await test('accepts the reserved _meta param on tools/call and rejects malformed values', async () => {
await withClient(async client => {
// A valid `_meta` object (e.g. progressToken) must not block the tool call.
const withMeta = await client.callToolRaw({
name: 'memory_doctor',
arguments: {},
_meta: { progressToken: 'progress-123' },
});
assert.ok(Array.isArray(withMeta.content));
// Baseline: no `_meta` still works.
const withoutMeta = await client.callToolRaw({
name: 'memory_doctor',
arguments: {},
});
assert.ok(Array.isArray(withoutMeta.content));
// A malformed `_meta` (null, array, or scalar) must be rejected.
for (const badMeta of [null, ['not', 'an', 'object'], 'string', 42, true]) {
await assert.rejects(
client.callToolRaw({ name: 'memory_doctor', arguments: {}, _meta: badMeta }),
/-32602/,
`expected _meta=${JSON.stringify(badMeta)} to be rejected`
);
}
// Unrelated top-level params must still be rejected.
await assert.rejects(
client.callToolRaw({ name: 'memory_doctor', arguments: {}, unexpected: true }),
/-32602/
);
});
});
await test('starts when the npm bin invokes the server through a symlink', async () => {
const binRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-bin-'));
const binPath = path.join(binRoot, 'ecc-memory-mcp');
fs.symlinkSync(SERVER, binPath);
try {
await withClient(async client => {
const tools = await client.listTools();
assert.strictEqual(tools.tools.length, 4);
}, { server: binPath });
} finally {
fs.rmSync(binRoot, { recursive: true, force: true });
}
});
await test('rejects an oversized partial line and recovers at the next message boundary', async () => {
const {
MAX_MESSAGE_BYTES,
runStdioServer,
} = await import(pathToFileURL(SERVER).href);
const input = new PassThrough();
const output = new PassThrough();
let rawOutput = '';
output.on('data', chunk => {
rawOutput += chunk.toString('utf8');
});
runStdioServer({
input,
output,
serviceOptions: { harness: 'claude' },
});
input.write(Buffer.alloc(MAX_MESSAGE_BYTES + 1, 0x78));
input.write(`\n${JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'bounded-test', version: '1.0.0' },
},
})}\n`);
input.end();
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for bounded output.')), 3000);
const poll = () => {
if (rawOutput.trim().split('\n').length >= 2) {
clearTimeout(timeout);
resolve();
} else {
setImmediate(poll);
}
};
poll();
});
const messages = rawOutput.trim().split('\n').map(line => JSON.parse(line));
assert.strictEqual(messages.length, 2);
assert.strictEqual(messages[0].error.code, -32700);
assert.strictEqual(messages[1].result.protocolVersion, '2025-11-25');
});
await test('shares a saved handoff through MCP search and read', async () => {
await withClient(async client => {
const savedResult = await client.callTool({
name: 'memory_save',
arguments: {
title: 'Codex to Claude handoff',
body: 'The migration is green; review the rollout note.',
kind: 'handoff',
scope: 'project',
targetHarnesses: ['claude'],
tags: ['migration'],
},
});
assert.strictEqual(savedResult.isError, undefined);
const saved = parseTextResult(savedResult);
assert.strictEqual(saved.memory.trust, 'unreviewed');
assert.strictEqual(saved.memory.sourceHarness, 'claude');
assert.strictEqual(Object.hasOwn(saved.memory, 'body'), false);
const searchResult = await client.callTool({
name: 'memory_search',
arguments: {
query: 'migration rollout',
limit: 5,
},
});
const search = parseTextResult(searchResult);
assert.strictEqual(search.results.length, 1);
assert.strictEqual(search.results[0].memory.id, saved.memory.id);
const readResult = await client.callTool({
name: 'memory_read',
arguments: { id: saved.memory.id },
});
const read = parseTextResult(readResult);
assert.strictEqual(read.memory.body, 'The migration is green; review the rollout note.');
const doctorResult = await client.callTool({
name: 'memory_doctor',
arguments: {},
});
const doctor = parseTextResult(doctorResult);
assert.strictEqual(doctor.ok, true);
assert.strictEqual(doctor.memoryCount, 1);
});
});
await test('rejects caller identity spoofing and hides other-harness memories', async () => {
await withClient(async client => {
await assert.rejects(
() => client.callTool({
name: 'memory_save',
arguments: {
title: 'Spoofed source',
body: 'This must not be accepted.',
sourceHarness: 'hermes',
},
}),
/-32602/
);
await assert.rejects(
() => client.callTool({
name: 'memory_search',
arguments: {
query: '',
targetHarness: 'hermes',
},
}),
/-32602/
);
const savedResult = await client.callTool({
name: 'memory_save',
arguments: {
title: 'Hermes-only handoff',
body: 'Only Hermes should receive this context.',
kind: 'handoff',
targetHarnesses: ['hermes'],
},
});
const saved = parseTextResult(savedResult);
assert.strictEqual(saved.memory.sourceHarness, 'claude');
const searchResult = await client.callTool({
name: 'memory_search',
arguments: { query: 'Hermes-only' },
});
assert.strictEqual(parseTextResult(searchResult).results.length, 0);
const readResult = await client.callTool({
name: 'memory_read',
arguments: { id: saved.memory.id },
});
assert.strictEqual(readResult.isError, true);
assert.strictEqual(parseTextResult(readResult).error.code, 'MEMORY_READ_FAILED');
const doctor = parseTextResult(await client.callTool({
name: 'memory_doctor',
arguments: {},
}));
assert.strictEqual(doctor.memoryCount, 0);
assert.strictEqual(Object.hasOwn(doctor, 'brokenLinks'), false);
assert.strictEqual(Object.hasOwn(doctor, 'invalidFiles'), false);
assert.strictEqual(JSON.stringify(doctor).includes(saved.memory.id), false);
});
});
await test('filters harness-visible backlinks before applying the response cap', async () => {
await withClient(async (client, fixture) => {
const roots = resolveVaultRoots({
cwd: fixture.projectRoot,
env: fixture.env,
});
const saveWithId = (input, id) => saveMemory(input, {
roots,
now: () => '2026-07-26T20:00:00.000Z',
idFactory: () => id,
});
const targetId = 'mem_backlink_target';
saveWithId({
title: 'Backlink target',
body: 'Visible target body.',
targetHarnesses: ['claude'],
}, targetId);
for (let index = 0; index < MAX_RESULTS; index += 1) {
saveWithId({
title: `Hidden backlink ${index}`,
body: 'Only Hermes may see this backlink.',
targetHarnesses: ['hermes'],
links: [targetId],
}, `mem_backlink_hidden_${String(index).padStart(3, '0')}`);
}
saveWithId({
title: 'Visible backlink',
body: 'Claude must still receive this backlink.',
targetHarnesses: ['claude'],
links: [targetId],
}, 'mem_backlink_visible_zzz');
const read = parseTextResult(await client.callTool({
name: 'memory_read',
arguments: { id: targetId },
}));
assert.deepStrictEqual(
read.backlinks.map(memory => memory.id),
['mem_backlink_visible_zzz']
);
assert.strictEqual(read.backlinksTruncated, false);
});
});
await test('denies user scope unless the server explicitly grants it', async () => {
await withClient(async client => {
await assert.rejects(
() => client.callTool({
name: 'memory_save',
arguments: {
title: 'Private preference',
body: 'Keep this in the user vault.',
scope: 'user',
},
}),
/user memory scope is disabled/
);
await assert.rejects(
() => client.callTool({
name: 'memory_search',
arguments: { scopes: ['user'] },
}),
/user memory scope is disabled/
);
await assert.rejects(
() => client.callTool({
name: 'memory_read',
arguments: {
id: 'mem_20260726_user_scope_denied',
scope: 'user',
},
}),
/user memory scope is disabled/
);
});
await withClient(async client => {
const savedResult = await client.callTool({
name: 'memory_save',
arguments: {
title: 'Private preference',
body: 'Keep this in the user vault.',
scope: 'user',
},
});
const saved = parseTextResult(savedResult);
assert.strictEqual(saved.memory.scope, 'user');
const defaultSearch = parseTextResult(await client.callTool({
name: 'memory_search',
arguments: { query: 'Private preference' },
}));
assert.strictEqual(defaultSearch.results.length, 0);
const userSearch = parseTextResult(await client.callTool({
name: 'memory_search',
arguments: {
query: 'Private preference',
scopes: ['user'],
},
}));
assert.strictEqual(userSearch.results[0].memory.id, saved.memory.id);
const userRead = parseTextResult(await client.callTool({
name: 'memory_read',
arguments: {
id: saved.memory.id,
scope: 'user',
},
}));
assert.strictEqual(userRead.memory.id, saved.memory.id);
}, { env: { ECC_MEMORY_ALLOW_USER_SCOPE: '1' } });
});
await test('requires server identity and strictly validates JSON-RPC envelopes', async () => {
const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href);
assert.throws(
() => createMemoryMcpService({ env: {} }),
/ECC_MEMORY_HARNESS/
);
const fixture = createFixture({ ECC_MEMORY_HARNESS: undefined });
try {
const started = spawnSync(process.execPath, [SERVER], {
cwd: fixture.projectRoot,
env: fixture.env,
encoding: 'utf8',
});
assert.strictEqual(started.error, undefined);
assert.strictEqual(started.status, 1);
assert.match(started.stderr, /ECC_MEMORY_HARNESS/);
assert.ok(!started.stderr.includes('\n at '));
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
const service = createMemoryMcpService({ harness: 'claude' });
for (const id of [null, false, {}, [], 1.5, Number.MAX_SAFE_INTEGER + 1, '']) {
const response = await service.handle({
jsonrpc: '2.0',
id,
method: 'initialize',
params: {},
});
assert.strictEqual(response.id, null);
assert.strictEqual(response.error.code, -32600);
}
const initialized = await service.handle({
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'strict-test', version: '1.0.0' },
},
});
assert.strictEqual(initialized.id, 0);
await service.handle({
jsonrpc: '2.0',
method: 'notifications/initialized',
params: {},
});
for (const toolArguments of [null, false, 0, '', []]) {
const response = await service.handle({
jsonrpc: '2.0',
id: `args-${String(toolArguments)}`,
method: 'tools/call',
params: {
name: 'memory_doctor',
arguments: toolArguments,
},
});
assert.strictEqual(response.error.code, -32602);
}
const invalidParams = await service.handle({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: [],
});
assert.strictEqual(invalidParams.error.code, -32600);
});
await test('bounds queued transport work under a single-chunk request flood', async () => {
const {
MAX_PENDING_MESSAGES,
runStdioServer,
} = await import(pathToFileURL(SERVER).href);
const input = new PassThrough();
const output = new PassThrough();
let rawOutput = '';
output.on('data', chunk => {
rawOutput += chunk.toString('utf8');
});
runStdioServer({
input,
output,
serviceOptions: { harness: 'claude' },
});
const requests = [
{
jsonrpc: '2.0',
id: 'init',
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'flood-test', version: '1.0.0' },
},
},
{
jsonrpc: '2.0',
method: 'notifications/initialized',
params: {},
},
...Array.from({ length: MAX_PENDING_MESSAGES * 4 }, (_, index) => ({
jsonrpc: '2.0',
id: `ping-${index}`,
method: 'ping',
params: {},
})),
];
input.end(`${requests.map(JSON.stringify).join('\n')}\n`);
await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timed out waiting for queue-limit response.')),
3000
);
const poll = () => {
if (rawOutput.includes('queue limit exceeded')) {
clearTimeout(timeout);
resolve();
} else {
setImmediate(poll);
}
};
poll();
});
const messages = rawOutput.trim().split('\n').map(line => JSON.parse(line));
assert.ok(messages.some(message => message.error?.code === -32000));
assert.ok(messages.length <= MAX_PENDING_MESSAGES + 2);
});
await test('bounds serialized tool responses before writing to stdout', async () => {
const {
MAX_RESPONSE_BYTES,
textResult,
} = await import(pathToFileURL(SERVER).href);
assert.throws(
() => textResult({ body: 'x'.repeat(MAX_RESPONSE_BYTES + 1) }),
/bounded output limit/
);
});
await test('returns a structured tool error without a stack trace for secret-bearing writes', async () => {
await withClient(async client => {
await assert.rejects(
() => client.callTool({
name: 'memory_save',
arguments: {
title: 'Empty body',
body: '',
},
}),
/-32602/
);
const secret = `ghp_${'A1'.repeat(12)}`;
const result = await client.callTool({
name: 'memory_save',
arguments: {
title: 'Do not persist this',
body: `credential ${secret}`,
},
});
assert.strictEqual(result.isError, true);
const error = parseTextResult(result);
assert.strictEqual(error.error.code, 'MEMORY_WRITE_REJECTED');
assert.ok(error.error.message.includes('suspected secret'));
assert.ok(!JSON.stringify(error).includes(secret));
assert.ok(!JSON.stringify(error).includes('\n at '));
});
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
if (failed > 0) {
process.exit(1);
}
}
main().catch(error => {
console.error(error);
process.exit(1);
});