yt-studio-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. yt_studio_mcp-0.1.0/.github/workflows/ci.yml +33 -0
  2. yt_studio_mcp-0.1.0/.github/workflows/publish.yml +18 -0
  3. yt_studio_mcp-0.1.0/.gitignore +9 -0
  4. yt_studio_mcp-0.1.0/LICENSE +21 -0
  5. yt_studio_mcp-0.1.0/PKG-INFO +141 -0
  6. yt_studio_mcp-0.1.0/README.md +117 -0
  7. yt_studio_mcp-0.1.0/pyproject.toml +49 -0
  8. yt_studio_mcp-0.1.0/src/yt_studio_mcp/__init__.py +1 -0
  9. yt_studio_mcp-0.1.0/src/yt_studio_mcp/__main__.py +39 -0
  10. yt_studio_mcp-0.1.0/src/yt_studio_mcp/auth.py +73 -0
  11. yt_studio_mcp-0.1.0/src/yt_studio_mcp/client.py +145 -0
  12. yt_studio_mcp-0.1.0/src/yt_studio_mcp/llm.py +71 -0
  13. yt_studio_mcp-0.1.0/src/yt_studio_mcp/scan_cli.py +135 -0
  14. yt_studio_mcp-0.1.0/src/yt_studio_mcp/secrets.py +100 -0
  15. yt_studio_mcp-0.1.0/src/yt_studio_mcp/server.py +37 -0
  16. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/__init__.py +0 -0
  17. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/analytics.py +75 -0
  18. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/banned_words.py +179 -0
  19. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/bulk.py +167 -0
  20. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/captions.py +58 -0
  21. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/comments.py +118 -0
  22. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/giveaway.py +328 -0
  23. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/live.py +170 -0
  24. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/meta.py +45 -0
  25. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/playlists.py +101 -0
  26. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/questions.py +126 -0
  27. yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/videos.py +188 -0
  28. yt_studio_mcp-0.1.0/tests/test_auth.py +31 -0
  29. yt_studio_mcp-0.1.0/tests/test_banned_words.py +23 -0
  30. yt_studio_mcp-0.1.0/tests/test_bulk.py +27 -0
  31. yt_studio_mcp-0.1.0/tests/test_client.py +78 -0
  32. yt_studio_mcp-0.1.0/tests/test_giveaway.py +95 -0
  33. yt_studio_mcp-0.1.0/tests/test_llm.py +72 -0
  34. yt_studio_mcp-0.1.0/tests/test_questions.py +12 -0
  35. yt_studio_mcp-0.1.0/tests/test_secrets.py +80 -0
  36. yt_studio_mcp-0.1.0/tests/test_server.py +41 -0
