@mambalabsdev/mcp-youtube-channel-transcript-extractor 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 +1 -1
- package/build/index.js +132 -43
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# YouTube Channel Stats Extractor MCP Server
|
|
2
2
|
|
|
3
|
-
[](https://smithery.ai/servers/mambabuilt/mcp-youtube-channel-transcript-extractor) [](https://glama.ai/mcp/servers/mambalabsdev/mcp-youtube-channel-transcript-extractor) [](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [](https://github.com/mambalabsdev/mcp-youtube-channel-transcript-extractor/blob/main/LICENSE)
|
|
3
|
+
[](https://smithery.ai/servers/mambabuilt/mcp-youtube-channel-transcript-extractor) [](https://glama.ai/mcp/servers/mambalabsdev/mcp-youtube-channel-transcript-extractor) [](https://registry.modelcontextprotocol.io/v0/servers?search=com.mambabuilt/mcp-youtube-channel-transcript-extractor&limit=1) [](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [](https://github.com/mambalabsdev/mcp-youtube-channel-transcript-extractor/blob/main/LICENSE)
|
|
4
4
|
|
|
5
5
|
MCP server for the Mamba Labs **YouTube Channel Stats Extractor** actor on Apify.
|
|
6
6
|
|
package/build/index.js
CHANGED
|
@@ -9,7 +9,6 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
9
9
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
10
10
|
// Distinctive UA so Apify run meta.userAgent marks MCP-originated runs.
|
|
11
11
|
const USER_AGENT = `mambalabs-mcp ${pkg.name}@${pkg.version}`;
|
|
12
|
-
const APIFY_TOKEN = process.env.APIFY_TOKEN;
|
|
13
12
|
// Drop undefined values so optional inputs are not sent to the actor at all.
|
|
14
13
|
function compact(obj) {
|
|
15
14
|
const out = {};
|
|
@@ -26,36 +25,49 @@ function compact(obj) {
|
|
|
26
25
|
function boolToString(v) {
|
|
27
26
|
return v === undefined ? undefined : v ? "true" : "false";
|
|
28
27
|
}
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// How long the actor run itself is allowed to take, in seconds. One value for
|
|
29
|
+
// every Mamba Labs wrapper, set 2026-10-05: start and poll exists so a long run
|
|
30
|
+
// survives, and a shorter limit would end the long runs it was built for. Past
|
|
31
|
+
// this limit the run ends TIMED-OUT and the caller is told so, with the run id.
|
|
32
|
+
const ACTOR_RUN_TIMEOUT_SECS = 1800;
|
|
33
|
+
// How long this wrapper waits for that run, in milliseconds. The actor's own
|
|
34
|
+
// timeout plus two minutes, so the run's own TIMED-OUT status is what the
|
|
35
|
+
// caller sees rather than the wrapper giving up first and reporting nothing.
|
|
36
|
+
const WRAPPER_WAIT_MS = (ACTOR_RUN_TIMEOUT_SECS + 120) * 1000;
|
|
37
|
+
const POLL_INTERVAL_MS = 3000;
|
|
38
|
+
// memory=1024 matches the actor's declared defaultRunOptions.memoryMbytes.
|
|
39
|
+
// `apify-actor-start` bills once per GB with a minimum of one, so an explicit
|
|
40
|
+
// value keeps the caller from paying for more memory than the actor asks for.
|
|
41
|
+
// Keep this in step with the actor's defaultRunOptions.
|
|
42
|
+
const RUN_QUERY = `timeout=${ACTOR_RUN_TIMEOUT_SECS}&memory=1024`;
|
|
43
|
+
const TERMINAL = new Set(["SUCCEEDED", "FAILED", "TIMED-OUT", "ABORTED", "ABORTING"]);
|
|
44
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
|
+
// Shared caller. actorPath is the actor's immutable Apify actor ID (a stable key
|
|
46
|
+
// that survives Store renames).
|
|
47
|
+
//
|
|
48
|
+
// START AND POLL, NOT RUN-SYNC. Apify's synchronous endpoints carry a platform
|
|
49
|
+
// ceiling of 300 seconds on the HTTP wait itself and answer 408 past it, so a
|
|
50
|
+
// long run reads as a timeout even though the actor goes on and bills. Starting
|
|
51
|
+
// the run, polling it to a terminal status and then reading the dataset waits
|
|
52
|
+
// as long as the actor needs.
|
|
53
|
+
//
|
|
54
|
+
// The token is read here rather than at module load, so the tool registers
|
|
55
|
+
// unconditionally and a server started without APIFY_TOKEN still advertises its
|
|
56
|
+
// capabilities.
|
|
57
|
+
//
|
|
58
|
+
// The wrapper never reinterprets a row's status field: not_extractable,
|
|
59
|
+
// blocked and not_found are different answers and are passed through unchanged.
|
|
31
60
|
async function runActor(actorPath, actorLabel, input) {
|
|
61
|
+
const APIFY_TOKEN = process.env.APIFY_TOKEN;
|
|
32
62
|
if (!APIFY_TOKEN) {
|
|
33
63
|
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
64
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const url = `https://api.apify.com/v2/acts/${actorPath}/run-sync-get-dataset-items?timeout=300&memory=1024`;
|
|
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) {
|
|
65
|
+
const headers = {
|
|
66
|
+
Authorization: `Bearer ${APIFY_TOKEN}`,
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"User-Agent": USER_AGENT,
|
|
69
|
+
};
|
|
70
|
+
const httpError = async (response) => {
|
|
59
71
|
let detail = "";
|
|
60
72
|
try {
|
|
61
73
|
const body = (await response.json());
|
|
@@ -65,27 +77,104 @@ async function runActor(actorPath, actorLabel, input) {
|
|
|
65
77
|
catch {
|
|
66
78
|
detail = "";
|
|
67
79
|
}
|
|
68
|
-
let message;
|
|
69
80
|
switch (response.status) {
|
|
81
|
+
case 400:
|
|
82
|
+
return `The ${actorLabel} run was rejected as invalid input.${detail}`;
|
|
70
83
|
case 401:
|
|
71
|
-
|
|
72
|
-
break;
|
|
84
|
+
return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
|
|
73
85
|
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;
|
|
86
|
+
return "Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
|
|
79
87
|
default:
|
|
80
|
-
|
|
88
|
+
return `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
// 1. Start the run.
|
|
92
|
+
let started;
|
|
93
|
+
try {
|
|
94
|
+
started = await fetch(`https://api.apify.com/v2/acts/${actorPath}/runs?${RUN_QUERY}`, { method: "POST", headers, body: JSON.stringify(input) });
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
98
|
+
return { isError: true, content: [{ type: "text", text: `Could not reach the Apify API: ${message}` }] };
|
|
99
|
+
}
|
|
100
|
+
if (!started.ok) {
|
|
101
|
+
return { isError: true, content: [{ type: "text", text: await httpError(started) }] };
|
|
102
|
+
}
|
|
103
|
+
let run;
|
|
104
|
+
try {
|
|
105
|
+
run = (await started.json()).data ?? {};
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned a response that could not be parsed: ${message}` }] };
|
|
110
|
+
}
|
|
111
|
+
const runId = run.id;
|
|
112
|
+
if (!runId) {
|
|
113
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned no run id, so there is nothing to wait for.` }] };
|
|
114
|
+
}
|
|
115
|
+
// 2. Poll to a terminal status.
|
|
116
|
+
const deadline = Date.now() + WRAPPER_WAIT_MS;
|
|
117
|
+
let status = run.status ?? "READY";
|
|
118
|
+
let datasetId = run.defaultDatasetId;
|
|
119
|
+
while (!TERMINAL.has(status)) {
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
return {
|
|
122
|
+
isError: true,
|
|
123
|
+
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}` }],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
await sleep(POLL_INTERVAL_MS);
|
|
127
|
+
let poll;
|
|
128
|
+
try {
|
|
129
|
+
poll = await fetch(`https://api.apify.com/v2/actor-runs/${runId}`, { headers });
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
return { isError: true, content: [{ type: "text", text: `Lost contact with the Apify API while waiting for ${actorLabel} run ${runId}: ${message}` }] };
|
|
134
|
+
}
|
|
135
|
+
if (!poll.ok) {
|
|
136
|
+
return { isError: true, content: [{ type: "text", text: await httpError(poll) }] };
|
|
81
137
|
}
|
|
82
|
-
|
|
138
|
+
const body = (await poll.json());
|
|
139
|
+
status = body.data?.status ?? status;
|
|
140
|
+
datasetId = body.data?.defaultDatasetId ?? datasetId;
|
|
141
|
+
}
|
|
142
|
+
// 3. A run that did not succeed is a failure the caller must see, never an
|
|
143
|
+
// empty success.
|
|
144
|
+
if (status !== "SUCCEEDED") {
|
|
145
|
+
return {
|
|
146
|
+
isError: true,
|
|
147
|
+
content: [{ type: "text", text: `The ${actorLabel} run did not succeed (run ID: ${runId}, status: ${status}). Read it at https://console.apify.com/actors/runs/${runId}` }],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!datasetId) {
|
|
151
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run ${runId} succeeded but reported no dataset, so there is nothing to return.` }] };
|
|
152
|
+
}
|
|
153
|
+
// 4. Read the dataset.
|
|
154
|
+
let ds;
|
|
155
|
+
try {
|
|
156
|
+
ds = await fetch(`https://api.apify.com/v2/datasets/${datasetId}/items?format=json`, { headers });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
160
|
+
return { isError: true, content: [{ type: "text", text: `Could not read the ${actorLabel} dataset: ${message}` }] };
|
|
161
|
+
}
|
|
162
|
+
if (!ds.ok) {
|
|
163
|
+
return { isError: true, content: [{ type: "text", text: await httpError(ds) }] };
|
|
164
|
+
}
|
|
165
|
+
let items;
|
|
166
|
+
try {
|
|
167
|
+
items = await ds.json();
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run returned a response that could not be parsed: ${message}` }] };
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(items)) {
|
|
174
|
+
const asObj = items;
|
|
175
|
+
const detail = asObj?.error?.message ? `${asObj.error.message}` : JSON.stringify(items);
|
|
176
|
+
return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run did not return a dataset. ${detail}` }] };
|
|
83
177
|
}
|
|
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
178
|
return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] };
|
|
90
179
|
}
|
|
91
180
|
const server = new McpServer({
|
|
@@ -95,7 +184,7 @@ const server = new McpServer({
|
|
|
95
184
|
// YouTube Channel Stats Extractor (immutable actor ID 6hSpD3kd2s2IQ4VSs)
|
|
96
185
|
server.registerTool("get_youtube_channel_stats", {
|
|
97
186
|
title: "Get YouTube Channel Stats",
|
|
98
|
-
description: "Resolve a company domain, or a YouTube handle, to that company's YouTube channel. Returns the subscriber count, the stable UC channel id, channel name, creation date and description, as one flat Clay ready row. Subscriber counts are rounded by YouTube above roughly a thousand subscribers, on the page and in its official API alike, and the row flags that. Optional transcript pulling is available and is CURRENTLY GATED by YouTube: the caption track list is readable and the caption content endpoint returns an empty body without a proof of origin token, which the row reports as not_extractable rather than as a video having no captions. No API key needed. Read only; requires an APIFY_TOKEN and consumes Apify credits per call.",
|
|
187
|
+
description: "Resolve a company domain, or a YouTube handle, to that company's YouTube channel. Returns the subscriber count, the stable UC channel id, channel name, creation date and description, as one flat Clay ready row. Subscriber counts are rounded by YouTube above roughly a thousand subscribers, on the page and in its official API alike, and the row flags that. Optional transcript pulling is available and is CURRENTLY GATED by YouTube: the caption track list is readable and the caption content endpoint returns an empty body without a proof of origin token, which the row reports as not_extractable rather than as a video having no captions. A company with no channel reports youtube_status not_found. Read youtube_status before any count. Use it to look up one known company channel per call; it does not search YouTube, list a channel's videos, or return video detail, and the package name mentions transcripts only because transcript pulling is an option. No API key needed. Read only; requires an APIFY_TOKEN and consumes Apify credits per call.",
|
|
99
188
|
annotations: {
|
|
100
189
|
title: "Get YouTube Channel Stats",
|
|
101
190
|
readOnlyHint: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mambalabsdev/mcp-youtube-channel-transcript-extractor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "MCP server for the Mamba Labs YouTube Channel Stats Extractor actor on Apify. Resolve a company domain or YouTube handle to the channel with subscriber count and channel metadata.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc && chmod +x build/index.js",
|
|
17
|
-
"prepublishOnly": "npm run build"
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"test": "npm run build && node --test test/*.test.js",
|
|
19
|
+
"sync-fixture": "node scripts/sync-input-schema-fixture.mjs"
|
|
18
20
|
},
|
|
19
21
|
"keywords": [
|
|
20
22
|
"mcp",
|
|
@@ -37,12 +39,12 @@
|
|
|
37
39
|
"node": ">=18"
|
|
38
40
|
},
|
|
39
41
|
"dependencies": {
|
|
40
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
41
|
-
"zod": "^
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
43
|
+
"zod": "^4.5.4"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
|
44
|
-
"@types/node": "^
|
|
45
|
-
"typescript": "
|
|
46
|
+
"@types/node": "^26.4.1",
|
|
47
|
+
"typescript": "5.9.3"
|
|
46
48
|
},
|
|
47
49
|
"mcpName": "com.mambabuilt/mcp-youtube-channel-transcript-extractor"
|
|
48
50
|
}
|