@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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/build/index.js +132 -43
  3. package/package.json +8 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # YouTube Channel Stats Extractor MCP Server
2
2
 
3
- [![Smithery](https://smithery.ai/badge/mambabuilt/mcp-youtube-channel-transcript-extractor)](https://smithery.ai/servers/mambabuilt/mcp-youtube-channel-transcript-extractor) [![Glama score](https://glama.ai/mcp/servers/mambalabsdev/mcp-youtube-channel-transcript-extractor/badges/score.svg)](https://glama.ai/mcp/servers/mambalabsdev/mcp-youtube-channel-transcript-extractor) [![npm version](https://img.shields.io/npm/v/@mambalabsdev/mcp-youtube-channel-transcript-extractor)](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [![npm downloads](https://img.shields.io/npm/dm/@mambalabsdev/mcp-youtube-channel-transcript-extractor)](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [![license](https://img.shields.io/github/license/mambalabsdev/mcp-youtube-channel-transcript-extractor)](https://github.com/mambalabsdev/mcp-youtube-channel-transcript-extractor/blob/main/LICENSE)
3
+ [![Smithery](https://smithery.ai/badge/mambabuilt/mcp-youtube-channel-transcript-extractor)](https://smithery.ai/servers/mambabuilt/mcp-youtube-channel-transcript-extractor) [![Glama score](https://glama.ai/mcp/servers/mambalabsdev/mcp-youtube-channel-transcript-extractor/badges/score.svg)](https://glama.ai/mcp/servers/mambalabsdev/mcp-youtube-channel-transcript-extractor) [![MCP Registry](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.modelcontextprotocol.io%2Fv0%2Fservers%3Fsearch%3Dcom.mambabuilt%252Fmcp-youtube-channel-transcript-extractor%26limit%3D1&query=%24.servers%5B0%5D._meta%5B%22io.modelcontextprotocol.registry%2Fofficial%22%5D.status&label=mcp%20registry&color=blue)](https://registry.modelcontextprotocol.io/v0/servers?search=com.mambabuilt/mcp-youtube-channel-transcript-extractor&limit=1) [![npm version](https://img.shields.io/npm/v/@mambalabsdev/mcp-youtube-channel-transcript-extractor)](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [![npm downloads](https://img.shields.io/npm/dm/@mambalabsdev/mcp-youtube-channel-transcript-extractor)](https://www.npmjs.com/package/@mambalabsdev/mcp-youtube-channel-transcript-extractor) [![license](https://img.shields.io/github/license/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
- // actorPath is the actor's IMMUTABLE Apify actor id, not its slug, so a Store
30
- // rename never breaks these calls.
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
- // memory=1024 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
38
- // minimum of one, so leaving the default in place would charge the caller
39
- // more start events per run than the actor asks for. Keep this in step with
40
- // the actor's defaultRunOptions.
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
- message = "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
72
- break;
84
+ return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
73
85
  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;
86
+ return "Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
79
87
  default:
80
- message = `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
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
- return { isError: true, content: [{ type: "text", text: message }] };
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.0.0",
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.12.0",
41
- "zod": "^3.23.8"
42
+ "@modelcontextprotocol/sdk": "^1.32.0",
43
+ "zod": "^4.5.4"
42
44
  },
43
45
  "devDependencies": {
44
- "@types/node": "^20.14.0",
45
- "typescript": "^5.6.0"
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
  }