@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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/build/index.js +138 -31
  3. 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 url = `https://api.apify.com/v2/acts/${actorPath}/run-sync-get-dataset-items?timeout=300`;
30
- let response;
31
- try {
32
- response = await fetch(url, {
33
- method: "POST",
34
- headers: {
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
- message = "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
60
- break;
70
+ return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
61
71
  case 402:
62
- message =
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
- message = `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
74
+ return `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
70
75
  }
71
- return { isError: true, content: [{ type: "text", text: message }] };
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.1.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": "^3.23.8"
44
+ "zod": "^4.5.4"
44
45
  },
45
46
  "devDependencies": {
46
- "@types/node": "^20.14.0",
47
- "typescript": "^5.6.0"
47
+ "@types/node": "^26.4.0",
48
+ "typescript": "5.9.3"
48
49
  },
49
50
  "mcpName": "com.mambabuilt/mcp-ai-tooling-detector"
50
51
  }