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.
- yt_studio_mcp-0.1.0/.github/workflows/ci.yml +33 -0
- yt_studio_mcp-0.1.0/.github/workflows/publish.yml +18 -0
- yt_studio_mcp-0.1.0/.gitignore +9 -0
- yt_studio_mcp-0.1.0/LICENSE +21 -0
- yt_studio_mcp-0.1.0/PKG-INFO +141 -0
- yt_studio_mcp-0.1.0/README.md +117 -0
- yt_studio_mcp-0.1.0/pyproject.toml +49 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/__init__.py +1 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/__main__.py +39 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/auth.py +73 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/client.py +145 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/llm.py +71 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/scan_cli.py +135 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/secrets.py +100 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/server.py +37 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/__init__.py +0 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/analytics.py +75 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/banned_words.py +179 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/bulk.py +167 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/captions.py +58 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/comments.py +118 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/giveaway.py +328 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/live.py +170 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/meta.py +45 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/playlists.py +101 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/questions.py +126 -0
- yt_studio_mcp-0.1.0/src/yt_studio_mcp/tools/videos.py +188 -0
- yt_studio_mcp-0.1.0/tests/test_auth.py +31 -0
- yt_studio_mcp-0.1.0/tests/test_banned_words.py +23 -0
- yt_studio_mcp-0.1.0/tests/test_bulk.py +27 -0
- yt_studio_mcp-0.1.0/tests/test_client.py +78 -0
- yt_studio_mcp-0.1.0/tests/test_giveaway.py +95 -0
- yt_studio_mcp-0.1.0/tests/test_llm.py +72 -0
- yt_studio_mcp-0.1.0/tests/test_questions.py +12 -0
- yt_studio_mcp-0.1.0/tests/test_secrets.py +80 -0
- 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,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
|