@mackody/quickenrich-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +39 -0
- package/.env.example +17 -0
- package/.mcp.json +17 -0
- package/README.md +283 -0
- package/agents/AGENTS.md +6 -0
- package/agents/CLAUDE.md +6 -0
- package/package.json +40 -0
- package/scripts/agent-docs-smoke.mjs +72 -0
- package/scripts/agent-envelope-workflow-smoke.mjs +92 -0
- package/scripts/agent-surface-smoke.mjs +102 -0
- package/scripts/benchmark-contract-smoke.mjs +46 -0
- package/scripts/envelope-budget-smoke.mjs +37 -0
- package/scripts/killer-contract-smoke.mjs +206 -0
- package/scripts/lib/benchmark.mjs +92 -0
- package/scripts/lib/core.mjs +3303 -0
- package/scripts/live-benchmark.mjs +518 -0
- package/scripts/live-smoke.mjs +61 -0
- package/scripts/local-floor-benchmark.mjs +366 -0
- package/scripts/mcp-config-smoke.mjs +110 -0
- package/scripts/mcp-live-smoke.mjs +115 -0
- package/scripts/mock-api-smoke.mjs +112 -0
- package/scripts/native-capabilities-smoke.mjs +273 -0
- package/scripts/native-live-smoke.mjs +43 -0
- package/scripts/offline-contract-smoke.mjs +80 -0
- package/scripts/ops-contract-smoke.mjs +270 -0
- package/scripts/paged-finder-floor-smoke.mjs +93 -0
- package/scripts/perf-contract-smoke.mjs +111 -0
- package/scripts/quickenrich-agent-docs.mjs +109 -0
- package/scripts/quickenrich-mcp.mjs +201 -0
- package/scripts/quickenrich-token.mjs +175 -0
- package/scripts/rate-limit-contract-smoke.mjs +70 -0
- package/scripts/reverse-concurrency-floor-smoke.mjs +82 -0
- package/scripts/serialization-floor-smoke.mjs +42 -0
- package/scripts/token-bucket-contract-smoke.mjs +121 -0
- package/scripts/token-cli-smoke.mjs +59 -0
- package/skills/quickenrich/SKILL.md +20 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { callQuickEnrichTool, createClient } from "./lib/core.mjs";
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const repoRoot = join(__dirname, "..");
|
|
13
|
+
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function round(value) {
|
|
19
|
+
return Math.round(Number(value) * 100) / 100;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function median(values) {
|
|
23
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
24
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const stats = new Map();
|
|
28
|
+
const mockLatencyMs = envInteger("QUICKENRICH_FLOOR_MOCK_LATENCY_MS", 0, 1_000, 0);
|
|
29
|
+
|
|
30
|
+
function recordStart(path) {
|
|
31
|
+
const entry = stats.get(path) || { requests: 0, active: 0, maxActive: 0 };
|
|
32
|
+
entry.requests += 1;
|
|
33
|
+
entry.active += 1;
|
|
34
|
+
entry.maxActive = Math.max(entry.maxActive, entry.active);
|
|
35
|
+
stats.set(path, entry);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function recordEnd(path) {
|
|
39
|
+
const entry = stats.get(path);
|
|
40
|
+
if (entry) entry.active -= 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const server = createServer(async (req, res) => {
|
|
44
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
45
|
+
const path = url.pathname;
|
|
46
|
+
recordStart(path);
|
|
47
|
+
try {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
50
|
+
const bodyText = Buffer.concat(chunks).toString("utf8");
|
|
51
|
+
const body = bodyText ? JSON.parse(bodyText) : {};
|
|
52
|
+
res.setHeader("content-type", "application/json");
|
|
53
|
+
if (mockLatencyMs > 0) await sleep(mockLatencyMs);
|
|
54
|
+
|
|
55
|
+
if (path === "/api/employees/email-search") {
|
|
56
|
+
const email = url.searchParams.get("email");
|
|
57
|
+
res.end(JSON.stringify({
|
|
58
|
+
success: true,
|
|
59
|
+
data: {
|
|
60
|
+
first_name: "Bulk",
|
|
61
|
+
last_name: "Email",
|
|
62
|
+
email,
|
|
63
|
+
title: "Founder",
|
|
64
|
+
company_url: "floor.dev",
|
|
65
|
+
},
|
|
66
|
+
meta: { credits_used: 1, remaining_credits: 9999 },
|
|
67
|
+
}));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (path === "/api/employees/search") {
|
|
72
|
+
const first = url.searchParams.get("first_name") || "Exact";
|
|
73
|
+
const last = url.searchParams.get("last_name") || "Person";
|
|
74
|
+
res.end(JSON.stringify({
|
|
75
|
+
success: true,
|
|
76
|
+
data: {
|
|
77
|
+
first_name: first,
|
|
78
|
+
last_name: last,
|
|
79
|
+
title: "Founder",
|
|
80
|
+
email: `${first.toLowerCase()}.${last.toLowerCase()}@floor.dev`,
|
|
81
|
+
employee_linkedin: `https://linkedin.com/in/${first.toLowerCase()}-${last.toLowerCase()}`,
|
|
82
|
+
company_url: url.searchParams.get("company_url") || "floor.dev",
|
|
83
|
+
},
|
|
84
|
+
meta: { credits_used: 1, remaining_credits: 9999 },
|
|
85
|
+
}));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (path === "/api/employees/phone-search") {
|
|
90
|
+
res.end(JSON.stringify({
|
|
91
|
+
success: true,
|
|
92
|
+
data: {
|
|
93
|
+
employee_phone: "(415) 555-0100",
|
|
94
|
+
company_url: url.searchParams.get("company_url") || "floor.dev",
|
|
95
|
+
},
|
|
96
|
+
meta: { credits_used: 1, remaining_credits: 9999 },
|
|
97
|
+
}));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (path === "/api/employees/contact-finder") {
|
|
102
|
+
const page = Number(body.page || 1);
|
|
103
|
+
res.end(JSON.stringify({
|
|
104
|
+
success: true,
|
|
105
|
+
data: Array.from({ length: 5 }, (_, index) => ({
|
|
106
|
+
first_name: `Contact${page}_${index}`,
|
|
107
|
+
last_name: "Floor",
|
|
108
|
+
title: "Founder",
|
|
109
|
+
employee_linkedin: `https://linkedin.com/in/contact-${page}-${index}`,
|
|
110
|
+
company_url: "contact-floor.dev",
|
|
111
|
+
has_email: true,
|
|
112
|
+
})),
|
|
113
|
+
meta: { page, per_page: body.per_page || 5, total: 20, last_page: 4, credits_used: 0, remaining_credits: 9999 },
|
|
114
|
+
}));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (path === "/api/companies/company-finder") {
|
|
119
|
+
const page = Number(body.page || 1);
|
|
120
|
+
res.end(JSON.stringify({
|
|
121
|
+
success: true,
|
|
122
|
+
data: Array.from({ length: 5 }, (_, index) => ({
|
|
123
|
+
company_name: `Company ${page}-${index}`,
|
|
124
|
+
url: `company-${page}-${index}.dev`,
|
|
125
|
+
email_domain: `company-${page}-${index}.dev`,
|
|
126
|
+
linkedin_url: `https://linkedin.com/company/company-${page}-${index}`,
|
|
127
|
+
services: ["DevOps", "AWS"],
|
|
128
|
+
country_code: "US",
|
|
129
|
+
})),
|
|
130
|
+
meta: { page, per_page: body.per_page || 5, total: 20, last_page: 4, credits_used: 5, remaining_credits: 9999 },
|
|
131
|
+
}));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
res.statusCode = 404;
|
|
136
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
137
|
+
} finally {
|
|
138
|
+
recordEnd(path);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
143
|
+
const { port } = server.address();
|
|
144
|
+
|
|
145
|
+
process.env.QUICKENRICH_API_KEY = "floor-benchmark-key";
|
|
146
|
+
process.env.QUICKENRICH_API_BASE_URL = `http://127.0.0.1:${port}`;
|
|
147
|
+
|
|
148
|
+
const client = createClient();
|
|
149
|
+
const mcp = startMcpClient();
|
|
150
|
+
const rows = [];
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await initializeMcp(mcp);
|
|
154
|
+
await runScenario("reverse_csv_jsonl", {
|
|
155
|
+
tool: "quickenrich_batch_reverse_email",
|
|
156
|
+
args: {
|
|
157
|
+
input_csv: makeEmailCsv(64),
|
|
158
|
+
concurrency: 64,
|
|
159
|
+
requests_per_minute: 100_000,
|
|
160
|
+
burst_size: 128,
|
|
161
|
+
export_format: "jsonl",
|
|
162
|
+
response: { mode: "full", maxOutputChars: 1_000_000 },
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
await runScenario("exact_csv_phone_csv_export", {
|
|
166
|
+
tool: "quickenrich_batch_employee_search",
|
|
167
|
+
args: {
|
|
168
|
+
input_csv: makePersonCsv(32),
|
|
169
|
+
include_phone: true,
|
|
170
|
+
concurrency: 32,
|
|
171
|
+
requests_per_minute: 100_000,
|
|
172
|
+
phone_requests_per_minute: 100_000,
|
|
173
|
+
burst_size: 128,
|
|
174
|
+
export_format: "csv",
|
|
175
|
+
response: { mode: "full", maxOutputChars: 1_000_000 },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
await runScenario("contact_finder_paging_jsonl", {
|
|
179
|
+
tool: "quickenrich_batch_pull_leads",
|
|
180
|
+
args: {
|
|
181
|
+
mode: "contact_finder",
|
|
182
|
+
filters: { company_name: ["Floor"] },
|
|
183
|
+
pages_per_company: 4,
|
|
184
|
+
per_page: 5,
|
|
185
|
+
max_results: 20,
|
|
186
|
+
concurrency: 4,
|
|
187
|
+
requests_per_minute: 100_000,
|
|
188
|
+
burst_size: 4,
|
|
189
|
+
export_format: "jsonl",
|
|
190
|
+
response: { mode: "full", maxOutputChars: 1_000_000 },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
await runScenario("company_finder_paging_csv", {
|
|
194
|
+
tool: "quickenrich_batch_pull_leads",
|
|
195
|
+
args: {
|
|
196
|
+
mode: "company_finder",
|
|
197
|
+
filters: { services: ["DevOps"], country_code: ["US"] },
|
|
198
|
+
pages_per_company: 4,
|
|
199
|
+
per_page: 5,
|
|
200
|
+
max_results: 20,
|
|
201
|
+
concurrency: 4,
|
|
202
|
+
requests_per_minute: 100_000,
|
|
203
|
+
burst_size: 4,
|
|
204
|
+
export_format: "csv",
|
|
205
|
+
response: { mode: "full", maxOutputChars: 1_000_000 },
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const slowRows = rows.filter((row) => !row.directOk || !row.mcpOk || row.mcpOverDirectRatio > 2.5);
|
|
210
|
+
if (slowRows.length) {
|
|
211
|
+
throw new Error(`Local MCP floor regression: ${JSON.stringify(slowRows)}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(JSON.stringify({
|
|
215
|
+
ok: true,
|
|
216
|
+
apiBaseUrl: process.env.QUICKENRICH_API_BASE_URL,
|
|
217
|
+
mockLatencyMs,
|
|
218
|
+
rows,
|
|
219
|
+
endpointStats: Object.fromEntries([...stats.entries()].map(([path, entry]) => [
|
|
220
|
+
path,
|
|
221
|
+
{ requests: entry.requests, maxActive: entry.maxActive },
|
|
222
|
+
])),
|
|
223
|
+
}, null, 2));
|
|
224
|
+
} finally {
|
|
225
|
+
await mcp.close();
|
|
226
|
+
server.close();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function runScenario(label, { tool, args }) {
|
|
230
|
+
const directDurations = [];
|
|
231
|
+
const mcpDurations = [];
|
|
232
|
+
let directResult;
|
|
233
|
+
let mcpResult;
|
|
234
|
+
for (let iteration = 0; iteration < 3; iteration += 1) {
|
|
235
|
+
const directStart = performance.now();
|
|
236
|
+
directResult = await callQuickEnrichTool(tool, args, client);
|
|
237
|
+
directDurations.push(performance.now() - directStart);
|
|
238
|
+
|
|
239
|
+
const mcpStart = performance.now();
|
|
240
|
+
const response = await mcp.request("tools/call", { name: tool, arguments: args }, 20_000);
|
|
241
|
+
if (response.error) throw new Error(`${label} MCP failed: ${JSON.stringify(response.error)}`);
|
|
242
|
+
mcpResult = JSON.parse(response.result?.content?.[0]?.text || "{}");
|
|
243
|
+
mcpDurations.push(performance.now() - mcpStart);
|
|
244
|
+
}
|
|
245
|
+
const directMs = round(median(directDurations));
|
|
246
|
+
const mcpMs = round(median(mcpDurations));
|
|
247
|
+
rows.push({
|
|
248
|
+
label,
|
|
249
|
+
directCoreMs: directMs,
|
|
250
|
+
mcpStdioMs: mcpMs,
|
|
251
|
+
mcpOverDirectRatio: round(mcpMs / directMs),
|
|
252
|
+
requestCount: mcpResult.requestCount,
|
|
253
|
+
itemCount: mcpResult.foundCount ?? mcpResult.leadCount ?? mcpResult.companyCount,
|
|
254
|
+
exportBytes: Buffer.byteLength(mcpResult.export?.text || ""),
|
|
255
|
+
directOk: directResult.ok,
|
|
256
|
+
mcpOk: mcpResult.ok,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function makeEmailCsv(count) {
|
|
261
|
+
return [
|
|
262
|
+
"email",
|
|
263
|
+
...Array.from({ length: count }, (_, index) => `person-${index}@floor.dev`),
|
|
264
|
+
].join("\n");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function makePersonCsv(count) {
|
|
268
|
+
return [
|
|
269
|
+
"company_url,first_name,last_name",
|
|
270
|
+
...Array.from({ length: count }, (_, index) => `floor.dev,First${index},Last${index}`),
|
|
271
|
+
].join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function envInteger(name, min, max, fallback) {
|
|
275
|
+
const value = Number.parseInt(process.env[name] || "", 10);
|
|
276
|
+
if (!Number.isFinite(value)) return fallback;
|
|
277
|
+
return Math.min(max, Math.max(min, value));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function initializeMcp(mcp) {
|
|
281
|
+
const init = await mcp.request("initialize", { protocolVersion: "2024-11-05" });
|
|
282
|
+
if (init.error) throw new Error(`MCP initialize failed: ${JSON.stringify(init.error)}`);
|
|
283
|
+
const list = await mcp.request("tools/list");
|
|
284
|
+
if (list.error) throw new Error(`MCP tools/list failed: ${JSON.stringify(list.error)}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function startMcpClient() {
|
|
288
|
+
const mcpConfig = JSON.parse(readFileSync(join(repoRoot, ".mcp.json"), "utf8"));
|
|
289
|
+
const serverConfig = mcpConfig.mcpServers?.["quick-enrich"];
|
|
290
|
+
if (!serverConfig?.command) throw new Error("Missing quick-enrich MCP server command.");
|
|
291
|
+
|
|
292
|
+
const proc = spawn(serverConfig.command, serverConfig.args || [], {
|
|
293
|
+
cwd: repoRoot,
|
|
294
|
+
env: {
|
|
295
|
+
...process.env,
|
|
296
|
+
...(serverConfig.env || {}),
|
|
297
|
+
CLAUDE_PLUGIN_ROOT: repoRoot,
|
|
298
|
+
},
|
|
299
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
300
|
+
});
|
|
301
|
+
let buffer = Buffer.alloc(0);
|
|
302
|
+
let nextId = 1;
|
|
303
|
+
let stderr = "";
|
|
304
|
+
const pending = new Map();
|
|
305
|
+
|
|
306
|
+
proc.stdout.on("data", (chunk) => {
|
|
307
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
308
|
+
parseMcpFrames();
|
|
309
|
+
});
|
|
310
|
+
proc.stderr.on("data", (chunk) => {
|
|
311
|
+
stderr += chunk.toString("utf8");
|
|
312
|
+
stderr = stderr.slice(-8000);
|
|
313
|
+
});
|
|
314
|
+
proc.on("exit", (code, signal) => {
|
|
315
|
+
const error = new Error(`MCP process exited: code=${code} signal=${signal} stderr=${stderr.trim()}`);
|
|
316
|
+
for (const waiter of pending.values()) {
|
|
317
|
+
clearTimeout(waiter.timeout);
|
|
318
|
+
waiter.reject(error);
|
|
319
|
+
}
|
|
320
|
+
pending.clear();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
function request(method, params = {}, timeoutMs = 10_000) {
|
|
324
|
+
const id = nextId;
|
|
325
|
+
nextId += 1;
|
|
326
|
+
const message = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
const timeout = setTimeout(() => {
|
|
329
|
+
pending.delete(id);
|
|
330
|
+
reject(new Error(`Timed out waiting for MCP response id=${id} method=${method}`));
|
|
331
|
+
}, timeoutMs);
|
|
332
|
+
pending.set(id, { resolve, reject, timeout });
|
|
333
|
+
proc.stdin.write(`Content-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function parseMcpFrames() {
|
|
338
|
+
while (true) {
|
|
339
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
340
|
+
if (headerEnd === -1) return;
|
|
341
|
+
const header = buffer.slice(0, headerEnd).toString("utf8");
|
|
342
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
343
|
+
if (!match) throw new Error(`Invalid MCP header: ${header}`);
|
|
344
|
+
const length = Number(match[1]);
|
|
345
|
+
const bodyStart = headerEnd + 4;
|
|
346
|
+
const bodyEnd = bodyStart + length;
|
|
347
|
+
if (buffer.length < bodyEnd) return;
|
|
348
|
+
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
|
|
349
|
+
buffer = buffer.slice(bodyEnd);
|
|
350
|
+
const response = JSON.parse(body);
|
|
351
|
+
const waiter = pending.get(response.id);
|
|
352
|
+
if (!waiter) continue;
|
|
353
|
+
pending.delete(response.id);
|
|
354
|
+
clearTimeout(waiter.timeout);
|
|
355
|
+
waiter.resolve(response);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function close() {
|
|
360
|
+
if (proc.exitCode !== null || proc.killed) return;
|
|
361
|
+
proc.kill();
|
|
362
|
+
await new Promise((resolve) => proc.once("exit", resolve));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return { request, close };
|
|
366
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const repoRoot = join(__dirname, "..");
|
|
10
|
+
const mcpConfig = JSON.parse(readFileSync(join(repoRoot, ".mcp.json"), "utf8"));
|
|
11
|
+
const server = mcpConfig.mcpServers?.["quick-enrich"];
|
|
12
|
+
|
|
13
|
+
function assert(condition, message) {
|
|
14
|
+
if (!condition) throw new Error(message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sendRpc(proc, id, method, params = {}) {
|
|
18
|
+
const message = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
19
|
+
proc.stdin.write(`Content-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readResponses(proc, wantedIds, timeoutMs = 5000) {
|
|
23
|
+
let buffer = Buffer.alloc(0);
|
|
24
|
+
const responses = new Map();
|
|
25
|
+
proc.stdout.on("data", (chunk) => {
|
|
26
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
27
|
+
while (true) {
|
|
28
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
29
|
+
if (headerEnd === -1) return;
|
|
30
|
+
const header = buffer.slice(0, headerEnd).toString("utf8");
|
|
31
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
32
|
+
if (!match) throw new Error(`Invalid MCP header: ${header}`);
|
|
33
|
+
const length = Number(match[1]);
|
|
34
|
+
const bodyStart = headerEnd + 4;
|
|
35
|
+
const bodyEnd = bodyStart + length;
|
|
36
|
+
if (buffer.length < bodyEnd) return;
|
|
37
|
+
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
|
|
38
|
+
buffer = buffer.slice(bodyEnd);
|
|
39
|
+
const response = JSON.parse(body);
|
|
40
|
+
responses.set(response.id, response);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
proc.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${wantedIds.join(", ")}`)), timeoutMs);
|
|
46
|
+
const interval = setInterval(() => {
|
|
47
|
+
if (wantedIds.every((id) => responses.has(id))) {
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
clearInterval(interval);
|
|
50
|
+
resolve(wantedIds.map((id) => responses.get(id)));
|
|
51
|
+
}
|
|
52
|
+
}, 25);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assert(server?.command, "Missing quick-enrich MCP server command.");
|
|
57
|
+
assert(Array.isArray(server.args), "Missing quick-enrich MCP args.");
|
|
58
|
+
|
|
59
|
+
const proc = spawn(server.command, server.args, {
|
|
60
|
+
cwd: repoRoot,
|
|
61
|
+
env: {
|
|
62
|
+
...process.env,
|
|
63
|
+
...(server.env || {}),
|
|
64
|
+
QUICKENRICH_API_KEY: "",
|
|
65
|
+
QUICKENRICH_TOKEN: "",
|
|
66
|
+
QUICKENRICH_MCP_CONFIG: join(repoRoot, "tmp", "missing-config.json"),
|
|
67
|
+
CLAUDE_PLUGIN_ROOT: repoRoot,
|
|
68
|
+
},
|
|
69
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
sendRpc(proc, 1, "initialize", { protocolVersion: "2024-11-05" });
|
|
74
|
+
sendRpc(proc, 2, "resources/list");
|
|
75
|
+
sendRpc(proc, 3, "prompts/list");
|
|
76
|
+
sendRpc(proc, 4, "tools/list");
|
|
77
|
+
sendRpc(proc, 5, "tools/call", { name: "quickenrich_auth_status", arguments: {} });
|
|
78
|
+
sendRpc(proc, 6, "tools/call", {
|
|
79
|
+
name: "quickenrich_natural_language",
|
|
80
|
+
arguments: { request: "Batch pull founder leads for example.com and sample.io", execute: false },
|
|
81
|
+
});
|
|
82
|
+
sendRpc(proc, 7, "tools/call", {
|
|
83
|
+
name: "quickenrich_batch_pull_leads",
|
|
84
|
+
arguments: { companies: [{ company_url: "example.com" }], dry_run: true },
|
|
85
|
+
});
|
|
86
|
+
const [, resources, prompts, list, status, nl, dryRun] = await readResponses(proc, [1, 2, 3, 4, 5, 6, 7]);
|
|
87
|
+
assert(resources.result?.resources?.length === 0, "Expected empty resources.");
|
|
88
|
+
assert(prompts.result?.prompts?.length === 0, "Expected empty prompts.");
|
|
89
|
+
const names = list.result?.tools?.map((tool) => tool.name) || [];
|
|
90
|
+
const toolBytes = Buffer.byteLength(JSON.stringify(list.result?.tools || []), "utf8");
|
|
91
|
+
assert(names.length <= 9, `Expected compact agent surface, got ${names.length} tools.`);
|
|
92
|
+
assert(toolBytes <= 8_000, `Expected compact tools/list <=8KB, got ${toolBytes} bytes.`);
|
|
93
|
+
assert(names.includes("quickenrich_batch_pull_leads"), "Missing batch tool.");
|
|
94
|
+
assert(names.includes("quickenrich_batch_reverse_email"), "Missing batch reverse-email tool.");
|
|
95
|
+
assert(names.includes("quickenrich_batch_employee_search"), "Missing batch employee-search tool.");
|
|
96
|
+
assert(names.includes("quickenrich_pipeline"), "Missing native pipeline tool.");
|
|
97
|
+
assert(names.includes("quickenrich_lookup_values"), "Missing lookup discovery tool.");
|
|
98
|
+
assert(!names.includes("quickenrich_employee_search"), "Default config should keep exact-person lookup in full mode.");
|
|
99
|
+
assert(!names.includes("quickenrich_company_finder"), "Default config should not expose full low-level surface.");
|
|
100
|
+
assert(!String(status.result?.content?.[0]?.text || "").includes("\n"), "Default MCP tool output should be compact single-line JSON.");
|
|
101
|
+
const statusPayload = JSON.parse(status.result?.content?.[0]?.text || "{}");
|
|
102
|
+
assert(statusPayload.configured === false, "Auth status should be unconfigured in smoke.");
|
|
103
|
+
const nlPayload = JSON.parse(nl.result?.content?.[0]?.text || "{}");
|
|
104
|
+
assert(nlPayload.plan?.tool === "quickenrich_batch_pull_leads", "NL planner should choose batch tool.");
|
|
105
|
+
const dryRunPayload = JSON.parse(dryRun.result?.content?.[0]?.text || "{}");
|
|
106
|
+
assert(dryRunPayload.dryRun === true, "Batch dry-run should not require auth.");
|
|
107
|
+
console.log(JSON.stringify({ ok: true, tools: names.length }, null, 2));
|
|
108
|
+
} finally {
|
|
109
|
+
proc.kill();
|
|
110
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { authStatus } from "./lib/core.mjs";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const repoRoot = join(__dirname, "..");
|
|
11
|
+
const status = authStatus();
|
|
12
|
+
|
|
13
|
+
function assert(condition, message) {
|
|
14
|
+
if (!condition) throw new Error(message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!status.configured) {
|
|
18
|
+
console.log(JSON.stringify({
|
|
19
|
+
ok: true,
|
|
20
|
+
skipped: true,
|
|
21
|
+
reason: "Quick Enrich API key is not configured. Set QUICKENRICH_API_KEY or run node scripts/quickenrich-token.mjs set.",
|
|
22
|
+
}, null, 2));
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mcpConfig = JSON.parse(readFileSync(join(repoRoot, ".mcp.json"), "utf8"));
|
|
27
|
+
const server = mcpConfig.mcpServers?.["quick-enrich"];
|
|
28
|
+
assert(server?.command, "Missing quick-enrich MCP server command.");
|
|
29
|
+
|
|
30
|
+
function sendRpc(proc, id, method, params = {}) {
|
|
31
|
+
const message = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
32
|
+
proc.stdin.write(`Content-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readResponses(proc, wantedIds, timeoutMs = 10_000) {
|
|
36
|
+
let buffer = Buffer.alloc(0);
|
|
37
|
+
const responses = new Map();
|
|
38
|
+
proc.stdout.on("data", (chunk) => {
|
|
39
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
40
|
+
while (true) {
|
|
41
|
+
const headerEnd = buffer.indexOf("\r\n\r\n");
|
|
42
|
+
if (headerEnd === -1) return;
|
|
43
|
+
const header = buffer.slice(0, headerEnd).toString("utf8");
|
|
44
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
45
|
+
if (!match) throw new Error(`Invalid MCP header: ${header}`);
|
|
46
|
+
const length = Number(match[1]);
|
|
47
|
+
const bodyStart = headerEnd + 4;
|
|
48
|
+
const bodyEnd = bodyStart + length;
|
|
49
|
+
if (buffer.length < bodyEnd) return;
|
|
50
|
+
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8");
|
|
51
|
+
buffer = buffer.slice(bodyEnd);
|
|
52
|
+
const response = JSON.parse(body);
|
|
53
|
+
responses.set(response.id, response);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
proc.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${wantedIds.join(", ")}`)), timeoutMs);
|
|
59
|
+
const interval = setInterval(() => {
|
|
60
|
+
if (wantedIds.every((id) => responses.has(id))) {
|
|
61
|
+
clearTimeout(timeout);
|
|
62
|
+
clearInterval(interval);
|
|
63
|
+
resolve(wantedIds.map((id) => responses.get(id)));
|
|
64
|
+
}
|
|
65
|
+
}, 25);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const proc = spawn(server.command, server.args, {
|
|
70
|
+
cwd: repoRoot,
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
...(server.env || {}),
|
|
74
|
+
CLAUDE_PLUGIN_ROOT: repoRoot,
|
|
75
|
+
},
|
|
76
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const email = `qe-mcp-live-${Date.now()}-${Math.random().toString(16).slice(2)}@gmail.com`;
|
|
81
|
+
sendRpc(proc, 1, "initialize", { protocolVersion: "2024-11-05" });
|
|
82
|
+
sendRpc(proc, 2, "tools/list");
|
|
83
|
+
sendRpc(proc, 3, "tools/call", {
|
|
84
|
+
name: "quickenrich_reverse_email",
|
|
85
|
+
arguments: {
|
|
86
|
+
email,
|
|
87
|
+
response: { mode: "compact", maxOutputChars: 4000 },
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
const [, listResponse, callResponse] = await readResponses(proc, [1, 2, 3]);
|
|
91
|
+
const tools = listResponse.result?.tools?.map((tool) => tool.name) || [];
|
|
92
|
+
assert(tools.length <= 9, `Expected compact default MCP surface, got ${tools.length}.`);
|
|
93
|
+
assert(tools.includes("quickenrich_reverse_email"), "Default MCP surface must include reverse-email probe tool.");
|
|
94
|
+
assert(tools.includes("quickenrich_batch_reverse_email"), "Default MCP surface must include batch reverse-email tool.");
|
|
95
|
+
assert(tools.includes("quickenrich_batch_employee_search"), "Default MCP surface must include batch employee-search tool.");
|
|
96
|
+
assert(tools.includes("quickenrich_lookup_values"), "Default MCP surface must include lookup discovery tool.");
|
|
97
|
+
assert(!callResponse.error, `MCP live tool call failed: ${JSON.stringify(callResponse.error)}`);
|
|
98
|
+
const payload = JSON.parse(callResponse.result?.content?.[0]?.text || "{}");
|
|
99
|
+
assert(payload.ok === true, "MCP live reverse-email probe did not return ok=true.");
|
|
100
|
+
assert(payload.found === false, "Random live smoke email should not be found.");
|
|
101
|
+
assert(Number(payload.meta?.credits_used || 0) === 0, "MCP live smoke should not use credits.");
|
|
102
|
+
|
|
103
|
+
console.log(JSON.stringify({
|
|
104
|
+
ok: true,
|
|
105
|
+
skipped: false,
|
|
106
|
+
tools: tools.length,
|
|
107
|
+
authProbe: {
|
|
108
|
+
found: payload.found,
|
|
109
|
+
meta: payload.meta,
|
|
110
|
+
rateLimit: payload.rateLimit,
|
|
111
|
+
},
|
|
112
|
+
}, null, 2));
|
|
113
|
+
} finally {
|
|
114
|
+
proc.kill();
|
|
115
|
+
}
|