@@ -0,0 +1,33 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install -e '.[dev]'
16
+ - name: Lint
17
+ run: ruff check src tests
18
+ - name: Tests
19
+ run: pytest
20
+ - name: Forbidden terms check
21
+ env:
22
+ FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
23
+ run: |
24
+ if [ -n "$FORBIDDEN_TERMS" ] && git grep -qiE "$FORBIDDEN_TERMS" -- . ; then
25
+ echo "::error::forbidden terms found in repository"
26
+ exit 1
27
+ fi
28
+ - name: Secret shape check
29
+ run: |
30
+ if git grep -nE '(refresh_token|client_secret)"?\s*[:=]\s*"[A-Za-z0-9_/-]{20,}' -- ':!tests' ; then
31
+ echo "::error::possible hardcoded credential"
32
+ exit 1
33
+ fi
@@ -0,0 +1,18 @@
1
+ name: Publish
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ jobs:
7
+ pypi:
8
+ runs-on: ubuntu-latest
9
+ environment: pypi
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build && python -m build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ docs/superpowers/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ credentials.json
9
+ client_secret*.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aaronckj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: yt-studio-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for managing a YouTube channel: videos, comments, playlists, live streams, analytics, and auditable comment-entry giveaways.
5
+ Project-URL: Homepage, https://github.com/aaronckj/yt-studio-mcp
6
+ Project-URL: Issues, https://github.com/aaronckj/yt-studio-mcp/issues
7
+ Author: aaronckj
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: giveaway,mcp,model-context-protocol,youtube,youtube-api
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: google-api-python-client>=2.100
17
+ Requires-Dist: google-auth-oauthlib>=1.2
18
+ Requires-Dist: google-auth>=2.30
19
+ Requires-Dist: mcp>=1.2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: ruff>=0.6; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # yt-studio-mcp
26
+
27
+ An MCP (Model Context Protocol) server for managing a YouTube channel through
28
+ the official Google APIs — videos, comments and moderation, playlists, live
29
+ broadcasts, captions, analytics — plus an **auditable giveaway suite** for
30
+ running comment-entry giveaways with deterministic, independently verifiable
31
+ winner drawing.
32
+
33
+ - Official APIs only (YouTube Data v3 + YouTube Analytics v2). No scraping.
34
+ - Every mutating tool accepts `dry_run=true` and returns a preview instead of writing.
35
+ - No secrets in this repo, in logs, or in your MCP client config.
36
+
37
+ ## Quick start
38
+
39
+ ### 1. Create Google credentials (~5 minutes, one time)
40
+
41
+ 1. Go to [console.cloud.google.com](https://console.cloud.google.com), create a project.
42
+ 2. **APIs & Services → Library**: enable **YouTube Data API v3** and **YouTube Analytics API**.
43
+ 3. **APIs & Services → OAuth consent screen**: External, add yourself as a test user.
44
+ 4. **APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app**;
45
+ download the JSON as `client_secret.json`.
46
+
47
+ ### 2. Authorize your channel
48
+
49
+ ```bash
50
+ uvx yt-studio-mcp auth --client-secret /path/to/client_secret.json
51
+ ```
52
+
53
+ A browser opens — sign in and **pick the channel** (Brand Accounts appear as
54
+ separate choices). The refresh token is stored locally (see Secret storage).
55
+
56
+ ### 3. Connect your MCP client
57
+
58
+ ```bash
59
+ # Claude Code
60
+ claude mcp add yt-studio -s user -- uvx yt-studio-mcp
61
+ ```
62
+
63
+ ## Tools
64
+
65
+ | Area | Tools |
66
+ |---|---|
67
+ | Channel/videos | `channel_info`, `list_videos`, `get_video`, `update_video`, `set_thumbnail`, `upload_video`, `delete_video` |
68
+ | Playlists | `list_playlists`, `create_playlist`, `delete_playlist`, `add_to_playlist`, `remove_from_playlist`, `list_playlist_items` |
69
+ | Comments | `list_comments`, `post_video_question`, `reply_to_comment`, `moderate_comment`, `mark_spam`, `delete_comment` |
70
+ | Live | `list_broadcasts`, `create_broadcast`, `list_streams`, `bind_broadcast`, `live_chat_messages`, `post_chat_message`, `delete_chat_message`, `ban_chat_user` |
71
+ | Captions | `list_captions`, `upload_caption`, `download_caption` |
72
+ | Analytics | `channel_report`, `video_report`, `top_videos` |
73
+ | Giveaways | `collect_entries`, `draw_winners`, `make_verification_code`, `check_verification_reply`, `post_winner_reply` |
74
+ | Meta | `quota_status`, `health_check` |
75
+
76
+ Known API limitations (documented, not bugs): comments cannot be pinned via
77
+ the API (`post_video_question` reminds you to pin manually in Studio), and
78
+ Community-tab posts have no public API.
79
+
80
+ ## Running a giveaway
81
+
82
+ The giveaway suite implements a common official-rules pattern: *comment on
83
+ any video during the entry window; each distinct video counts as one entry,
84
+ capped per person; winners drawn at random.*
85
+
86
+ ```text
87
+ 1. collect_entries(start="2026-01-01T00:00:00Z", end="2026-01-31T23:59:59Z",
88
+ max_entries_per_user=5, exclude_channel_ids=[...])
89
+ → walks every upload's comments, derives entries, writes a snapshot
90
+ file and returns its SHA-256 entry hash. Announce the hash if you
91
+ want third-party verifiability.
92
+
93
+ 2. draw_winners(snapshot_path, n=5, seed="any-public-string")
94
+ → deterministic: a seeded shuffle over the canonically sorted entries,
95
+ first n distinct channels win. Anyone with the snapshot + seed can
96
+ re-run the draw and get identical winners. Records an audit file.
97
+ Screen-record this step for extra transparency.
98
+
99
+ 3. make_verification_code(audit_path, winner_channel_id)
100
+ → short code you send to the claimed winner over your announced contact
101
+ channel.
102
+
103
+ 4. check_verification_reply(audit_path, comment_id, winner_channel_id)
104
+ → confirms the reply was authored by the winning channel and contains
105
+ the code — proves account ownership before shipping a prize.
106
+
107
+ 5. post_winner_reply(comment_id, "Congratulations! ...")
108
+ ```
109
+
110
+ Entries and audit records live in `~/.config/yt-studio-mcp/giveaways/`.
111
+
112
+ *This tool automates entry collection and drawing; giveaway laws vary by
113
+ jurisdiction and platform policies apply (e.g. YouTube's contest policies).
114
+ You are responsible for your own official rules and compliance.*
115
+
116
+ ## Secret storage
117
+
118
+ | Backend | Select with | Stores |
119
+ |---|---|---|
120
+ | `file` (default) | — | `~/.config/yt-studio-mcp/credentials.json`, chmod 0600 |
121
+ | `env` | `YT_MCP_SECRETS=env` | reads `YT_MCP_REFRESH_TOKEN`, `YT_MCP_CLIENT_ID`, `YT_MCP_CLIENT_SECRET` |
122
+ | `vaultproxy` | `YT_MCP_SECRETS=vaultproxy` (+ `VAULTPROXY_URL`) | items in a Vaultwarden vault via a local vaultproxy HTTP API |
123
+
124
+ ## Quota
125
+
126
+ The Data API's default allocation is 10,000 units/day. Most reads cost 1
127
+ unit; writes cost ~50; a video upload costs 1,600. `quota_status()` shows a
128
+ session estimate and warns at 80%. `collect_entries` reports what it spent.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ pip install -e '.[dev]'
134
+ ruff check src tests && pytest
135
+ ```
136
+
137
+ Tests are fully offline (mocked Google API). PRs welcome.
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,117 @@
1
+ # yt-studio-mcp
2
+
3
+ An MCP (Model Context Protocol) server for managing a YouTube channel through
4
+ the official Google APIs — videos, comments and moderation, playlists, live
5
+ broadcasts, captions, analytics — plus an **auditable giveaway suite** for
6
+ running comment-entry giveaways with deterministic, independently verifiable
7
+ winner drawing.
8
+
9
+ - Official APIs only (YouTube Data v3 + YouTube Analytics v2). No scraping.
10
+ - Every mutating tool accepts `dry_run=true` and returns a preview instead of writing.
11
+ - No secrets in this repo, in logs, or in your MCP client config.
12
+
13
+ ## Quick start
14
+
15
+ ### 1. Create Google credentials (~5 minutes, one time)
16
+
17
+ 1. Go to [console.cloud.google.com](https://console.cloud.google.com), create a project.
18
+ 2. **APIs & Services → Library**: enable **YouTube Data API v3** and **YouTube Analytics API**.
19
+ 3. **APIs & Services → OAuth consent screen**: External, add yourself as a test user.
20
+ 4. **APIs & Services → Credentials → Create credentials → OAuth client ID → Desktop app**;
21
+ download the JSON as `client_secret.json`.
22
+
23
+ ### 2. Authorize your channel
24
+
25
+ ```bash
26
+ uvx yt-studio-mcp auth --client-secret /path/to/client_secret.json
27
+ ```
28
+
29
+ A browser opens — sign in and **pick the channel** (Brand Accounts appear as
30
+ separate choices). The refresh token is stored locally (see Secret storage).
31
+
32
+ ### 3. Connect your MCP client
33
+
34
+ ```bash
35
+ # Claude Code
36
+ claude mcp add yt-studio -s user -- uvx yt-studio-mcp
37
+ ```
38
+
39
+ ## Tools
40
+
41
+ | Area | Tools |
42
+ |---|---|
43
+ | Channel/videos | `channel_info`, `list_videos`, `get_video`, `update_video`, `set_thumbnail`, `upload_video`, `delete_video` |
44
+ | Playlists | `list_playlists`, `create_playlist`, `delete_playlist`, `add_to_playlist`, `remove_from_playlist`, `list_playlist_items` |
45
+ | Comments | `list_comments`, `post_video_question`, `reply_to_comment`, `moderate_comment`, `mark_spam`, `delete_comment` |
46
+ | Live | `list_broadcasts`, `create_broadcast`, `list_streams`, `bind_broadcast`, `live_chat_messages`, `post_chat_message`, `delete_chat_message`, `ban_chat_user` |
47
+ | Captions | `list_captions`, `upload_caption`, `download_caption` |
48
+ | Analytics | `channel_report`, `video_report`, `top_videos` |
49
+ | Giveaways | `collect_entries`, `draw_winners`, `make_verification_code`, `check_verification_reply`, `post_winner_reply` |
50
+ | Meta | `quota_status`, `health_check` |
51
+
52
+ Known API limitations (documented, not bugs): comments cannot be pinned via
53
+ the API (`post_video_question` reminds you to pin manually in Studio), and
54
+ Community-tab posts have no public API.
55
+
56
+ ## Running a giveaway
57
+
58
+ The giveaway suite implements a common official-rules pattern: *comment on
59
+ any video during the entry window; each distinct video counts as one entry,
60
+ capped per person; winners drawn at random.*
61
+
62
+ ```text
63
+ 1. collect_entries(start="2026-01-01T00:00:00Z", end="2026-01-31T23:59:59Z",
64
+ max_entries_per_user=5, exclude_channel_ids=[...])
65
+ → walks every upload's comments, derives entries, writes a snapshot
66
+ file and returns its SHA-256 entry hash. Announce the hash if you
67
+ want third-party verifiability.
68
+
69
+ 2. draw_winners(snapshot_path, n=5, seed="any-public-string")
70
+ → deterministic: a seeded shuffle over the canonically sorted entries,
71
+ first n distinct channels win. Anyone with the snapshot + seed can
72
+ re-run the draw and get identical winners. Records an audit file.
73
+ Screen-record this step for extra transparency.
74
+
75
+ 3. make_verification_code(audit_path, winner_channel_id)
76
+ → short code you send to the claimed winner over your announced contact
77
+ channel.
78
+
79
+ 4. check_verification_reply(audit_path, comment_id, winner_channel_id)
80
+ → confirms the reply was authored by the winning channel and contains
81
+ the code — proves account ownership before shipping a prize.
82
+
83
+ 5. post_winner_reply(comment_id, "Congratulations! ...")
84
+ ```
85
+
86
+ Entries and audit records live in `~/.config/yt-studio-mcp/giveaways/`.
87
+
88
+ *This tool automates entry collection and drawing; giveaway laws vary by
89
+ jurisdiction and platform policies apply (e.g. YouTube's contest policies).
90
+ You are responsible for your own official rules and compliance.*
91
+
92
+ ## Secret storage
93
+
94
+ | Backend | Select with | Stores |
95
+ |---|---|---|
96
+ | `file` (default) | — | `~/.config/yt-studio-mcp/credentials.json`, chmod 0600 |
97
+ | `env` | `YT_MCP_SECRETS=env` | reads `YT_MCP_REFRESH_TOKEN`, `YT_MCP_CLIENT_ID`, `YT_MCP_CLIENT_SECRET` |
98
+ | `vaultproxy` | `YT_MCP_SECRETS=vaultproxy` (+ `VAULTPROXY_URL`) | items in a Vaultwarden vault via a local vaultproxy HTTP API |
99
+
100
+ ## Quota
101
+
102
+ The Data API's default allocation is 10,000 units/day. Most reads cost 1
103
+ unit; writes cost ~50; a video upload costs 1,600. `quota_status()` shows a
104
+ session estimate and warns at 80%. `collect_entries` reports what it spent.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ pip install -e '.[dev]'
110
+ ruff check src tests && pytest
111
+ ```
112
+
113
+ Tests are fully offline (mocked Google API). PRs welcome.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "yt-studio-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for managing a YouTube channel: videos, comments, playlists, live streams, analytics, and auditable comment-entry giveaways."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "aaronckj" }]
13
+ keywords = ["mcp", "youtube", "youtube-api", "giveaway", "model-context-protocol"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ ]
20
+ dependencies = [
21
+ "mcp>=1.2",
22
+ "google-api-python-client>=2.100",
23
+ "google-auth>=2.30",
24
+ "google-auth-oauthlib>=1.2",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8", "ruff>=0.6"]
29
+
30
+ [project.scripts]
31
+ yt-studio-mcp = "yt_studio_mcp.__main__:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/aaronckj/yt-studio-mcp"
35
+ Issues = "https://github.com/aaronckj/yt-studio-mcp/issues"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/yt_studio_mcp"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ target-version = "py311"
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "I", "UP", "B"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ addopts = "-q"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,39 @@
1
+ """CLI entry: `yt-studio-mcp` serves; `yt-studio-mcp auth` authorizes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def main() -> None:
10
+ if len(sys.argv) > 1 and sys.argv[1] == "scan":
11
+ from .scan_cli import main as scan_main
12
+
13
+ raise SystemExit(scan_main(sys.argv[2:]))
14
+
15
+ parser = argparse.ArgumentParser(prog="yt-studio-mcp")
16
+ sub = parser.add_subparsers(dest="command")
17
+ auth_p = sub.add_parser("auth", help="one-time interactive OAuth consent")
18
+ auth_p.add_argument(
19
+ "--client-secret",
20
+ required=True,
21
+ help="path to the OAuth client_secret.json downloaded from Google Cloud",
22
+ )
23
+ sub.add_parser("serve", help="run the MCP server (default)")
24
+ sub.add_parser("scan", help="headless spam scan for cron (see scan --help)")
25
+ args = parser.parse_args()
26
+
27
+ if args.command == "auth":
28
+ from .auth import run_auth_flow
29
+
30
+ print(run_auth_flow(args.client_secret), file=sys.stderr)
31
+ return
32
+
33
+ from .server import build_app
34
+
35
+ build_app().run()
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,73 @@
1
+ """OAuth: one-time interactive flow plus credential rebuild/refresh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from google.auth.transport.requests import Request as GoogleRequest
9
+ from google.oauth2.credentials import Credentials
10
+
11
+ from .secrets import SecretStore, get_store
12
+
13
+ SCOPES = [
14
+ "https://www.googleapis.com/auth/youtube.force-ssl",
15
+ "https://www.googleapis.com/auth/youtube.upload",
16
+ "https://www.googleapis.com/auth/yt-analytics.readonly",
17
+ ]
18
+ TOKEN_URI = "https://oauth2.googleapis.com/token"
19
+
20
+
21
+ class AuthError(RuntimeError):
22
+ pass
23
+
24
+
25
+ def run_auth_flow(client_secret_path: str, store: SecretStore | None = None) -> str:
26
+ """Interactive browser consent. Stores refresh token + client info. Returns channel hint."""
27
+ from google_auth_oauthlib.flow import InstalledAppFlow
28
+
29
+ store = store or get_store()
30
+ path = Path(client_secret_path)
31
+ if not path.exists():
32
+ raise AuthError(f"client secret file not found: {path}")
33
+ flow = InstalledAppFlow.from_client_secrets_file(str(path), scopes=SCOPES)
34
+ creds = flow.run_local_server(port=0, prompt="consent")
35
+ if not creds.refresh_token:
36
+ raise AuthError("no refresh token returned; remove prior grants and retry")
37
+ conf = json.loads(path.read_text())
38
+ client = conf.get("installed") or conf.get("web") or {}
39
+ store.set("refresh_token", creds.refresh_token)
40
+ store.set("client_id", client.get("client_id", creds.client_id or ""))
41
+ store.set("client_secret", client.get("client_secret", creds.client_secret or ""))
42
+ return "authorized; credentials stored"
43
+
44
+
45
+ def _refresh(creds: Credentials) -> None:
46
+ creds.refresh(GoogleRequest())
47
+
48
+
49
+ def get_credentials(store: SecretStore | None = None) -> Credentials:
50
+ """Rebuild Credentials from the secret store and refresh the access token."""
51
+ store = store or get_store()
52
+ refresh_token = store.get("refresh_token")
53
+ client_id = store.get("client_id")
54
+ client_secret = store.get("client_secret")
55
+ if not (refresh_token and client_id and client_secret):
56
+ raise AuthError(
57
+ "no stored credentials; run `yt-studio-mcp auth --client-secret <path>` first"
58
+ )
59
+ creds = Credentials(
60
+ token=None,
61
+ refresh_token=refresh_token,
62
+ client_id=client_id,
63
+ client_secret=client_secret,
64
+ token_uri=TOKEN_URI,
65
+ scopes=SCOPES,
66
+ )
67
+ try:
68
+ _refresh(creds)
69
+ except Exception as exc:
70
+ raise AuthError(
71
+ f"token refresh failed ({exc}); re-run `yt-studio-mcp auth`"
72
+ ) from exc
73
+ return creds
@@ -0,0 +1,145 @@
1
+ """Google API client wrapper: quota accounting, error mapping, logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from typing import Any
9
+
10
+ from googleapiclient.errors import HttpError
11
+
12
+ logger = logging.getLogger("yt_studio_mcp")
13
+ if not logger.handlers:
14
+ handler = logging.StreamHandler()
15
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
16
+ logger.addHandler(handler)
17
+ logger.setLevel(logging.INFO)
18
+
19
+ # Data API v3 quota costs (units) by operation family.
20
+ COSTS = {
21
+ "list": 1,
22
+ "insert": 50,
23
+ "update": 50,
24
+ "delete": 50,
25
+ "setModerationStatus": 50,
26
+ "markAsSpam": 50,
27
+ "thumbnails.set": 50,
28
+ "videos.insert": 1600,
29
+ "captions.insert": 400,
30
+ "captions.update": 450,
31
+ "captions.download": 200,
32
+ "analytics.query": 0,
33
+ }
34
+
35
+ HINTS = {
36
+ "quotaExceeded": "Daily quota exhausted; resets midnight Pacific. Check quota_status().",
37
+ "forbidden": "Check the authorized channel and OAuth scopes; re-run `yt-studio-mcp auth`.",
38
+ "notFound": "The referenced resource id does not exist or is not visible to this channel.",
39
+ "authError": "Re-run `yt-studio-mcp auth`.",
40
+ "commentsDisabled": "Comments are disabled on that video.",
41
+ }
42
+
43
+
44
+ class ApiError(RuntimeError):
45
+ def __init__(self, reason: str, status: int, detail: str):
46
+ self.reason = reason
47
+ self.status = status
48
+ self.hint = HINTS.get(reason, "")
49
+ super().__init__(f"{reason} (HTTP {status}): {detail} {self.hint}".strip())
50
+
51
+
52
+ class QuotaTracker:
53
+ def __init__(self) -> None:
54
+ self.spent = 0
55
+ self.calls = 0
56
+
57
+ def add(self, cost: int) -> None:
58
+ self.spent += cost
59
+ self.calls += 1
60
+
61
+
62
+ class YT:
63
+ """Lazy holder for the Data v3 and Analytics v2 services."""
64
+
65
+ def __init__(self, data_service: Any = None, analytics_service: Any = None):
66
+ self._data = data_service
67
+ self._analytics = analytics_service
68
+ self.quota = QuotaTracker()
69
+
70
+ @property
71
+ def data(self) -> Any:
72
+ if self._data is None:
73
+ from googleapiclient.discovery import build
74
+
75
+ from .auth import get_credentials
76
+
77
+ self._data = build(
78
+ "youtube", "v3", credentials=get_credentials(), cache_discovery=False
79
+ )
80
+ return self._data
81
+
82
+ @property
83
+ def analytics(self) -> Any:
84
+ if self._analytics is None:
85
+ from googleapiclient.discovery import build
86
+
87
+ from .auth import get_credentials
88
+
89
+ self._analytics = build(
90
+ "youtubeAnalytics", "v2", credentials=get_credentials(), cache_discovery=False
91
+ )
92
+ return self._analytics
93
+
94
+ def call(self, request: Any, op: str = "list") -> dict:
95
+ """Execute a prepared API request with quota accounting and error mapping."""
96
+ cost = COSTS.get(op, COSTS.get(op.split(".")[-1], 1))
97
+ started = time.monotonic()
98
+ try:
99
+ result = request.execute()
100
+ self.quota.add(cost)
101
+ logger.info("api op=%s cost=%s ms=%d", op, cost, (time.monotonic() - started) * 1000)
102
+ return result if result is not None else {}
103
+ except HttpError as exc:
104
+ self.quota.add(cost)
105
+ reason = "unknown"
106
+ detail = str(exc)
107
+ try:
108
+ body = json.loads(exc.content.decode())
109
+ err = body.get("error", {})
110
+ detail = err.get("message", detail)
111
+ errors = err.get("errors") or [{}]
112
+ reason = errors[0].get("reason", err.get("status", "unknown"))
113
+ except Exception: # noqa: BLE001 - best-effort body parse
114
+ pass
115
+ logger.error("api op=%s failed reason=%s detail=%s", op, reason, detail)
116
+ raise ApiError(reason, exc.resp.status if exc.resp else 0, detail) from exc
117
+
118
+ def paginate(self, resource: Any, op: str, limit: int, **params) -> list[dict]:
119
+ """Collect up to `limit` items across pages of a .list endpoint."""
120
+ items: list[dict] = []
121
+ token = None
122
+ while len(items) < limit:
123
+ page = self.call(
124
+ resource.list(**params, maxResults=min(50, limit - len(items)), pageToken=token),
125
+ op=op,
126
+ )
127
+ items.extend(page.get("items", []))
128
+ token = page.get("nextPageToken")
129
+ if not token:
130
+ break
131
+ return items[:limit]
132
+
133
+
134
+ def preview(action: str, would: dict) -> dict:
135
+ return {"preview": True, "action": action, "would": would}
136
+
137
+
138
+ _yt: YT | None = None
139
+
140
+
141
+ def get_yt() -> YT:
142
+ global _yt
143
+ if _yt is None:
144
+ _yt = YT()
145
+ return _yt