@mambalabsdev/mcp-ai-tooling-detector 1.1.1 → 1.3.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/README.md +5 -0
- package/build/index.js +138 -31
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -76,11 +76,16 @@ Calls consume Apify credits, billed per domain analyzed. Free Apify plans get 15
|
|
|
76
76
|
|---|---|---|---|
|
|
77
77
|
| `domain` | string | one of these | A single company domain, e.g. `intercom.com`. |
|
|
78
78
|
| `domains` | string[] | one of these | Batch mode. Takes precedence over `domain`. |
|
|
79
|
+
| `vendors` | array | no | Report only these AI vendors, one or more of the 52 fingerprinted tools (`sierra`, `decagon`, `intercom_fin`, `openai_api`, `anthropic_api`, `pinecone`, `langchain` and the rest). The site wide AI maturity read is never narrowed by this. Omit for every vendor. |
|
|
79
80
|
| `check_pricing` | boolean | no | Fetch and score the pricing page. Default true. Setting it false is faster but caps the result at `deployed`, because `commercialized` can only be proven on a pricing page. |
|
|
80
81
|
| `skipCache` | boolean | no | Force a fresh analysis and ignore the 7 day result cache. |
|
|
82
|
+
| `request_timeout_ms` | integer | no | Per-request timeout in milliseconds, 3000 to 20000. Default 9000. |
|
|
83
|
+
| `batchSize` | integer | no | Batch mode only: how many domains the actor analyzes at once, 1 to 10. Default 5. |
|
|
81
84
|
|
|
82
85
|
The tool is read-only and idempotent. It never writes anything.
|
|
83
86
|
|
|
87
|
+
The tool starts the actor run and polls it to a finished status, so a long run is not cut off at 300 seconds. The run is allowed 1,800 seconds. If it is still going two minutes after that, the call stops waiting and returns the run ID with a link to it in the Apify Console, where the results land when it finishes. A run that does not succeed comes back as an error with its run ID and status.
|
|
88
|
+
|
|
84
89
|
## Full actor documentation
|
|
85
90
|
|
|
86
91
|
Input reference, the complete output field list, pricing tiers, error handling, and the measured limitations are on the actor's Apify Store page:
|
package/build/index.js
CHANGED
|
@@ -19,31 +19,41 @@ function compact(obj) {
|
|
|
19
19
|
}
|
|
20
20
|
return out;
|
|
21
21
|
}
|
|
22
|
+
// How long the actor run itself is allowed to take, in seconds. One value for
|
|
23
|
+
// every Mamba Labs wrapper, set 2026-10-05: start and poll exists so a long run
|
|
24
|
+
// survives, and a shorter limit would end the long runs it was built for. Past
|
|
25
|
+
// this limit the run ends TIMED-OUT and the caller is told so, with the run id.
|
|
26
|
+
const ACTOR_RUN_TIMEOUT_SECS = 1800;
|
|
27
|
+
// How long this wrapper waits for that run, in milliseconds. The actor's own
|
|
28
|
+
// timeout plus two minutes, so the run's own TIMED-OUT status is what the
|
|
29
|
+
// caller sees rather than the wrapper giving up first and reporting nothing.
|
|
30
|
+
const WRAPPER_WAIT_MS = (ACTOR_RUN_TIMEOUT_SECS + 120) * 1000;
|
|
31
|
+
const POLL_INTERVAL_MS = Number(process.env.MAMBA_POLL_INTERVAL_MS) || 3000;
|
|
32
|
+
const TERMINAL = new Set(["SUCCEEDED", "FAILED", "TIMED-OUT", "ABORTED", "ABORTING"]);
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
34
|
// Shared caller. actorPath is the actor's immutable Apify actor ID (a stable key
|
|
23
35
|
// that survives Store renames). The /v2/acts/{id} endpoint accepts it directly,
|
|
24
36
|
// so a Store rename never breaks these calls.
|
|
37
|
+
//
|
|
38
|
+
// START AND POLL, NOT RUN-SYNC. Apify's synchronous endpoints carry a platform
|
|
39
|
+
// ceiling of 300 seconds on the HTTP wait itself and answer 408 past it while
|
|
40
|
+
// the run goes on and keeps billing. Starting the run, polling it to a terminal
|
|
41
|
+
// status and then reading the dataset waits as long as the actor needs.
|
|
42
|
+
//
|
|
43
|
+
// The token is read here rather than at module load, so the tool registers
|
|
44
|
+
// unconditionally and a server started without APIFY_TOKEN still advertises its
|
|
45
|
+
// capabilities instead of reporting none.
|
|
25
46
|
async function runActor(actorPath, actorLabel, input) {
|
|
47
|
+
const APIFY_TOKEN = process.env.APIFY_TOKEN;
|
|
26
48
|
if (!APIFY_TOKEN) {
|
|
27
49
|
return { isError: true, content: [{ type: "text", text: "APIFY_TOKEN is not set. Create a token at https://console.apify.com/account/integrations and set it as the APIFY_TOKEN environment variable." }] };
|
|
28
50
|
}
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
Authorization: `Bearer ${APIFY_TOKEN}`,
|
|
36
|
-
"Content-Type": "application/json",
|
|
37
|
-
"User-Agent": USER_AGENT,
|
|
38
|
-
},
|
|
39
|
-
body: JSON.stringify(input),
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
-
return { isError: true, content: [{ type: "text", text: `Could not reach the Apify API: ${message}` }] };
|
|
45
|
-
}
|
|
46
|
-
if (!response.ok) {
|
|
51
|
+
const headers = {
|
|
52
|
+
Authorization: `Bearer ${APIFY_TOKEN}`,
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"User-Agent": USER_AGENT,
|
|
55
|
+
};
|
|
56
|
+
const httpError = async (response) => {
|
|
47
57
|
let detail = "";
|
|
48
58
|
try {
|
|
49
59
|
const body = (await response.json());
|
|
@@ -53,24 +63,106 @@ async function runActor(actorPath, actorLabel, input) {
|
|
|
53
63
|
catch {
|
|
54
64
|
detail = "";
|
|
55
65
|
}
|
|
56
|
-
let message;
|
|
57
66
|
switch (response.status) {
|
|
67
|
+
case 400:
|
|
68
|
+
return `The ${actorLabel} run was rejected as invalid input.${detail}`;
|
|
58
69
|
case 401:
|
|
59
|
-
|
|
60
|
-
break;
|
|
70
|
+
return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
|
|
61
71
|
case 402:
|
|
62
|
-
|
|
63
|
-
"Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
|
|
64
|
-
break;
|
|
65
|
-
case 408:
|
|
66
|
-
message = `The ${actorLabel} run timed out after 300 seconds. Try again, or run the actor on Apify directly for longer jobs.`;
|
|
67
|
-
break;
|
|
72
|
+
return "Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
|
|
68
73
|
default:
|
|
69
|
-
|
|
74
|
+
return `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
|
|
70
75
|
}
|
|
71
|
-
|
|
76
|
+
};
|
|
77
|
+
// 1. Start the run.
|
|
78
|
+
let started;
|
|
79
|
+
try {
|
|
80
|
+
started = await fetch(`https://api.apify.com/v2/acts/${actorPath}/runs?timeout=${ACTOR_RUN_TIMEOUT_SECS}`, { method: "POST", headers, body: JSON.stringify(input) });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
84
|
+
return { isError: true, content: [{ type: "text", text: `Could not reach the Apify API: ${message}` }] };
|
|
85
|
+
}
|
|
86
|
+
if (!started.ok) {
|
|
87
|
+
return { isError: true, content: [{ type: "text", text: await httpError(started) }] };
|
|
88
|
+
}
|
|
89
|
+
let run;
|
|
90
|
+
try {
|
|
91
|
+
run = (await started.json()).data ?? {};
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
95
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned a response that could not be parsed: ${message}` }] };
|
|
96
|
+
}
|
|
97
|
+
const runId = run.id;
|
|
98
|
+
if (!runId) {
|
|
99
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned no run id, so there is nothing to wait for.` }] };
|
|
100
|
+
}
|
|
101
|
+
// 2. Poll to a terminal status.
|
|
102
|
+
const deadline = Date.now() + WRAPPER_WAIT_MS;
|
|
103
|
+
let status = run.status ?? "READY";
|
|
104
|
+
let datasetId = run.defaultDatasetId;
|
|
105
|
+
while (!TERMINAL.has(status)) {
|
|
106
|
+
if (Date.now() >= deadline) {
|
|
107
|
+
return {
|
|
108
|
+
isError: true,
|
|
109
|
+
content: [{ type: "text", text: `The ${actorLabel} run ${runId} was still ${status} after ${Math.round(WRAPPER_WAIT_MS / 1000)} seconds and this call stopped waiting. The run itself is still on Apify: read it at https://console.apify.com/actors/runs/${runId}` }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
await sleep(POLL_INTERVAL_MS);
|
|
113
|
+
let poll;
|
|
114
|
+
try {
|
|
115
|
+
poll = await fetch(`https://api.apify.com/v2/actor-runs/${runId}`, { headers });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
119
|
+
return { isError: true, content: [{ type: "text", text: `Lost contact with the Apify API while waiting for ${actorLabel} run ${runId}: ${message}` }] };
|
|
120
|
+
}
|
|
121
|
+
if (!poll.ok) {
|
|
122
|
+
return { isError: true, content: [{ type: "text", text: await httpError(poll) }] };
|
|
123
|
+
}
|
|
124
|
+
const body = (await poll.json());
|
|
125
|
+
status = body.data?.status ?? status;
|
|
126
|
+
datasetId = body.data?.defaultDatasetId ?? datasetId;
|
|
127
|
+
}
|
|
128
|
+
// 3. A run that did not succeed is a failure the caller must see, never an
|
|
129
|
+
// empty success, so a crashed run never reads as "no results found".
|
|
130
|
+
if (status !== "SUCCEEDED") {
|
|
131
|
+
return {
|
|
132
|
+
isError: true,
|
|
133
|
+
content: [{ type: "text", text: `The ${actorLabel} run did not succeed (run ID: ${runId}, status: ${status}).` }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!datasetId) {
|
|
137
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run ${runId} succeeded but reported no dataset, so there is nothing to return.` }] };
|
|
138
|
+
}
|
|
139
|
+
// 4. Read the dataset.
|
|
140
|
+
let ds;
|
|
141
|
+
try {
|
|
142
|
+
ds = await fetch(`https://api.apify.com/v2/datasets/${datasetId}/items?format=json`, { headers });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
146
|
+
return { isError: true, content: [{ type: "text", text: `Could not read the ${actorLabel} dataset: ${message}` }] };
|
|
147
|
+
}
|
|
148
|
+
if (!ds.ok) {
|
|
149
|
+
return { isError: true, content: [{ type: "text", text: await httpError(ds) }] };
|
|
150
|
+
}
|
|
151
|
+
let items;
|
|
152
|
+
try {
|
|
153
|
+
items = await ds.json();
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
157
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run returned a response that could not be parsed: ${message}` }] };
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(items)) {
|
|
160
|
+
const asObj = items;
|
|
161
|
+
const detail = asObj?.error?.message
|
|
162
|
+
? `${asObj.error.message}`
|
|
163
|
+
: JSON.stringify(items);
|
|
164
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run did not return a dataset. ${detail}` }] };
|
|
72
165
|
}
|
|
73
|
-
const items = await response.json();
|
|
74
166
|
return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] };
|
|
75
167
|
}
|
|
76
168
|
const server = new McpServer({
|
|
@@ -97,6 +189,10 @@ server.registerTool("detect_ai_tooling", {
|
|
|
97
189
|
.array(z.string())
|
|
98
190
|
.optional()
|
|
99
191
|
.describe("Batch mode: several company domains analyzed in one call. Takes precedence over domain."),
|
|
192
|
+
vendors: z
|
|
193
|
+
.array(z.enum(["sierra", "decagon", "ada", "forethought", "intercom_fin", "ultimate_ai", "netomi", "yellow_ai", "cognigy", "kore_ai", "chatbase", "voiceflow", "sendbird_ai", "intercom", "zendesk", "drift", "gorgias", "freshworks", "tidio", "crisp", "kustomer", "qualified", "hubspot_breeze", "algolia", "glean", "inkeep", "kapa_ai", "mendable", "mutiny", "dynamic_yield", "openai_api", "anthropic_api", "azure_openai", "aws_bedrock", "google_gemini", "cohere", "mistral", "groq", "together_ai", "fireworks_ai", "replicate", "perplexity_api", "huggingface", "openrouter", "cloudflare_ai_gateway", "pinecone", "weaviate", "qdrant", "vercel_ai_sdk", "langchain", "llamaindex", "assistant_ui"]))
|
|
194
|
+
.optional()
|
|
195
|
+
.describe("Report only these AI vendors, which turns the actor into competitive intelligence for any one of the 52 fingerprinted tools. Detection is unchanged either way, so a filtered call costs the same. The site wide AI maturity read (ai_maturity, confidence, uses_ai, the llms.txt and robots.txt checks, the pricing score) is never narrowed by this. Omit for every vendor."),
|
|
100
196
|
check_pricing: z
|
|
101
197
|
.boolean()
|
|
102
198
|
.optional()
|
|
@@ -108,10 +204,19 @@ server.registerTool("detect_ai_tooling", {
|
|
|
108
204
|
request_timeout_ms: z
|
|
109
205
|
.number()
|
|
110
206
|
.int()
|
|
207
|
+
.min(3000)
|
|
208
|
+
.max(20000)
|
|
111
209
|
.optional()
|
|
112
210
|
.describe("Per-request timeout in milliseconds. 3000 to 20000. Default: 9000."),
|
|
211
|
+
batchSize: z
|
|
212
|
+
.number()
|
|
213
|
+
.int()
|
|
214
|
+
.min(1)
|
|
215
|
+
.max(10)
|
|
216
|
+
.optional()
|
|
217
|
+
.describe("Batch mode only: how many domains the actor analyzes at once. 1 to 10. Default: 5."),
|
|
113
218
|
},
|
|
114
|
-
}, async ({ domain, domains, check_pricing, skipCache, request_timeout_ms }) => {
|
|
219
|
+
}, async ({ domain, domains, vendors, check_pricing, skipCache, request_timeout_ms, batchSize }) => {
|
|
115
220
|
const hasSingle = domain !== undefined && domain !== "";
|
|
116
221
|
const hasBatch = Array.isArray(domains) && domains.length > 0;
|
|
117
222
|
if (!hasSingle && !hasBatch) {
|
|
@@ -123,9 +228,11 @@ server.registerTool("detect_ai_tooling", {
|
|
|
123
228
|
return runActor("EwkHhmqiuJgRoVEbE", "AI Tooling Detector", compact({
|
|
124
229
|
domain: hasBatch ? undefined : domain,
|
|
125
230
|
domains: hasBatch ? domains : undefined,
|
|
231
|
+
vendors,
|
|
126
232
|
check_pricing,
|
|
127
233
|
skipCache,
|
|
128
234
|
request_timeout_ms,
|
|
235
|
+
batchSize,
|
|
129
236
|
}));
|
|
130
237
|
});
|
|
131
238
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mambalabsdev/mcp-ai-tooling-detector",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "MCP server for the Mamba Labs AI Tooling Detector actor on Apify. Give it a company domain and it returns whether the company only talks about AI, actually deploys AI tooling on its site, or charges for AI, with the evidence, as Clay-ready JSON.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && chmod +x build/index.js",
|
|
17
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
17
18
|
"prepublishOnly": "npm run build"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [
|
|
@@ -40,11 +41,11 @@
|
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
43
|
-
"zod": "^
|
|
44
|
+
"zod": "^4.5.4"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^
|
|
47
|
-
"typescript": "
|
|
47
|
+
"@types/node": "^26.4.0",
|
|
48
|
+
"typescript": "5.9.3"
|
|
48
49
|
},
|
|
49
50
|
"mcpName": "com.mambabuilt/mcp-ai-tooling-detector"
|
|
50
51
|
}
|