204 lines
6.4 KiB
TypeScript
204 lines
6.4 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { promises as fs } from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { saveMcpRaw } from './raw-logger.js';
|
|
|
|
describe('saveMcpRaw', () => {
|
|
let workspace: string;
|
|
|
|
beforeEach(async () => {
|
|
workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-raw-'));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(workspace, { recursive: true, force: true });
|
|
});
|
|
|
|
async function listMcpDir(serverId: string): Promise<string[]> {
|
|
try {
|
|
return await fs.readdir(path.join(workspace, 'logs', 'mcp', serverId));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
it('writes a JSON file under logs/mcp/{serverId}/ with the expected structure', async () => {
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: 'canva',
|
|
toolName: 'generate_designs',
|
|
args: { prompt: 'cat' },
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
isError: false,
|
|
output: 'hello',
|
|
savedPaths: [],
|
|
});
|
|
|
|
const files = await listMcpDir('canva');
|
|
expect(files.length).toBe(1);
|
|
expect(files[0]).toMatch(/^generate_designs-.*\.json$/);
|
|
|
|
const body = JSON.parse(
|
|
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'canva', files[0]), 'utf-8'),
|
|
);
|
|
expect(body.serverId).toBe('canva');
|
|
expect(body.toolName).toBe('generate_designs');
|
|
expect(body.arguments).toEqual({ prompt: 'cat' });
|
|
expect(body.isError).toBe(false);
|
|
expect(body.content).toEqual([{ type: 'text', text: 'hello' }]);
|
|
expect(body.output).toBe('hello');
|
|
expect(body.savedBinaries).toEqual([]);
|
|
expect(typeof body.timestamp).toBe('string');
|
|
});
|
|
|
|
it('appends one line per call to logs/mcp-history.jsonl', async () => {
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: 'canva',
|
|
toolName: 'tool_a',
|
|
args: {},
|
|
content: [{ type: 'text', text: 'A' }],
|
|
isError: false,
|
|
output: 'A',
|
|
savedPaths: [],
|
|
});
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: 'canva',
|
|
toolName: 'tool_b',
|
|
args: {},
|
|
content: [{ type: 'text', text: 'B' }],
|
|
isError: true,
|
|
output: 'B',
|
|
savedPaths: [],
|
|
});
|
|
|
|
const lines = (
|
|
await fs.readFile(path.join(workspace, 'logs', 'mcp-history.jsonl'), 'utf-8')
|
|
)
|
|
.trim()
|
|
.split('\n');
|
|
expect(lines.length).toBe(2);
|
|
const e0 = JSON.parse(lines[0]);
|
|
const e1 = JSON.parse(lines[1]);
|
|
expect(e0.toolName).toBe('tool_a');
|
|
expect(e0.isError).toBe(false);
|
|
expect(e0.serverId).toBe('canva');
|
|
expect(e0.filename).toMatch(/^logs\/mcp\/canva\/tool_a-/);
|
|
expect(e0.bytes).toBeGreaterThan(0);
|
|
expect(e1.toolName).toBe('tool_b');
|
|
expect(e1.isError).toBe(true);
|
|
});
|
|
|
|
it('redacts secrets in arguments', async () => {
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: 'oauth',
|
|
toolName: 'tok',
|
|
args: {
|
|
prompt: 'hi',
|
|
access_token: 'super-secret',
|
|
nested: { refresh_token: 'r' },
|
|
},
|
|
content: [],
|
|
isError: false,
|
|
output: '',
|
|
savedPaths: [],
|
|
});
|
|
const files = await listMcpDir('oauth');
|
|
const body = JSON.parse(
|
|
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'oauth', files[0]), 'utf-8'),
|
|
);
|
|
expect(body.arguments.prompt).toBe('hi');
|
|
expect(body.arguments.access_token).toBe('***');
|
|
expect(body.arguments.nested.refresh_token).toBe('***');
|
|
});
|
|
|
|
it('strips base64 from image and resource blocks (replaces with reference)', async () => {
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: 'canva',
|
|
toolName: 'render',
|
|
args: {},
|
|
content: [
|
|
{ type: 'image', data: 'AAAA'.repeat(10000), mimeType: 'image/png' },
|
|
{ type: 'resource', resource: { blob: 'BBBB'.repeat(10000), mimeType: 'application/pdf' } },
|
|
{ type: 'text', text: 'caption' },
|
|
],
|
|
isError: false,
|
|
output: 'Saved: output/mcp/canva/render-x.png',
|
|
savedPaths: ['output/mcp/canva/render-x.png', 'output/mcp/canva/render-y.pdf'],
|
|
});
|
|
const files = await listMcpDir('canva');
|
|
const body = JSON.parse(
|
|
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'canva', files[0]), 'utf-8'),
|
|
);
|
|
expect(body.content[0].type).toBe('image');
|
|
expect(body.content[0].data).toMatch(/^<base64 omitted/);
|
|
expect(body.content[0].mimeType).toBe('image/png');
|
|
expect(body.content[1].resource.blob).toMatch(/^<base64 omitted/);
|
|
expect(body.content[1].resource.mimeType).toBe('application/pdf');
|
|
expect(body.content[2]).toEqual({ type: 'text', text: 'caption' });
|
|
expect(body.savedBinaries).toEqual([
|
|
'output/mcp/canva/render-x.png',
|
|
'output/mcp/canva/render-y.pdf',
|
|
]);
|
|
});
|
|
|
|
it('saves failure responses as well (isError true with synthetic content)', async () => {
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: 'canva',
|
|
toolName: 'fail',
|
|
args: { x: 1 },
|
|
content: [],
|
|
isError: true,
|
|
output: 'MCP call failed: boom',
|
|
savedPaths: [],
|
|
});
|
|
const files = await listMcpDir('canva');
|
|
expect(files.length).toBe(1);
|
|
const body = JSON.parse(
|
|
await fs.readFile(path.join(workspace, 'logs', 'mcp', 'canva', files[0]), 'utf-8'),
|
|
);
|
|
expect(body.isError).toBe(true);
|
|
expect(body.output).toBe('MCP call failed: boom');
|
|
});
|
|
|
|
it('sanitizes unsafe serverId / toolName for filesystem paths', async () => {
|
|
saveMcpRaw({
|
|
workspacePath: workspace,
|
|
serverId: '../escape',
|
|
toolName: 'weird/tool name',
|
|
args: {},
|
|
content: [],
|
|
isError: false,
|
|
output: '',
|
|
savedPaths: [],
|
|
});
|
|
// Must not create files outside workspace
|
|
const outside = await fs.readdir(path.dirname(workspace));
|
|
expect(outside.some((f) => f === 'escape')).toBe(false);
|
|
// Should still write under logs/mcp/ with sanitized names
|
|
const root = await fs.readdir(path.join(workspace, 'logs', 'mcp'));
|
|
expect(root.length).toBe(1);
|
|
expect(root[0]).not.toContain('/');
|
|
expect(root[0]).not.toContain('..');
|
|
});
|
|
|
|
it('does not throw when workspacePath is invalid (best-effort)', () => {
|
|
expect(() =>
|
|
saveMcpRaw({
|
|
workspacePath: '/nonexistent/path/that/should/fail/\0invalid',
|
|
serverId: 'x',
|
|
toolName: 'y',
|
|
args: {},
|
|
content: [],
|
|
isError: false,
|
|
output: '',
|
|
savedPaths: [],
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
});
|