mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* 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>
688 lines
21 KiB
JavaScript
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);
|
|
});
|