Skip to content

Commit d3637db

Browse files
authored
Merge pull request #31 from jkawamoto/pagination
Add response limit and transcript pagination support
2 parents 308aeea + f341bc0 commit d3637db

File tree

4 files changed

+103
-12
lines changed

4 files changed

+103
-12
lines changed

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Fetches the transcript of a specified YouTube video.
1919
#### Parameters
2020
- **url** *(string)*: The full URL of the YouTube video. This field is required.
2121
- **lang** *(string, optional)*: The desired language for the transcript. Defaults to `en` if not specified.
22+
- **next_cursor** *(string, optional)*: Cursor to retrieve the next page of the transcript.
2223

2324
## Installation
2425
> [!NOTE]
@@ -67,6 +68,30 @@ npx -y @smithery/cli list clients
6768

6869
Refer to the [Smithery CLI documentation](https://github.com/smithery-ai/cli) for additional details.
6970

71+
## Response Pagination
72+
When retrieving transcripts for longer videos, the content may exceed the token size limits of the LLM.
73+
To avoid this issue, this server splits transcripts that exceed 50,000 characters.
74+
If a transcript is split, the response will include a `next_cursor`.
75+
To retrieve the next part, include this `next_cursor` value in your request.
76+
77+
The token size limits vary depending on the LLM and language you are using. If you need to split responses into smaller chunks, you can adjust this using the `--response-limit` command line argument. For example, the configuration below splits responses to contain no more than 15,000 characters each:
78+
79+
```json
80+
{
81+
"mcpServers": {
82+
"youtube-transcript": {
83+
"command": "uvx",
84+
"args": [
85+
"--from",
86+
"git+https://github.com/jkawamoto/mcp-youtube-transcript",
87+
"mcp-youtube-transcript",
88+
"--response-limit",
89+
"15000"
90+
]
91+
}
92+
}
93+
}
94+
```
7095

7196
## Using Proxy Servers
7297
In environments where access to YouTube is restricted, you can use proxy servers.

src/mcp_youtube_transcript/__init__.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from contextlib import asynccontextmanager
99
from dataclasses import dataclass
1010
from functools import lru_cache, partial
11+
from itertools import islice
1112
from typing import AsyncIterator, Tuple
1213
from typing import Final
1314
from urllib.parse import urlparse, parse_qs
@@ -35,7 +36,7 @@ async def _app_lifespan(_server: FastMCP, proxy_config: ProxyConfig | None) -> A
3536

3637

3738
@lru_cache
38-
def _get_transcript(ctx: AppContext, video_id: str, lang: str) -> Tuple[str, str]:
39+
def _get_transcript(ctx: AppContext, video_id: str, lang: str) -> Tuple[str, list[str]]:
3940
if lang == "en":
4041
languages = ["en"]
4142
else:
@@ -49,17 +50,19 @@ def _get_transcript(ctx: AppContext, video_id: str, lang: str) -> Tuple[str, str
4950
title = soup.title.string if soup.title and soup.title.string else "Transcript"
5051

5152
transcripts = ctx.ytt_api.fetch(video_id, languages=languages)
52-
return title, "\n".join((item.text for item in transcripts))
53+
return title, [item.text for item in transcripts]
5354

5455

5556
class Transcript(BaseModel):
5657
"""Transcript of a YouTube video."""
5758

5859
title: str = Field(description="Title of the video")
5960
transcript: str = Field(description="Transcript of the video")
61+
next_cursor: str | None = Field(description="Cursor to retrieve the next page of the transcript", default=None)
6062

6163

6264
def server(
65+
response_limit: int | None = None,
6366
webshare_proxy_username: str | None = None,
6467
webshare_proxy_password: str | None = None,
6568
http_proxy: str | None = None,
@@ -80,6 +83,7 @@ async def get_transcript(
8083
ctx: Context,
8184
url: str = Field(description="The URL of the YouTube video"),
8285
lang: str = Field(description="The preferred language for the transcript", default="en"),
86+
next_cursor: str | None = Field(description="Cursor to retrieve the next page of the transcript", default=None),
8387
) -> Transcript:
8488
"""Retrieves the transcript of a YouTube video."""
8589
parsed_url = urlparse(url)
@@ -92,8 +96,20 @@ async def get_transcript(
9296
video_id = q[0]
9397

9498
app_ctx: AppContext = ctx.request_context.lifespan_context # type: ignore
95-
title, transcript = _get_transcript(app_ctx, video_id, lang)
96-
return Transcript(title=title, transcript=transcript)
99+
title, transcripts = _get_transcript(app_ctx, video_id, lang)
100+
101+
if response_limit is None or response_limit <= 0:
102+
return Transcript(title=title, transcript="\n".join(transcripts))
103+
104+
res = ""
105+
cursor = None
106+
for i, line in islice(enumerate(transcripts), int(next_cursor or 0), None):
107+
if len(res) + len(line) + 1 > response_limit:
108+
cursor = str(i)
109+
break
110+
res += f"{line}\n"
111+
112+
return Transcript(title=title, transcript=res[:-1], next_cursor=cursor)
97113

98114
return mcp
99115

src/mcp_youtube_transcript/cli.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313

1414

1515
@click.command()
16+
@click.option(
17+
"--response-limit",
18+
type=int,
19+
help="Maximum number of characters each response contains. Set a negative value to disable pagination.",
20+
default=50000,
21+
)
1622
@click.option(
1723
"--webshare-proxy-username",
1824
metavar="NAME",
@@ -29,6 +35,7 @@
2935
@click.option("--https-proxy", metavar="URL", envvar="HTTPS_PROXY", help="HTTPS proxy server URL.")
3036
@click.version_option()
3137
def main(
38+
response_limit: int | None,
3239
webshare_proxy_username: str | None,
3340
webshare_proxy_password: str | None,
3441
http_proxy: str | None,
@@ -40,6 +47,5 @@ def main(
4047
logger = logging.getLogger(__name__)
4148

4249
logger.info("starting Youtube Transcript MCP server")
43-
mcp = server(webshare_proxy_username, webshare_proxy_password, http_proxy, https_proxy)
44-
mcp.run()
50+
server(response_limit, webshare_proxy_username, webshare_proxy_password, http_proxy, https_proxy).run()
4551
logger.info("closed Youtube Transcript MCP server")

tests/test_mcp.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717

1818
from mcp_youtube_transcript import Transcript
1919

20-
params = StdioServerParameters(command="uv", args=["run", "mcp-youtube-transcript"])
21-
2220

2321
def fetch_title(url: str, lang: str) -> str:
2422
res = requests.get(f"https://www.youtube.com/watch?v={url}", headers={"Accept-Language": lang})
@@ -28,6 +26,7 @@ def fetch_title(url: str, lang: str) -> str:
2826

2927
@pytest.fixture(scope="module")
3028
async def mcp_client_session() -> AsyncGenerator[ClientSession, None]:
29+
params = StdioServerParameters(command="uv", args=["run", "mcp-youtube-transcript", "--response-limit", "-1"])
3130
async with stdio_client(params) as streams:
3231
async with ClientSession(streams[0], streams[1]) as session:
3332
await session.initialize()
@@ -49,7 +48,8 @@ async def test_get_transcript(mcp_client_session: ClientSession) -> None:
4948

5049
title = fetch_title(video_id, "en")
5150
expect = Transcript(
52-
title=title, transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id)))
51+
title=title,
52+
transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id))),
5353
)
5454

5555
res = await mcp_client_session.call_tool(
@@ -72,7 +72,8 @@ async def test_get_transcript_with_language(mcp_client_session: ClientSession) -
7272

7373
title = fetch_title(video_id, "ja")
7474
expect = Transcript(
75-
title=title, transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id, ["ja"])))
75+
title=title,
76+
transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id, ["ja"]))),
7677
)
7778

