@mambalabsdev/mcp-meta-brand-presence-mapper 1.0.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/build/index.js +117 -37
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -46,12 +46,12 @@ Tool: `map_meta_brand_presence`
46
46
 
47
47
  | Input | Type | Meaning |
48
48
  |---|---|---|
49
- | `company_domain` | string | Bare company domain, for example shopify.com. Supply this or a handle. With a domain the actor runs full discovery; with a handle it skips straight to |
49
+ | `company_domain` | string | Bare company domain, for example shopify.com. Supply this or a handle. With a domain the actor runs full discovery; with a handle it skips straight to the fetch. |
50
50
  | `company_name` | string | Optional. Improves search accuracy and is what the identity gate checks a discovered profile against, so supplying it reduces wrong matches. |
51
- | `handle` | string | Optional. The Instagram handle with or without the leading @. Supplying it skips Instagram discovery AND gives Threads its handle for free, because Th |
52
- | `platforms` | array | Which of the three Meta surfaces to map. Default is all three. Dropping Facebook is the common choice: it is the least reliable of the three and it co |
53
- | `includeFollowerCounts` | boolean | When "true" (default) the profile page is fetched and the counts are extracted. Set "false" to resolve the profile URL only, which is cheaper and need |
54
- | `skipCache` | boolean | When "false" (default) a successful lookup is cached for seven days and reused. Set "true" to force a fresh fetch. Sent as a string for Clay compatibi |
51
+ | `handle` | string | Optional. The Instagram handle with or without the leading @. Supplying it skips Instagram discovery AND gives Threads its handle for free, because Threads handles are Instagram handles (5 of 5 measured). |
52
+ | `platforms` | array | Which of the three Meta surfaces to map. Default is all three. Dropping Facebook is the common choice: it is the least reliable of the three and it costs a fetch to find that out. |
53
+ | `includeFollowerCounts` | boolean | Default true: the profile page is fetched and the follower counts are extracted. Set false to resolve the profile URL only, which is cheaper and needs no proxy. |
54
+ | `skipCache` | boolean | Default false: a successful lookup is cached for seven days and reused. Set true to force a fresh fetch. |
55
55
 
56
56
  ## Reading the output
57
57
 
package/build/index.js CHANGED
@@ -19,6 +19,20 @@ function compact(obj) {
19
19
  }
20
20
  return out;
21
21
  }
22
+ // START AND POLL, NOT RUN-SYNC. Apify's synchronous endpoint carries a platform
23
+ // ceiling of 300 seconds on the HTTP wait and answers 408 past it while the run
24
+ // keeps going and keeps billing. Starting the run, polling it to a terminal
25
+ // status, and then reading the dataset waits as long as the actor needs.
26
+ //
27
+ // How long the actor run itself may take, in seconds: long enough for a large
28
+ // batch, short enough that a hung run cannot bill indefinitely.
29
+ const ACTOR_RUN_TIMEOUT_SECS = 1800;
30
+ // How long this wrapper waits: the run's own timeout plus two minutes, so the
31
+ // run's TIMED-OUT status is what the caller sees.
32
+ const WRAPPER_WAIT_MS = (ACTOR_RUN_TIMEOUT_SECS + 120) * 1000;
33
+ const POLL_INTERVAL_MS = Number(process.env.MAMBA_POLL_INTERVAL_MS ?? 3000);
34
+ const TERMINAL = new Set(["SUCCEEDED", "FAILED", "TIMED-OUT", "ABORTED", "ABORTING"]);
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
36
  // The actor types its switches as strings ("true"/"false") for Clay
23
37
  // compatibility, because Clay sends every input as a string and a boolean typed
24
38
  // field silently receives "false" and reads it as truthy. The model gets a real
@@ -33,29 +47,17 @@ async function runActor(actorPath, actorLabel, input) {
33
47
  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." }] };
34
48
  }
35
49
  // memory=512 is deliberate and matches the actor's declared
36
- // defaultRunOptions.memoryMbytes. run-sync-get-dataset-items runs at 2048 MB
37
- // unless told otherwise, and `apify-actor-start` bills once per GB with a
50
+ // defaultRunOptions.memoryMbytes. an unspecified memory can run at 2048
51
+ // MB, and `apify-actor-start` bills once per GB with a
38
52
  // minimum of one, so leaving the default in place would charge the caller
