Skip to content

Commit 45ce84d

Browse files
authored
Merge pull request #20 from jkawamoto/http_session
Reuse `requests.Session` for improved efficiency
2 parents bf79943 + 4f7a70f commit 45ce84d

File tree

4 files changed

+125
-74
lines changed

4 files changed

+125
-74
lines changed

src/mcp_youtube_transcript/server.py

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

1114
import requests
1215
from bs4 import BeautifulSoup
1316
from mcp.server import FastMCP
17+
from mcp.server.fastmcp import Context
1418
from pydantic import Field
1519
from youtube_transcript_api import YouTubeTranscriptApi
1620
from youtube_transcript_api.proxies import WebshareProxyConfig, GenericProxyConfig, ProxyConfig
1721

1822

23+
@dataclass(frozen=True)
24+
class AppContext:
25+
http_client: requests.Session
26+
ytt_api: YouTubeTranscriptApi
27+
28+
29+
@asynccontextmanager
30+
async def _app_lifespan(_server: FastMCP, proxy_config: ProxyConfig | None) -> AsyncIterator[AppContext]:
31+
with requests.Session() as http_client:
32+
ytt_api = YouTubeTranscriptApi(http_client=http_client, proxy_config=proxy_config)
33+
yield AppContext(http_client=http_client, ytt_api=ytt_api)
34+
35+
36+
@lru_cache
37+
def _get_transcript(ctx: AppContext, video_id: str, lang: str) -> str:
38+
if lang == "en":
39+
languages = ["en"]
40+
else:
41+
languages = [lang, "en"]
42+
43+
page = ctx.http_client.get(
44+
f"https://www.youtube.com/watch?v={video_id}", headers={"Accept-Language": ",".join(languages)}
45+
)
46+
page.raise_for_status()
47+
soup = BeautifulSoup(page.text, "html.parser")
48+
title = soup.title.string if soup.title else "Transcript"
49+
50+
transcripts = ctx.ytt_api.fetch(video_id, languages=languages)
51+
52+
return f"# {title}\n" + "\n".join((item.text for item in transcripts))
53+
54+
1955
def new_server(
2056
webshare_proxy_username: str | None = None,
2157
webshare_proxy_password: str | None = None,
@@ -30,36 +66,16 @@ def new_server(
3066
elif http_proxy or https_proxy:
3167
proxy_config = GenericProxyConfig(http_proxy, https_proxy)
3268

33-
ytt_api = YouTubeTranscriptApi(proxy_config=proxy_config)
34-
35-
@lru_cache
36-
def _get_transcript(video_id: str, lang: str) -> str:
37-
if lang == "en":
38-
languages = ["en"]
39-
else:
40-
languages = [lang, "en"]
41-
42-
page = requests.get(
43-
f"https://www.youtube.com/watch?v={video_id}", headers={"Accept-Language": ",".join(languages)}
44-
)
45-
page.raise_for_status()
46-
soup = BeautifulSoup(page.text, "html.parser")
47-
title = soup.title.string if soup.title else "Transcript"
48-
49-
transcripts = ytt_api.fetch(video_id, languages=languages)
50-
51-
return f"# {title}\n" + "\n".join((item.text for item in transcripts))
52-
53-
mcp = FastMCP("Youtube Transcript")
69+
mcp = FastMCP("Youtube Transcript", lifespan=partial(_app_lifespan, proxy_config=proxy_config))
5470

5571
@mcp.tool()
56-
def get_transcript(
72+
async def get_transcript(
73+
ctx: Context,
5774
url: str = Field(description="The URL of the YouTube video"),
5875
lang: str = Field(description="The preferred language for the transcript", default="en"),
5976
) -> str:
6077
"""Retrieves the transcript of a YouTube video."""
6178
parsed_url = urlparse(url)
62-
6379
if parsed_url.hostname == "youtu.be":
6480
video_id = parsed_url.path.lstrip("/")
6581
else:
@@ -68,6 +84,7 @@ def get_transcript(
6884
raise ValueError(f"couldn't find a video ID from the provided URL: {url}.")
6985
video_id = q[0]
7086

71-
return _get_transcript(video_id, lang)
87+
app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore
88+
return _get_transcript(app_ctx, video_id, lang)
7289

7390
return mcp

tests/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# conftest.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 pytest
9+
10+
11+
@pytest.fixture(scope="module")
12+
def anyio_backend() -> str:
13+
return "asyncio"

tests/test_mcp.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,6 @@ def fetch_title(url: str, lang: str) -> str:
2424
return soup.title.string or "" if soup.title else ""
2525

2626

27-
@pytest.fixture(scope="module")
28-
def anyio_backend() -> str:
29-
return "asyncio"
30-
31-
3227
@pytest.fixture(scope="module")
3328
async def mcp_client_session() -> AsyncGenerator[ClientSession, None]:
3429
async with stdio_client(params) as streams:

tests/test_server.py

Lines changed: 70 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,12 @@
55
# This software is released under the MIT License.
66
#
77
# http://opensource.org/licenses/mit-license.php
8-
from typing import Any, TypeGuard, Iterator
9-
from unittest.mock import MagicMock
8+
from typing import Any, TypeGuard
109

1110
import pytest
12-
from pytest_mock import MockFixture
1311
from youtube_transcript_api.proxies import WebshareProxyConfig, GenericProxyConfig
1412

15-
from mcp_youtube_transcript.server import new_server
16-
17-
18-
@pytest.fixture
19-
def mock_api(mocker: MockFixture) -> Iterator[MagicMock]:
20-
yield mocker.patch("mcp_youtube_transcript.server.YouTubeTranscriptApi")
13+
from mcp_youtube_transcript.server import new_server, AppContext
2114

2215

2316
def is_webshare_proxy_config(obj: Any) -> TypeGuard[WebshareProxyConfig]:
@@ -28,85 +21,118 @@ def is_generic_proxy_config(obj: Any) -> TypeGuard[GenericProxyConfig]:
2821
return isinstance(obj, GenericProxyConfig)
2922

3023

31-
def test_new_server(mock_api: MagicMock) -> None:
32-
new_server()
24+
@pytest.mark.anyio
25+
async def test_new_server() -> None:
26+
mcp = new_server()
3327

34-
mock_api.assert_called_once_with(proxy_config=None)
28+
app_ctx: AppContext
29+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
30+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
31+
assert not app_ctx.http_client.proxies
32+
assert not app_ctx.ytt_api._fetcher._proxy_config
3533

3634

37-
def test_new_server_with_webshare_proxy(mock_api: MagicMock) -> None:
35+
@pytest.mark.anyio
36+
async def test_new_server_with_webshare_proxy() -> None:
3837
webshare_proxy_username = "test_user"
3938
webshare_proxy_password = "test_pass"
39+
proxy_config = WebshareProxyConfig(webshare_proxy_username, webshare_proxy_password)
4040

41-
new_server(
41+
mcp = new_server(
4242
webshare_proxy_username=webshare_proxy_username,
4343
webshare_proxy_password=webshare_proxy_password,
4444
)
4545

46-
mock_api.assert_called_once()
47-
proxy_config = mock_api.call_args.kwargs["proxy_config"]
48-
assert is_webshare_proxy_config(proxy_config)
49-
assert proxy_config.proxy_username == webshare_proxy_username
50-
assert proxy_config.proxy_password == webshare_proxy_password
46+
app_ctx: AppContext
47+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
48+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
49+
assert app_ctx.http_client.proxies == proxy_config.to_requests_dict()
50+
assert is_webshare_proxy_config(app_ctx.ytt_api._fetcher._proxy_config)
51+
assert app_ctx.ytt_api._fetcher._proxy_config.proxy_username == webshare_proxy_username
52+
assert app_ctx.ytt_api._fetcher._proxy_config.proxy_password == webshare_proxy_password
5153

5254

53-
def test_new_server_with_only_webshare_proxy_user(mock_api: MagicMock) -> None:
55+
@pytest.mark.anyio
56+
async def test_new_server_with_only_webshare_proxy_user() -> None:
5457
webshare_proxy_username = "test_user"
5558

56-
new_server(
59+
mcp = new_server(
5760
webshare_proxy_username=webshare_proxy_username,
5861
)
5962

60-
mock_api.assert_called_once_with(proxy_config=None)
63+
app_ctx: AppContext
64+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
65+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
66+
assert not app_ctx.http_client.proxies
67+
assert not app_ctx.ytt_api._fetcher._proxy_config
6168

6269

63-
def test_new_server_with_only_webshare_proxy_password(mock_api: MagicMock) -> None:
70+
@pytest.mark.anyio
71+
async def test_new_server_with_only_webshare_proxy_password() -> None:
6472
webshare_proxy_password = "test_pass"
6573

66-
new_server(
74+
mcp = new_server(
6775
webshare_proxy_password=webshare_proxy_password,
6876
)
6977

70-
mock_api.assert_called_once_with(proxy_config=None)
78+
app_ctx: AppContext
79+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
80+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
81+
assert not app_ctx.http_client.proxies
82+
assert not app_ctx.ytt_api._fetcher._proxy_config
7183

7284

73-
def test_new_server_with_generic_proxy(mock_api: MagicMock) -> None:
85+
@pytest.mark.anyio
86+
async def test_new_server_with_generic_proxy() -> None:
7487
http_proxy = "http://localhost:8080"
7588
https_proxy = "https://localhost:8080"
89+
proxy_config = GenericProxyConfig(http_proxy, https_proxy)
7690

77-
new_server(
91+
mcp = new_server(
7892
http_proxy=http_proxy,
7993
https_proxy=https_proxy,
8094
)
8195

82-
mock_api.assert_called_once()
83-
proxy_config = mock_api.call_args.kwargs["proxy_config"]
84-
assert is_generic_proxy_config(proxy_config)
85-
assert proxy_config.http_url == http_proxy
86-
assert proxy_config.https_url == https_proxy
96+
app_ctx: AppContext
97+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
98+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
99+
assert app_ctx.http_client.proxies == proxy_config.to_requests_dict()
100+
assert is_generic_proxy_config(app_ctx.ytt_api._fetcher._proxy_config)
101+
assert app_ctx.ytt_api._fetcher._proxy_config.http_url == http_proxy
102+
assert app_ctx.ytt_api._fetcher._proxy_config.https_url == https_proxy
87103

88104

89-
def test_new_server_with_http_proxy(mock_api: MagicMock) -> None:
105+
@pytest.mark.anyio
106+
async def test_new_server_with_http_proxy() -> None:
90107
http_proxy = "http://localhost:8080"
108+
proxy_config = GenericProxyConfig(http_proxy)
91109

92-
new_server(
110+
mcp = new_server(
93111
http_proxy=http_proxy,
94112
)
95113

96-
mock_api.assert_called_once()
97-
proxy_config = mock_api.call_args.kwargs["proxy_config"]
98-
assert is_generic_proxy_config(proxy_config)
99-
assert proxy_config.http_url == http_proxy
114+
app_ctx: AppContext
115+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
116+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
117+
assert app_ctx.http_client.proxies == proxy_config.to_requests_dict()
118+
assert is_generic_proxy_config(app_ctx.ytt_api._fetcher._proxy_config)
119+
assert app_ctx.ytt_api._fetcher._proxy_config.http_url == http_proxy
120+
assert app_ctx.ytt_api._fetcher._proxy_config.https_url is None
100121

101122

102-
def test_new_server_with_https_proxy(mock_api: MagicMock) -> None:
123+
@pytest.mark.anyio
124+
async def test_new_server_with_https_proxy() -> None:
103125
https_proxy = "https://localhost:8080"
126+
proxy_config = GenericProxyConfig(https_url=https_proxy)
104127

105-
new_server(
128+
mcp = new_server(
106129
https_proxy=https_proxy,
107130
)
108131

109-
mock_api.assert_called_once()
110-
proxy_config = mock_api.call_args.kwargs["proxy_config"]
111-
assert is_generic_proxy_config(proxy_config)
112-
assert proxy_config.https_url == https_proxy
132+
app_ctx: AppContext
133+
async with mcp.settings.lifespan(mcp) as app_ctx: # type: ignore
134+
assert app_ctx.http_client == app_ctx.ytt_api._fetcher._http_client
135+
assert app_ctx.http_client.proxies == proxy_config.to_requests_dict()
136+
assert is_generic_proxy_config(app_ctx.ytt_api._fetcher._proxy_config)
137+
assert app_ctx.ytt_api._fetcher._proxy_config.http_url is None
138+
assert app_ctx.ytt_api._fetcher._proxy_config.https_url == https_proxy

0 commit comments

Comments
 (0)