429 lines
17 KiB
JavaScript
429 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { createInterface } from "node:readline";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
demoProfiles,
|
|
findTool,
|
|
fixtureProfiles,
|
|
listTools,
|
|
} from "../mcp-fixtures/catalog.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(__dirname, "../..");
|
|
const stdioServerPath = resolve(repoRoot, "scripts/mcp-fixtures/servers/stdio-fixture.mjs");
|
|
const httpServerPath = resolve(repoRoot, "scripts/mcp-fixtures/servers/http-fixture.mjs");
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
paperclipUrl: process.env.PAPERCLIP_API_URL ?? "http://127.0.0.1:3100/api",
|
|
requirePaperclip: false,
|
|
json: false,
|
|
};
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === "--") continue;
|
|
if (arg === "--paperclip-url") args.paperclipUrl = argv[++i];
|
|
else if (arg === "--require-paperclip") args.requirePaperclip = true;
|
|
else if (arg === "--json") args.json = true;
|
|
else if (arg === "--help") {
|
|
console.log(`Usage: node scripts/smoke/mcp-fixture-harness.mjs [--paperclip-url URL] [--require-paperclip] [--json]`);
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function normalizePaperclipUrl(raw) {
|
|
const url = new URL(raw);
|
|
if (url.pathname.endsWith("/api")) {
|
|
url.pathname = url.pathname.slice(0, -4) || "/";
|
|
}
|
|
return url.toString().replace(/\/$/, "");
|
|
}
|
|
|
|
async function checkPaperclipHealth(rawUrl, required) {
|
|
const baseUrl = normalizePaperclipUrl(rawUrl);
|
|
try {
|
|
const response = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(1500) });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return { ok: true, baseUrl };
|
|
} catch (error) {
|
|
if (required) {
|
|
throw new Error(`Paperclip health check failed at ${baseUrl}/api/health: ${error.message}`);
|
|
}
|
|
return { ok: false, baseUrl, skippedReason: error.message };
|
|
}
|
|
}
|
|
|
|
function redactHostileText(value) {
|
|
return JSON.stringify(value)
|
|
.replace(/pc_live_[A-Za-z0-9_=-]+/g, "[REDACTED_SECRET]")
|
|
.replace(/PAPERCLIP_API_KEY/g, "[REDACTED_ENV_NAME]");
|
|
}
|
|
|
|
function fingerprintTool(tool) {
|
|
return JSON.stringify({
|
|
name: tool.name,
|
|
schemaVersion: tool.schemaVersion,
|
|
inputSchema: tool.inputSchema,
|
|
});
|
|
}
|
|
|
|
class StdioFixtureClient {
|
|
constructor() {
|
|
this.nextId = 1;
|
|
this.pending = new Map();
|
|
this.process = null;
|
|
}
|
|
|
|
async start() {
|
|
this.process = spawn(process.execPath, [stdioServerPath], {
|
|
cwd: repoRoot,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
const rl = createInterface({ input: this.process.stdout });
|
|
rl.on("line", (line) => {
|
|
let response;
|
|
try {
|
|
response = JSON.parse(line);
|
|
} catch {
|
|
return;
|
|
}
|
|
const pending = this.pending.get(response.id);
|
|
if (!pending) return;
|
|
this.pending.delete(response.id);
|
|
pending.resolve(response);
|
|
});
|
|
this.process.stderr.on("data", (chunk) => {
|
|
process.stderr.write(`[mcp-stdio-fixture] ${chunk}`);
|
|
});
|
|
await this.request("health");
|
|
}
|
|
|
|
request(method, params = {}) {
|
|
const id = String(this.nextId++);
|
|
return new Promise((resolveRequest, reject) => {
|
|
this.pending.set(id, { resolve: resolveRequest, reject });
|
|
this.process.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
|
|
setTimeout(() => {
|
|
if (this.pending.has(id)) {
|
|
this.pending.delete(id);
|
|
reject(new Error(`stdio fixture request timed out: ${method}`));
|
|
}
|
|
}, 2000).unref();
|
|
});
|
|
}
|
|
|
|
async listTools() {
|
|
const response = await this.request("list_tools");
|
|
return response.tools;
|
|
}
|
|
|
|
async callTool(name, input) {
|
|
return this.request("call_tool", { name, input });
|
|
}
|
|
|
|
async stop() {
|
|
if (!this.process || this.process.killed) return;
|
|
this.process.kill("SIGTERM");
|
|
await Promise.race([
|
|
once(this.process, "exit"),
|
|
new Promise((resolveStop) => setTimeout(resolveStop, 500)),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class HttpFixtureClient {
|
|
constructor() {
|
|
this.process = null;
|
|
this.baseUrl = null;
|
|
}
|
|
|
|
async start() {
|
|
this.process = spawn(process.execPath, [httpServerPath], {
|
|
cwd: repoRoot,
|
|
env: { ...process.env, PORT: "0" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
this.process.stderr.on("data", (chunk) => {
|
|
process.stderr.write(`[mcp-http-fixture] ${chunk}`);
|
|
});
|
|
const rl = createInterface({ input: this.process.stdout });
|
|
const ready = await new Promise((resolveReady, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("http fixture did not become ready")), 2000);
|
|
rl.on("line", (line) => {
|
|
const event = JSON.parse(line);
|
|
if (event.event === "ready") {
|
|
clearTimeout(timer);
|
|
resolveReady(event);
|
|
}
|
|
});
|
|
});
|
|
this.baseUrl = `http://${ready.host}:${ready.port}`;
|
|
const health = await fetch(`${this.baseUrl}/health`);
|
|
if (!health.ok) throw new Error(`http fixture health failed: ${health.status}`);
|
|
}
|
|
|
|
async listTools() {
|
|
const response = await fetch(`${this.baseUrl}/catalog`);
|
|
const body = await response.json();
|
|
return body.tools;
|
|
}
|
|
|
|
async callTool(name, input) {
|
|
const response = await fetch(`${this.baseUrl}/tools/call`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name, input }),
|
|
});
|
|
return response.json();
|
|
}
|
|
|
|
async stop() {
|
|
if (!this.process || this.process.killed) return;
|
|
this.process.kill("SIGTERM");
|
|
await Promise.race([
|
|
once(this.process, "exit"),
|
|
new Promise((resolveStop) => setTimeout(resolveStop, 500)),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class SmokePolicyHarness {
|
|
constructor({ stdioClient, httpClient }) {
|
|
this.stdioClient = stdioClient;
|
|
this.httpClient = httpClient;
|
|
this.audit = [];
|
|
this.pendingApprovals = new Map();
|
|
this.idempotency = new Map();
|
|
this.quarantine = new Set();
|
|
this.baselineFingerprints = new Map(listTools().map((tool) => [tool.name, fingerprintTool(tool)]));
|
|
}
|
|
|
|
profile(profileId) {
|
|
const profile = fixtureProfiles.find((candidate) => candidate.id === profileId);
|
|
if (!profile) throw new Error(`Unknown profile: ${profileId}`);
|
|
return profile;
|
|
}
|
|
|
|
isAllowedByProfile(profile, tool) {
|
|
if (this.quarantine.has(tool.name)) return { outcome: "quarantined" };
|
|
const riskAllowed = tool.risk === "low" || profile.allowRisks?.includes(tool.risk) || !profile.denyRisks?.includes(tool.risk);
|
|
if (!riskAllowed) return { outcome: "denied" };
|
|
if (profile.allowCapabilities.includes(tool.capability)) return { outcome: "allowed" };
|
|
if (profile.approvalCapabilities.includes(tool.capability) || tool.approvalRequired) return { outcome: "approval_required" };
|
|
return { outcome: "denied" };
|
|
}
|
|
|
|
async call(profileId, toolName, input = {}, options = {}) {
|
|
const profile = this.profile(profileId);
|
|
const tool = findTool(toolName);
|
|
const idempotencyKey = options.idempotencyKey ?? null;
|
|
if (idempotencyKey && this.idempotency.has(idempotencyKey)) {
|
|
const cached = this.idempotency.get(idempotencyKey);
|
|
this.audit.push({ toolName, profileId, outcome: "idempotent_replay", idempotencyKey });
|
|
return cached;
|
|
}
|
|
|
|
const decision = this.isAllowedByProfile(profile, tool);
|
|
if (decision.outcome === "denied" || decision.outcome === "quarantined") {
|
|
const response = { ok: false, outcome: decision.outcome, toolName };
|
|
this.audit.push({ toolName, profileId, outcome: decision.outcome });
|
|
return response;
|
|
}
|
|
|
|
if (decision.outcome === "approval_required" && !options.approved) {
|
|
const approvalId = `approval-${this.pendingApprovals.size + 1}`;
|
|
const response = { ok: false, outcome: "approval_required", approvalId, toolName };
|
|
this.pendingApprovals.set(approvalId, { profileId, toolName, input, idempotencyKey });
|
|
this.audit.push({ toolName, profileId, outcome: "approval_required", approvalId });
|
|
return response;
|
|
}
|
|
|
|
const client = tool.transport === "stdio" ? this.stdioClient : this.httpClient;
|
|
const response = await client.callTool(toolName, input);
|
|
let outcome = response.ok ? "allowed" : response.error?.code ?? "fixture_error";
|
|
if (tool.hostile && response.ok) {
|
|
const redacted = redactHostileText(response.result);
|
|
outcome = redacted.includes("Ignore prior instructions") ? "malicious_result_quarantined" : "allowed";
|
|
response.result = { redacted, quarantined: outcome === "malicious_result_quarantined" };
|
|
}
|
|
const wrapped = { ...response, outcome, toolName };
|
|
if (idempotencyKey && response.ok) this.idempotency.set(idempotencyKey, wrapped);
|
|
this.audit.push({ toolName, profileId, outcome, transport: tool.transport, idempotencyKey });
|
|
return wrapped;
|
|
}
|
|
|
|
async approve(approvalId) {
|
|
const pending = this.pendingApprovals.get(approvalId);
|
|
if (!pending) throw new Error(`Unknown approval: ${approvalId}`);
|
|
this.pendingApprovals.delete(approvalId);
|
|
return this.call(pending.profileId, pending.toolName, pending.input, {
|
|
approved: true,
|
|
idempotencyKey: pending.idempotencyKey,
|
|
});
|
|
}
|
|
|
|
discoverSchemaChanges(tools) {
|
|
const quarantined = [];
|
|
for (const tool of tools) {
|
|
const baseline = this.baselineFingerprints.get(tool.name);
|
|
if (baseline && baseline !== fingerprintTool(tool)) {
|
|
this.quarantine.add(tool.name);
|
|
quarantined.push(tool.name);
|
|
this.audit.push({ toolName: tool.name, outcome: "schema_change_quarantined" });
|
|
}
|
|
}
|
|
return quarantined;
|
|
}
|
|
}
|
|
|
|
async function runCase(results, name, fn) {
|
|
try {
|
|
await fn();
|
|
results.push({ name, ok: true });
|
|
} catch (error) {
|
|
results.push({ name, ok: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const paperclip = await checkPaperclipHealth(args.paperclipUrl, args.requirePaperclip);
|
|
const stdioClient = new StdioFixtureClient();
|
|
const httpClient = new HttpFixtureClient();
|
|
const results = [];
|
|
|
|
try {
|
|
await stdioClient.start();
|
|
await httpClient.start();
|
|
const harness = new SmokePolicyHarness({ stdioClient, httpClient });
|
|
|
|
await runCase(results, "fixture catalog includes required profiles and demos", async () => {
|
|
assert(fixtureProfiles.length === 4, "expected four profile definitions");
|
|
assert(demoProfiles.length === 8, "expected eight first-install demo definitions");
|
|
const tools = [...await stdioClient.listTools(), ...await httpClient.listTools()];
|
|
for (const fixture of [
|
|
"echo-calculator-time",
|
|
"todo-kv",
|
|
"outbox-email",
|
|
"mock-social-blog",
|
|
"malicious",
|
|
"slow-crashing-stdio",
|
|
"fake-oauth-missing-secret",
|
|
]) {
|
|
assert(tools.some((tool) => tool.fixture === fixture), `missing fixture ${fixture}`);
|
|
}
|
|
assert(tools.some((tool) => tool.transport === "stdio"), "missing stdio fixture");
|
|
assert(tools.some((tool) => tool.transport === "http"), "missing http fixture");
|
|
});
|
|
|
|
await runCase(results, "allow and deny decisions are enforced", async () => {
|
|
const allowed = await harness.call("read-only", "calculator.add", { a: 2, b: 3 });
|
|
assert(allowed.ok && allowed.result.value === 5, "calculator.add should be allowed");
|
|
const denied = await harness.call("read-only", "kv.set", { key: "a", value: "b" });
|
|
assert(!denied.ok && denied.outcome === "denied", "kv.set should be denied for read-only");
|
|
});
|
|
|
|
await runCase(results, "approval-gated writes execute after approval", async () => {
|
|
const pending = await harness.call("approval-gated-writes", "email.send", {
|
|
to: "qa@example.com",
|
|
subject: "fixture",
|
|
body: "deterministic",
|
|
}, { idempotencyKey: "send-email-1" });
|
|
assert(pending.outcome === "approval_required", "email.send should require approval");
|
|
const approved = await harness.approve(pending.approvalId);
|
|
assert(approved.ok && approved.result.message.status === "sent", "approved email.send should execute");
|
|
});
|
|
|
|
await runCase(results, "audit trail records decisions and transports", async () => {
|
|
assert(harness.audit.some((event) => event.outcome === "denied" && event.toolName === "kv.set"), "missing deny audit");
|
|
assert(harness.audit.some((event) => event.outcome === "approval_required" && event.toolName === "email.send"), "missing approval audit");
|
|
assert(harness.audit.some((event) => event.transport === "stdio"), "missing stdio audit");
|
|
assert(harness.audit.some((event) => event.transport === "http"), "missing http audit");
|
|
});
|
|
|
|
await runCase(results, "runtime lifecycle handles slow and crashing stdio fixtures", async () => {
|
|
const slow = await harness.call("runtime-lifecycle", "slow.ping", { delayMs: 10 });
|
|
assert(slow.ok && slow.result.pong === true, "slow.ping should return");
|
|
const crash = await harness.call("runtime-lifecycle", "crash.now", {});
|
|
assert(!crash.ok && crash.outcome === "fixture_crash", "crash.now should report fixture_crash");
|
|
const afterCrash = await harness.call("runtime-lifecycle", "time.now", {});
|
|
assert(afterCrash.ok, "stdio fixture should keep serving after synthetic crash response");
|
|
});
|
|
|
|
await runCase(results, "secret failures are categorized without real credentials", async () => {
|
|
const response = await harness.call("read-only", "secret.read", {});
|
|
assert(!response.ok && response.outcome === "secret_missing", "secret.read should fail with secret_missing");
|
|
});
|
|
|
|
await runCase(results, "schema changes quarantine changed tools", async () => {
|
|
const flip = await harness.call("runtime-lifecycle", "fixture.schemaFlip", { toolName: "kv.set" });
|
|
assert(flip.ok, "fixture.schemaFlip should execute");
|
|
const changedTools = await httpClient.listTools();
|
|
const quarantined = harness.discoverSchemaChanges(changedTools);
|
|
assert(quarantined.includes("kv.set"), "kv.set should be quarantined after schema change");
|
|
const blocked = await harness.call("approval-gated-writes", "kv.set", { key: "a", value: "b" });
|
|
assert(blocked.outcome === "quarantined", "quarantined tool should not execute");
|
|
});
|
|
|
|
await runCase(results, "malicious result is redacted and quarantined", async () => {
|
|
const response = await harness.call("security-hostile", "malicious.result", {});
|
|
assert(response.ok, "malicious result fixture should return a result");
|
|
assert(response.outcome === "malicious_result_quarantined", "malicious result should be quarantined");
|
|
assert(!response.result.redacted.includes("pc_live_should_be_redacted"), "synthetic secret should be redacted");
|
|
assert(!response.result.redacted.includes("PAPERCLIP_API_KEY"), "env key name should be redacted");
|
|
});
|
|
|
|
await runCase(results, "idempotent approved writes only execute once", async () => {
|
|
const first = await harness.call("approval-gated-writes", "email.send", {
|
|
to: "qa@example.com",
|
|
subject: "idempotent",
|
|
body: "only once",
|
|
}, { approved: true, idempotencyKey: "send-email-idempotent" });
|
|
const second = await harness.call("approval-gated-writes", "email.send", {
|
|
to: "qa@example.com",
|
|
subject: "idempotent",
|
|
body: "only once",
|
|
}, { approved: true, idempotencyKey: "send-email-idempotent" });
|
|
assert(first.result.message.id === second.result.message.id, "idempotent replay should return cached message");
|
|
assert(harness.audit.some((event) => event.outcome === "idempotent_replay"), "missing idempotent replay audit");
|
|
});
|
|
|
|
const summary = {
|
|
ok: results.every((result) => result.ok),
|
|
paperclip,
|
|
results,
|
|
auditEvents: harness.audit.length,
|
|
profiles: fixtureProfiles.map((profile) => profile.id),
|
|
demos: demoProfiles.map((demo) => demo.id),
|
|
};
|
|
if (args.json) {
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
} else {
|
|
console.log(`MCP fixture smoke: ${summary.ok ? "PASS" : "FAIL"}`);
|
|
console.log(`Paperclip health: ${paperclip.ok ? "ok" : `skipped (${paperclip.skippedReason})`}`);
|
|
for (const result of results) {
|
|
console.log(`${result.ok ? "PASS" : "FAIL"} ${result.name}${result.error ? ` - ${result.error}` : ""}`);
|
|
}
|
|
}
|
|
if (!summary.ok) process.exitCode = 1;
|
|
} finally {
|
|
await Promise.allSettled([stdioClient.stop(), httpClient.stop()]);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.message);
|
|
process.exitCode = 1;
|
|
});
|