mcp-youtube-intelligence 0.2.5 → 0.2.7
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 +25 -1
- package/build/analyze_channel.js +82 -18
- package/build/cli.js +30 -0
- package/build/index.js +5 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ You need API keys for three services:
|
|
|
19
19
|
| `APIFY_TOKEN` | [Apify Console](https://console.apify.com/) → Account → Integrations → API token |
|
|
20
20
|
| `GEMINI_API_KEY` | [Google AI Studio](https://aistudio.google.com/) → Get API key |
|
|
21
21
|
|
|
22
|
-
`GEMINI_API_KEY` is optional — if omitted, the tool falls back to word-frequency topic extraction instead of semantic analysis.
|
|
22
|
+
`GEMINI_API_KEY` is optional — if omitted, the tool falls back to word-frequency topic extraction instead of semantic analysis. The same fallback applies when a configured Gemini call fails (invalid key, exhausted quota, blocked content); the result's `note` says why.
|
|
23
23
|
|
|
24
24
|
**Optional**
|
|
25
25
|
|
|
@@ -33,6 +33,16 @@ You need API keys for three services:
|
|
|
33
33
|
npm install -g mcp-youtube-intelligence
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
+
## CLI flags
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
mcp-youtube-intelligence --version # or -v — print the installed version and exit
|
|
40
|
+
mcp-youtube-intelligence --help # or -h — print usage and exit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Running the command with no flags starts the MCP server itself (stdio transport) — this is what
|
|
44
|
+
an MCP client config invokes; it's not meant to be run bare in a terminal for interactive use.
|
|
45
|
+
|
|
36
46
|
## Usage
|
|
37
47
|
|
|
38
48
|
Add to your Claude Desktop / MCP client config:
|
|
@@ -69,6 +79,20 @@ channel_url: YouTube channel URL or @handle. Accepts:
|
|
|
69
79
|
max_videos: Number of recent videos to analyze (default: 5, max: 50)
|
|
70
80
|
```
|
|
71
81
|
|
|
82
|
+
Returns a JSON object:
|
|
83
|
+
|
|
84
|
+
| Field | Meaning |
|
|
85
|
+
|-------|---------|
|
|
86
|
+
| `channel_id`, `channel_title` | The resolved channel |
|
|
87
|
+
| `channel_url` | Your `channel_url` input, echoed back |
|
|
88
|
+
| `sample_video_ids` | The most recent uploads that were analyzed |
|
|
89
|
+
| `videos_analyzed` | How many videos were fetched from the channel's uploads playlist |
|
|
90
|
+
| `transcripts_available` | How many of those came back with caption content |
|
|
91
|
+
| `topics` | Up to 20 top keywords by frequency across all transcripts. Always computed |
|
|
92
|
+
| `topics_structured` | Per video: `theme`, `entities[]`, `tags[]` from Gemini. Empty when Gemini is not configured or fails. Only the first 1,000 words of each transcript are sent, and a video Gemini blocks or returns nothing usable for is left out |
|
|
93
|
+
| `note` | Which analysis mode ran. When Gemini was configured but failed, it also says why |
|
|
94
|
+
| `output_path` | Where the JSON artifact was written. Absent if the write failed |
|
|
95
|
+
|
|
72
96
|
**Example prompt:** "Analyze the @fireship YouTube channel and tell me what topics they cover most."
|
|
73
97
|
|
|
74
98
|
## Development
|
package/build/analyze_channel.js
CHANGED
|
@@ -25,6 +25,7 @@ const GEMINI_TRANSCRIPT_WORD_LIMIT = 1000;
|
|
|
25
25
|
const DEFAULT_OUTPUT_DIR = "./output/";
|
|
26
26
|
const MAX_VIDEOS = 50;
|
|
27
27
|
const FETCH_TIMEOUT_MS = 30_000; // bound every outbound request so a stalled external API can't hang the tool call indefinitely
|
|
28
|
+
const NOTE_CAUSE_MAX_CHARS = 200; // cap on a failure cause quoted in the result's `note`
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
29
30
|
// Fetch helper
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -97,6 +98,9 @@ export function getGeminiApiKey() {
|
|
|
97
98
|
// ---------------------------------------------------------------------------
|
|
98
99
|
export function parseChannelInput(input) {
|
|
99
100
|
const trimmed = input.trim();
|
|
101
|
+
if (!trimmed) {
|
|
102
|
+
throw new Error("channel_url must not be empty.");
|
|
103
|
+
}
|
|
100
104
|
// Bare @handle — e.g. @fireship or @web.dev (periods allowed in handles)
|
|
101
105
|
if (/^@[\w.-]+$/.test(trimmed))
|
|
102
106
|
return { type: "handle", value: trimmed };
|
|
@@ -408,6 +412,24 @@ const TECH_TERMS = new Set([
|
|
|
408
412
|
"ui", "ux", "ci", "cd", "ide", "orm", "jwt", "ssh", "tcp", "dns", "cdn",
|
|
409
413
|
"js", "ts", "tsx", "jsx", "php", "os",
|
|
410
414
|
]);
|
|
415
|
+
// Google APIs report errors as {"error": {"code", "message", "status", "details": [{"reason"}]}}.
|
|
416
|
+
// Returns the readable message and, when present, the machine-readable reason. A body in any
|
|
417
|
+
// other shape (plain text, an HTML error page from a proxy) comes back trimmed and otherwise
|
|
418
|
+
// untouched.
|
|
419
|
+
function parseGoogleError(body) {
|
|
420
|
+
try {
|
|
421
|
+
const error = JSON.parse(body)?.error;
|
|
422
|
+
if (typeof error?.message === "string" && error.message) {
|
|
423
|
+
const details = Array.isArray(error.details) ? error.details : [];
|
|
424
|
+
const reason = details.map((d) => d?.reason).find((r) => typeof r === "string");
|
|
425
|
+
return { message: error.message, reason };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Not JSON — fall through to the raw text.
|
|
430
|
+
}
|
|
431
|
+
return { message: body.trim() };
|
|
432
|
+
}
|
|
411
433
|
export async function extractTopicsWithLLM(transcriptItems, geminiApiKey) {
|
|
412
434
|
const results = [];
|
|
413
435
|
let attempted = 0;
|
|
@@ -472,38 +494,68 @@ ${truncated}`;
|
|
|
472
494
|
}, "Gemini API");
|
|
473
495
|
}
|
|
474
496
|
if (!resp.ok) {
|
|
475
|
-
const
|
|
476
|
-
|
|
497
|
+
const { message, reason } = parseGoogleError(await resp.text());
|
|
498
|
+
const failure = `Gemini API error ${resp.status}: ${message}`;
|
|
499
|
+
// Google reports a bad or expired key as HTTP 400 with reason API_KEY_INVALID, not as
|
|
500
|
+
// 401/403, so the status alone missed the commonest credential failure.
|
|
501
|
+
const badKey = reason === "API_KEY_INVALID" || /^API key (not valid|expired)/i.test(message);
|
|
502
|
+
if (resp.status === 401 || resp.status === 403 || badKey) {
|
|
477
503
|
// Auth failures are invariant across requests using the same key — every
|
|
478
504
|
// remaining video would fail identically, so stop instead of repeating a
|
|
479
505
|
// doomed call up to max_videos times.
|
|
480
|
-
lastRequestError =
|
|
506
|
+
lastRequestError = failure;
|
|
481
507
|
console.warn(`[analyze_channel] Gemini authentication failed (${resp.status}), aborting remaining videos: ${lastRequestError}`);
|
|
482
508
|
break;
|
|
483
509
|
}
|
|
484
|
-
throw new Error(
|
|
510
|
+
throw new Error(failure);
|
|
485
511
|
}
|
|
486
512
|
const body = await parseJsonResponse(resp, "Gemini API");
|
|
487
|
-
const
|
|
488
|
-
|
|
513
|
+
const candidate = body.candidates?.[0];
|
|
514
|
+
const text = candidate?.content?.parts?.[0]?.text;
|
|
515
|
+
// Each of the four checks below counts toward the same "every attempted video
|
|
516
|
+
// produced nothing usable" total-failure check after the loop as an HTTP-level
|
|
517
|
+
// failure does: a Gemini that answers 200 with nothing parseable is as much an
|
|
518
|
+
// outage as one that answers 5xx, and must not be reported as a clean success.
|
|
519
|
+
if (typeof text !== "string" || !text.trim()) {
|
|
520
|
+
// A blocked prompt or a stopped generation is still HTTP 200. This used to fall
|
|
521
|
+
// through a "{}" default and be recorded as a successful, empty analysis.
|
|
522
|
+
const why = body.promptFeedback?.blockReason ?? candidate?.finishReason;
|
|
523
|
+
lastRequestError = `Gemini returned no content for video ${videoId}${why ? ` (${why})` : ""}`;
|
|
524
|
+
console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
let parsed;
|
|
489
528
|
try {
|
|
490
529
|
parsed = JSON.parse(text);
|
|
491
530
|
}
|
|
492
531
|
catch {
|
|
493
|
-
// Counts toward the same "every attempted video produced nothing usable"
|
|
494
|
-
// check below as an HTTP-level failure — an all-malformed-JSON response is
|
|
495
|
-
// just as much a Gemini outage as an all-5xx one, and previously only the
|
|
496
|
-
// latter was surfaced as a thrown error instead of a silent empty success.
|
|
497
532
|
lastRequestError = `Gemini returned unparseable JSON for video ${videoId}`;
|
|
498
533
|
console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
|
|
499
534
|
continue;
|
|
500
535
|
}
|
|
501
|
-
|
|
536
|
+
// Valid JSON is not necessarily an object: `null` used to throw a cryptic
|
|
537
|
+
// "Cannot read properties of null", and an array or a bare scalar was recorded
|
|
538
|
+
// as an empty entry.
|
|
539
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
540
|
+
lastRequestError = `Gemini returned an unexpected JSON shape for video ${videoId}`;
|
|
541
|
+
console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const fields = parsed;
|
|
545
|
+
const entry = {
|
|
502
546
|
video_id: videoId,
|
|
503
|
-
theme:
|
|
504
|
-
entities:
|
|
505
|
-
tags:
|
|
506
|
-
}
|
|
547
|
+
theme: fields.theme ?? "",
|
|
548
|
+
entities: fields.entities ?? [],
|
|
549
|
+
tags: fields.tags ?? [],
|
|
550
|
+
};
|
|
551
|
+
// Partial objects stay valid (only a theme, only tags, ...). One carrying nothing
|
|
552
|
+
// at all tells the caller nothing and would inflate the "semantic topics" count.
|
|
553
|
+
if (!entry.theme && !entry.entities.length && !entry.tags.length) {
|
|
554
|
+
lastRequestError = `Gemini returned no usable topics for video ${videoId}`;
|
|
555
|
+
console.warn(`[analyze_channel] ${lastRequestError}, skipping`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
results.push(entry);
|
|
507
559
|
}
|
|
508
560
|
catch (err) {
|
|
509
561
|
// Isolate this video's failure so one bad request doesn't discard
|
|
@@ -552,6 +604,14 @@ export function extractTopics(transcriptItems, topN = 20) {
|
|
|
552
604
|
// ---------------------------------------------------------------------------
|
|
553
605
|
// Main: analyzeChannel
|
|
554
606
|
// ---------------------------------------------------------------------------
|
|
607
|
+
// The note is read by the MCP client's model, which cannot see this server's stderr, so a
|
|
608
|
+
// failure cause has to travel in the result itself. Collapse it to one short line, and scrub
|
|
609
|
+
// the API key: it rides in the request URL, and some runtimes echo the URL in network errors.
|
|
610
|
+
function summarizeFailure(message, secret) {
|
|
611
|
+
const scrubbed = secret ? message.split(secret).join("[redacted]") : message;
|
|
612
|
+
const oneLine = scrubbed.replace(/\s+/g, " ").trim() || "unknown error";
|
|
613
|
+
return oneLine.length > NOTE_CAUSE_MAX_CHARS ? `${oneLine.slice(0, NOTE_CAUSE_MAX_CHARS)}…` : oneLine;
|
|
614
|
+
}
|
|
555
615
|
export async function analyzeChannel(channelInput, maxVideos) {
|
|
556
616
|
if (maxVideos < 1)
|
|
557
617
|
throw new Error(`max_videos must be at least 1, got ${maxVideos}`);
|
|
@@ -585,20 +645,24 @@ export async function analyzeChannel(channelInput, maxVideos) {
|
|
|
585
645
|
let topics_structured = [];
|
|
586
646
|
let geminiUsed = false;
|
|
587
647
|
let geminiConfigured = false;
|
|
648
|
+
let geminiKey;
|
|
649
|
+
let geminiFailure = "";
|
|
588
650
|
try {
|
|
589
|
-
|
|
651
|
+
geminiKey = getGeminiApiKey();
|
|
590
652
|
geminiConfigured = true;
|
|
591
653
|
topics_structured = await extractTopicsWithLLM(transcriptItems, geminiKey);
|
|
592
654
|
geminiUsed = true;
|
|
593
655
|
}
|
|
594
656
|
catch (err) {
|
|
595
657
|
// Graceful degradation — topics_structured stays empty; topics is the fallback
|
|
596
|
-
|
|
658
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
659
|
+
geminiFailure = summarizeFailure(reason, geminiKey);
|
|
660
|
+
console.warn(`[analyze_channel] Gemini extraction unavailable, using keyword fallback: ${reason}`);
|
|
597
661
|
}
|
|
598
662
|
const note = geminiUsed
|
|
599
663
|
? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics_structured.length} semantic topics (topics_structured) via Gemini, ${topics.length} keyword topics as supplemental.`
|
|
600
664
|
: geminiConfigured
|
|
601
|
-
? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (Gemini
|
|
665
|
+
? `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (Gemini extraction failed — ${geminiFailure}).`
|
|
602
666
|
: `Analyzed ${videoIds.length} video(s), ${transcriptsAvailable} with transcripts — ${topics.length} keyword topics only (GEMINI_API_KEY not configured). Set GEMINI_API_KEY for richer structured analysis.`;
|
|
603
667
|
const result = {
|
|
604
668
|
channel_id: channel.channelId,
|
package/build/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure argv → output-text mapping for the --version/--help flags, split out of
|
|
3
|
+
* index.ts so it can be unit tested without importing index.ts itself — that
|
|
4
|
+
* module connects a stdio MCP transport as a top-level side effect on import,
|
|
5
|
+
* which would hang a test runner.
|
|
6
|
+
*/
|
|
7
|
+
const HELP_TEXT = (version) => `mcp-youtube-intelligence v${version} — MCP server for YouTube channel intelligence
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
mcp-youtube-intelligence [options]
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--help, -h Show this help message
|
|
14
|
+
--version, -v Print the installed version
|
|
15
|
+
|
|
16
|
+
Tools provided:
|
|
17
|
+
analyze_channel Extract transcripts, topics, and competitive signals from a YouTube channel`;
|
|
18
|
+
/**
|
|
19
|
+
* Returns the text to print for --version/-v or --help/-h, or null if argv
|
|
20
|
+
* contains neither (the normal MCP-server-startup path). --version/-v takes
|
|
21
|
+
* precedence when both are present.
|
|
22
|
+
*/
|
|
23
|
+
export function getCliOutput(argv, version) {
|
|
24
|
+
if (argv.includes("--version") || argv.includes("-v"))
|
|
25
|
+
return version;
|
|
26
|
+
if (argv.includes("--help") || argv.includes("-h"))
|
|
27
|
+
return HELP_TEXT(version);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=cli.js.map
|
package/build/index.js
CHANGED
|
@@ -11,24 +11,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
11
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { analyzeChannel } from "./analyze_channel.js";
|
|
14
|
+
import { getCliOutput } from "./cli.js";
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const { version: packageVersion } = require("../package.json");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
21
|
-
console.log(`mcp-youtube-intelligence v${packageVersion} — MCP server for YouTube channel intelligence
|
|
22
|
-
|
|
23
|
-
Usage:
|
|
24
|
-
mcp-youtube-intelligence [options]
|
|
25
|
-
|
|
26
|
-
Options:
|
|
27
|
-
--help, -h Show this help message
|
|
28
|
-
--version, -v Print the installed version
|
|
29
|
-
|
|
30
|
-
Tools provided:
|
|
31
|
-
analyze_channel Extract transcripts, topics, and competitive signals from a YouTube channel`);
|
|
17
|
+
const cliOutput = getCliOutput(process.argv, packageVersion);
|
|
18
|
+
if (cliOutput !== null) {
|
|
19
|
+
console.log(cliOutput);
|
|
32
20
|
process.exit(0);
|
|
33
21
|
}
|
|
34
22
|
const server = new McpServer({
|
|
@@ -37,7 +25,7 @@ const server = new McpServer({
|
|
|
37
25
|
});
|
|
38
26
|
server.registerTool("analyze_channel", {
|
|
39
27
|
title: "Analyze YouTube Channel",
|
|
40
|
-
description: "Analyze a YouTube channel and return a JSON object with: channel_id, channel_title, channel_url, sample_video_ids[], videos_analyzed (count of videos fetched from playlist), transcripts_available (count with actual caption content), topics[] (top keyword frequencies across all transcripts), topics_structured[] (per-video semantic analysis — each entry has video_id/theme/entities[]/tags[]), note (which analysis mode ran), and optional output_path (local artifact path). Requires YOUTUBE_API_KEY and APIFY_TOKEN; set GEMINI_API_KEY for topics_structured semantic analysis (falls back to keyword-only when absent). Supported channel inputs: @handle (e.g. @fireship), youtube.com/@handle URL, /channel/UC... URL, bare 24-char UCxxxxxx ID, or legacy /c/ and /user/ URLs.",
|
|
28
|
+
description: "Analyze a YouTube channel and return a JSON object with: channel_id, channel_title, channel_url, sample_video_ids[], videos_analyzed (count of videos fetched from playlist), transcripts_available (count with actual caption content), topics[] (top keyword frequencies across all transcripts), topics_structured[] (per-video semantic analysis — each entry has video_id/theme/entities[]/tags[]), note (which analysis mode ran — and, when Gemini was configured but failed, why), and optional output_path (local artifact path). Requires YOUTUBE_API_KEY and APIFY_TOKEN; set GEMINI_API_KEY for topics_structured semantic analysis (falls back to keyword-only when absent). Supported channel inputs: @handle (e.g. @fireship), youtube.com/@handle URL, /channel/UC... URL, bare 24-char UCxxxxxx ID, or legacy /c/ and /user/ URLs.",
|
|
41
29
|
inputSchema: {
|
|
42
30
|
channel_url: z.string().describe("YouTube channel URL or @handle (e.g. @fireship, https://www.youtube.com/@fireship, UCxxxxxxx)"),
|
|
43
31
|
max_videos: z.number().int().min(1).max(50).optional().default(5).describe("Number of recent videos to analyze (default 5, max 50)"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-youtube-intelligence",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "MCP server for extracting structured intelligence from YouTube channels and videos — transcripts, topics, competitive signals",
|
|
5
5
|
"mcpName": "io.github.dmitriusan/mcp-youtube-intelligence",
|
|
6
6
|
"main": "build/index.js",
|