39
53
  // more start events per run than the actor asks for. Keep this in step with
40
54
  // the actor's defaultRunOptions.
41
- const url = `https://api.apify.com/v2/acts/${actorPath}/run-sync-get-dataset-items?timeout=300&memory=512`;
42
- let response;
43
- try {
44
- response = await fetch(url, {
45
- method: "POST",
46
- headers: {
47
- Authorization: `Bearer ${APIFY_TOKEN}`,
48
- "Content-Type": "application/json",
49
- "User-Agent": USER_AGENT,
50
- },
51
- body: JSON.stringify(input),
52
- });
53
- }
54
- catch (err) {
55
- const message = err instanceof Error ? err.message : String(err);
56
- return { isError: true, content: [{ type: "text", text: `Could not reach the Apify API: ${message}` }] };
57
- }
58
- if (!response.ok) {
55
+ const headers = {
56
+ Authorization: `Bearer ${APIFY_TOKEN}`,
57
+ "Content-Type": "application/json",
58
+ "User-Agent": USER_AGENT,
59
+ };
60
+ const httpError = async (response) => {
59
61
  let detail = "";
60
62
  try {
61
63
  const body = (await response.json());
@@ -65,27 +67,105 @@ async function runActor(actorPath, actorLabel, input) {
65
67
  catch {
66
68
  detail = "";
67
69
  }
68
- let message;
69
70
  switch (response.status) {
71
+ case 400:
72
+ return `The ${actorLabel} run was rejected as invalid input.${detail}`;
70
73
  case 401:
71
- message = "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
72
- break;
74
+ return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
73
75
  case 402:
74
- message = "Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
75
- break;
76
- case 408:
77
- message = `The ${actorLabel} run timed out after 300 seconds. Try again, or run the actor on Apify directly for longer jobs.`;
78
- break;
76
+ return "Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
79
77
  default:
80
- message = `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
78
+ return `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
79
+ }
80
+ };
81
+ // 1. Start the run.
82
+ let started;
83
+ try {
84
+ started = await fetch(`https://api.apify.com/v2/acts/${actorPath}/runs?timeout=${ACTOR_RUN_TIMEOUT_SECS}&memory=512`, { method: "POST", headers, body: JSON.stringify(input) });
85
+ }
86
+ catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ return { isError: true, content: [{ type: "text", text: `Could not reach the Apify API: ${message}` }] };
89
+ }
90
+ if (!started.ok) {
91
+ return { isError: true, content: [{ type: "text", text: await httpError(started) }] };
92
+ }
93
+ let run;
94
+ try {
95
+ run = (await started.json()).data ?? {};
96
+ }
97
+ catch (err) {
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned a response that could not be parsed: ${message}` }] };
100
+ }
101
+ const runId = run.id;
102
+ if (!runId) {
103
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned no run id, so there is nothing to wait for.` }] };
104
+ }
105
+ // 2. Poll to a terminal status.
106
+ const deadline = Date.now() + WRAPPER_WAIT_MS;
107
+ let status = run.status ?? "READY";
108
+ let datasetId = run.defaultDatasetId;
109
+ while (!TERMINAL.has(status)) {
110
+ if (Date.now() >= deadline) {
111
+ return {
112
+ isError: true,
113
+ 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}` }],
114
+ };
115
+ }
116
+ await sleep(POLL_INTERVAL_MS);
117
+ let poll;
118
+ try {
119
+ poll = await fetch(`https://api.apify.com/v2/actor-runs/${runId}`, { headers });
120
+ }
121
+ catch (err) {
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ return { isError: true, content: [{ type: "text", text: `Lost contact with the Apify API while waiting for ${actorLabel} run ${runId}: ${message}` }] };
124
+ }
125
+ if (!poll.ok) {
126
+ return { isError: true, content: [{ type: "text", text: await httpError(poll) }] };
81
127
  }
82
- return { isError: true, content: [{ type: "text", text: message }] };
128
+ const body = (await poll.json());
129
+ status = body.data?.status ?? status;
130
+ datasetId = body.data?.defaultDatasetId ?? datasetId;
131
+ }
132
+ // 3. A run that did not succeed is a failure the caller must see, never an
133
+ // empty success.
134
+ if (status !== "SUCCEEDED") {
135
+ return {
136
+ isError: true,
137
+ content: [{ type: "text", text: `The ${actorLabel} run did not succeed (run ID: ${runId}, status: ${status}).` }],
138
+ };
139
+ }
140
+ if (!datasetId) {
141
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run ${runId} succeeded but reported no dataset, so there is nothing to return.` }] };
142
+ }
143
+ // 4. Read the dataset. Pass actor output through unchanged: the wrapper never
144
+ // reinterprets a status field.
145
+ let ds;
146
+ try {
147
+ ds = await fetch(`https://api.apify.com/v2/datasets/${datasetId}/items?format=json`, { headers });
148
+ }
149
+ catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ return { isError: true, content: [{ type: "text", text: `Could not read the ${actorLabel} dataset: ${message}` }] };
152
+ }
153
+ if (!ds.ok) {
154
+ return { isError: true, content: [{ type: "text", text: await httpError(ds) }] };
155
+ }
156
+ let items;
157
+ try {
158
+ items = await ds.json();
159
+ }
160
+ catch (err) {
161
+ const message = err instanceof Error ? err.message : String(err);
162
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run returned a response that could not be parsed: ${message}` }] };
163
+ }
164
+ if (!Array.isArray(items)) {
165
+ const asObj = items;
166
+ const detail = asObj?.error?.message ? `${asObj.error.message}` : JSON.stringify(items);
167
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run did not return a dataset. ${detail}` }] };
83
168
  }