7879
res = await mcp_client_session.call_tool(
@@ -97,7 +98,8 @@ async def test_get_transcript_fallback_language(
9798

9899
title = fetch_title(video_id, "en")
99100
expect = Transcript(
100-
title=title, transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id)))
101+
title=title,
102+
transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id))),
101103
)
102104

103105
res = await mcp_client_session.call_tool(
@@ -140,7 +142,8 @@ async def test_get_transcript_with_short_url(mcp_client_session: ClientSession)
140142

141143
title = fetch_title(video_id, "en")
142144
expect = Transcript(
143-
title=title, transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id)))
145+
title=title,
146+
transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id))),
144147
)
145148

146149
res = await mcp_client_session.call_tool(
@@ -152,3 +155,44 @@ async def test_get_transcript_with_short_url(mcp_client_session: ClientSession)
152155
transcript = Transcript.model_validate_json(res.content[0].text)
153156
assert transcript == expect
154157
assert not res.isError
158+
159+
160+
@pytest.fixture(scope="module")
161+
async def mcp_client_session_with_response_limit() -> AsyncGenerator[ClientSession, None]:
162+
params = StdioServerParameters(command="uv", args=["run", "mcp-youtube-transcript", "--response-limit", "3000"])
163+
async with stdio_client(params) as streams:
164+
async with ClientSession(streams[0], streams[1]) as session:
165+
await session.initialize()
166+
yield session
167+
168+
169+
@pytest.mark.skipif(os.getenv("CI") == "true", reason="Skipping this test on CI")
170+
@pytest.mark.default_cassette("LPZh9BOjkQs.yaml")
171+
@pytest.mark.vcr
172+
@pytest.mark.anyio
173+
async def test_get_transcript_with_response_limit(mcp_client_session_with_response_limit: ClientSession) -> None:
174+
video_id = "LPZh9BOjkQs"
175+
176+
expect = Transcript(
177+
title=fetch_title(video_id, "en"),
178+
transcript="\n".join((item.text for item in YouTubeTranscriptApi().fetch(video_id))),
179+
)
180+
181+
transcript = ""
182+
cursor = None
183+
while True:
184+
res = await mcp_client_session_with_response_limit.call_tool(
185+
"get_transcript",
186+
arguments={"url": f"https://www.youtube.com/watch?v={video_id}", "next_cursor": cursor},
187+
)
188+
assert not res.isError
189+
assert isinstance(res.content[0], TextContent)
190+
191+
t = Transcript.model_validate_json(res.content[0].text)
192+
transcript += t.transcript + "\n"
193+
if t.next_cursor is None:
194+
break
195+
cursor = t.next_cursor
196+
197+
assert t.title == expect.title
198+
assert transcript[:-1] == expect.transcript

0 commit comments

Comments
 (0)