# Directory Structure
```
├── .gitignore
├── CHANGELOG.md
├── cog.toml
├── LICENSE
├── pyproject.toml
├── README.md
├── ruff.toml
├── src
│ └── mcp_youtube
│ ├── __init__.py
│ ├── py.typed
│ ├── server.py
│ └── tools.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | .env
2 | .venv
3 | *.egg-info
4 | __pycache__
5 | *.pyc
6 |
7 | *.session
8 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Youtube MCP server
2 |
3 | - [Youtube MCP server](#youtube-mcp-server)
4 | - [About](#about)
5 | - [What is MCP?](#what-is-mcp)
6 | - [What does this server do?](#what-does-this-server-do)
7 | - [Practical use cases](#practical-use-cases)
8 | - [Prerequisites](#prerequisites)
9 | - [Installation](#installation)
10 | - [Configuration](#configuration)
11 | - [Claude Desktop Configuration](#claude-desktop-configuration)
12 | - [Development](#development)
13 | - [Getting started](#getting-started)
14 | - [Debugging the server in the Inspector](#debugging-the-server-in-the-inspector)
15 | - [Troubleshooting](#troubleshooting)
16 | - [Message 'Could not connect to MCP server mcp-youtube'](#message-could-not-connect-to-mcp-server-mcp-youtube)
17 |
18 | ## About
19 |
20 | The server is a bridge between the Youtube API and the AI assistants and is based on the [Model Context Protocol](https://modelcontextprotocol.io).
21 |
22 | <a href="https://glama.ai/mcp/servers/gzrh7914k6">
23 | <img width="380" height="200" src="https://glama.ai/mcp/servers/gzrh7914k6/badge" alt="Youtube Server MCP server" />
24 | </a>
25 |
26 | ## What is MCP?
27 |
28 | The Model Context Protocol (MCP) is a system that lets AI apps, like Claude Desktop, connect to external tools and data sources. It gives a clear and safe way for AI assistants to work with local services and APIs while keeping the user in control.
29 |
30 | ## What does this server do?
31 |
32 | - [x] Download closed captions for the given video
33 |
34 | ## Practical use cases
35 |
36 | - [x] Create a summary of the video
37 |
38 | ## Prerequisites
39 |
40 | - [`uv` tool](https://docs.astral.sh/uv/getting-started/installation/)
41 |
42 | ## Installation
43 |
44 | ```bash
45 | uv tool install git+https://github.com/sparfenyuk/mcp-youtube
46 | ```
47 |
48 | > [!NOTE]
49 | > If you have already installed the server, you can update it using `uv tool upgrade --reinstall` command.
50 |
51 | > [!NOTE]
52 | > If you want to delete the server, use the `uv tool uninstall mcp-youtube` command.
53 |
54 | ## Configuration
55 |
56 | ### Claude Desktop Configuration
57 |
58 | Configure Claude Desktop to recognize the Youtube MCP server.
59 |
60 | 1. Open the Claude Desktop configuration file:
61 | - in MacOS, the configuration file is located at `~/Library/Application Support/Claude/claude_desktop_config.json`
62 | - in Windows, the configuration file is located at `%APPDATA%\Claude\claude_desktop_config.json`
63 |
64 | > __Note:__
65 | > You can also find claude_desktop_config.json inside the settings of Claude Desktop app
66 |
67 | 2. Add the server configuration
68 |
69 | ```json
70 | {
71 | "mcpServers": {
72 | "mcp-youtube": {
73 | "command": "mcp-youtube",
74 | }
75 | }
76 | }
77 | }
78 | ```
79 |
80 | ## Development
81 |
82 | ### Getting started
83 |
84 | 1. Clone the repository
85 | 2. Install the dependencies
86 |
87 | ```bash
88 | uv sync
89 | ```
90 |
91 | 3. Run the server
92 |
93 | ```bash
94 | uv run mcp-youtube --help
95 | ```
96 |
97 | Tools can be added to the `src/mcp_youtube/tools.py` file.
98 |
99 | How to add a new tool:
100 |
101 | 1. Create a new class that inherits from ToolArgs
102 |
103 | ```python
104 | class NewTool(ToolArgs):
105 | """Description of the new tool."""
106 | pass
107 | ```
108 |
109 | Attributes of the class will be used as arguments for the tool.
110 | The class docstring will be used as the tool description.
111 |
112 | 2. Implement the tool_runner function for the new class
113 |
114 | ```python
115 | @tool_runner.register
116 | async def new_tool(args: NewTool) -> t.Sequence[TextContent | ImageContent | EmbeddedResource]:
117 | pass
118 | ```
119 |
120 | The function should return a sequence of TextContent, ImageContent or EmbeddedResource.
121 | The function should be async and accept a single argument of the new class.
122 |
123 | 3. Done! Restart the client and the new tool should be available.
124 |
125 | Validation can accomplished either through Claude Desktop or by running the tool directly.
126 |
127 | ### Debugging the server in the Inspector
128 |
129 | The MCP inspector is a tool that helps to debug the server using fancy UI. To run it, use the following command:
130 |
131 | ```bash
132 | npx @modelcontextprotocol/inspector uv run mcp-youtube
133 | ```
134 |
135 | ## Troubleshooting
136 |
137 | ### Message 'Could not connect to MCP server mcp-youtube'
138 |
139 | If you see the message 'Could not connect to MCP server mcp-youtube' in Claude Desktop, it means that the server configuration is incorrect.
140 |
141 | Try the following:
142 |
143 | - Use the full path to the `mcp-youtube` binary in the configuration file
```
--------------------------------------------------------------------------------
/src/mcp_youtube/__init__.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 |
3 | from typer import Context, Typer
4 |
5 | app = Typer()
6 |
7 |
8 | @app.callback(invoke_without_command=True)
9 | def _run(ctx: Context) -> None:
10 | if ctx.invoked_subcommand is None:
11 | # This will run if no subcommand is specified
12 | run()
13 |
14 |
15 | @app.command()
16 | def run() -> None:
17 | """Run the mcp-youtube server."""
18 | from .server import run_mcp_server
19 |
20 | asyncio.run(run_mcp_server())
21 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "mcp-youtube"
3 | version = "0.1.0"
4 | description = "MCP server to work with YouTube"
5 | requires-python = ">=3.11"
6 | dependencies = [
7 | "mcp>=1.1.0",
8 | "pydantic>=2.0.0",
9 | "pydantic-settings>=2.6.0",
10 | "typer>=0.15.0",
11 | "xdg-base-dirs>=6.0.0",
12 | "youtube-transcript-api",
13 | ]
14 |
15 | [build-system]
16 | requires = ["setuptools>=70"]
17 | build-backend = "setuptools.build_meta"
18 |
19 | [dependency-groups]
20 | dev = ["mypy>=1.13.0"]
21 |
22 | [project.scripts]
23 | mcp-youtube = "mcp_youtube:app"
24 |
25 | [tool.mypy]
26 | plugins = ["pydantic.mypy"]
27 |
28 | [tool.setuptools.package-data]
29 | "*" = ["py.typed"]
30 |
```
--------------------------------------------------------------------------------
/cog.toml:
--------------------------------------------------------------------------------
```toml
1 | from_latest_tag = false
2 | ignore_merge_commits = false
3 | disable_changelog = false
4 | disable_bump_commit = false
5 | generate_mono_repository_global_tag = true
6 | generate_mono_repository_package_tags = true
7 | branch_whitelist = []
8 | skip_ci = "[skip ci]"
9 | skip_untracked = false
10 | pre_bump_hooks = []
11 | post_bump_hooks = ["uv build"]
12 | pre_package_bump_hooks = []
13 | post_package_bump_hooks = []
14 | tag_prefix = "v"
15 |
16 | [git_hooks.commit-msg]
17 | script = """#!/bin/sh
18 | set -e
19 | cog verify --file $1
20 | cog check
21 | """
22 |
23 |
24 | [commit_types]
25 |
26 | [changelog]
27 | path = "CHANGELOG.md"
28 | authors = [{ signature = "Sergey Parfenyuk", username = "sparfenyuk" }]
29 | template = "remote"
30 | remote = "github.com"
31 | owner = "sparfenyuk"
32 | repository = "mcp-youtube"
33 |
34 | [bump_profiles]
35 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 | All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
3 |
4 | - - -
5 | ## [v0.1.0](https://github.com/sparfenyuk/mcp-youtube/compare/0afe9c0beeaef4a80b2fe10fe90ab9374878b006..v0.1.0) - 2024-12-14
6 | #### Bug Fixes
7 | - rename argument in prompt - ([0ac0972](https://github.com/sparfenyuk/mcp-youtube/commit/0ac09729fb4c0ceaefd8865257744c3216b4c689)) - [@sparfenyuk](https://github.com/sparfenyuk)
8 | #### Features
9 | - download cc for a given youtube link - ([0afe9c0](https://github.com/sparfenyuk/mcp-youtube/commit/0afe9c0beeaef4a80b2fe10fe90ab9374878b006)) - [@sparfenyuk](https://github.com/sparfenyuk)
10 |
11 | - - -
12 |
13 | Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
```
--------------------------------------------------------------------------------
/ruff.toml:
--------------------------------------------------------------------------------
```toml
1 | # Exclude a variety of commonly ignored directories.
2 | exclude = [
3 | ".bzr",
4 | ".direnv",
5 | ".eggs",
6 | ".git",
7 | ".git-rewrite",
8 | ".hg",
9 | ".ipynb_checkpoints",
10 | ".mypy_cache",
11 | ".nox",
12 | ".pants.d",
13 | ".pyenv",
14 | ".pytest_cache",
15 | ".pytype",
16 | ".ruff_cache",
17 | ".svn",
18 | ".tox",
19 | ".venv",
20 | ".vscode",
21 | "__pypackages__",
22 | "_build",
23 | "buck-out",
24 | "build",
25 | "dist",
26 | "node_modules",
27 | "site-packages",
28 | "venv",
29 | ]
30 |
31 | # Same as Black.
32 | line-length = 120
33 | indent-width = 4
34 |
35 | [lint]
36 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
37 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
38 | # McCabe complexity (`C901`) by default.
39 | select = ["ALL"]
40 | ignore = ["D", "TRY003", "EM101", "EM102", "TCH"]
41 |
42 | # Allow fix for all enabled rules (when `--fix`) is provided.
43 | fixable = ["ALL"]
44 | unfixable = []
45 |
46 | # Allow unused variables when underscore-prefixed.
47 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
48 |
49 | [format]
50 | # Like Black, use double quotes for strings.
51 | quote-style = "double"
52 |
53 | # Like Black, indent with spaces, rather than tabs.
54 | indent-style = "space"
55 |
56 | # Like Black, respect magic trailing commas.
57 | skip-magic-trailing-comma = false
58 |
59 | # Like Black, automatically detect the appropriate line ending.
60 | line-ending = "auto"
61 |
62 | # Enable auto-formatting of code examples in docstrings. Markdown,
63 | # reStructuredText code/literal blocks and doctests are all supported.
64 | #
65 | # This is currently disabled by default, but it is planned for this
66 | # to be opt-out in the future.
67 | docstring-code-format = false
68 |
69 | # Set the line length limit used when formatting code snippets in
70 | # docstrings.
71 | #
72 | # This only has an effect when the `docstring-code-format` setting is
73 | # enabled.
74 | docstring-code-line-length = "dynamic"
75 |
```
--------------------------------------------------------------------------------
/src/mcp_youtube/server.py:
--------------------------------------------------------------------------------
```python
1 | from __future__ import annotations
2 |
3 | import inspect
4 | import logging
5 | import typing as t
6 | from collections.abc import Sequence
7 | from functools import cache
8 |
9 | from mcp.server import Server
10 | from mcp.types import (
11 | EmbeddedResource,
12 | GetPromptResult,
13 | ImageContent,
14 | Prompt,
15 | PromptArgument,
16 | PromptMessage,
17 | Resource,
18 | ResourceTemplate,
19 | TextContent,
20 | Tool,
21 | )
22 | from pydantic.networks import AnyUrl
23 |
24 | from . import tools
25 |
26 | logging.basicConfig(level=logging.DEBUG)
27 | logger = logging.getLogger(__name__)
28 | app = Server("mcp-youtube")
29 |
30 |
31 | @cache
32 | def enumerate_available_tools() -> t.Generator[tuple[str, Tool], t.Any, None]:
33 | for _, tool_args in inspect.getmembers(tools, inspect.isclass):
34 | if issubclass(tool_args, tools.ToolArgs) and tool_args != tools.ToolArgs:
35 | logger.debug("Found tool: %s", tool_args)
36 | description = tools.tool_description(tool_args)
37 | yield description.name, description
38 |
39 |
40 | mapping: dict[str, Tool] = dict(enumerate_available_tools())
41 |
42 |
43 | @app.list_prompts()
44 | async def list_prompts() -> list[Prompt]:
45 | """List available prompts."""
46 | return [
47 | Prompt(
48 | name="YoutubeVideoSummary",
49 | description="Create a summary of the given Youtube video.",
50 | arguments=[PromptArgument(name="video_url", description="URL of the Youtube video", required=True)],
51 | ),
52 | ]
53 |
54 |
55 | @app.get_prompt()
56 | async def get_prompt(name: str, args: dict[str, str] | None = None) -> GetPromptResult:
57 | """Get a prompt by name."""
58 | if name == "YoutubeVideoSummary":
59 | url = args.get("video_url") if args else None
60 | if not url:
61 | raise ValueError("video_url is required")
62 | return GetPromptResult(
63 | messages=[
64 | PromptMessage(
65 | role="user",
66 | content=TextContent(
67 | type="text",
68 | text=f"Create a summary of the video {url} using closed captions. Define key takeaways, "
69 | "interesting facts, and the main topic of the video.",
70 | ),
71 | ),
72 | ],
73 | )
74 |
75 | raise ValueError(f"Unknown prompt: {name}")
76 |
77 |
78 | @app.list_resources()
79 | async def list_resources() -> list[Resource]:
80 | """List available resources."""
81 | return []
82 |
83 |
84 | @app.read_resource()
85 | async def get_resource(uri: AnyUrl) -> str | bytes:
86 | """Get a resource by URI."""
87 | return "{id: 1, name: 'test'}"
88 |
89 |
90 | @app.list_tools()
91 | async def list_tools() -> list[Tool]:
92 | """List available tools."""
93 | return list(mapping.values())
94 |
95 |
96 | @app.list_resource_templates()
97 | async def list_resource_templates() -> list[ResourceTemplate]:
98 | """List available resources."""
99 | return []
100 |
101 |
102 | @app.progress_notification()
103 | async def progress_notification(pogress: str | int, p: float, s: float | None) -> None:
104 | """Progress notification."""
105 |
106 |
107 | @app.call_tool()
108 | async def call_tool(name: str, arguments: t.Any) -> Sequence[TextContent | ImageContent | EmbeddedResource]: # noqa: ANN401
109 | """Handle tool calls for command line run."""
110 |
111 | if not isinstance(arguments, dict):
112 | raise TypeError("arguments must be dictionary")
113 |
114 | tool = mapping.get(name)
115 | if not tool:
116 | raise ValueError(f"Unknown tool: {name}")
117 |
118 | try:
119 | args = tools.tool_args(tool, **arguments)
120 | return await tools.tool_runner(args)
121 | except Exception as e:
122 | logger.exception("Error running tool: %s", name)
123 | raise RuntimeError(f"Caught Exception. Error: {e}") from e
124 |
125 |
126 | async def run_mcp_server() -> None:
127 | # Import here to avoid issues with event loops
128 | from mcp.server.stdio import stdio_server
129 |
130 | async with stdio_server() as (read_stream, write_stream):
131 | await app.run(read_stream, write_stream, app.create_initialization_options())
132 |
```
--------------------------------------------------------------------------------
/src/mcp_youtube/tools.py:
--------------------------------------------------------------------------------
```python
1 | from __future__ import annotations
2 |
3 | import json
4 | import logging
5 | import sys
6 | import typing as t
7 | from functools import singledispatch
8 | from urllib.parse import parse_qs, urlparse
9 |
10 | from mcp.server.session import ServerSession
11 | from mcp.types import (
12 | EmbeddedResource,
13 | ImageContent,
14 | TextContent,
15 | Tool,
16 | )
17 | from pydantic import BaseModel, ConfigDict
18 | from xdg_base_dirs import xdg_cache_home
19 | from youtube_transcript_api import YouTubeTranscriptApi # type: ignore[import-untyped]
20 |
21 | logger = logging.getLogger(__name__)
22 |
23 |
24 | # How to add a new tool:
25 | #
26 | # 1. Create a new class that inherits from ToolArgs
27 | # ```python
28 | # class NewTool(ToolArgs):
29 | # """Description of the new tool."""
30 | # pass
31 | # ```
32 | # Attributes of the class will be used as arguments for the tool.
33 | # The class docstring will be used as the tool description.
34 | #
35 | # 2. Implement the tool_runner function for the new class
36 | # ```python
37 | # @tool_runner.register
38 | # async def new_tool(args: NewTool) -> t.Sequence[TextContent | ImageContent | EmbeddedResource]:
39 | # pass
40 | # ```
41 | # The function should return a sequence of TextContent, ImageContent or EmbeddedResource.
42 | # The function should be async and accept a single argument of the new class.
43 | #
44 | # 3. Done! Restart the client and the new tool should be available.
45 |
46 |
47 | class ToolArgs(BaseModel):
48 | model_config = ConfigDict()
49 |
50 |
51 | @singledispatch
52 | async def tool_runner(
53 | args, # noqa: ANN001
54 | ) -> t.Sequence[TextContent | ImageContent | EmbeddedResource]:
55 | raise NotImplementedError(f"Unsupported type: {type(args)}")
56 |
57 |
58 | def tool_description(args: type[ToolArgs]) -> Tool:
59 | return Tool(
60 | name=args.__name__,
61 | description=args.__doc__,
62 | inputSchema=args.model_json_schema(),
63 | )
64 |
65 |
66 | def tool_args(tool: Tool, *args, **kwargs) -> ToolArgs: # noqa: ANN002, ANN003
67 | return sys.modules[__name__].__dict__[tool.name](*args, **kwargs)
68 |
69 |
70 | ## Tools ##
71 |
72 | ### Download close captions from YouTube video ###
73 |
74 |
75 | class DownloadClosedCaptions(ToolArgs):
76 | """Download closed captions from YouTube video."""
77 |
78 | video_url: str
79 |
80 |
81 | def _parse_youtube_url(url: str) -> str | None:
82 | """
83 | Parse a YouTube URL and extract the video ID from the v= parameter.
84 |
85 | Args:
86 | url (str): YouTube URL in various formats
87 |
88 | Returns:
89 | str: Video ID if found, None otherwise
90 |
91 | Examples:
92 | >>> parse_youtube_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
93 | 'dQw4w9WgXcQ'
94 | >>> parse_youtube_url("https://youtu.be/dQw4w9WgXcQ")
95 | 'dQw4w9WgXcQ'
96 | >>> parse_youtube_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=123")
97 | 'dQw4w9WgXcQ'
98 | """
99 |
100 | # Handle youtu.be format
101 | if "youtu.be" in url:
102 | return url.split("/")[-1].split("?")[0]
103 |
104 | # Handle regular youtube.com format
105 | try:
106 | parsed_url = urlparse(url)
107 | if "youtube.com" in parsed_url.netloc:
108 | params = parse_qs(parsed_url.query)
109 | if "v" in params:
110 | return params["v"][0]
111 | except: # noqa: E722, S110
112 | pass
113 |
114 | return None
115 |
116 |
117 | @tool_runner.register
118 | async def download_closed_captions(
119 | args: DownloadClosedCaptions,
120 | ) -> t.Sequence[TextContent | ImageContent | EmbeddedResource]:
121 | transcripts_dir = xdg_cache_home() / "mcp-youtube" / "transcripts"
122 | transcripts_dir.mkdir(parents=True, exist_ok=True)
123 |
124 | video_id = _parse_youtube_url(args.video_url)
125 | if not video_id:
126 | raise ValueError(f"Unrecognized YouTube URL: {args.video_url}")
127 |
128 | if not transcripts_dir.joinpath(f"{video_id}.json").exists():
129 | transcript = YouTubeTranscriptApi.get_transcript(video_id)
130 | if not transcript or not isinstance(transcript, list):
131 | raise ValueError("No transcript found for the video.")
132 |
133 | json_data = json.dumps(transcript, indent=None)
134 | transcripts_dir.joinpath(f"{video_id}.json").write_text(json_data)
135 |
136 | else:
137 | json_data = transcripts_dir.joinpath(f"{video_id}.json").read_text()
138 | transcript = json.loads(json_data)
139 |
140 | content = " ".join([line["text"] for line in transcript])
141 |
142 | return [
143 | TextContent(
144 | type="text",
145 | text=content,
146 | ),
147 | ]
148 |
```