84
- // A 2xx normally carries the dataset array. Pass actor output through
85
- // unchanged: the wrapper must never reinterpret a status field, because
86
- // not_extractable, blocked and not_found are different answers and collapsing
87
- // them is exactly the defect the actor was built to avoid.
88
- const items = await response.json();
89
169
  return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] };
90
170
  }
91
171
  const server = new McpServer({
@@ -118,10 +198,10 @@ server.registerTool("map_meta_brand_presence", {
118
198
  .describe("Which of the three Meta surfaces to map. Default is all three. Dropping Facebook is the common choice: it is the least reliable of the three and it costs a fetch to find that out."),
119
199
  includeFollowerCounts: z.boolean()
120
200
  .optional()
121
- .describe("When \"true\" (default) the profile page is fetched and the counts are extracted. Set \"false\" to resolve the profile URL only, which is cheaper and needs no proxy. Sent as a string for Clay compatibility."),
201
+ .describe("Default true: the profile page is fetched and the follower counts are extracted. Set false to resolve the profile URL only, which is cheaper and needs no proxy."),
122
202
  skipCache: z.boolean()
123
203
  .optional()
124
- .describe("When \"false\" (default) a successful lookup is cached for seven days and reused. Set \"true\" to force a fresh fetch. Sent as a string for Clay compatibility."),
204
+ .describe("Default false: a successful lookup is cached for seven days and reused. Set true to force a fresh fetch."),
125
205
  },
126
206
  }, async ({ company_domain, company_name, handle, platforms, includeFollowerCounts, skipCache }) => {
127
207
  return runActor("HXh65I0JBjPsunbY8", "Instagram Threads Facebook Brand Presence Mapper", compact({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mambalabsdev/mcp-meta-brand-presence-mapper",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for the Mamba Labs Instagram Threads Facebook Brand Presence Mapper actor on Apify. Resolve a company domain to its Instagram, Threads and Facebook accounts with follower and post counts.",
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": [
@@ -39,11 +40,11 @@
39
40
  },
40
41
  "dependencies": {
41
42
  "@modelcontextprotocol/sdk": "^1.12.0",
42
- "zod": "^3.23.8"
43
+ "zod": "^4.5.4"
43
44
  },
44
45
  "devDependencies": {
45
- "@types/node": "^20.14.0",
46
- "typescript": "^5.6.0"
46
+ "@types/node": "^26.4.1",
47
+ "typescript": "5.9.3"
47
48
  },
48
49
  "mcpName": "com.mambabuilt/mcp-meta-brand-presence-mapper"
49
50
  }