This commit is contained in:
56
packages/memory-mcp/tests/erase.test.ts
Normal file
56
packages/memory-mcp/tests/erase.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* erase_memory tool — the safety gate + mode validation.
|
||||
*
|
||||
* These guard paths return BEFORE any db access (getPersonalDb / MindErasure), so
|
||||
* they exercise the injection-defense confirm gate + the frame-XOR-subject
|
||||
* validation without initializing the memory engine. The actual erasure paths are
|
||||
* covered by the substrate (hive-mind-core erasure.test.ts) and the route
|
||||
* (server memory-erase-endpoint.test.ts), which call the same MindErasure
|
||||
* primitives this tool delegates to.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { registerEraseTools } from '../src/tools/erase.js';
|
||||
|
||||
type ToolResult = { content: Array<{ type: string; text: string }>; isError?: boolean };
|
||||
type Handler = (args: Record<string, unknown>) => Promise<ToolResult>;
|
||||
|
||||
/** Register the tool on a stub server and capture its handler (4th tool() arg). */
|
||||
function captureHandler(): Handler {
|
||||
let handler: Handler | undefined;
|
||||
const server = {
|
||||
tool: (_name: string, _desc: string, _schema: unknown, h: Handler) => { handler = h; },
|
||||
} as unknown as McpServer;
|
||||
registerEraseTools(server);
|
||||
if (!handler) throw new Error('erase_memory handler was not registered');
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe('erase_memory — safety gate + mode validation', () => {
|
||||
const handler = captureHandler();
|
||||
|
||||
it('refuses without confirm=true (deny-default injection defense)', async () => {
|
||||
const res = await handler({ frame_id: 1 });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0].text).toMatch(/confirm=true/i);
|
||||
});
|
||||
|
||||
it('refuses an explicit confirm=false', async () => {
|
||||
const res = await handler({ confirm: false, source: 'claude', source_ref: 'x' });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0].text).toMatch(/confirm=true/i);
|
||||
});
|
||||
|
||||
it('refuses when NEITHER frame_id nor source/source_ref is given', async () => {
|
||||
const res = await handler({ confirm: true });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0].text).toMatch(/exactly one target/i);
|
||||
});
|
||||
|
||||
it('refuses when BOTH frame_id and a subject are given', async () => {
|
||||
const res = await handler({ confirm: true, frame_id: 1, source: 'claude', source_ref: 'x' });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(res.content[0].text).toMatch(/not both/i);
|
||||
});
|
||||
});
|
||||
235
packages/memory-mcp/tests/runtime.test.ts
Normal file
235
packages/memory-mcp/tests/runtime.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
||||
const SERVER_ENTRY = path.join(ROOT, 'packages', 'memory-mcp', 'dist', 'index.js');
|
||||
|
||||
function bin(name: string): string {
|
||||
return process.platform === 'win32' ? `${name}.cmd` : name;
|
||||
}
|
||||
|
||||
interface AsyncRunResult {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function run(command: string, args: string[]): Promise<AsyncRunResult> {
|
||||
return runInCwd(command, args, ROOT);
|
||||
}
|
||||
|
||||
function runInCwd(command: string, args: string[], cwd: string): Promise<AsyncRunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (status, signal) => resolve({ status, signal, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const timer = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([work, timer]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const WAGGLE_MEMORY_MCP_PACKAGE_CLOSURE = [
|
||||
'@waggle/shared',
|
||||
'@waggle/hive-mind-core',
|
||||
'@waggle/core',
|
||||
'@waggle/wiki-compiler',
|
||||
'waggle-memory-mcp',
|
||||
] as const;
|
||||
|
||||
describe('waggle-memory-mcp built runtime', () => {
|
||||
it('completes MCP initialize and lists read-only tools from built JS', async () => {
|
||||
const coreBuild = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/core']);
|
||||
expect(coreBuild.status).toBe(0);
|
||||
|
||||
const wikiBuild = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/wiki-compiler']);
|
||||
expect(wikiBuild.status).toBe(0);
|
||||
|
||||
const mcpBuild = await run(bin('npm'), ['run', 'build', '--workspace', 'waggle-memory-mcp']);
|
||||
expect(mcpBuild.status).toBe(0);
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-memory-mcp-'));
|
||||
const client = new Client({ name: 'waggle-memory-mcp-runtime-test', version: '0.0.0' });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [SERVER_ENTRY],
|
||||
env: {
|
||||
...process.env,
|
||||
WAGGLE_DATA_DIR: dataDir,
|
||||
WAGGLE_EMBEDDING_PROVIDER: 'mock',
|
||||
WAGGLE_MCP_SCOPES: 'memory:read',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(client.connect(transport), 10_000);
|
||||
const tools = await withTimeout(client.listTools(), 10_000);
|
||||
const names = tools.tools.map((tool) => tool.name);
|
||||
|
||||
expect(names).toContain('recall_memory');
|
||||
expect(names).toContain('search_entities');
|
||||
expect(names).not.toContain('save_memory');
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('saves and recalls memory through the built write-scope MCP server', async () => {
|
||||
const coreBuild = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/core']);
|
||||
expect(coreBuild.status).toBe(0);
|
||||
|
||||
const wikiBuild = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle/wiki-compiler']);
|
||||
expect(wikiBuild.status).toBe(0);
|
||||
|
||||
const mcpBuild = await run(bin('npm'), ['run', 'build', '--workspace', 'waggle-memory-mcp']);
|
||||
expect(mcpBuild.status).toBe(0);
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-memory-mcp-write-'));
|
||||
const client = new Client({ name: 'waggle-memory-mcp-write-test', version: '0.0.0' });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [SERVER_ENTRY],
|
||||
env: {
|
||||
...process.env,
|
||||
WAGGLE_DATA_DIR: dataDir,
|
||||
WAGGLE_EMBEDDING_PROVIDER: 'mock',
|
||||
WAGGLE_MCP_SCOPES: 'memory:write',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
const memoryText = `MCP write roundtrip ${Date.now()} keeps honeycomb context`;
|
||||
|
||||
try {
|
||||
await withTimeout(client.connect(transport), 10_000);
|
||||
const tools = await withTimeout(client.listTools(), 10_000);
|
||||
const names = tools.tools.map((tool) => tool.name);
|
||||
expect(names).toContain('save_memory');
|
||||
|
||||
const saved = await withTimeout(client.callTool({
|
||||
name: 'save_memory',
|
||||
arguments: {
|
||||
content: memoryText,
|
||||
importance: 'important',
|
||||
source: 'tool_verified',
|
||||
},
|
||||
}), 10_000);
|
||||
const savedText = saved.content
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
expect(savedText).toContain(memoryText);
|
||||
|
||||
const recalled = await withTimeout(client.callTool({
|
||||
name: 'recall_memory',
|
||||
arguments: {
|
||||
query: 'honeycomb context',
|
||||
limit: 5,
|
||||
scope: 'personal',
|
||||
},
|
||||
}), 10_000);
|
||||
const recalledText = recalled.content
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
|
||||
expect(recalledText).toContain(memoryText);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('installs the local package closure and lists tools from the installed server', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-memory-mcp-installed-'));
|
||||
const dataDir = path.join(tempDir, 'data');
|
||||
const packsDir = path.join(tempDir, 'packs');
|
||||
const projectDir = path.join(tempDir, 'project');
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.mkdirSync(packsDir, { recursive: true });
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
const client = new Client({ name: 'waggle-memory-mcp-installed-test', version: '0.0.0' });
|
||||
|
||||
try {
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const workspace of WAGGLE_MEMORY_MCP_PACKAGE_CLOSURE) {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', workspace]);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', workspace, '--pack-destination', packsDir, '--json'],
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(packsDir, packResult.filename).replace(/\\/g, '/');
|
||||
dependencies[workspace] = `file:${tarball}`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module', dependencies }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwd(
|
||||
bin('npm'),
|
||||
['install', '--no-audit', '--no-fund', '--prefer-offline'],
|
||||
projectDir,
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const installedEntry = path.join(
|
||||
projectDir,
|
||||
'node_modules',
|
||||
'waggle-memory-mcp',
|
||||
'dist',
|
||||
'index.js',
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [installedEntry],
|
||||
env: {
|
||||
...process.env,
|
||||
WAGGLE_DATA_DIR: dataDir,
|
||||
WAGGLE_EMBEDDING_PROVIDER: 'mock',
|
||||
WAGGLE_MCP_SCOPES: 'memory:read',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
|
||||
await withTimeout(client.connect(transport), 10_000);
|
||||
const tools = await withTimeout(client.listTools(), 10_000);
|
||||
const names = tools.tools.map((tool) => tool.name);
|
||||
|
||||
expect(names).toContain('recall_memory');
|
||||
expect(names).toContain('search_entities');
|
||||
expect(names).not.toContain('save_memory');
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
140
packages/memory-mcp/tests/scope.test.ts
Normal file
140
packages/memory-mcp/tests/scope.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
|
||||
import {
|
||||
parseScopes,
|
||||
isFullAccess,
|
||||
isToolAllowed,
|
||||
scopeGatedServer,
|
||||
READ_TOOLS,
|
||||
WRITE_TOOLS,
|
||||
} from '../src/scope.js';
|
||||
|
||||
import { registerMemoryTools } from '../src/tools/memory.js';
|
||||
import { registerKnowledgeTools } from '../src/tools/knowledge.js';
|
||||
import { registerIdentityTools } from '../src/tools/identity.js';
|
||||
import { registerAwarenessTools } from '../src/tools/awareness.js';
|
||||
import { registerWorkspaceTools } from '../src/tools/workspace.js';
|
||||
import { registerHarvestTools } from '../src/tools/harvest.js';
|
||||
import { registerCleanupTools } from '../src/tools/cleanup.js';
|
||||
import { registerEraseTools } from '../src/tools/erase.js';
|
||||
import { registerIngestTools } from '../src/tools/ingest.js';
|
||||
import { registerWikiTools } from '../src/tools/wiki.js';
|
||||
|
||||
function makeStub(): { server: McpServer; names: () => string[] } {
|
||||
const tools: string[] = [];
|
||||
const server = {
|
||||
tool: (name: string) => { tools.push(name); },
|
||||
resource: () => { /* read-only resources, not gated */ },
|
||||
} as unknown as McpServer;
|
||||
return { server, names: () => tools.slice().sort() };
|
||||
}
|
||||
|
||||
function registerAll(server: McpServer): void {
|
||||
registerMemoryTools(server);
|
||||
registerKnowledgeTools(server);
|
||||
registerIdentityTools(server);
|
||||
registerAwarenessTools(server);
|
||||
registerWorkspaceTools(server);
|
||||
registerHarvestTools(server);
|
||||
registerCleanupTools(server);
|
||||
registerEraseTools(server);
|
||||
registerIngestTools(server);
|
||||
registerWikiTools(server);
|
||||
}
|
||||
|
||||
describe('parseScopes', () => {
|
||||
it('defaults to full read+write when unset', () => {
|
||||
const s = parseScopes(undefined);
|
||||
expect(s.has('memory:read')).toBe(true);
|
||||
expect(s.has('memory:write')).toBe(true);
|
||||
expect(isFullAccess(s)).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to full read+write when empty/whitespace', () => {
|
||||
expect(isFullAccess(parseScopes(''))).toBe(true);
|
||||
expect(isFullAccess(parseScopes(' '))).toBe(true);
|
||||
});
|
||||
|
||||
it('read-only scope grants read but not write', () => {
|
||||
const s = parseScopes('memory:read');
|
||||
expect(s.has('memory:read')).toBe(true);
|
||||
expect(s.has('memory:write')).toBe(false);
|
||||
expect(isFullAccess(s)).toBe(false);
|
||||
});
|
||||
|
||||
it('write scope implies read (write-implies-read)', () => {
|
||||
const s = parseScopes('memory:write');
|
||||
expect(s.has('memory:read')).toBe(true);
|
||||
expect(s.has('memory:write')).toBe(true);
|
||||
});
|
||||
|
||||
it('comma list with both scopes parses to full (order/space tolerant)', () => {
|
||||
expect(isFullAccess(parseScopes('memory:read,memory:write'))).toBe(true);
|
||||
expect(isFullAccess(parseScopes(' memory:write , memory:read '))).toBe(true);
|
||||
});
|
||||
|
||||
it('explicit-but-unrecognized value falls closed to read-only', () => {
|
||||
const s = parseScopes('memory:bogus');
|
||||
expect(s.has('memory:read')).toBe(true);
|
||||
expect(s.has('memory:write')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolAllowed', () => {
|
||||
const ro = parseScopes('memory:read');
|
||||
const rw = parseScopes('memory:write');
|
||||
|
||||
it('read-only allows every read tool', () => {
|
||||
for (const name of READ_TOOLS) expect(isToolAllowed(name, ro)).toBe(true);
|
||||
});
|
||||
|
||||
it('read-only denies every write tool', () => {
|
||||
for (const name of WRITE_TOOLS) expect(isToolAllowed(name, ro)).toBe(false);
|
||||
});
|
||||
|
||||
it('write scope allows both read and write tools', () => {
|
||||
for (const name of [...READ_TOOLS, ...WRITE_TOOLS]) {
|
||||
expect(isToolAllowed(name, rw)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('unknown tool names fail safe (treated as write)', () => {
|
||||
expect(isToolAllowed('totally_new_tool', ro)).toBe(false);
|
||||
expect(isToolAllowed('totally_new_tool', rw)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeGatedServer registration gating', () => {
|
||||
it('read-only scope registers only the 9 read tools — never save/cleanup/ingest', () => {
|
||||
const { server, names } = makeStub();
|
||||
const gated = scopeGatedServer(server, parseScopes('memory:read'));
|
||||
registerAll(gated);
|
||||
|
||||
expect(names()).toEqual([...READ_TOOLS].sort());
|
||||
expect(names()).not.toContain('save_memory');
|
||||
expect(names()).not.toContain('cleanup_frames');
|
||||
expect(names()).not.toContain('cleanup_entities');
|
||||
expect(names()).not.toContain('ingest_source');
|
||||
expect(names()).not.toContain('erase_memory'); // destructive Art.17 tool is write-only
|
||||
expect(names()).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('write scope (implies read) registers all 22 tools incl. erase_memory', () => {
|
||||
const { server, names } = makeStub();
|
||||
const gated = scopeGatedServer(server, parseScopes('memory:write'));
|
||||
registerAll(gated);
|
||||
expect(names()).toContain('erase_memory');
|
||||
expect(names()).toHaveLength(22);
|
||||
});
|
||||
|
||||
it('default (unset) is unchanged — proxy skipped, all 22 tools register directly', () => {
|
||||
const { server, names } = makeStub();
|
||||
const scopes = parseScopes(undefined);
|
||||
// mirror index.ts: full access skips the proxy entirely
|
||||
const targetServer = isFullAccess(scopes) ? server : scopeGatedServer(server, scopes);
|
||||
expect(targetServer).toBe(server);
|
||||
registerAll(targetServer);
|
||||
expect(names()).toHaveLength(22);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user