#
tokens: 6133/50000 9/9 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```