@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,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quickenrich-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone MCP server and CLI for Quick Enrich lead enrichment APIs.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Cody Robertson"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://app.quickenrich.io/docs",
|
|
9
|
+
"repository": "https://github.com/codyrobertson/quickenrich-mcp",
|
|
10
|
+
"license": "UNLICENSED",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"quickenrich",
|
|
13
|
+
"quick-enrich",
|
|
14
|
+
"mcp",
|
|
15
|
+
"lead-enrichment",
|
|
16
|
+
"prospecting"
|
|
17
|
+
],
|
|
18
|
+
"skills": "./skills/",
|
|
19
|
+
"interface": {
|
|
20
|
+
"displayName": "Quick Enrich MCP",
|
|
21
|
+
"shortDescription": "Use Quick Enrich APIs through a compact, batch-ready MCP server.",
|
|
22
|
+
"longDescription": "Quick Enrich MCP connects Codex and other MCP clients to Quick Enrich's documented APIs with secure local token storage, bearer-token auth, compact agent envelopes, exact person enrichment, reverse-email lookup, phone lookup, domain lead pulls, contact/company finder calls, and credit-aware batch lead pulling. It is a standalone repo and does not depend on PLM/Hermes runtime code.",
|
|
23
|
+
"developerName": "Cody Robertson",
|
|
24
|
+
"category": "Productivity",
|
|
25
|
+
"capabilities": [
|
|
26
|
+
"Read",
|
|
27
|
+
"Automation"
|
|
28
|
+
],
|
|
29
|
+
"websiteURL": "https://app.quickenrich.io/docs",
|
|
30
|
+
"defaultPrompt": [
|
|
31
|
+
"Check Quick Enrich MCP auth status.",
|
|
32
|
+
"Pull founder leads for these company domains.",
|
|
33
|
+
"Find a verified email from a LinkedIn profile."
|
|
34
|
+
],
|
|
35
|
+
"brandColor": "#2563EB",
|
|
36
|
+
"screenshots": []
|
|
37
|
+
},
|
|
38
|
+
"mcpServers": "./.mcp.json"
|
|
39
|
+
}
|
package/.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
QUICKENRICH_API_KEY=your-quickenrich-key
|
|
2
|
+
QUICKENRICH_MCP_SERVER_NAME=quick-enrich
|
|
3
|
+
QUICKENRICH_MCP_TOOL_MODE=agent
|
|
4
|
+
QUICKENRICH_MCP_RESPONSE_MODE=compact
|
|
5
|
+
QUICKENRICH_API_BASE_URL=https://app.quickenrich.io
|
|
6
|
+
QUICKENRICH_API_TIMEOUT_MS=30000
|
|
7
|
+
QUICKENRICH_API_RETRY_COUNT=2
|
|
8
|
+
QUICKENRICH_API_RETRY_BASE_MS=250
|
|
9
|
+
QUICKENRICH_API_USE_FETCH=0
|
|
10
|
+
QUICKENRICH_MCP_MAX_OUTPUT_CHARS=12000
|
|
11
|
+
QUICKENRICH_DOMAIN_SEARCH_REQUESTS_PER_MINUTE=300
|
|
12
|
+
QUICKENRICH_DOMAIN_SEARCH_BURST_SIZE=8
|
|
13
|
+
QUICKENRICH_EMPLOYEE_SEARCH_BURST_SIZE=16
|
|
14
|
+
QUICKENRICH_PHONE_SEARCH_BURST_SIZE=16
|
|
15
|
+
QUICKENRICH_REVERSE_EMAIL_BURST_SIZE=16
|
|
16
|
+
QUICKENRICH_CONTACT_FINDER_BURST_SIZE=4
|
|
17
|
+
QUICKENRICH_COMPANY_FINDER_BURST_SIZE=4
|
package/.mcp.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"quick-enrich": {
|
|
4
|
+
"command": "sh",
|
|
5
|
+
"args": [
|
|
6
|
+
"-lc",
|
|
7
|
+
"exec node \"${CLAUDE_PLUGIN_ROOT:-.}/scripts/quickenrich-mcp.mjs\""
|
|
8
|
+
],
|
|
9
|
+
"cwd": ".",
|
|
10
|
+
"env": {
|
|
11
|
+
"QUICKENRICH_MCP_SERVER_NAME": "quick-enrich",
|
|
12
|
+
"QUICKENRICH_MCP_TOOL_MODE": "agent",
|
|
13
|
+
"QUICKENRICH_MCP_RESPONSE_MODE": "compact"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Quick Enrich MCP
|
|
2
|
+
|
|
3
|
+
Standalone MCP server for Quick Enrich's documented APIs. It is built for internal prospecting workflows: secure local API-key storage, exact person enrichment, reverse-email/phone lookup, domain lead pulls, contact/company finder calls, and compact batch envelopes for agent use.
|
|
4
|
+
|
|
5
|
+
Docs source: [Quick Enrich API Documentation](https://app.quickenrich.io/docs).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
npm run test
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Package/agent setup after install:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
quickenrich-token set
|
|
18
|
+
quickenrich-token doctor
|
|
19
|
+
quickenrich-agent-docs install --cwd /path/to/agent-project
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`quickenrich-agent-docs install` appends exactly two managed Quick Enrich how-to-use lines to `CLAUDE.md` and `AGENTS.md` in the target project. It is idempotent. Remove those managed lines with:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
quickenrich-agent-docs uninstall --cwd /path/to/agent-project
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Set an API key with either an environment variable:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
export QUICKENRICH_API_KEY=...
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
or the local secure config:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
node scripts/quickenrich-token.mjs set
|
|
38
|
+
node scripts/quickenrich-token.mjs status
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The token CLI stores the key at `~/.config/quickenrich-mcp/config.json` with `0600` permissions. Env vars override the config file.
|
|
42
|
+
|
|
43
|
+
## MCP
|
|
44
|
+
|
|
45
|
+
Use the bundled `.mcp.json` or run directly:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
node scripts/quickenrich-mcp.mjs
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
MCP server alias: `quick-enrich`
|
|
52
|
+
|
|
53
|
+
Default agent tools:
|
|
54
|
+
|
|
55
|
+
- `quickenrich_auth_status`
|
|
56
|
+
- `quickenrich_agent_graph`
|
|
57
|
+
- `quickenrich_natural_language`
|
|
58
|
+
- `quickenrich_pipeline`
|
|
59
|
+
- `quickenrich_batch_pull_leads`
|
|
60
|
+
- `quickenrich_batch_reverse_email`
|
|
61
|
+
- `quickenrich_batch_employee_search`
|
|
62
|
+
- `quickenrich_reverse_email`
|
|
63
|
+
- `quickenrich_lookup_values`
|
|
64
|
+
|
|
65
|
+
Full-mode tools (`QUICKENRICH_MCP_TOOL_MODE=full`) also expose:
|
|
66
|
+
|
|
67
|
+
- `quickenrich_pipeline`
|
|
68
|
+
- `quickenrich_employee_search`
|
|
69
|
+
- `quickenrich_phone_search`
|
|
70
|
+
- `quickenrich_reverse_email`
|
|
71
|
+
- `quickenrich_batch_reverse_email`
|
|
72
|
+
- `quickenrich_batch_employee_search`
|
|
73
|
+
- `quickenrich_domain_search`
|
|
74
|
+
- `quickenrich_contact_finder`
|
|
75
|
+
- `quickenrich_company_finder`
|
|
76
|
+
- `quickenrich_batch_pull_leads`
|
|
77
|
+
- `quickenrich_lookup_values`
|
|
78
|
+
|
|
79
|
+
Use `QUICKENRICH_MCP_TOOL_MODE=minimal` for only auth, graph, natural-language planning, and batch pull. Use `QUICKENRICH_MCP_TOOL_MODE=full` when you want every low-level endpoint and verbose schema descriptions.
|
|
80
|
+
|
|
81
|
+
Default agent mode intentionally emits slim schemas and short tool descriptions so cold agents do not spend context on prose they can recover from `quickenrich_agent_graph`. The default `tools/list` surface is budgeted under 8 KB and includes lookup discovery because finder filters need valid country, industry, employee-range, revenue-range, and service values.
|
|
82
|
+
|
|
83
|
+
Responses default to compact mode with a 12 KB output budget, 12 array items, 48 object keys, depth 4, and 240-character strings. Pass `response: { "mode": "full", "maxOutputChars": 64000 }` for rawer output, or `response: { "mode": "meta" }` for shape-only output.
|
|
84
|
+
|
|
85
|
+
MCP tool text is emitted as single-line JSON by default to reduce stdio bytes and token overhead. Set `QUICKENRICH_MCP_PRETTY_JSON=1` only when debugging by hand.
|
|
86
|
+
|
|
87
|
+
The API client uses a native keep-alive HTTP transport by default for lower batch overhead. Set `QUICKENRICH_API_USE_FETCH=1` to force the global `fetch` transport while debugging.
|
|
88
|
+
|
|
89
|
+
## Batch Pull
|
|
90
|
+
|
|
91
|
+
Example MCP arguments:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"companies": [
|
|
96
|
+
{ "company_url": "example.com", "title": "Founder" },
|
|
97
|
+
{ "company_url": "shop.example", "title": "CEO" }
|
|
98
|
+
],
|
|
99
|
+
"per_company_limit": 20,
|
|
100
|
+
"pages_per_company": 1,
|
|
101
|
+
"has_email": true,
|
|
102
|
+
"concurrency": 8,
|
|
103
|
+
"requests_per_minute": 300,
|
|
104
|
+
"burst_size": 1,
|
|
105
|
+
"max_credits": 100,
|
|
106
|
+
"export_format": "jsonl",
|
|
107
|
+
"enrich_contacts": false
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Native batch surfaces:
|
|
112
|
+
|
|
113
|
+
- `quickenrich_pipeline`: native company-finder to domain-search workflow with durable output and projected rows.
|
|
114
|
+
- `quickenrich_batch_pull_leads`: domain lead pulls plus Contact Finder and Company Finder batch modes.
|
|
115
|
+
- `quickenrich_batch_reverse_email`: deduped reverse-email lookup from `emails`, `input_csv`, or `input_jsonl`.
|
|
116
|
+
- `quickenrich_batch_employee_search`: exact-person lookup from `persons`, `input_csv`, or `input_jsonl`, with optional phone enrichment.
|
|
117
|
+
|
|
118
|
+
Batch-native controls:
|
|
119
|
+
|
|
120
|
+
- `max_credits`: stops starting new paid calls after the budget is reached. Paid lanes reserve estimated credits before starting work, so they keep safe parallelism without overspending the caller's budget.
|
|
121
|
+
- `resume_state`: skips previously completed or failed keys and returns `resumeState` with `completed`, `failed`, and `pending` keys for continuing large runs.
|
|
122
|
+
- `input_file`: local CSV/JSONL path or `file://` URL for large jobs without pasting rows into prompts.
|
|
123
|
+
- `output_file`: local JSONL/CSV artifact path for results.
|
|
124
|
+
- `resume_file`: local JSON checkpoint merged with `resume_state` and updated as work completes.
|
|
125
|
+
- `projection`: `people`, `companies`, `emails`, `phones`, `ids`, or `full` for token-efficient row shape.
|
|
126
|
+
- `cache_mode`: `off`, `read`, `write`, `readwrite`, `refresh`, or `cache_only` for local duplicate-spend avoidance.
|
|
127
|
+
- `credit_ledger`: appends a local JSONL credit/request ledger when enabled.
|
|
128
|
+
- Finder-mode resumes include a filter `fingerprint` and `nextPage`; pass the returned `resumeState` back unchanged to continue a large Contact Finder or Company Finder run.
|
|
129
|
+
- Reverse-email batches locally fail invalid/reserved email domains before calling Quick Enrich.
|
|
130
|
+
- `validate_filters`: validates lookup-backed Contact Finder and Company Finder fields before calling vendor search endpoints.
|
|
131
|
+
- `requests_per_minute` and `burst_size`: endpoint-specific pacing controls. Env defaults are also supported, e.g. `QUICKENRICH_EMPLOYEE_SEARCH_REQUESTS_PER_MINUTE`.
|
|
132
|
+
- `export_format`: `jsonl` or `csv`; returned inline as `export.text`.
|
|
133
|
+
- `input_csv` / `input_jsonl`: native text ingestion for domain-search, reverse-email, and exact-person batch tools.
|
|
134
|
+
- `response:{mode:"meta"}` keeps scalar metrics and collection lengths while omitting records for low-token planning passes.
|
|
135
|
+
- `max_results`: target result count for Contact Finder and Company Finder pagination.
|
|
136
|
+
- Finder auto-pagination runs page 1 first to discover `last_page`, then fans out remaining safe pages up to `concurrency`. Credit-limited Company Finder fanout is capped by the remaining reserved budget.
|
|
137
|
+
|
|
138
|
+
Typed finder builders:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"mode": "contact_finder",
|
|
143
|
+
"filters": {
|
|
144
|
+
"company_name": { "include": ["Acme"], "exclude": [] },
|
|
145
|
+
"country_code": ["US"],
|
|
146
|
+
"title": { "include": ["CEO"], "exclude": ["Intern"] }
|
|
147
|
+
},
|
|
148
|
+
"has_email": true,
|
|
149
|
+
"has_phone": true,
|
|
150
|
+
"max_results": 100,
|
|
151
|
+
"per_page": 50
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Company Finder uses company-shaped output with `domain`, `linkedin_url`, `services`, snippets, firmographics, and contact fields preserved. Supported typed company filters include `home_page_text`, `bio_li`, `services`, `industry`, `number_of_employees`, `revenue`, `country_code`, `city`, `company_name`, and `company_url`.
|
|
156
|
+
|
|
157
|
+
Lookup-backed validation covers Contact Finder `country_code`, `industry_linkedin`, `number_of_employees`, and `revenue`; Company Finder `services`, `industry`, `country_code`, `number_of_employees`, and `revenue`. Validation is opt-in because public lookup endpoints are external dependencies; successful lookup reads are cached in-process.
|
|
158
|
+
|
|
159
|
+
Agent graph:
|
|
160
|
+
|
|
161
|
+
- `quickenrich_agent_graph` returns `endpointCoverage`, `agentRouting`, `budgetMath`, `lookupHints`, `inputHints`, and `envelopePolicy` so cold agents can choose native batch tools without reading docs.
|
|
162
|
+
- `quickenrich_natural_language` routes CSV/JSONL reverse-email, exact-person, domain-search, finder, and resume intents to the native batch tools. It returns `requires` for missing payloads and refuses `execute=true` when required fields are absent.
|
|
163
|
+
|
|
164
|
+
Credit/rate notes from the current docs:
|
|
165
|
+
|
|
166
|
+
- Search, phone, reverse-email: 1000 requests/minute.
|
|
167
|
+
- Domain search: 300 requests/minute, up to 20 contacts/page.
|
|
168
|
+
- Contact finder and company finder: 120 requests/minute.
|
|
169
|
+
- Contact finder is discovery-only and does not return email/phone values; use `enrich_contacts: true` only when you intentionally want follow-up employee/phone lookups.
|
|
170
|
+
- For domain search, use `has_email: true` when you only want contacts with an email signal. This is useful before paid enrichment and keeps agent envelopes cleaner.
|
|
171
|
+
|
|
172
|
+
Domain batch pulls use a shared token-bucket limiter per MCP client plus a rolling 60-second cap. The default is Quick Enrich's published `requests_per_minute=300` with `burst_size=8`, so small deduped batches start immediately while sustained traffic still refills at one token every 200 ms without exceeding the rolling minute ceiling. Set `burst_size:1` for strict one-at-a-time pacing, or set a higher lane only with provider approval:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
QUICKENRICH_DOMAIN_SEARCH_REQUESTS_PER_MINUTE=300 \
|
|
176
|
+
QUICKENRICH_DOMAIN_SEARCH_BURST_SIZE=8 \
|
|
177
|
+
node scripts/quickenrich-mcp.mjs
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The batch result includes `throttle` metadata showing the active ceiling, burst, total acquired slots, and sleep time. Repeated calls through the same MCP process share buckets, so follow-up batches do not reset the burst. If a final 429 includes `Retry-After`, the limiter cools down before later starts.
|
|
181
|
+
|
|
182
|
+
Use `throttle_scope:"shared"` to coordinate the rolling request ceiling across multiple local MCP processes through a small local file lock. This does not bypass published Quick Enrich limits; it keeps separate agents inside the same ceiling.
|
|
183
|
+
|
|
184
|
+
Other batch lanes also use small compliant default bursts: employee search, phone search, and reverse email default to `burst_size=16`; Contact Finder and Company Finder default to `burst_size=4`.
|
|
185
|
+
|
|
186
|
+
## Smoke Tests
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npm run test
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The default suite includes:
|
|
193
|
+
|
|
194
|
+
- agent docs install/uninstall smoke
|
|
195
|
+
- token CLI config and redaction smoke
|
|
196
|
+
- offline contract smoke
|
|
197
|
+
- mocked Quick Enrich API smoke
|
|
198
|
+
- duplicate-request coalescing and concurrency smoke
|
|
199
|
+
- native batch capabilities smoke
|
|
200
|
+
- resumability and finder-validation ops smoke
|
|
201
|
+
- request pacing/rate-limit contract smoke
|
|
202
|
+
- shared token-bucket burst contract smoke
|
|
203
|
+
- reverse-email high-concurrency floor smoke
|
|
204
|
+
- paged finder floor smoke
|
|
205
|
+
- serialization floor smoke
|
|
206
|
+
- local direct-vs-MCP floor benchmark
|
|
207
|
+
- benchmark fixture/comparison contract smoke
|
|
208
|
+
- default agent-surface smoke
|
|
209
|
+
- compact envelope budget smoke
|
|
210
|
+
- cold-agent envelope/workflow smoke
|
|
211
|
+
- MCP stdio config smoke
|
|
212
|
+
- live Quick Enrich auth smoke when a key is configured
|
|
213
|
+
- live native batch no-credit smoke when a key is configured
|
|
214
|
+
- live MCP stdio auth smoke when a key is configured
|
|
215
|
+
|
|
216
|
+
The live auth probes use random reverse-email no-match calls and fail if Quick Enrich reports `credits_used > 0`.
|
|
217
|
+
|
|
218
|
+
Observed vendor diagnostic: `GET /api/lookups/country-codes` returned HTTP 500 during live testing on 2026-07-08. The live smoke records this as a non-blocking diagnostic because the authenticated no-match probe is the real auth gate.
|
|
219
|
+
|
|
220
|
+
## Live Benchmark
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npm run bench:live
|
|
224
|
+
npm run bench:floor
|
|
225
|
+
npm run bench:credit
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The default benchmark uses random no-match company domains with a title filter and fails closed if Quick Enrich returns records or reports any credit usage. It compares:
|
|
229
|
+
|
|
230
|
+
- `raw_sequential`: direct Quick Enrich API calls for every input row, including duplicates.
|
|
231
|
+
- `raw_concurrent_deduped`: direct Quick Enrich API calls after caller-side dedupe and concurrency.
|
|
232
|
+
- `direct_core_batch`: the local batch tool path without stdio transport.
|
|
233
|
+
- `mcp_batch_stdio`: the real MCP stdio tool path.
|
|
234
|
+
|
|
235
|
+
Default shape: 2 iterations, 12 input companies per iteration, 6 unique domains, duplicate factor 2, concurrency 8, `per_page=5`. Override with `QUICKENRICH_BENCH_ITERATIONS`, `QUICKENRICH_BENCH_UNIQUE`, `QUICKENRICH_BENCH_DUPLICATE_FACTOR`, `QUICKENRICH_BENCH_CONCURRENCY`, and `QUICKENRICH_BENCH_PER_PAGE`.
|
|
236
|
+
|
|
237
|
+
Set `QUICKENRICH_BENCH_META_LANE=1` to add an extra `mcp_batch_stdio_meta` diagnostic lane. It is opt-in because shared token buckets make back-to-back lanes intentionally affect each other.
|
|
238
|
+
|
|
239
|
+
`npm run bench:credit` sets `QUICKENRICH_BENCH_SPEND_CREDITS=1`, uses real domains, and requires at least one credit to be spent unless `QUICKENRICH_BENCH_REQUIRE_CREDITS=0` is set. Override paid-run domains with `QUICKENRICH_BENCH_DOMAINS="example.com,sample.com"`.
|
|
240
|
+
|
|
241
|
+
The fair production claim is that MCP batch should beat naive raw sequential API usage by coalescing duplicate work and running the remaining requests concurrently. An already-optimized raw client that dedupes and parallelizes requests can be as fast or faster because it has no JSON-RPC stdio envelope.
|
|
242
|
+
|
|
243
|
+
`npm run bench:floor` uses a local mock API and spends no credits. It measures implementation overhead without vendor latency by default; set `QUICKENRICH_FLOOR_MOCK_LATENCY_MS` to model response latency. Latest local floor run on 2026-07-09:
|
|
244
|
+
|
|
245
|
+
- `reverse_csv_jsonl`: direct core 8.71 ms, MCP stdio 7.43 ms, 64 requests, 64 found, 11.1 KB JSONL export.
|
|
246
|
+
- `exact_csv_phone_csv_export`: direct core 6.99 ms, MCP stdio 6.51 ms, 64 requests, 32 found, 4.6 KB CSV export.
|
|
247
|
+
- `contact_finder_paging_jsonl`: direct core 2.30 ms, MCP stdio 1.43 ms, 4 paged requests, 20 leads, 4.5 KB JSONL export.
|
|
248
|
+
- `company_finder_paging_csv`: direct core 1.20 ms, MCP stdio 1.33 ms, 4 paged requests, 20 companies, 2.7 KB CSV export.
|
|
249
|
+
- Guardrail: local MCP stdio must stay within 2.5x of direct core for every floor lane.
|
|
250
|
+
|
|
251
|
+
Latest default token-bucket no-credit local run on 2026-07-09 against `https://app.quickenrich.io`:
|
|
252
|
+
|
|
253
|
+
- Shape: 2 iterations, 12 input companies, 6 unique no-match domains, duplicate factor 2, default `300/min` with burst `8`.
|
|
254
|
+
- `raw_sequential`: 1602.61 ms average, 12 requests/iteration, 0 credits.
|
|
255
|
+
- `raw_concurrent_deduped`: 194.37 ms average, 6 requests/iteration, 0 credits.
|
|
256
|
+
- `direct_core_batch`: 213.22 ms average, 6 requests/iteration, 0 credits.
|
|
257
|
+
- `mcp_batch_stdio`: 241.81 ms average, 6 requests/iteration, 0 credits.
|
|
258
|
+
- Result: MCP stdio batch was 6.63x faster than naive raw sequential API usage and cut request volume 2x.
|
|
259
|
+
|
|
260
|
+
Latest default token-bucket paid local run on 2026-07-09:
|
|
261
|
+
|
|
262
|
+
- Shape: 1 iteration, `danosseasoning.com` and `sagegoddess.com`, duplicate factor 2, default `300/min` with burst `8`.
|
|
263
|
+
- `raw_sequential`: 666.60 ms, 4 requests, 4 credits, 4 leads.
|
|
264
|
+
- `raw_concurrent_deduped`: 237.11 ms, 2 requests, 2 credits, 2 leads.
|
|
265
|
+
- `direct_core_batch`: 266.64 ms, 2 requests, 2 credits, 2 leads.
|
|
266
|
+
- `mcp_batch_stdio`: 318.64 ms, 2 requests, 2 credits, 2 leads.
|
|
267
|
+
- Result: MCP stdio batch was 2.09x faster than naive raw sequential API usage, cut request volume 2x, and saved 50% credits on duplicate inputs.
|
|
268
|
+
|
|
269
|
+
## NPM Publish Prep
|
|
270
|
+
|
|
271
|
+
Dry-run the package contents before publishing:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
npm run check
|
|
275
|
+
npm run smoke
|
|
276
|
+
npm run pack:dry-run
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Actual publish is intentionally manual:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
npm publish --access public
|
|
283
|
+
```
|
package/agents/AGENTS.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Quick Enrich MCP Agent Notes
|
|
2
|
+
|
|
3
|
+
- Quick Enrich MCP: call `quickenrich_agent_graph` first, then prefer `quickenrich_pipeline` or batch tools with `input_file`, `output_file`, `resume_file`, `projection`, and `max_credits`.
|
|
4
|
+
- Quick Enrich MCP auth: run `quickenrich-token set` once and `quickenrich-token doctor` before live runs; never paste API keys into prompts.
|
|
5
|
+
|
|
6
|
+
For large CSV/JSONL work, do not paste rows into the prompt. Pass `input_file`, write records with `output_file`, checkpoint with `resume_file`, and keep live calls inside published Quick Enrich limits.
|
package/agents/CLAUDE.md
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Quick Enrich MCP Agent Notes
|
|
2
|
+
|
|
3
|
+
- Quick Enrich MCP: call `quickenrich_agent_graph` first, then prefer `quickenrich_pipeline` or batch tools with `input_file`, `output_file`, `resume_file`, `projection`, and `max_credits`.
|
|
4
|
+
- Quick Enrich MCP auth: run `quickenrich-token set` once and `quickenrich-token doctor` before live runs; never paste API keys into prompts.
|
|
5
|
+
|
|
6
|
+
Use `response:{mode:"meta"}` for planning, `projection:"emails"` or `projection:"people"` for compact row handoff, and `throttle_scope:"shared"` when multiple local MCP processes may run at once.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mackody/quickenrich-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Standalone MCP server and CLI for Quick Enrich lead enrichment APIs.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"quickenrich-agent-docs": "scripts/quickenrich-agent-docs.mjs",
|
|
8
|
+
"quickenrich-mcp": "scripts/quickenrich-mcp.mjs",
|
|
9
|
+
"quickenrich-token": "scripts/quickenrich-token.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
".codex-plugin/",
|
|
13
|
+
".env.example",
|
|
14
|
+
".mcp.json",
|
|
15
|
+
"agents/",
|
|
16
|
+
"scripts/",
|
|
17
|
+
"skills/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"agent-docs:install": "node scripts/quickenrich-agent-docs.mjs install",
|
|
22
|
+
"agent-docs:uninstall": "node scripts/quickenrich-agent-docs.mjs uninstall",
|
|
23
|
+
"check": "node --check scripts/quickenrich-agent-docs.mjs && node --check scripts/quickenrich-mcp.mjs && node --check scripts/quickenrich-token.mjs && node --check scripts/lib/core.mjs && node --check scripts/lib/benchmark.mjs && node --check scripts/agent-docs-smoke.mjs && node --check scripts/token-cli-smoke.mjs && node --check scripts/offline-contract-smoke.mjs && node --check scripts/mock-api-smoke.mjs && node --check scripts/perf-contract-smoke.mjs && node --check scripts/rate-limit-contract-smoke.mjs && node --check scripts/token-bucket-contract-smoke.mjs && node --check scripts/reverse-concurrency-floor-smoke.mjs && node --check scripts/paged-finder-floor-smoke.mjs && node --check scripts/serialization-floor-smoke.mjs && node --check scripts/local-floor-benchmark.mjs && node --check scripts/native-capabilities-smoke.mjs && node --check scripts/ops-contract-smoke.mjs && node --check scripts/benchmark-contract-smoke.mjs && node --check scripts/agent-surface-smoke.mjs && node --check scripts/envelope-budget-smoke.mjs && node --check scripts/agent-envelope-workflow-smoke.mjs && node --check scripts/killer-contract-smoke.mjs && node --check scripts/live-smoke.mjs && node --check scripts/native-live-smoke.mjs && node --check scripts/mcp-live-smoke.mjs && node --check scripts/mcp-config-smoke.mjs && node --check scripts/live-benchmark.mjs",
|
|
24
|
+
"smoke": "node scripts/agent-docs-smoke.mjs && node scripts/token-cli-smoke.mjs && node scripts/offline-contract-smoke.mjs && node scripts/mock-api-smoke.mjs && node scripts/perf-contract-smoke.mjs && node scripts/rate-limit-contract-smoke.mjs && node scripts/token-bucket-contract-smoke.mjs && node scripts/reverse-concurrency-floor-smoke.mjs && node scripts/paged-finder-floor-smoke.mjs && node scripts/serialization-floor-smoke.mjs && node scripts/local-floor-benchmark.mjs && node scripts/native-capabilities-smoke.mjs && node scripts/ops-contract-smoke.mjs && node scripts/benchmark-contract-smoke.mjs && node scripts/agent-surface-smoke.mjs && node scripts/envelope-budget-smoke.mjs && node scripts/agent-envelope-workflow-smoke.mjs && node scripts/killer-contract-smoke.mjs && node scripts/mcp-config-smoke.mjs",
|
|
25
|
+
"smoke:live": "node scripts/live-smoke.mjs && node scripts/native-live-smoke.mjs && node scripts/mcp-live-smoke.mjs",
|
|
26
|
+
"bench:live": "node scripts/live-benchmark.mjs",
|
|
27
|
+
"bench:floor": "node scripts/local-floor-benchmark.mjs",
|
|
28
|
+
"bench:credit": "QUICKENRICH_BENCH_SPEND_CREDITS=1 node scripts/live-benchmark.mjs",
|
|
29
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
30
|
+
"prepublishOnly": "npm run check && npm run smoke",
|
|
31
|
+
"test": "npm run check && npm run smoke && npm run smoke:live"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"license": "UNLICENSED"
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
function assert(condition, message) {
|
|
9
|
+
if (!condition) throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function run(args, cwd) {
|
|
13
|
+
const result = spawnSync(process.execPath, ["scripts/quickenrich-agent-docs.mjs", ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
});
|
|
17
|
+
if (result.status !== 0) throw new Error(`Command failed: ${args.join(" ")}\n${result.stderr || result.stdout}`);
|
|
18
|
+
return JSON.parse(result.stdout);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function managedLines(text) {
|
|
22
|
+
return text.split(/\r?\n/).filter((line) => line.includes("<!-- quickenrich-mcp:line"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const repoRoot = new URL("..", import.meta.url).pathname;
|
|
26
|
+
const tempDir = mkdtempSync(join(tmpdir(), "qe-agent-docs-"));
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const claudePath = join(tempDir, "CLAUDE.md");
|
|
30
|
+
const agentsPath = join(tempDir, "AGENTS.md");
|
|
31
|
+
writeFileSync(claudePath, "# Existing Claude\n\nKeep this line.\n");
|
|
32
|
+
writeFileSync(agentsPath, "# Existing Agents\n\nKeep this line.\n");
|
|
33
|
+
|
|
34
|
+
const installed = run(["install", "--cwd", tempDir, "--json"], repoRoot);
|
|
35
|
+
assert(installed.ok === true, "install should report ok=true.");
|
|
36
|
+
assert(installed.action === "install", "install action should be reported.");
|
|
37
|
+
assert(installed.files.length === 2, "install should touch CLAUDE.md and AGENTS.md.");
|
|
38
|
+
|
|
39
|
+
const claudeInstalled = readFileSync(claudePath, "utf8");
|
|
40
|
+
const agentsInstalled = readFileSync(agentsPath, "utf8");
|
|
41
|
+
assert(claudeInstalled.includes("Keep this line."), "install should preserve existing CLAUDE.md content.");
|
|
42
|
+
assert(agentsInstalled.includes("Keep this line."), "install should preserve existing AGENTS.md content.");
|
|
43
|
+
assert(managedLines(claudeInstalled).length === 2, "install should append exactly two managed CLAUDE.md lines.");
|
|
44
|
+
assert(managedLines(agentsInstalled).length === 2, "install should append exactly two managed AGENTS.md lines.");
|
|
45
|
+
assert(claudeInstalled.includes("quickenrich_agent_graph"), "managed lines should explain agent graph usage.");
|
|
46
|
+
assert(agentsInstalled.includes("quickenrich-token doctor"), "managed lines should explain auth/doctor usage.");
|
|
47
|
+
|
|
48
|
+
run(["install", "--cwd", tempDir, "--json"], repoRoot);
|
|
49
|
+
assert(managedLines(readFileSync(claudePath, "utf8")).length === 2, "install should be idempotent for CLAUDE.md.");
|
|
50
|
+
assert(managedLines(readFileSync(agentsPath, "utf8")).length === 2, "install should be idempotent for AGENTS.md.");
|
|
51
|
+
|
|
52
|
+
const uninstalled = run(["uninstall", "--cwd", tempDir, "--json"], repoRoot);
|
|
53
|
+
assert(uninstalled.ok === true, "uninstall should report ok=true.");
|
|
54
|
+
assert(uninstalled.action === "uninstall", "uninstall action should be reported.");
|
|
55
|
+
assert(managedLines(readFileSync(claudePath, "utf8")).length === 0, "uninstall should remove managed CLAUDE.md lines.");
|
|
56
|
+
assert(managedLines(readFileSync(agentsPath, "utf8")).length === 0, "uninstall should remove managed AGENTS.md lines.");
|
|
57
|
+
assert(readFileSync(claudePath, "utf8").includes("Keep this line."), "uninstall should preserve existing CLAUDE.md content.");
|
|
58
|
+
assert(readFileSync(agentsPath, "utf8").includes("Keep this line."), "uninstall should preserve existing AGENTS.md content.");
|
|
59
|
+
|
|
60
|
+
run(["uninstall", "--cwd", tempDir, "--json"], repoRoot);
|
|
61
|
+
assert(managedLines(readFileSync(claudePath, "utf8")).length === 0, "uninstall should be idempotent for CLAUDE.md.");
|
|
62
|
+
assert(managedLines(readFileSync(agentsPath, "utf8")).length === 0, "uninstall should be idempotent for AGENTS.md.");
|
|
63
|
+
|
|
64
|
+
const claudeTemplate = readFileSync(join(repoRoot, "agents", "CLAUDE.md"), "utf8");
|
|
65
|
+
const agentsTemplate = readFileSync(join(repoRoot, "agents", "AGENTS.md"), "utf8");
|
|
66
|
+
assert(claudeTemplate.includes("quickenrich_pipeline"), "agent CLAUDE.md should document the pipeline tool.");
|
|
67
|
+
assert(agentsTemplate.includes("input_file"), "agent AGENTS.md should document file-based bulk inputs.");
|
|
68
|
+
|
|
69
|
+
console.log(JSON.stringify({ ok: true }, null, 2));
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
TOOL_DEFINITIONS,
|
|
5
|
+
agentGraph,
|
|
6
|
+
callQuickEnrichTool,
|
|
7
|
+
compactToolPayload,
|
|
8
|
+
planNaturalLanguage,
|
|
9
|
+
selectTools,
|
|
10
|
+
toolPayloadText,
|
|
11
|
+
} from "./lib/core.mjs";
|
|
12
|
+
|
|
13
|
+
function assert(condition, message) {
|
|
14
|
+
if (!condition) throw new Error(message);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function bytes(value) {
|
|
18
|
+
return Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const agentTools = selectTools(TOOL_DEFINITIONS);
|
|
22
|
+
const fullTools = selectTools(TOOL_DEFINITIONS, "full");
|
|
23
|
+
const agentToolBytes = bytes({ tools: agentTools });
|
|
24
|
+
const fullToolBytes = bytes({ tools: fullTools });
|
|
25
|
+
|
|
26
|
+
assert(agentTools.length <= 9, `Default agent surface should stay <=9 tools, got ${agentTools.length}.`);
|
|
27
|
+
assert(agentToolBytes <= 8_000, `Default tools/list surface is too large: ${agentToolBytes} bytes.`);
|
|
28
|
+
assert(fullToolBytes > agentToolBytes, "Full tool surface should remain more verbose than agent surface.");
|
|
29
|
+
assert(agentTools.some((tool) => tool.name === "quickenrich_lookup_values"), "Agent surface should expose lookup discovery.");
|
|
30
|
+
assert(agentTools.every((tool) => !JSON.stringify(tool).includes("description\":\"CSV text")), "Agent surface should strip verbose nested schema descriptions.");
|
|
31
|
+
|
|
32
|
+
const graph = agentGraph();
|
|
33
|
+
const graphCompact = compactToolPayload(graph);
|
|
34
|
+
const graphMeta = compactToolPayload(graph, { mode: "meta" });
|
|
35
|
+
const graphFull = compactToolPayload(graph, { mode: "full", maxOutputChars: 100_000 });
|
|
36
|
+
const graphCompactText = toolPayloadText(graphCompact);
|
|
37
|
+
const graphMetaText = toolPayloadText(graphMeta);
|
|
38
|
+
const graphFullText = toolPayloadText(graphFull);
|
|
39
|
+
|
|
40
|
+
assert(bytes(graphCompactText) <= 8_000, `Agent graph compact envelope is too large: ${bytes(graphCompactText)} bytes.`);
|
|
41
|
+
assert(bytes(graphMetaText) <= 1_600, `Agent graph meta envelope is too large: ${bytes(graphMetaText)} bytes.`);
|
|
42
|
+
assert(graphCompact._meta.responseMode === "compact", "Agent graph compact response mode should be compact.");
|
|
43
|
+
assert(graphMeta._meta.responseMode === "meta", "Agent graph meta response mode should be meta.");
|
|
44
|
+
assert(graphFull._meta.responseMode === "full", "Agent graph full response mode should be full.");
|
|
45
|
+
assert(graph.lookupHints?.tool === "quickenrich_lookup_values", "Agent graph should point filter discovery to lookup_values.");
|
|
46
|
+
assert(graph.budgetMath?.exact_email_phone?.includes("phone"), "Agent graph should explain email+phone budget math.");
|
|
47
|
+
|
|
48
|
+
const reverseCsv = planNaturalLanguage("Reverse lookup CSV emails: email\nada@example.com\ngrace@example.com");
|
|
49
|
+
assert(reverseCsv.tool === "quickenrich_batch_reverse_email", `CSV email route chose ${reverseCsv.tool}.`);
|
|
50
|
+
assert(reverseCsv.arguments.emails.length === 2, "CSV email route should extract embedded emails.");
|
|
51
|
+
|
|
52
|
+
const reverseCsvPlaceholder = planNaturalLanguage("Reverse lookup a CSV of emails");
|
|
53
|
+
assert(reverseCsvPlaceholder.tool === "quickenrich_batch_reverse_email", `CSV placeholder route chose ${reverseCsvPlaceholder.tool}.`);
|
|
54
|
+
assert(reverseCsvPlaceholder.requires.includes("input_csv"), "CSV placeholder route should require input_csv.");
|
|
55
|
+
|
|
56
|
+
const exactJsonl = planNaturalLanguage('Batch exact employee search from JSONL: {"company_url":"example.com","first_name":"Ada","last_name":"Lovelace"}');
|
|
57
|
+
assert(exactJsonl.tool === "quickenrich_batch_employee_search", `Exact JSONL route chose ${exactJsonl.tool}.`);
|
|
58
|
+
assert(exactJsonl.arguments.response?.mode === "meta", "Exact JSONL route should default to meta planning output.");
|
|
59
|
+
assert(exactJsonl.requires.includes("input_jsonl"), "Exact JSONL route should require input_jsonl payload.");
|
|
60
|
+
|
|
61
|
+
const domainCsv = planNaturalLanguage("Use CSV domain_search for company_url,title\nexample.com,Founder\nsample.io,CEO");
|
|
62
|
+
assert(domainCsv.tool === "quickenrich_batch_pull_leads", `Domain CSV route chose ${domainCsv.tool}.`);
|
|
63
|
+
assert(domainCsv.arguments.mode === "domain_search", `Domain CSV route mode=${domainCsv.arguments.mode}.`);
|
|
64
|
+
assert(!domainCsv.arguments.companies, "Domain CSV route should avoid expanding file-shaped input into object arrays.");
|
|
65
|
+
assert(domainCsv.requires.includes("input_csv"), "Domain CSV route should require input_csv payload.");
|
|
66
|
+
|
|
67
|
+
const creditPlan = planNaturalLanguage("pull founder leads from 50 company domains with max 100 credits");
|
|
68
|
+
assert(creditPlan.tool === "quickenrich_batch_pull_leads", `Credit lead route chose ${creditPlan.tool}.`);
|
|
69
|
+
assert(creditPlan.arguments.max_credits === 100, `Expected max_credits=100, got ${creditPlan.arguments.max_credits}.`);
|
|
70
|
+
assert(creditPlan.requires.includes("companies or input_csv/input_jsonl"), "Credit lead route should ask for company input when no domains are present.");
|
|
71
|
+
|
|
72
|
+
const resumePlan = planNaturalLanguage("continue a paged company finder run");
|
|
73
|
+
assert(resumePlan.tool === "quickenrich_batch_pull_leads", `Resume route chose ${resumePlan.tool}.`);
|
|
74
|
+
assert(resumePlan.arguments.mode === "company_finder", `Resume route mode=${resumePlan.arguments.mode}.`);
|
|
75
|
+
assert(resumePlan.requires.includes("resume_state"), "Resume route should require resume_state.");
|
|
76
|
+
|
|
77
|
+
const blockedExecute = await callQuickEnrichTool("quickenrich_natural_language", {
|
|
78
|
+
request: "reverse lookup a CSV of emails",
|
|
79
|
+
execute: true,
|
|
80
|
+
response: { mode: "full" },
|
|
81
|
+
});
|
|
82
|
+
assert(blockedExecute.ok === false, "Natural-language execute should not run incomplete plans.");
|
|
83
|
+
assert(blockedExecute.error.includes("input_csv"), "Blocked execute should name the missing input_csv requirement.");
|
|
84
|
+
|
|
85
|
+
console.log(JSON.stringify({
|
|
86
|
+
ok: true,
|
|
87
|
+
agentTools: agentTools.length,
|
|
88
|
+
agentToolBytes,
|
|
89
|
+
graphCompactBytes: bytes(graphCompactText),
|
|
90
|
+
graphMetaBytes: bytes(graphMetaText),
|
|
91
|
+
graphFullBytes: bytes(graphFullText),
|
|
92
|
+
}, null, 2));
|