Skip to content

Commit 799cd41

Browse files
authored
Merge pull request #22 from jkawamoto/bump
Refactor and update project dependencies
2 parents fece98e + daa9ce0 commit 799cd41

File tree

8 files changed

+524
-489
lines changed

8 files changed

+524
-489
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ repos:
1212
hooks:
1313
- id: yamlfmt
1414
- repo: https://github.com/astral-sh/ruff-pre-commit
15-
rev: v0.11.2
15+
rev: v0.11.8
1616
hooks:
1717
- id: ruff
1818
args: [--fix]

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ requires = [ "hatchling" ]
55

66
[project]
77
name = "mcp-youtube-transcript"
8-
version = "0.3.2"
8+
version = "0.3.3"
99
description = "MCP server retrieving transcripts of YouTube videos"
1010
readme = "README.md"
1111
authors = [
@@ -31,7 +31,7 @@ dependencies = [
3131
"youtube-transcript-api>=1.0.3",
3232
]
3333

34-
scripts.mcp-youtube-transcript = "mcp_youtube_transcript:main"
34+
scripts.mcp-youtube-transcript = "mcp_youtube_transcript.cli:main"
3535

3636
[dependency-groups]
3737
dev = [
@@ -51,7 +51,7 @@ line-length = 120
5151
indent = 4
5252

5353
[tool.bumpversion]
54-
current_version = "0.3.2"
54+
current_version = "0.3.3"
5555
commit = true
5656
pre_commit_hooks = [
5757
"uv sync",

src/mcp_youtube_transcript/__init__.py

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,90 @@
55
# This software is released under the MIT License.
66
#
77
# http://opensource.org/licenses/mit-license.php
8-
import logging
8+
from contextlib import asynccontextmanager
9+
from dataclasses import dataclass
10+
from functools import lru_cache, partial
11+
from typing import AsyncIterator
912
from typing import Final
13+
from urllib.parse import urlparse, parse_qs
1014

11-
import rich_click as click
12-
13-
from mcp_youtube_transcript.server import new_server
14-
15-
16-
@click.command()
17-
@click.option(
18-
"--webshare-proxy-username",
19-
metavar="NAME",
20-
envvar="WEBSHARE_PROXY_USERNAME",
21-
help="Webshare proxy service username.",
22-
)
23-
@click.option(
24-
"--webshare-proxy-password",
25-
metavar="PASSWORD",
26-
envvar="WEBSHARE_PROXY_PASSWORD",
27-
help="Webshare proxy service password.",
28-
)
29-
@click.option("--http-proxy", metavar="URL", envvar="HTTP_PROXY", help="HTTP proxy server URL.")
30-
@click.option("--https-proxy", metavar="URL", envvar="HTTPS_PROXY", help="HTTPS proxy server URL.")
31-
@click.version_option()
32-
def main(
33-
webshare_proxy_username: str | None,
34-
webshare_proxy_password: str | None,
35-
http_proxy: str | None,
36-
https_proxy: str | None,
37-
) -> None:
38-
"""YouTube Transcript MCP server."""
39-
40-
logging.basicConfig(level=logging.INFO)
41-
logger = logging.getLogger(__name__)
42-
43-
logger.info("starting Youtube Transcript MCP server")
44-
mcp = new_server(webshare_proxy_username, webshare_proxy_password, http_proxy, https_proxy)
45-
mcp.run()
46-
logger.info("closed Youtube Transcript MCP server")
47-
48-
49-
__all__: Final = ["main"]
15+
import requests
16+
from bs4 import BeautifulSoup
17+
from mcp.server import FastMCP
18+
from mcp.server.fastmcp import Context
19+
from pydantic import Field
20+
from youtube_transcript_api import YouTubeTranscriptApi
21+
from youtube_transcript_api.proxies import WebshareProxyConfig, GenericProxyConfig, ProxyConfig
22+
23+
24+
@dataclass(frozen=True)
25+
class AppContext:
26+
http_client: requests.Session
27+
ytt_api: YouTubeTranscriptApi
28+
29+
30+
@asynccontextmanager
31+
async def _app_lifespan(_server: FastMCP, proxy_config: ProxyConfig | None) -> AsyncIterator[AppContext]:
32+
with requests.Session() as http_client:
33+
ytt_api = YouTubeTranscriptApi(http_client=http_client, proxy_config=proxy_config)
34+
yield AppContext(http_client=http_client, ytt_api=ytt_api)
35+
36+
37+
@lru_cache
38+
def _get_transcript(ctx: AppContext, video_id: str, lang: str) -> str:
39+
if lang == "en":
40+
languages = ["en"]
41+
else:
42+
languages = [lang, "en"]
43+
44+
page = ctx.http_client.get(
45+
f"https://www.youtube.com/watch?v={video_id}", headers={"Accept-Language": ",".join(languages)}
46+
)
47+
page.raise_for_status()
48+
soup = BeautifulSoup(page.text, "html.parser")
49+
title = soup.title.string if soup.title else "Transcript"
50+
51+
transcripts = ctx.ytt_api.fetch(video_id, languages=languages)
52+
53+
return f"# {title}\n" + "\n".join((item.text for item in transcripts))
54+
55+
56+
def server(
57+
webshare_proxy_username: str | None = None,
58+
webshare_proxy_password: str | None = None,
59+
http_proxy: str | None = None,
60+
https_proxy: str | None = None,
61+
) -> FastMCP:
62+
"""Initializes the MCP server."""
63+
64+
proxy_config: ProxyConfig | None = None
65+
if webshare_proxy_username and webshare_proxy_password:
66+
proxy_config = WebshareProxyConfig(webshare_proxy_username, webshare_proxy_password)
67+
elif http_proxy or https_proxy:
68+
proxy_config = GenericProxyConfig(http_proxy, https_proxy)
69+
70+
mcp = FastMCP("Youtube Transcript", lifespan=partial(_app_lifespan, proxy_config=proxy_config))
71+
72+
@mcp.tool()
73+
async def get_transcript(
74+
ctx: Context,
75+
url: str = Field(description="The URL of the YouTube video"),
76+
lang: str = Field(description="The preferred language for the transcript", default="en"),
77+
) -> str:
78+
"""Retrieves the transcript of a YouTube video."""
79+
parsed_url = urlparse(url)
80+
if parsed_url.hostname == "youtu.be":
81+
video_id = parsed_url.path.lstrip("/")
82+
else:
83+
q = parse_qs(parsed_url.query).get("v")
84+
if q is None:
85+
raise ValueError(f"couldn't find a video ID from the provided URL: {url}.")
86+
video_id = q[0]
87+
88+
app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore
89+
return _get_transcript(app_ctx, video_id, lang)
90+
91+
return mcp
92+
93+
94+
__all__: Final = ["server"]

src/mcp_youtube_transcript/__main__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
# This software is released under the MIT License.
66
#
77
# http://opensource.org/licenses/mit-license.php
8-
9-
from . import main
8+
from mcp_youtube_transcript.cli import main
109

1110
main()

src/mcp_youtube_transcript/cli.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# cli.py
2+
#
3+
# Copyright (c) 2025 Junpei Kawamoto
4+
#
5+
# This software is released under the MIT License.
6+
#
7+
# http://opensource.org/licenses/mit-license.php
8+
import logging
9+
10+
import click
11+
12+
from mcp_youtube_transcript import server
13+
14+
15+
@click.command()
16+
@click.option(
17+
"--webshare-proxy-username",
18+
metavar="NAME",
19+
envvar="WEBSHARE_PROXY_USERNAME",
20+
help="Webshare proxy service username.",
21+
)
22+
@click.option(
23+
"--webshare-proxy-password",
24+
metavar="PASSWORD",
25+
envvar="WEBSHARE_PROXY_PASSWORD",
26+
help="Webshare proxy service password.",
27+
)
28+
@click.option("--http-proxy", metavar="URL", envvar="HTTP_PROXY", help="HTTP proxy server URL.")
29+
@click.option("--https-proxy", metavar="URL", envvar="HTTPS_PROXY", help="HTTPS proxy server URL.")
30+
@click.version_option()
31+
def main(
32+
webshare_proxy_username: str | None,
33+
webshare_proxy_password: str | None,
34+
http_proxy: str | None,
35+
https_proxy: str | None,
36+
) -> None:
37+
"""YouTube Transcript MCP server."""
38+
39+
logging.basicConfig(level=logging.INFO)
40+
logger = logging.getLogger(__name__)
41+
42+
logger.info("starting Youtube Transcript MCP server")
43+
mcp = server(webshare_proxy_username, webshare_proxy_password, http_proxy, https_proxy)
44+
mcp.run()
45+
logger.info("closed Youtube Transcript MCP server")

src/mcp_youtube_transcript/server.py

Lines changed: 0 additions & 90 deletions
This file was deleted.

tests/test_server.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import pytest
1111
from youtube_transcript_api.proxies import WebshareProxyConfig, GenericProxyConfig
1212

13-
from mcp_youtube_transcript.server import new_server, AppContext
13+
from mcp_youtube_transcript import server, AppContext
1414

1515

1616
def is_webshare_proxy_config(obj: Any) -> TypeGuard[WebshareProxyConfig]:
@@ -23,7 +23,7 @@ def is_generic_proxy_config(obj: Any) -> TypeGuard[GenericProxyConfig]:
2323

2424
@pytest.mark.anyio
2525
async def test_new_server() -> None:
26-
mcp = new_server()
26+
mcp = server()
2727

2828
app_ctx: AppContext
2929
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
@@ -38,7 +38,7 @@ async def test_new_server_with_webshare_proxy() -> None:
3838
webshare_proxy_password = "test_pass"
3939
proxy_config = WebshareProxyConfig(webshare_proxy_username, webshare_proxy_password)
4040

41-
mcp = new_server(
41+
mcp = server(
4242
webshare_proxy_username=webshare_proxy_username,
4343
webshare_proxy_password=webshare_proxy_password,
4444
)
@@ -56,7 +56,7 @@ async def test_new_server_with_webshare_proxy() -> None:
5656
async def test_new_server_with_only_webshare_proxy_user() -> None:
5757
webshare_proxy_username = "test_user"
5858

59-
mcp = new_server(
59+
mcp = server(
6060
webshare_proxy_username=webshare_proxy_username,
6161
)
6262

@@ -71,7 +71,7 @@ async def test_new_server_with_only_webshare_proxy_user() -> None:
7171
async def test_new_server_with_only_webshare_proxy_password() -> None:
7272
webshare_proxy_password = "test_pass"
7373

74-
mcp = new_server(
74+
mcp = server(
7575
webshare_proxy_password=webshare_proxy_password,
7676
)
7777

@@ -88,7 +88,7 @@ async def test_new_server_with_generic_proxy() -> None:
8888
https_proxy = "https://localhost:8080"
8989
proxy_config = GenericProxyConfig(http_proxy, https_proxy)
9090

91-
mcp = new_server(
91+
mcp = server(
9292
http_proxy=http_proxy,
9393
https_proxy=https_proxy,
9494
)
@@ -107,7 +107,7 @@ async def test_new_server_with_http_proxy() -> None:
107107
http_proxy = "http://localhost:8080"
108108
proxy_config = GenericProxyConfig(http_proxy)
109109

110-
mcp = new_server(
110+
mcp = server(
111111
http_proxy=http_proxy,
112112
)
113113

@@ -125,7 +125,7 @@ async def test_new_server_with_https_proxy() -> None:
125125
https_proxy = "https://localhost:8080"
126126
proxy_config = GenericProxyConfig(https_url=https_proxy)
127127

128-
mcp = new_server(
128+
mcp = server(
129129
https_proxy=https_proxy,
130130
)
131131

0 commit comments

Comments
 (0)