tubekit-mcp
Compliance-aware Model Context Protocol server for YouTube publishing, analytics, and creator insights — every action gated, metered, and audited before it touches a channel.
tubekit-mcp is an MCP server: you connect it to an MCP client (Claude Desktop,
Claude Code, …) and the assistant can then publish and manage videos on a
YouTube channel on your behalf, with compliance checks, quota accounting,
idempotent uploads and a tamper-evident audit log on every state-changing call.
Licensed under FSL-1.1-MIT — run and self-host it freely,
commercially or not; each release becomes plain MIT two years after publication.
Architecture
A single tool call (e.g. upload_video) flows through a compliance gate, a
quota / idempotency ledger, an OAuth refresh, the YouTube API, and a
tamper-evident audit row — with the pure core kept free of protocol concerns,
a boundary enforced in CI by an AST fitness test
(tests/unit/services/test_no_fastmcp_imports.py).
flowchart LR
client["MCP client<br/>(Claude Desktop / Code)"]
subgraph edge["Protocol edge"]
transport["Transport<br/>stdio · HTTP + Bearer"]
tool["Tool wrapper"]
end
subgraph core["Pure core — no protocol imports (CI-enforced)"]
compliance{"Compliance<br/>gate"}
ledger["Quota +<br/>idempotency<br/>ledger"]
oauth["OAuth refresh<br/>(Fernet at rest)"]
end
upstream["YouTube<br/>Data / Analytics API"]
audit[("Tamper-evident<br/>hash-chained<br/>audit log")]
client --> transport --> tool --> compliance
compliance -->|pass| ledger --> oauth --> upstream
compliance -->|fail| suggestion["Suggestion →<br/>agent retries"]
tool -. audited .-> audit
ledger -. audited .-> audit
oauth -. audited .-> audit
Why this exists. Agents are starting to operate real YouTube channels — and a channel is an asset you can lose. One bad call can publish a video that violates policy, burn the day's API quota (an upload costs 1600 of the default 10,000 daily units), or double-publish on a retry. Generic YouTube wrappers hand the agent the raw API;
tubekitwraps every call in the guardrails an unattended operator needs: a compliance gate before anything goes live, quota accounted before it is spent, idempotent retries, OAuth tokens encrypted at rest (fingerprint-only logging), and a tamper-evident audit chain of everything the agent did. Full design rationale in ADR 0001.
Usage
Prerequisites
Before an assistant can act on a channel you need, once per channel:
- A Google Cloud project with the YouTube Data API v3 (and YouTube
Analytics API if you use
get_analytics) enabled. - An OAuth client of type Desktop app, downloaded as
client_secret.json. - The channel whose Google account will grant the consent.
The OAuth grant is interactive and only happens here — tubekit stores the
resulting refresh token Fernet-encrypted and never prints it. Never used
Google Cloud Console? docs/runbooks/gcp-setup.md
walks the whole thing from zero (project, APIs, consent screen, client type,
and the two Google policies — token expiry in testing, private-locked uploads
for unaudited projects — that surprise people later). The consent flows
themselves are in
docs/runbooks/oauth-bootstrap.md.
Install
The package is not yet on PyPI; install from source:
git clone https://github.com/santiriera626/tubekit-mcp.git tubekit-mcp && cd tubekit-mcp
uv sync --all-extras # installs the tubekit / tubekit-mcp-* entry points
make db-upgrade # builds the state schema (wraps `tubekit db upgrade`;
# `tubekit db current` prints the applied revision)
(uv itself installs with curl -LsSf https://astral.sh/uv/install.sh | sh.)
Try it in 60 seconds — no Google account needed
The compliance engine is pure-local, so you can watch the gate and its
self-correction Suggestions work before touching Google Cloud:
cat > video.json <<'EOF'
{
"title": "Test Upload — Compliance Gate Demo",
"description": "Demo description for the compliance gate.",
"tags": ["demo", "test"],
"ai_disclosure": false,
"made_for_kids": null,
"category_id": "10"
}
EOF
uv run tubekit compliance test video.json
Compliance: PASSED (8 rules evaluated)
WARNING AI_DISCLOSURE_MISSING: AI disclosure flag is false; confirm this is the
channel owner's intended policy for AI-generated content.
suggestion: action=set field=metadata.ai_disclosure value=True
WARNING KIDS_FLAG_NOT_SET: made_for_kids is unset; set it explicitly to true or
false (this rule does not classify by content).
suggestion: action=set field=metadata.made_for_kids value=False
An error-severity violation — say a 126-character title — fails the gate with
exit 1 and a machine-applicable fix (suggestion: action=truncate field=metadata.title value=100). That is the same Suggestion an agent
receives from a rejected upload_video and applies before retrying (ADR D8).
tubekit compliance list prints the rule catalog; tubekit compliance manifest exports it for external auditors.
Install with an AI agent
Rather not type the commands above yourself? Paste the block below into Claude
Code (or any coding agent with shell access) and it will clone, install and
register the server for you. It stops at the boundary above: no Google
account, client_secret.json or OAuth consent is involved.
Install tubekit-mcp and register it as an MCP server. Stop and report if any step fails.
1. Clone the repo and enter it (install uv first if missing:
curl -LsSf https://astral.sh/uv/install.sh | sh):
git clone https://github.com/santiriera626/tubekit-mcp.git tubekit-mcp && cd tubekit-mcp
2. Install dependencies, apply the database migrations and create the Fernet
master key (the server refuses to start without one):
uv sync --all-extras
make db-upgrade
uv run tubekit auth init-master-key
(Idempotent: an existing key is reported and left untouched.)
3. Run the zero-credential compliance smoke check to confirm the install works:
cat > video.json <<'EOF'
{
"title": "Test Upload — Compliance Gate Demo",
"description": "Demo description for the compliance gate.",
"tags": ["demo", "test"],
"ai_disclosure": false,
"made_for_kids": null,
"category_id": "10"
}
EOF
uv run tubekit compliance test video.json
Expect "Compliance: PASSED (8 rules evaluated)" and exit code 0.
4. Register the server with Claude Code over stdio, using absolute paths:
claude mcp add tubekit \
-e TUBEKIT_STATE_DB_PATH="$(pwd)/state.db" \
-e TUBEKIT_MASTER_KEY_PATH="$HOME/.config/tubekit/master.key" \
-- "$(pwd)/.venv/bin/tubekit-mcp-stdio"
Do not run `tubekit auth setup-channel` or open any Google consent screen —
OAuth channel bootstrap is a separate, human-in-the-loop step documented in
docs/runbooks/oauth-bootstrap.md. Stop after step 4 and tell me to run that
myself.
Restart your MCP client after registration; it can now call tubekit.health()
and list the full tool catalog. Every tool that takes a channel argument —
validate_compliance included — needs a configured channel, which means
completing One-time setup — including the OAuth consent in
docs/runbooks/oauth-bootstrap.md —
yourself. The compliance gate itself is testable without any channel via
step 3's tubekit compliance test.
One-time setup
# 1. Create the Fernet master key used to encrypt stored tokens (mode 0600).
# Skip if the agent install above already created it.
tubekit auth init-master-key
# 2. Bootstrap a channel — opens a browser for the OAuth consent.
# The alias is positional; --channel-id, --gcp-project, --client-secrets
# and --scopes are all required (see docs/runbooks/oauth-bootstrap.md).
tubekit auth setup-channel mychannel \
--channel-id UCxxxxxxxxxxxxxxxxxxxxxx \
--gcp-project my-gcp-project \
--client-secrets ./client_secret.json \
--scopes youtube.upload,youtube.readonly
# …or, on a headless box (no browser), append --device-code:
tubekit auth setup-channel mychannel \
--channel-id UCxxxxxxxxxxxxxxxxxxxxxx \
--gcp-project my-gcp-project \
--client-secrets ./client_secret.json \
--scopes youtube.upload,youtube.readonly \
--device-code
# 3. Define the channel registry — setup-channel persists only the encrypted
# token; the registry is a TOML you write yourself. The table name is the
# alias. Full field reference: docs/runbooks/vps-deploy.md §5.
mkdir -p ~/.config/tubekit/channels
cat > ~/.config/tubekit/channels/mychannel.toml <<'EOF'
[mychannel]
channel_id = "UCxxxxxxxxxxxxxxxxxxxxxx"
gcp_project_id = "my-gcp-project"
oauth_scopes = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.readonly",
]
EOF
# 4. Verify — `show` prints one channel's status (token fingerprint only),
# `list` prints every configured alias
tubekit channels show mychannel --channels-dir ~/.config/tubekit/channels
tubekit channels list --channels-dir ~/.config/tubekit/channels
Connect to an MCP client (stdio)
Most clients launch the server over stdio. Add this to the client's MCP
config (for Claude Desktop: claude_desktop_config.json):
{
"mcpServers": {
"tubekit": {
"command": "tubekit-mcp-stdio",
"env": {
"TUBEKIT_STATE_DB_PATH": "/absolute/path/to/state.db",
"TUBEKIT_MASTER_KEY_PATH": "/absolute/path/to/master.key"
}
}
}
}
Restart the client. The assistant now sees the tools below plus a
tubekit.health() probe. Ask it, e.g., "upload ./intro.mp4 to mychannel" and
it will call upload_video, which gates on compliance, charges quota, refreshes
OAuth and writes an audit row — all transparently.
Available tools
| Tool | What it does | OAuth scope | Quota |
|---|---|---|---|
validate_compliance(channel, metadata, thumbnail_present=False) |
Run the rule registry against metadata; returns a ComplianceReport. Pure-local, no YouTube call. |
— | 0 |
upload_video(channel, video_path, metadata, idempotency_key, privacy_status="private", notify_subscribers=False) |
Upload a video after an internal compliance gate. Idempotent via idempotency_key. |
youtube.upload |
1600 |
set_thumbnail(channel, video_id, thumbnail_path) |
Set a thumbnail on an existing video. | youtube |
50 |
update_metadata(channel, video_id, patch) |
Partially update title/description/tags/privacy. Compliance-gated on the post-patch metadata. | youtube |
50 |
get_video_status(channel, video_id) |
Read processing/privacy status of a video. | youtube.readonly |
1 |
get_analytics(channel, metrics, start_date, end_date, dimensions=None) |
Channel-level metrics over a window. Uses the separate Analytics quota bucket. | yt-analytics.readonly |
1 (Analytics) |
get_portfolio_report(channels=None, period="28d", start_date=None, end_date=None) |
One row per channel: period totals + vs-prior deltas (views, revenue), YPP progress for non-monetized channels. Per-channel failures are isolated. | yt-analytics.readonly + youtube.readonly (+yt-analytics-monetary.readonly for revenue) |
3/channel (Analytics) |
get_channel_report(channel, period="28d", start_date=None, end_date=None, granularity="auto") |
Single-channel health report: totals, trend series, top videos, traffic sources, Shorts/long-form split, revenue or YPP progress, lifetime snapshot. | yt-analytics.readonly + youtube.readonly (+monetary for revenue) |
7 (Analytics) |
get_video_report(channel, video_id, period="lifetime", start_date=None, end_date=None, granularity="auto") |
Per-video deep dive: retention proxy, traffic sources, revenue when monetized; window starts at publication by default. | yt-analytics.readonly + youtube.readonly (+monetary for revenue) |
5 (Analytics) |
list_my_videos(channel, page_size=50, page_token=None) |
Paginated list of channel uploads. | youtube.readonly |
1 |
get_comments_digest(channels=None, force_sync=False) |
Portfolio-wide comment counts (unread/unanswered/held/spam, per video). | youtube.force-ssl |
4/channel |
list_comments(channel, video_id=None, only="unread", search=None, order="time", page_size=20, page=1) |
Compact comment listing from the local mirror. | youtube.force-ssl |
1 |
get_comment_thread(channel, comment_id) |
One thread in full: untruncated text + all replies. | youtube.force-ssl |
2 |
mark_comments_reviewed(channel, ids=None, video_id=None, all_unread=False, state="read") |
Local triage state; never mutates YouTube. | — | 0 |
check_oauth(channel) |
Zero-quota OAuth liveness pre-flight: refresh the stored grant against Google's token endpoint and report oauth_ok + expiry. Run before a quota-spending tool. |
— | 0 |
request_ingest_upload(channel, filename, size_bytes, sha256) |
Issue a one-time brokered upload URL so a remote client can PUT a multi-GB video the server then ingests — no ssh. See media ingest. |
— | 0 |
list_playlists(channel, page_size=50, page_token=None) |
The channel's own playlists: id, title, privacy, item count. | youtube.readonly |
1 |
list_playlist_items(channel, playlist_id, page_size=50, page_token=None) |
One playlist's videos in order. Returns each playlist_item_id — the handle remove/reorder need. |
youtube.readonly |
1 |
create_playlist(channel, title, description="", privacy_status="private") |
Create a playlist. Defaults to private; going public is an explicit act. Not idempotent. | youtube |
50 |
update_playlist(channel, playlist_id, title=None, description=None, privacy_status=None) |
Patch title/description/privacy. Omitted fields are read and merged, never blanked. | youtube |
50 |
add_to_playlist(channel, playlist_id, video_id, position=None) |
Add a video (append, or insert at position). Not idempotent — YouTube allows duplicates. |
youtube |
50 |
reorder_playlist_item(channel, playlist_id, playlist_item_id, video_id, position) |
Move an entry to position (0 = first). |
youtube |
50 |
remove_from_playlist(channel, playlist_item_id) |
Remove one entry. The video itself is untouched. | youtube |
50 |
Playlist writes take a
playlist_item_id, never a bare video id: the same video may appear in a list more than once, so YouTube cannot disambiguate from the video alone. Get the ids from onelist_playlist_itemscall (1 unit) and reuse them — the services deliberately do not resolve them for you, because that would be a quota charge the caller never asked for.Playlists are not run through the compliance registry: it scores video metadata (thumbnail, category, tags, synthetic-media declaration), none of which a playlist has, and its
TITLE_TOO_LONGwould apply the video ceiling (100) to a resource whose real ceiling is 150. What is enforced instead is what Google documents for the playlist resource: a non-empty title, a validprivacyStatus, ≤150 characters of title, ≤5000 of description, and no<,>or U+2028 in either. Those limits live in the YouTube Help page, not in the Data API reference — which documents none, and is why they were missed at first.
Comments tools need the channel grant to carry
youtube.force-ssl(moderation-status filtering is owner-only). Verify withcheck_oauth; if missing, re-consent once:tubekit auth setup-channel --scopes …,https://www.googleapis.com/auth/youtube.force-ssl.
Revenue figures across the report tools are estimated ad + YouTube Premium
revenue only (memberships, Super Chat and Shopping are not exposed by the
channel-level APIs), lag ~2 days (period.revenue_complete_until), and are
finalized around the 10th of the following month. Thumbnail impressions/CTR are
not available in the targeted Analytics API. get_analytics remains the raw
escape hatch for any ad-hoc query.
Every tool returns a ToolResult envelope (ok + data, or a structured
ToolError whose code is one of the documented error codes). On a compliance
failure the error carries a Suggestion the assistant can apply and retry — the
self-correction loop described in ADR D8.
HTTP transport (multi-user)
For remote / multi-tenant use, run the HTTP transport instead. It requires a per-client Bearer API token scoped to specific channels and tools, with an optional per-token rate limit:
# Issue a token (the plain value is printed exactly once — store it now)
tubekit auth issue --name claude-prod --channels mychannel --tools "*" \
--rate-limit-per-min 30
# Serve over HTTP
tubekit-mcp-http --host 0.0.0.0 --port 8080
Clients send Authorization: Bearer <token>. Requests that are
unauthenticated, out-of-scope, or rate-limited get JSON-RPC errors
(-32001..-32006). GET /healthz returns the liveness payload; the
per-channel detail (aliases, OAuth expiry) is added only for a valid Bearer and
narrowed to that token's channels. Manage tokens with
tubekit auth list | show | revoke | rotate.
To run this centrally on a VPS (one server, many projects connecting over
HTTPS), follow docs/runbooks/vps-deploy.md —
a self-contained, step-by-step deployment guide (systemd + Traefik + per-project
tokens) written for an infra team.
Media ingest (remote upload_video)
upload_video(video_path) reads the file from the server's filesystem — a
client-local path can never resolve on a remote deployment. There are two ways
to get the media onto the server.
Brokered upload (recommended, no ssh). The agent orchestrates the whole
transfer over HTTP via the MCP. Call request_ingest_upload, then PUT the
bytes to the one-time URL it returns (same Bearer token), then upload_video
with the returned server-side path:
# 1. request_ingest_upload(channel, filename="video.mp4", size_bytes, sha256)
# → { upload_url, curl, video_path: "/ingest/video.mp4" }
# 2. transfer the bytes (the returned curl recipe):
curl -fSs -T video.mp4 -H "Authorization: Bearer $TUBEKIT_TOKEN" "<upload_url>"
# 3. upload_video(channel="...", video_path="/ingest/video.mp4", ...)
The server streams the body to a staging .part, verifies size_bytes + sha256,
and atomically publishes before upload_video reads it. The URL is a single-use,
channel-bound capability with a TTL; an interrupted transfer resumes via HEAD +
Content-Range. Design rationale: ADR 0002.
Requires TUBEKIT_INGEST_UPLOAD_URL_BASE to be set (otherwise the tool returns
precondition_failed).
rsync convention (fallback, requires ssh). Transfer out-of-band, then call the tool with the server-side path:
rsync -av --chmod=F644 video.mp4 <server>:/opt/tubekit-ingest/
# upload_video(channel="...", video_path="/ingest/video.mp4", ...)
Set TUBEKIT_INGEST_DIR to the in-server ingest path (e.g. /ingest): when a
video_path is unreadable the tool returns validation_error with a
suggestion pointing the calling agent at this convention. See the
media-ingest section of the VPS runbook
for the volume mount and retention policy.
Environment variables
All settings use the TUBEKIT_ prefix (see .env.example):
| Variable | Default | Purpose |
|---|---|---|
TUBEKIT_STATE_DB_PATH |
state.db |
SQLite file holding audit + state tables |
TUBEKIT_MASTER_KEY_PATH |
~/.config/tubekit/master.key |
Fernet key that encrypts stored OAuth tokens |
TUBEKIT_OAUTH_CLIENT_SECRETS_PATH |
unset | Path to client_secret.json (Google Cloud confidential client). Required at runtime — google-auth cannot refresh access tokens without it, so every channel tool fails if unset |
TUBEKIT_TOKEN_BACKEND |
sqlite_fernet |
Token store backend (sqlite_fernet | in_memory) |
TUBEKIT_AUDIT_RETENTION_DAYS |
365 |
Audit retention window |
TUBEKIT_OTLP_ENDPOINT |
unset | OTLP collector endpoint; unset → no-op telemetry |
TUBEKIT_INGEST_DIR |
unset | Server-side media ingest dir. Advertised in upload_video path errors, and — for HTTP callers only — the boundary their video_path/thumbnail_path must resolve inside. Unset means no such boundary: an HTTP token is then equivalent to read access to the server's filesystem |
TUBEKIT_INGEST_UPLOAD_URL_BASE |
unset | Public origin for brokered uploads; unset disables request_ingest_upload |
TUBEKIT_INGEST_UPLOAD_TTL_SECONDS |
3600 |
One-time brokered-upload token lifetime |
TUBEKIT_INGEST_MAX_BYTES |
21474836480 |
Max size of a single brokered upload (20 GiB) |
TUBEKIT_INGEST_MAX_PENDING |
32 |
Cap on concurrent pending upload tokens (disk-exhaustion guard) |
TUBEKIT_INGEST_PURGE_AFTER_UPLOAD |
true |
Delete the source file from the ingest dir once the upload/thumbnail is confirmed |
TUBEKIT_HTTP_KEEPALIVE_TIMEOUT |
5 |
uvicorn keep-alive (s); raise for long SSE sessions, align with the proxy idle timeout |
TUBEKIT_HTTP_LIMIT_CONCURRENCY |
unset | uvicorn max concurrent connections |
TUBEKIT_HTTP_GRACEFUL_SHUTDOWN_TIMEOUT |
unset | uvicorn graceful-shutdown timeout (s) |
TUBEKIT_HTTP_MAX_JSONRPC_BODY |
4194304 |
Max bytes the auth gate buffers for one JSON-RPC POST before authenticating (pre-auth DoS guard); oversized → 413 |
TUBEKIT_HTTP_FORWARDED_ALLOW_IPS |
unset | Reverse-proxy IPs/CIDRs whose X-Forwarded-* uvicorn may trust. Unset → uvicorn trusts only 127.0.0.1, so behind a proxy every audit row is stamped with the proxy's IP and the request scheme stays http. Use the proxy's own pinned address, not the bridge subnet — every container on a shared network resolves inside that CIDR and could forge the header. Never * (rejected at load): it would let any caller choose the audited IP |
TUBEKIT_HTTP_SSE_RESUMABLE |
false |
Enable MCP streamable-HTTP resumability (in-memory event store) so a dropped SSE session resumes via Last-Event-ID |
TUBEKIT_HTTP_SSE_RETRY_INTERVAL_MS |
2000 |
SSE reconnect hint (ms) advertised to clients; only used when resumable |
TUBEKIT_SQLITE_BUSY_TIMEOUT_MS |
5000 |
SQLite writer lock wait before SQLITE_BUSY |
TUBEKIT_LOG_LEVEL / TUBEKIT_LOG_FORMAT |
INFO / json |
Logging |
A lost
state.db/master.keypair means re-bootstrapping every channel. Back them up together — seedocs/runbooks/state-db-backup.md.
Hosted version
Gatecast is the hosted tier of tubekit — same engine, run and kept alive for you. It is pre-launch; the waiting list is the whole thing so far. Self-hosting with this repo is and stays fully supported.
Development
uv sync --all-extras
make lint # ruff
make type # mypy --strict
make test # pytest unit suite
make cov # coverage report
make demo-stack-up brings up the Jaeger/Prometheus/Grafana observability stack
with five provisioned dashboards (docker-compose.demo.yml, docs/grafana/).
Repository layout
docs/adr/ Architecture Decision Records (source of truth for design)
docs/grafana/ Provisioned Grafana dashboards + demo observability config
docs/runbooks/ Operational procedures (master key rotation, audit verify, …)
docs/ROADMAP.md Shipped, planned and under-exploration work
src/tubekit/ Library code (Alembic migrations ship in src/tubekit/migrations/)
tests/ Unit + integration suites
Contributing & security
- Contributions are welcome — see
CONTRIBUTING.mdfor the workflow and quality bar. - Report security vulnerabilities privately per
SECURITY.md. - Release history is in
CHANGELOG.md; the roadmap lives indocs/ROADMAP.md.
License
FSL-1.1-MIT © 2026 sriera
The Functional Source License in plain words: read it, run it, modify it, self-host it for your own channels — commercially or not. The one thing it forbids is offering tubekit itself to others as a competing product or service. Each release additionally becomes plain MIT two years after its publication, automatically and irrevocably.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tubekit_mcp-0.3.0.tar.gz.
File metadata
- Download URL: tubekit_mcp-0.3.0.tar.gz
- Upload date:
- Size: 558.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e2d86911a8da833824cf0caa092eef0aca201fa9400bea9b68a8df1a3268da00
|
|
| MD5 |
a13fbfd47d5eb212cb483bdc04cd4579
|
|
| BLAKE2b-256 |
5826a9f6c58ac97c4c498d1491fee94e975ac9003e45a3298eebad726ef1dd1b
|
File details
Details for the file tubekit_mcp-0.3.0-py3-none-any.whl.
File metadata
- Download URL: tubekit_mcp-0.3.0-py3-none-any.whl
- Upload date:
- Size: 229.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e0549c420a74b4d703bbee5cbfcb752ae9738a05b6e3f1c58388e342a7d3ae40
|
|
| MD5 |
3cd9570143eaf50bf377804c7db09b7d
|
|
| BLAKE2b-256 |
590544ffb48c79d212193d1dee151714ffdd8272d69daf5650836f7be458e734
|