@mambalabsdev/mcp-meta-brand-presence-mapper 1.0.0 → 1.1.1
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 -5
- package/build/index.js +117 -37
- package/package.json +6 -5
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
|
|
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
|
|
53
|
-
| `includeFollowerCounts` | boolean |
|
|
54
|
-
| `skipCache` | boolean |
|
|
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
|
|
37
|
-
//
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
72
|
-
break;
|
|
74
|
+
return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
|
|
73
75
|
case 402:
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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.
|
|
3
|
+
"version": "1.1.1",
|
|
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": [
|
|
@@ -38,12 +39,12 @@
|
|
|
38
39
|
"node": ">=18"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
42
|
-
"zod": "^
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
43
|
+
"zod": "^4.5.4"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@types/node": "^
|
|
46
|
-
"typescript": "
|
|
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
|
}
|