mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-17 23:28:04 +02:00
* feat: add read-only coordination inventory and overlap evaluation * test: make coordination process fixtures platform explicit * test: report bounded Stop wrapper failure diagnostics * test: clean up failed memory MCP sessions deterministically * fix: update js-yaml to patched 4.3.2 * feat(coordination): distinguish declared goals from open sessions
900 lines
30 KiB
JavaScript
900 lines
30 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.mcpDiagnostic ? JSON.stringify(error.mcpDiagnostic) : error.stack || error.message}`);
|
|
failed += 1;
|
|
}
|
|
}
|
|
|
|
function createFixture(extraEnv = {}) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-'));
|
|
try {
|
|
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',
|
|
ECC_MEMORY_ALLOW_USER_SCOPE: '0',
|
|
...extraEnv,
|
|
}).filter(([, value]) => typeof value === 'string')
|
|
),
|
|
};
|
|
} catch (error) {
|
|
try { fs.rmSync(root, { recursive: true, force: true }); }
|
|
catch {
|
|
const failure = new Error('MCP fixture cleanup failed', { cause: error });
|
|
failure.mcpCleanupFailure = 'fixture_removal_error';
|
|
throw failure;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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 started = Date.now();
|
|
const pending = new Map();
|
|
const mode = options.env?.ECC_MEMORY_ALLOW_USER_SCOPE === '1' ? 'allow' : 'deny';
|
|
let fixture;
|
|
let child;
|
|
let phase = 'setup';
|
|
let nextId = 1;
|
|
let stdout = Buffer.alloc(0);
|
|
let stdoutBytes = 0;
|
|
let stderrBytes = 0;
|
|
let closed = false;
|
|
let tearingDown = false;
|
|
let transportError;
|
|
let primaryError;
|
|
let primaryFailed = false;
|
|
let failureKind;
|
|
let failureElapsedMs;
|
|
let teardownStarted;
|
|
let failurePhase;
|
|
let cleanupFailure;
|
|
let killStatus = 'not_attempted';
|
|
let notifyClose;
|
|
const closePromise = new Promise(resolve => { notifyClose = resolve; });
|
|
let rejectTransport;
|
|
const transportFailure = new Promise((_, reject) => { rejectTransport = reject; });
|
|
// The child may fail before the initialize or callback race is installed.
|
|
transportFailure.catch(() => {});
|
|
|
|
const bounded = value => Math.min(2147483647, Math.max(0, Math.trunc(value)));
|
|
const safeCode = error => [
|
|
'EPIPE', 'ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'ECONNRESET',
|
|
'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END', 'ERR_ASSERTION',
|
|
].includes(error?.code) ? error.code : null;
|
|
const diagnostic = () => ({
|
|
phase: failurePhase || phase,
|
|
mode,
|
|
reason: failureKind || cleanupFailure || 'assertion_or_callback',
|
|
failureElapsedMs: failureElapsedMs ?? null,
|
|
teardownElapsedMs: bounded(Date.now() - teardownStarted),
|
|
elapsedMs: bounded(Date.now() - started),
|
|
stdoutBytes,
|
|
stderrBytes,
|
|
pendingRequests: pending.size,
|
|
childStarted: Boolean(child?.pid),
|
|
childClosed: closed,
|
|
exitCode: Number.isInteger(child?.exitCode) ? child.exitCode : null,
|
|
signal: ['SIGTERM', 'SIGKILL', 'SIGINT'].includes(child?.signalCode) ? child.signalCode : null,
|
|
errorCode: safeCode(primaryError),
|
|
cleanupFailure: cleanupFailure || null,
|
|
killStatus,
|
|
});
|
|
function settleAll(error) {
|
|
for (const waiter of pending.values()) waiter.reject(error);
|
|
pending.clear();
|
|
}
|
|
function fail(kind, cause) {
|
|
if (tearingDown) {
|
|
cleanupFailure ||= kind;
|
|
return;
|
|
}
|
|
if (transportError) return;
|
|
transportError = new Error(`MCP test client ${kind}`);
|
|
if (safeCode(cause)) transportError.code = safeCode(cause);
|
|
failurePhase = phase;
|
|
failureKind = kind;
|
|
settleAll(transportError);
|
|
rejectTransport(transportError);
|
|
}
|
|
function send(message) {
|
|
if (transportError) throw transportError;
|
|
try {
|
|
child.stdin.write(`${JSON.stringify(message)}\n`, error => {
|
|
if (error) fail('stdin_write_error', error);
|
|
});
|
|
} catch (error) {
|
|
fail('stdin_write_error', error);
|
|
throw transportError;
|
|
}
|
|
}
|
|
function request(method, params = {}) {
|
|
const id = nextId++;
|
|
const promise = new Promise((resolve, reject) => {
|
|
if (transportError || tearingDown || closed) {
|
|
reject(transportError || new Error('MCP test client is closed'));
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
fail('request_timeout');
|
|
}, 5000);
|
|
function settle(fn, value) {
|
|
clearTimeout(timer);
|
|
pending.delete(id);
|
|
fn(value);
|
|
}
|
|
pending.set(id, {
|
|
resolve: value => settle(resolve, value),
|
|
reject: error => settle(reject, error),
|
|
});
|
|
send({ jsonrpc: '2.0', id, method, params });
|
|
});
|
|
// Teardown rejects abandoned requests too, without an unhandled rejection.
|
|
promise.catch(() => {});
|
|
return promise;
|
|
}
|
|
|
|
try {
|
|
fixture = createFixture(options.env);
|
|
phase = 'spawn';
|
|
child = spawn(process.execPath, [options.server || SERVER], {
|
|
cwd: fixture.projectRoot,
|
|
env: fixture.env,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
child.on('error', error => fail('child_error', error));
|
|
child.on('exit', () => {
|
|
if (!tearingDown) fail('child_exit');
|
|
});
|
|
child.once('close', () => {
|
|
closed = true;
|
|
notifyClose();
|
|
if (!tearingDown) fail('child_close');
|
|
});
|
|
for (const stream of ['stdin', 'stdout', 'stderr']) {
|
|
child[stream].on('error', error => fail(`${stream}_error`, error));
|
|
}
|
|
child.stdout.on('end', () => { if (!tearingDown) fail('stdout_end'); });
|
|
for (const stream of ['stdin', 'stdout']) {
|
|
child[stream].on('close', () => { if (!tearingDown) fail(`${stream}_close`); });
|
|
}
|
|
child.stderr.on('data', chunk => {
|
|
stderrBytes = bounded(stderrBytes + chunk.length);
|
|
});
|
|
child.stdout.on('data', chunk => {
|
|
stdoutBytes = bounded(stdoutBytes + chunk.length);
|
|
if (transportError || tearingDown) return;
|
|
// Decode complete lines, so a UTF-8 character split across chunks survives.
|
|
stdout = Buffer.concat([stdout, chunk]);
|
|
let newlineIndex;
|
|
while ((newlineIndex = stdout.indexOf(10)) >= 0) {
|
|
if (newlineIndex > 1024 * 1024) { fail('oversized_frame'); return; }
|
|
const line = stdout.subarray(0, newlineIndex).toString('utf8');
|
|
stdout = stdout.subarray(newlineIndex + 1);
|
|
if (!line.trim()) continue;
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
if (!message || message.jsonrpc !== '2.0' || !Number.isInteger(message.id)
|
|
|| (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error'))
|
|
|| (Object.hasOwn(message, 'error') && (!message.error
|
|
|| !Number.isInteger(message.error.code) || typeof message.error.message !== 'string'))) {
|
|
fail('invalid_frame');
|
|
return;
|
|
}
|
|
} catch {
|
|
fail('malformed_frame');
|
|
return;
|
|
}
|
|
const waiter = pending.get(message.id);
|
|
if (waiter) {
|
|
if (message.error) {
|
|
// Existing authorization/protocol assertions inspect this RPC error.
|
|
// The test logger emits only mcpDiagnostic when it escapes the helper.
|
|
waiter.reject(new Error(`${message.error.code}: ${message.error.message}`));
|
|
} else {
|
|
waiter.resolve(message.result);
|
|
}
|
|
}
|
|
}
|
|
if (stdout.length > 1024 * 1024) fail('oversized_frame');
|
|
});
|
|
|
|
phase = 'initialize';
|
|
const initialized = await request('initialize', {
|
|
protocolVersion: '2025-11-25',
|
|
capabilities: {},
|
|
clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
|
|
});
|
|
phase = 'protocol';
|
|
assert.strictEqual(initialized.protocolVersion, '2025-11-25');
|
|
phase = 'notification';
|
|
send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
|
|
const client = {
|
|
listTools: () => request('tools/list'),
|
|
listToolsRaw: params => request('tools/list', params),
|
|
callTool: ({ name, arguments: toolArguments }) => request(
|
|
'tools/call',
|
|
{ name, arguments: toolArguments }
|
|
),
|
|
callToolRaw: params => request('tools/call', params),
|
|
};
|
|
phase = 'callback';
|
|
await Promise.race([Promise.resolve().then(() => fn(client, fixture)), transportFailure]);
|
|
if (transportError) throw transportError;
|
|
assert.strictEqual(pending.size, 0, 'MCP callback must await its requests');
|
|
} catch (error) {
|
|
primaryError = error;
|
|
primaryFailed = true;
|
|
failurePhase ||= phase;
|
|
failureElapsedMs = bounded(Date.now() - started);
|
|
if (error?.mcpCleanupFailure === 'fixture_removal_error') {
|
|
cleanupFailure ||= 'fixture_removal_error';
|
|
}
|
|
} finally {
|
|
tearingDown = true;
|
|
teardownStarted = Date.now();
|
|
phase = 'teardown';
|
|
settleAll(new Error('MCP test client is closing'));
|
|
stdout = Buffer.alloc(0);
|
|
if (child && !closed) {
|
|
// Keep the original total 2000 ms budget. Reserve its latter half for
|
|
// direct-child termination and stdio close, including on Windows.
|
|
let killTimer;
|
|
let deadlineTimer;
|
|
function terminate() {
|
|
try { killStatus = child.kill() ? 'requested' : 'not_sent'; }
|
|
catch { killStatus = 'error'; }
|
|
}
|
|
const deadline = new Promise(resolve => {
|
|
deadlineTimer = setTimeout(resolve, 2000);
|
|
killTimer = setTimeout(terminate, 1000);
|
|
});
|
|
try {
|
|
try { child.stdin.end(); }
|
|
catch {
|
|
cleanupFailure ||= 'stdin_end_error';
|
|
clearTimeout(killTimer);
|
|
terminate();
|
|
}
|
|
await Promise.race([closePromise, deadline]);
|
|
} finally {
|
|
clearTimeout(killTimer);
|
|
clearTimeout(deadlineTimer);
|
|
}
|
|
if (!closed) cleanupFailure ||= 'child_close_timeout';
|
|
}
|
|
if (fixture && (!child || closed)) {
|
|
try { fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
|
catch { cleanupFailure ||= 'fixture_removal_error'; }
|
|
}
|
|
}
|
|
if (primaryFailed || cleanupFailure) {
|
|
if (!primaryFailed) primaryError = new Error('MCP test client cleanup failed');
|
|
// Keep the primary assertion/RPC/callback error; cleanup must not replace it.
|
|
// A wrapper retains non-extensible or non-Error thrown values as its cause.
|
|
if (!primaryError || typeof primaryError !== 'object' || !Object.isExtensible(primaryError)
|
|
|| Object.getOwnPropertyDescriptor(primaryError, 'mcpDiagnostic')?.configurable === false
|
|
|| Object.getOwnPropertyDescriptor(primaryError, 'mcpCleanupFailure')?.configurable === false) {
|
|
primaryError = new Error('MCP test client failed', { cause: primaryError });
|
|
}
|
|
Object.defineProperty(primaryError, 'mcpDiagnostic', { value: diagnostic(), configurable: true });
|
|
if (cleanupFailure) {
|
|
Object.defineProperty(primaryError, 'mcpCleanupFailure', { value: cleanupFailure, configurable: true });
|
|
}
|
|
throw primaryError;
|
|
}
|
|
}
|
|
|
|
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 reserved tools/list params and rejects malformed values', async () => {
|
|
await withClient(async client => {
|
|
const withMeta = await client.listToolsRaw({
|
|
_meta: { progressToken: 'progress-123' },
|
|
});
|
|
assert.strictEqual(withMeta.tools.length, 4);
|
|
|
|
const withCursor = await client.listToolsRaw({ cursor: 'next-page' });
|
|
assert.strictEqual(withCursor.tools.length, 4);
|
|
|
|
const withCursorAndMeta = await client.listToolsRaw({
|
|
cursor: 'next-page',
|
|
_meta: { progressToken: 'progress-456' },
|
|
});
|
|
assert.strictEqual(withCursorAndMeta.tools.length, 4);
|
|
|
|
const withoutMeta = await client.listTools();
|
|
assert.deepStrictEqual(
|
|
withoutMeta.tools.map(tool => tool.name).sort(),
|
|
['memory_doctor', 'memory_read', 'memory_save', 'memory_search']
|
|
);
|
|
|
|
for (const badMeta of [null, ['not', 'an', 'object'], 'string', 42, true]) {
|
|
await assert.rejects(
|
|
client.listToolsRaw({ _meta: badMeta }),
|
|
/-32602/,
|
|
`expected _meta=${JSON.stringify(badMeta)} to be rejected`
|
|
);
|
|
}
|
|
|
|
for (const badCursor of [null, {}, [], 42, true]) {
|
|
await assert.rejects(
|
|
client.listToolsRaw({ cursor: badCursor }),
|
|
/-32602/,
|
|
`expected cursor=${JSON.stringify(badCursor)} to be rejected`
|
|
);
|
|
}
|
|
|
|
await assert.rejects(
|
|
client.listToolsRaw({ unexpected: true }),
|
|
/-32602/
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|