mirror of
https://github.com/Heretek-AI/chaturbate-mcp.git
synced 2026-10-11 20:59:55 +00:00
Initial commit: Chaturbate MCP server + companion skill
Read-only MCP server exposing Chaturbate's room-stats, affiliate-earnings, and live events APIs as six tools, built on the mcp 2.x MCPServer SDK and httpx2. Ships a companion Claude Code skill, offline pytest suite, and a live smoke test whose credentials are sourced in-memory only. Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: chaturbate
|
||||
description: Drive the chaturbate MCP server (room stats, affiliate earnings, live events) to answer questions about a Chaturbate room's performance, earnings, or recent activity. Use when the user asks about a Chaturbate account's stats, tips, viewers, followers, broadcast history, affiliate payouts, or event streams, or when chaturbate tool names (fetch_room_stats, fetch_affiliate_stats, fetch_events, config_status, get_events_schema) come up.
|
||||
---
|
||||
|
||||
# Chaturbate (chaturbate-mcp)
|
||||
|
||||
Use the `chaturbate` MCP server's tools to answer questions about the user's Chaturbate
|
||||
account. All tools are read-only.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The `chaturbate` MCP server is connected (`.mcp.json` at the repo root; approve under `/mcp` once).
|
||||
- Credentials are set in `<repo>/.env` (see `.env.example`). Run `config_status` first to confirm
|
||||
which credential sets are ready — if `stats_configured`/`events_configured` are false, tools that
|
||||
need them will error with a credential message.
|
||||
|
||||
## Tool reference
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `config_status` | Which credentials are configured (never prints values) |
|
||||
| `fetch_room_stats` | Account/room snapshot (upstream ~5 min stale) |
|
||||
| `fetch_affiliate_stats` | 9-program earnings report (raw JSON) |
|
||||
| `fetch_events` | Poll the events feed; thread `next_url` to stream |
|
||||
| `build_events_feed_url` | Construct a base feed URL (embeds the token!) |
|
||||
| `get_events_schema` | The 12 event methods + object shapes, for interpreting events |
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. Room snapshot ("what's my room status / viewer count")
|
||||
`config_status` → `fetch_room_stats`. Interpret:
|
||||
- `last_broadcast: -1` = never broadcast; otherwise an ISO datetime.
|
||||
- `time_online: -1` = offline now; otherwise minutes since going live.
|
||||
- Numbers can be up to ~5 minutes old — don't present them as live.
|
||||
- Unknown response keys are tolerated; pass them through to the user.
|
||||
|
||||
### 2. Affiliate earnings ("how much did I earn / how many signups")
|
||||
`fetch_affiliate_stats` **without** date params unless the user explicitly asks for a custom
|
||||
window. The default response covers the API's chosen range. Caveats to relay if relevant:
|
||||
- Range/breakdown params are undocumented upstream and can trigger 403 throttling when sent
|
||||
rapidly — prefer the default, and if a 403 appears, retry without extra params.
|
||||
- Earnings data is also refreshed on Chaturbate's ~5-minute cycle.
|
||||
|
||||
### 3. Live events ("what's happening in my room / new tips / follows")
|
||||
The feed is cursor-based and stateless — thread the cursor yourself across calls:
|
||||
|
||||
```
|
||||
1. fetch_events(timeout=0) # initial: recent events + next_url
|
||||
2. fetch_events(next_url="<from step 1>") # next batch, then repeat with the new next_url
|
||||
```
|
||||
|
||||
- `events: []` with a valid `next_url` = nothing new in that window — poll again.
|
||||
- Keep `timeout=0` for quick checks. A real wait is possible up to 90 s, but keep it ≤ 60 s so
|
||||
the MCP client's tool-call deadline isn't exceeded.
|
||||
- Use `get_events_schema` to interpret each event's `method`/`object`. Key ones: `tip`
|
||||
(tokens, anon), `chatMessage`, `privateMessage`, `follow`/`unfollow`, `userEnter`/`userLeave`,
|
||||
`mediaPurchase`, `fanclubJoin`, `broadcastStart`/`broadcastStop`, `roomSubjectChange`.
|
||||
- A quiet/empty feed is expected for an account that isn't currently broadcasting — not an error.
|
||||
|
||||
### 4. When tools error
|
||||
Run `config_status` first. "Missing credentials" → point the user at `.env`. An HTTP error body
|
||||
is redacted and safe to show.
|
||||
|
||||
## Secrets handling (hard rules)
|
||||
|
||||
- `fetch_events` output and `build_events_feed_url` return **URLs that embed the events token**.
|
||||
Never paste them verbatim into chat, files, or logs — redact the `/events/<username>/<token>/`
|
||||
segment when you must refer to them.
|
||||
- Never read, print, or commit anything in `review/` (a saved authtoken page with live tokens) or
|
||||
`mitm_mcp_traffic.db`, and never print `CHATURBATE_*` values.
|
||||
|
||||
## Limits / gotchas
|
||||
|
||||
- Stats refresh ~every 5 min upstream.
|
||||
- Affiliate date params are undocumented; 403 throttling risk under rapid odd-param requests.
|
||||
- Events rate limit: 2000 requests/minute; long polls hold a connection open.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copy this file to `.env` (gitignored) and fill in your values.
|
||||
# Chaturbate account used to authenticate all API calls.
|
||||
CHATURBATE_USERNAME=
|
||||
|
||||
# Token authorizing the room-stats API and the affiliate-earnings API.
|
||||
# The SAME token works for both. Created on https://chaturbate.com/statsapi/authtoken/
|
||||
# (Stats API scope). Shown as: https://chaturbate.com/statsapi/?username=<you>&token=<token>
|
||||
CHATURBATE_STATS_TOKEN=
|
||||
|
||||
# Events-scope token for the live events long-poll feed.
|
||||
# Shown on the authtoken page as: https://eventsapi.chaturbate.com/events/<you>/<token>/
|
||||
# A "private" scope exposes tips + private messages; a "public" scope omits them.
|
||||
CHATURBATE_EVENTS_TOKEN=
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Secrets / local data — NEVER commit
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
review/
|
||||
mitm_mcp_traffic.db
|
||||
|
||||
# Python / uv
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"chaturbate": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "--project", "${CLAUDE_PROJECT_DIR:-.}", "chaturbate-mcp"],
|
||||
"timeout": 120000
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -0,0 +1,35 @@
|
||||
# chaturbate-mcp
|
||||
|
||||
Read-only MCP server for Chaturbate's room-stats, affiliate-earnings, and live events APIs,
|
||||
plus a companion Claude Code skill. See `README.md` for setup and tool docs.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/chaturbate_mcp/` — Python package (mcp 2.x `MCPServer` + `httpx2`).
|
||||
- `server.py` — the 6 MCP tools (`config_status`, `fetch_room_stats`, `fetch_affiliate_stats`,
|
||||
`fetch_events`, `build_events_feed_url`, `get_events_schema`) and the `main()` entry point.
|
||||
- `config.py` — env + `.env` loading, `resolve_account()` credential resolution.
|
||||
- `api.py` — httpx2 GET helpers for the three endpoints; `ApiError` with redacted URLs.
|
||||
- `events_schema.py` — the 12 event methods and their object shapes.
|
||||
- `events_util.py` — events feed URL builder + host pinning for `next_url`.
|
||||
- `tests/` — offline pytest suite (uses `httpx2.MockTransport`; no network, no tokens).
|
||||
- `scripts/smoke_test.py` — live smoke test against the real API.
|
||||
- `.claude/skills/chaturbate/SKILL.md` — companion skill.
|
||||
|
||||
## Hard rules (secrets)
|
||||
|
||||
- **Never read, print, or commit anything under `review/`** — it is a saved copy of the user's
|
||||
Chaturbate authtoken page and contains **live API tokens**. It is gitignored; keep it that way.
|
||||
- **Never commit `mitm_mcp_traffic.db`** (captured traffic; gitignored).
|
||||
- **Never commit `.env`** or echo `CHATURBATE_*` values. Only `.env.example` (placeholders) is committed.
|
||||
- Events `next_url` values embed the events token — redact `/events/<user>/<token>/` when quoting.
|
||||
- The smoke test reads tokens from `review/…html` strictly in memory and prints PASS/FAIL summaries
|
||||
only. Do not add logging that dumps credentials or raw feed URLs.
|
||||
|
||||
## Tool/API gotchas
|
||||
|
||||
- Stats snapshots refresh ~every 5 minutes upstream.
|
||||
- Affiliate `start_date`/`end_date`/`breakdown` params are undocumented; rapid requests with them can
|
||||
trigger HTTP 403 throttling. Default tool behavior sends only `username`+`token`.
|
||||
- `fetch_events` is stateless: thread the returned `next_url`. Default `timeout=0` (quick peek);
|
||||
long polls (up to 90 s) may exceed client tool-call deadlines.
|
||||
@@ -0,0 +1,113 @@
|
||||
# chaturbate-mcp
|
||||
|
||||
Read-only [MCP](https://modelcontextprotocol.io) server exposing three Chaturbate APIs as
|
||||
tools, for use from Claude Code or any MCP client (e.g. lobehub):
|
||||
|
||||
| API | Endpoint | Tool | Data |
|
||||
|---|---|---|---|
|
||||
| Room/user stats | `chaturbate.com/statsapi/` | `fetch_room_stats` | viewers, token balance, satisfaction score, broadcast status… (refreshes ~every 5 min) |
|
||||
| Affiliate earnings | `chaturbate.com/affiliates/apistats/` | `fetch_affiliate_stats` | per-program earnings tables (9 programs), totals, payout |
|
||||
| Events feed | `eventsapi.chaturbate.com/events/…` | `fetch_events`, `build_events_feed_url`, `get_events_schema` | live long-poll stream: chat, tips, follows, media purchases, private messages… |
|
||||
|
||||
Built with the official Python MCP SDK (`mcp` ≥ 2.1, the release that renamed `FastMCP` →
|
||||
`MCPServer`) and `httpx2`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python ≥ 3.10 (developed on 3.14)
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
uv sync --dev
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your Chaturbate account values:
|
||||
|
||||
```sh
|
||||
cp .env.example .env # then edit
|
||||
```
|
||||
|
||||
Credentials are read from the environment, with `.env` as a local convenience:
|
||||
|
||||
- `CHATURBATE_USERNAME` — your Chaturbate account
|
||||
- `CHATURBATE_STATS_TOKEN` — Stats-API scope token. The **same token** authorizes both the
|
||||
room-stats and the affiliate-earnings endpoints.
|
||||
- `CHATURBATE_EVENTS_TOKEN` — Events-API scope token for the live feed. A *private* scope
|
||||
exposes tips + private messages; a *public* scope omits them.
|
||||
|
||||
Create/manage tokens at `https://chaturbate.com/statsapi/authtoken/`.
|
||||
|
||||
`.env` is gitignored. **Never commit a real token.**
|
||||
|
||||
## Run
|
||||
|
||||
Start the stdio server manually:
|
||||
|
||||
```sh
|
||||
uv run chaturbate-mcp
|
||||
```
|
||||
|
||||
For Claude Code, a project-scoped entry is committed in `.mcp.json` (launches via `uv`, no
|
||||
secrets in the config — the server reads them from `.env`). Approve the `chaturbate` server
|
||||
once under `/mcp`.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `config_status` | Which credential sets are configured (booleans, never values) |
|
||||
| `fetch_room_stats` | One-shot stats snapshot (optionally override `username`/`token`) |
|
||||
| `fetch_affiliate_stats` | 9-program earnings report. Sends **only** username+token unless you supply `start_date`/`end_date`/`breakdown` (undocumented upstream — see note) |
|
||||
| `fetch_events` | Stateless long-poll of the events feed; thread `next_url` to stream |
|
||||
| `build_events_feed_url` | Return the base feed URL for explicit cursor control |
|
||||
| `get_events_schema` | Reference table of the 12 event methods and their object shapes |
|
||||
|
||||
### Streaming events
|
||||
|
||||
The events feed is cursor-based and stateless:
|
||||
|
||||
1. `fetch_events(timeout=0)` → returns `{events, next_url}` (recent events + a cursor).
|
||||
2. Pass that `next_url` back to `fetch_events(next_url=…)` to receive the next batch.
|
||||
3. `events: []` + a valid `next_url` means *nothing new in the window* — poll again.
|
||||
|
||||
`timeout` (0–90 s) is how long the server waits for new events before returning. Default to
|
||||
`0` for a quick peek; a long poll can exceed an MCP client's tool-call deadline.
|
||||
|
||||
The companion skill `.claude/skills/chaturbate/` documents these workflows for Claude Code.
|
||||
|
||||
## Tests
|
||||
|
||||
Offline unit tests (no network, no tokens):
|
||||
|
||||
```sh
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
Live smoke test against the real API — it reads the account/tokens from
|
||||
`review/Stats Token Authorization - Chaturbate.html` **in memory** (or `CHATURBATE_*` env) and
|
||||
never prints or persists them:
|
||||
|
||||
```sh
|
||||
uv run python scripts/smoke_test.py
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- `review/` (a saved copy of your authtoken page — contains live tokens) and
|
||||
`mitm_mcp_traffic.db` are **gitignored** and must never be committed. See `CLAUDE.md`.
|
||||
- Real tokens live only in `.env` (gitignored) or the environment.
|
||||
- Events `next_url` values embed the events token. Treat tool output as sensitive: do not
|
||||
echo feed URLs verbatim into chat, logs, or files.
|
||||
- API error messages are redacted before they reach the client.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Stats are ~5 minutes stale** upstream; don't treat a single snapshot as live.
|
||||
- **Affiliate date-range params are undocumented** and unreliable — Chaturbate can answer
|
||||
rapid requests carrying them with HTTP 403 (throttling). The tool sends none by default.
|
||||
- The affiliate/room-stats data refreshes once every ~5 minutes regardless of request rate.
|
||||
- The events feed rate limit is 2000 requests/minute.
|
||||
@@ -0,0 +1,40 @@
|
||||
[project]
|
||||
name = "chaturbate-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Read-only MCP server exposing the Chaturbate room-stats, affiliate-earnings, and events APIs as tools."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"mcp>=2.1.1,<3", # 2.x renamed FastMCP -> MCPServer
|
||||
"httpx2>=2.12.0", # Pydantic continuation of httpx; already a dep of mcp 2.x
|
||||
"python-dotenv>=1.0.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
chaturbate-mcp = "chaturbate_mcp.server:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.9",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/chaturbate_mcp"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
split-on-trailing-comma = false
|
||||
@@ -0,0 +1,165 @@
|
||||
"""LIVE smoke test for the Chaturbate MCP HTTP layer.
|
||||
|
||||
Hits the real Chaturbate endpoints once each. Credentials come from the
|
||||
``CHATURBATE_*`` environment (including the project ``.env``) or, failing that,
|
||||
are regex-extracted IN MEMORY from ``review/Stats Token Authorization - Chaturbate.html``.
|
||||
Tokens are never written anywhere and never printed; output is PASS/FAIL + non-secret
|
||||
summaries only.
|
||||
|
||||
Usage: uv run python scripts/smoke_test.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx2
|
||||
|
||||
from chaturbate_mcp import api
|
||||
from chaturbate_mcp.config import load_env, project_root
|
||||
from chaturbate_mcp.events_util import events_feed_url
|
||||
|
||||
REVIEW_HTML = project_root() / "review" / "Stats Token Authorization - Chaturbate.html"
|
||||
|
||||
RESULTS: list[tuple[str, bool, str]] = []
|
||||
|
||||
|
||||
def record(name: str, ok: bool, detail: str) -> None:
|
||||
RESULTS.append((name, ok, detail))
|
||||
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
|
||||
|
||||
|
||||
def short_username(u: str) -> str:
|
||||
return f"{u[:2]}…{u[-1]}" if len(u) > 3 else u
|
||||
|
||||
|
||||
def creds_from_review() -> dict[str, str] | None:
|
||||
"""Extract username/stats/events tokens from the saved authtoken page, in memory."""
|
||||
if not REVIEW_HTML.exists():
|
||||
return None
|
||||
text = REVIEW_HTML.read_text(encoding="utf-8", errors="replace")
|
||||
m_stats = re.search(r"statsapi/\?username=([^&\"' ]+)&(?:amp;)?token=([A-Za-z0-9]+)", text)
|
||||
m_events = re.search(r"eventsapi\.chaturbate\.com/events/([^/\"' ]+)/([A-Za-z0-9]+)/", text)
|
||||
if not (m_stats and m_events):
|
||||
return None
|
||||
return {
|
||||
"username": m_stats.group(1),
|
||||
"stats_token": m_stats.group(2),
|
||||
"events_token": m_events.group(2),
|
||||
}
|
||||
|
||||
|
||||
def get_creds() -> dict[str, str]:
|
||||
load_env()
|
||||
|
||||
env = {
|
||||
"username": os.getenv("CHATURBATE_USERNAME"),
|
||||
"stats_token": os.getenv("CHATURBATE_STATS_TOKEN"),
|
||||
"events_token": os.getenv("CHATURBATE_EVENTS_TOKEN"),
|
||||
}
|
||||
if all(env.values()):
|
||||
return env # type: ignore[return-value]
|
||||
from_review = creds_from_review()
|
||||
if from_review:
|
||||
return from_review
|
||||
raise SystemExit(
|
||||
"No credentials found. Set CHATURBATE_USERNAME/CHATURBATE_STATS_TOKEN/"
|
||||
"CHATURBATE_EVENTS_TOKEN (or the project .env), or ensure "
|
||||
f"{REVIEW_HTML.relative_to(project_root())} exists with token URLs."
|
||||
)
|
||||
|
||||
|
||||
async def guarded(name: str, coro):
|
||||
"""Run one check with a single spaced retry (Chaturbate can 403-throttle)."""
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
return await coro()
|
||||
except api.ApiError as exc:
|
||||
if attempt == 1 and exc.status == 403:
|
||||
await asyncio.sleep(6) # back off once before reporting throttling
|
||||
continue
|
||||
raise
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
async def check_room_stats(u: str, tok: str) -> None:
|
||||
body = await guarded("room-stats", lambda: api.fetch_room_stats(u, tok))
|
||||
required = {"username", "token_balance", "num_followers", "num_viewers", "time_online", "last_broadcast"}
|
||||
missing = required - set(body)
|
||||
if missing:
|
||||
record("room-stats", False, f"missing keys: {sorted(missing)}")
|
||||
return
|
||||
record(
|
||||
"room-stats",
|
||||
True,
|
||||
f"user={short_username(body['username'])} followers={body['num_followers']} "
|
||||
f"viewers={body['num_viewers']} time_online={body['time_online']} last_broadcast={body['last_broadcast']}",
|
||||
)
|
||||
|
||||
|
||||
async def check_affiliate(u: str, tok: str) -> None:
|
||||
body = await guarded("affiliate-stats", lambda: api.fetch_affiliate_stats(u, tok))
|
||||
stats = body.get("stats")
|
||||
if not isinstance(stats, list) or len(stats) != 9:
|
||||
record("affiliate-stats", False, f"expected 9 programs, got {len(stats) if isinstance(stats, list) else type(stats).__name__}")
|
||||
return
|
||||
if not all({"program", "columns", "rows", "totals"} <= set(p) for p in stats):
|
||||
record("affiliate-stats", False, "program rows missing program/columns/rows/totals")
|
||||
return
|
||||
rng = body.get("range", {})
|
||||
record(
|
||||
"affiliate-stats",
|
||||
True,
|
||||
f"9 programs | range {rng.get('start_date')}..{rng.get('end_date')} | breakdown={body.get('breakdown')}",
|
||||
)
|
||||
|
||||
|
||||
async def check_events(u: str, tok: str) -> None:
|
||||
base = events_feed_url(u, tok, timeout=0)
|
||||
first = await guarded("events-initial", lambda: api.fetch_events_feed(base))
|
||||
if not isinstance(first.get("events"), list) or not isinstance(first.get("nextUrl"), str):
|
||||
record("events-initial", False, "payload missing 'events' list or 'nextUrl'")
|
||||
return
|
||||
methods = sorted({e.get("method") for e in first["events"] if isinstance(e, dict)})
|
||||
record(
|
||||
"events-initial",
|
||||
True,
|
||||
f"events={len(first['events'])} methods={methods if methods else '(none)'}",
|
||||
)
|
||||
# Follow nextUrl exactly once to confirm the cursor handshake, then STOP.
|
||||
nxt = first["nextUrl"]
|
||||
if not nxt.startswith("https://eventsapi.chaturbate.com/events/"):
|
||||
record("events-follow", False, "nextUrl is not on eventsapi.chaturbate.com")
|
||||
return
|
||||
second = await guarded("events-follow", lambda: api.fetch_events_feed(nxt))
|
||||
ok = isinstance(second.get("events"), list) and isinstance(second.get("nextUrl"), str)
|
||||
record("events-follow", ok, f"follow-up events={len(second.get('events', [])) if ok else 'n/a'}")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
creds = get_creds()
|
||||
u = creds["username"]
|
||||
print(f"account: {short_username(u)} | creds source: env/.env or review HTML (in memory)")
|
||||
try:
|
||||
await check_room_stats(u, creds["stats_token"])
|
||||
await asyncio.sleep(2)
|
||||
await check_affiliate(u, creds["stats_token"])
|
||||
await asyncio.sleep(2)
|
||||
await check_events(u, creds["events_token"])
|
||||
except api.ApiError as exc:
|
||||
record("network", False, str(exc))
|
||||
except httpx2.HTTPError as exc:
|
||||
record("network", False, f"HTTP transport error: {exc}")
|
||||
print()
|
||||
failures = [name for name, ok, _ in RESULTS if not ok]
|
||||
if failures:
|
||||
print(f"SMOKE TEST FAILED ({len(failures)}): {failures}")
|
||||
return 1
|
||||
print("SMOKE TEST PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -0,0 +1,3 @@
|
||||
"""chaturbate-mcp: read-only MCP tools for the Chaturbate stats, affiliate, and events APIs."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Support ``python -m chaturbate_mcp``."""
|
||||
|
||||
from .server import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Thin async HTTP layer over the three Chaturbate endpoints.
|
||||
|
||||
Uses ``httpx2`` (already a dependency of ``mcp`` 2.x), mirroring the httpx API.
|
||||
Error messages are redacted so a raised :class:`ApiError` never echoes a token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx2
|
||||
|
||||
STATS_URL = "https://chaturbate.com/statsapi/"
|
||||
AFFILIATE_URL = "https://chaturbate.com/affiliates/apistats/"
|
||||
EVENTS_HOST = "https://eventsapi.chaturbate.com"
|
||||
|
||||
# The events long-poll can hold a request up to 90s upstream; the client read
|
||||
# timeout must comfortably exceed the longest upstream wait.
|
||||
EVENTS_READ_TIMEOUT = 120.0
|
||||
DEFAULT_READ_TIMEOUT = 30.0
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
"""A non-200 / non-JSON / timed-out Chaturbate response, with secrets scrubbed."""
|
||||
|
||||
def __init__(self, status: int | None, url: str, detail: str):
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
super().__init__(f"Chaturbate API HTTP {status}: {detail} (url: {redact_url(url)})" if status else detail)
|
||||
|
||||
|
||||
def redact_url(url: str) -> str:
|
||||
"""Strip credentials from a URL for safe inclusion in errors and logs.
|
||||
|
||||
Handles two shapes: the events feed ``…/events/<username>/<token>/`` path form
|
||||
and the ``token=…`` query form used by the stats/affiliate endpoints.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
segs = parts.path.split("/")
|
||||
if len(segs) >= 4 and segs[1] == "events":
|
||||
# /events/<username>/<token>/...
|
||||
segs[3] = "<token>"
|
||||
path = "/".join(segs)
|
||||
else:
|
||||
path = parts.path
|
||||
query = re.sub(r"(token=)[^&]*", r"\1<redacted>", parts.query)
|
||||
return urlunsplit((parts.scheme, parts.netloc, path, query, ""))
|
||||
|
||||
|
||||
async def _get_json(
|
||||
url: str,
|
||||
*,
|
||||
params: Mapping[str, str] | None = None,
|
||||
timeout: float = DEFAULT_READ_TIMEOUT,
|
||||
transport: httpx2.BaseTransport | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
kwargs = {"timeout": timeout, "follow_redirects": True}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx2.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(url, params=dict(params) if params else None)
|
||||
except httpx2.TimeoutException as exc:
|
||||
raise ApiError(None, url, f"timed out after {timeout:.0f}s") from exc
|
||||
if resp.status_code != 200:
|
||||
raise ApiError(resp.status_code, str(resp.url), (resp.text or "")[:300])
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise ApiError(resp.status_code, str(resp.url), "non-JSON response body") from exc
|
||||
|
||||
|
||||
async def fetch_room_stats(
|
||||
username: str, token: str, transport: httpx2.BaseTransport | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""One-shot room/account stats snapshot (upstream refreshes ~every 5 minutes)."""
|
||||
return await _get_json(
|
||||
STATS_URL, params={"username": username, "token": token}, transport=transport
|
||||
)
|
||||
|
||||
|
||||
async def fetch_affiliate_stats(
|
||||
username: str,
|
||||
token: str,
|
||||
extra: Mapping[str, str] | None = None,
|
||||
transport: httpx2.BaseTransport | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Affiliate earnings report. Only ``username``+``token`` are sent by default.
|
||||
|
||||
``extra`` (best-effort, undocumented upstream range/breakdown params) is
|
||||
appended verbatim when the caller supplies it.
|
||||
"""
|
||||
params: dict[str, str] = {"username": username, "token": token}
|
||||
if extra:
|
||||
params.update({k: v for k, v in extra.items() if v})
|
||||
return await _get_json(AFFILIATE_URL, params=params, transport=transport)
|
||||
|
||||
|
||||
async def fetch_events_feed(
|
||||
url: str, transport: httpx2.BaseTransport | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""One long-poll GET against a feed URL (base or ``nextUrl``). URL not logged."""
|
||||
return await _get_json(url, timeout=EVENTS_READ_TIMEOUT, transport=transport)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Configuration: env-var + optional local ``.env`` loading.
|
||||
|
||||
Secrets never live in code. Real tokens come from ``CHATURBATE_*`` environment
|
||||
variables, optionally loaded from ``<repo-root>/.env`` (gitignored). Process
|
||||
environment always wins over ``.env`` values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
def project_root() -> Path:
|
||||
"""Repository root: ``CLAUDE_PROJECT_DIR`` (set by Claude Code) or package path.
|
||||
|
||||
``src/chaturbate_mcp/config.py`` -> ``parents[2]`` is the repo root, so this
|
||||
works under ``uv run`` from any cwd.
|
||||
"""
|
||||
env_dir = os.environ.get("CLAUDE_PROJECT_DIR")
|
||||
if env_dir:
|
||||
return Path(env_dir).resolve()
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
_loaded = False
|
||||
|
||||
|
||||
def load_env() -> Path:
|
||||
"""Load ``<repo-root>/.env`` once. Absent file is a harmless no-op."""
|
||||
global _loaded
|
||||
root = project_root()
|
||||
if not _loaded:
|
||||
load_dotenv(root / ".env", override=False)
|
||||
_loaded = True
|
||||
return root
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
username: str | None
|
||||
stats_token: str | None
|
||||
events_token: str | None
|
||||
|
||||
@property
|
||||
def stats_configured(self) -> bool:
|
||||
"""Both username and stats token present (stats token also authorizes the affiliate API)."""
|
||||
return bool(self.username and self.stats_token)
|
||||
|
||||
@property
|
||||
def events_configured(self) -> bool:
|
||||
return bool(self.username and self.events_token)
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
load_env()
|
||||
return Config(
|
||||
username=os.getenv("CHATURBATE_USERNAME") or None,
|
||||
stats_token=os.getenv("CHATURBATE_STATS_TOKEN") or None,
|
||||
events_token=os.getenv("CHATURBATE_EVENTS_TOKEN") or None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_account(username: str | None, token: str | None, *, events: bool = False) -> tuple[str, str]:
|
||||
"""Merge per-call overrides with environment config.
|
||||
|
||||
Raises ``ValueError`` (surfaced as a clean MCP tool error) when either half of
|
||||
a credential pair is missing. Never returns secrets in the message.
|
||||
"""
|
||||
cfg = get_config()
|
||||
u = username or cfg.username
|
||||
if events:
|
||||
t = token or cfg.events_token
|
||||
env = "CHATURBATE_EVENTS_TOKEN"
|
||||
else:
|
||||
t = token or cfg.stats_token
|
||||
env = "CHATURBATE_STATS_TOKEN"
|
||||
if not u or not t:
|
||||
raise ValueError(
|
||||
f"Missing credentials: provide username/token arguments or set "
|
||||
f"CHATURBATE_USERNAME and {env} in the environment (or the project .env)."
|
||||
)
|
||||
return u, t
|
||||
@@ -0,0 +1,48 @@
|
||||
"""The 12 Chaturbate events-feed methods and their ``event["object"]`` shapes.
|
||||
|
||||
Shapes distilled from the official Events API documentation (methods/*.html and
|
||||
objects.html). Used by the ``get_events_schema`` tool so an LLM can interpret raw
|
||||
events, and asserted by tests to stay in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
USER_OBJ = (
|
||||
"user: {username, inFanclub: bool, gender: 'm'|'f'|'t'|'c', hasTokens: bool, "
|
||||
"recentTips: 'none'|'some'|'lots'|'tons', isMod: bool}"
|
||||
)
|
||||
MSG_OBJ = (
|
||||
"message: {color, bgColor, message, font}"
|
||||
" — privateMessage adds fromUser (sender) and toUser (recipient)"
|
||||
)
|
||||
MEDIA_OBJ = "media: {id, type: 'photos'|'video', name, tokens}"
|
||||
TIP_OBJ = "tip: {tokens, isAnon, message}"
|
||||
|
||||
# method -> description of the event["object"] payload
|
||||
EVENT_OBJECT_SHAPES: dict[str, str] = {
|
||||
"broadcastStart": f"broadcaster: username, {USER_OBJ}",
|
||||
"broadcastStop": f"broadcaster: username, {USER_OBJ}",
|
||||
"chatMessage": f"broadcaster: username, {USER_OBJ}, {MSG_OBJ}",
|
||||
"fanclubJoin": f"broadcaster: username, {USER_OBJ}",
|
||||
"follow": f"broadcaster: username, {USER_OBJ}",
|
||||
"mediaPurchase": f"broadcaster: username, {USER_OBJ}, {MEDIA_OBJ}",
|
||||
"privateMessage": f"broadcaster: username, {USER_OBJ}, {MSG_OBJ}",
|
||||
"roomSubjectChange": "broadcaster: username, subject: str (new room title)",
|
||||
"tip": f"broadcaster: username, {USER_OBJ}, {TIP_OBJ}",
|
||||
"unfollow": f"broadcaster: username, {USER_OBJ}",
|
||||
"userEnter": f"broadcaster: username, {USER_OBJ}",
|
||||
"userLeave": f"broadcaster: username, {USER_OBJ}",
|
||||
}
|
||||
|
||||
|
||||
def events_schema_markdown() -> str:
|
||||
"""Markdown reference table: every event method and its ``object`` shape."""
|
||||
rows = "\n".join(f"| `{m}` | {s} |" for m, s in EVENT_OBJECT_SHAPES.items())
|
||||
return (
|
||||
"Every event in the feed: `{\"method\": str, \"id\": \"<ts>-<seq>\", \"object\": {...}}`. "
|
||||
"`object` always includes `broadcaster` (the room's username); the remaining fields depend on method.\n\n"
|
||||
"| method | event[\"object\"] |\n|---|---|\n"
|
||||
+ rows
|
||||
+ "\n\nWith a *private*-scope events token, `tip` and `privateMessage` events are included; "
|
||||
"a *public*-scope token omits them."
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Events-feed URL construction and validation.
|
||||
|
||||
The events feed URL carries the token in its path (``…/events/<username>/<token>/``),
|
||||
so any URL this module emits or accepts must stay pinned to the official events host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from .api import EVENTS_HOST
|
||||
|
||||
|
||||
def events_feed_url(username: str, token: str, timeout: int | None = None) -> str:
|
||||
"""Build the base long-poll feed URL for an account/token pair.
|
||||
|
||||
``timeout`` (0..90 s) is embedded as a query param; omit for the upstream
|
||||
default (10 s). The returned URL contains the events token — treat as secret.
|
||||
"""
|
||||
url = f"{EVENTS_HOST}/events/{quote(username, safe='')}/{quote(token, safe='')}/"
|
||||
if timeout is not None:
|
||||
url = f"{url}?timeout={int(timeout)}"
|
||||
return url
|
||||
|
||||
|
||||
def validate_events_url(url: str) -> str:
|
||||
"""Ensure a caller-supplied ``next_url`` targets the official events host.
|
||||
|
||||
Guards against a misdirected/forged URL smuggling the events token to a
|
||||
third-party host. Returns the URL unchanged when valid.
|
||||
"""
|
||||
prefix = f"{EVENTS_HOST}/events/"
|
||||
if not url.startswith(prefix):
|
||||
raise ValueError(
|
||||
"next_url must be a chaturbate events feed URL on eventsapi.chaturbate.com "
|
||||
"obtained from a prior fetch_events call"
|
||||
)
|
||||
return url
|
||||
@@ -0,0 +1,190 @@
|
||||
"""MCP server exposing Chaturbate's room-stats, affiliate, and events APIs as tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
from . import __version__
|
||||
from .api import ApiError
|
||||
from .api import fetch_affiliate_stats as api_affiliate_stats
|
||||
from .api import fetch_events_feed as api_events_feed
|
||||
from .api import fetch_room_stats as api_room_stats
|
||||
from .config import get_config, project_root, resolve_account
|
||||
from .events_schema import events_schema_markdown
|
||||
from .events_util import events_feed_url, validate_events_url
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
server = MCPServer(
|
||||
name="chaturbate",
|
||||
version=__version__,
|
||||
description=(
|
||||
"Read-only access to Chaturbate room stats, affiliate earnings, and the live "
|
||||
"events feed for a Chaturbate account."
|
||||
),
|
||||
)
|
||||
|
||||
def creds(username: str | None, token: str | None, *, events: bool = False) -> tuple[str, str]:
|
||||
"""Resolve credentials, surfacing a clear tool error when any half is missing."""
|
||||
try:
|
||||
return resolve_account(username, token, events=events)
|
||||
except ValueError as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
|
||||
async def _api(awaitable: Any) -> Any:
|
||||
"""Run an API call, converting transport failures into tool errors."""
|
||||
try:
|
||||
return await awaitable
|
||||
except ApiError as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
|
||||
@server.tool()
|
||||
async def config_status() -> dict[str, Any]:
|
||||
"""Report which Chaturbate credentials are configured. Never prints secrets.
|
||||
|
||||
Use this first to confirm the server is ready before calling the other tools.
|
||||
Returns booleans (not token values), the configured username, and where the
|
||||
environment was loaded from.
|
||||
"""
|
||||
cfg = get_config()
|
||||
return {
|
||||
"username": cfg.username,
|
||||
"stats_configured": cfg.stats_configured, # same token also authorizes affiliate API
|
||||
"events_configured": cfg.events_configured,
|
||||
"env_source": str(project_root() / ".env"),
|
||||
}
|
||||
|
||||
@server.tool()
|
||||
async def fetch_room_stats(username: str | None = None, token: str | None = None) -> dict[str, Any]:
|
||||
"""Fetch the current Chaturbate room/account stats snapshot.
|
||||
|
||||
Args:
|
||||
username: Chaturbate username. Defaults to CHATURBATE_USERNAME.
|
||||
token: Stats-API token. Defaults to CHATURBATE_STATS_TOKEN.
|
||||
|
||||
Returns the raw JSON object (unknown keys are passed through). Chaturbate
|
||||
refreshes this snapshot about every 5 minutes, so numbers can lag reality by
|
||||
up to 5 minutes. ``last_broadcast`` is an ISO datetime or -1 if never;
|
||||
``time_online`` is minutes, or -1 when offline.
|
||||
"""
|
||||
u, t = creds(username, token)
|
||||
return await _api(api_room_stats(u, t))
|
||||
|
||||
@server.tool()
|
||||
async def fetch_affiliate_stats(
|
||||
username: str | None = None,
|
||||
token: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
breakdown: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch the Chaturbate affiliate-earnings report (all 9 programs).
|
||||
|
||||
Args:
|
||||
username: Chaturbate username. Defaults to CHATURBATE_USERNAME.
|
||||
token: Stats token (same token authorizes this endpoint). Defaults to env.
|
||||
start_date: Best-effort YYYY-MM-DD filter — undocumented upstream; omit normally.
|
||||
end_date: Best-effort YYYY-MM-DD filter — undocumented upstream; omit normally.
|
||||
breakdown: Best-effort breakdown type — undocumented upstream; omit normally.
|
||||
|
||||
Returns the raw payload: ``{"stats": [9 program tables], "range", "breakdown"}``.
|
||||
Only username+token are sent by default. Upstream treats range params as
|
||||
undocumented and unreliable — rapid requests carrying them can trigger 403
|
||||
throttling. If you get a 403, retry without start_date/end_date/breakdown.
|
||||
"""
|
||||
u, t = creds(username, token)
|
||||
extra = {
|
||||
k: v
|
||||
for k, v in (
|
||||
("start_date", start_date),
|
||||
("end_date", end_date),
|
||||
("breakdown", breakdown),
|
||||
)
|
||||
if v is not None
|
||||
}
|
||||
return await _api(api_affiliate_stats(u, t, extra))
|
||||
|
||||
@server.tool()
|
||||
async def fetch_events(
|
||||
timeout: int = 0,
|
||||
next_url: str | None = None,
|
||||
username: str | None = None,
|
||||
token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Poll the live Chaturbate events feed (one request, no client state kept).
|
||||
|
||||
Args:
|
||||
timeout: Seconds the server holds the request for new events, 0..90.
|
||||
Default 0 returns immediately (recommended — a long poll can exceed the
|
||||
MCP client's tool-call deadline). Empty ``events`` + a valid
|
||||
``next_url`` means "nothing new in the window".
|
||||
next_url: Feed URL from a previous call; pass it back to advance the
|
||||
cursor. It embeds the events token — treat output as secret.
|
||||
When given, username/token/timeout are ignored.
|
||||
username: Chaturbate username. Defaults to CHATURBATE_USERNAME.
|
||||
token: Events-scope token. Defaults to CHATURBATE_EVENTS_TOKEN.
|
||||
|
||||
Returns ``{"events": [...], "next_url": str}``. Streaming recipe: call once
|
||||
(no next_url) to get recent events + a cursor, then keep passing that
|
||||
``next_url`` back to receive the next batch. Each event is
|
||||
``{"method", "id", "object"}`` — see get_events_schema for shapes.
|
||||
"""
|
||||
if not 0 <= timeout <= 90:
|
||||
raise ToolError("timeout must be between 0 and 90 seconds")
|
||||
if next_url:
|
||||
try:
|
||||
url = validate_events_url(next_url)
|
||||
except ValueError as exc:
|
||||
raise ToolError(str(exc)) from exc
|
||||
else:
|
||||
u, t = creds(username, token, events=True)
|
||||
url = events_feed_url(u, t, timeout)
|
||||
data = await _api(api_events_feed(url))
|
||||
if "events" not in data or "nextUrl" not in data:
|
||||
raise ToolError("Unexpected events-feed payload: missing 'events' or 'nextUrl'")
|
||||
return {"events": data["events"], "next_url": data["nextUrl"]}
|
||||
|
||||
@server.tool()
|
||||
async def build_events_feed_url(
|
||||
timeout: int | None = None,
|
||||
username: str | None = None,
|
||||
token: str | None = None,
|
||||
) -> str:
|
||||
"""Build the events-feed base URL for the configured account.
|
||||
|
||||
Args:
|
||||
timeout: Long-poll seconds 0..90 to embed; omit for the upstream default (10 s).
|
||||
username: Chaturbate username. Defaults to CHATURBATE_USERNAME.
|
||||
token: Events-scope token. Defaults to CHATURBATE_EVENTS_TOKEN.
|
||||
|
||||
Returns a URL you can pass to fetch_events(next_url=...). WARNING: the URL
|
||||
contains the events token — treat it as sensitive and do not echo it
|
||||
verbatim in chat or files.
|
||||
"""
|
||||
if timeout is not None and not 0 <= timeout <= 90:
|
||||
raise ToolError("timeout must be between 0 and 90 seconds")
|
||||
u, t = creds(username, token, events=True)
|
||||
return events_feed_url(u, t, timeout)
|
||||
|
||||
@server.tool()
|
||||
async def get_events_schema() -> str:
|
||||
"""Reference: the 12 events-feed methods and their object shapes.
|
||||
|
||||
Call this to interpret raw events returned by fetch_events. Returns a
|
||||
markdown table mapping each method to the fields on ``event["object"]``.
|
||||
"""
|
||||
return events_schema_markdown()
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point: run the stdio MCP server (blocking)."""
|
||||
build_server().run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
Every test is isolated from the real repo ``.env`` and from any ambient
|
||||
``CHATURBATE_*`` variables: tests opt in to credentials via ``monkeypatch.setenv``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from chaturbate_mcp import config
|
||||
|
||||
_CRED_ENV = ("CHATURBATE_USERNAME", "CHATURBATE_STATS_TOKEN", "CHATURBATE_EVENTS_TOKEN")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_env(monkeypatch):
|
||||
# Never read the repo .env during tests; skip the one-time load.
|
||||
monkeypatch.setattr(config, "_loaded", True)
|
||||
for var in _CRED_ENV:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def creds(monkeypatch):
|
||||
"""Install fake credentials into the environment."""
|
||||
|
||||
def _set(username: str = "testuser", stats: str = "STATS_TOK", events: str = "EV_TOK"):
|
||||
monkeypatch.setenv("CHATURBATE_USERNAME", username)
|
||||
monkeypatch.setenv("CHATURBATE_STATS_TOKEN", stats)
|
||||
monkeypatch.setenv("CHATURBATE_EVENTS_TOKEN", events)
|
||||
return {"username": username, "stats": stats, "events": events}
|
||||
|
||||
return _set
|
||||
@@ -0,0 +1,135 @@
|
||||
"""HTTP-layer tests against httpx2.MockTransport (no network, no real tokens)."""
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
from chaturbate_mcp import api
|
||||
from chaturbate_mcp.api import (
|
||||
ApiError,
|
||||
fetch_affiliate_stats,
|
||||
fetch_events_feed,
|
||||
fetch_room_stats,
|
||||
redact_url,
|
||||
)
|
||||
from chaturbate_mcp.events_util import events_feed_url
|
||||
|
||||
ROOM_SAMPLE = {
|
||||
"username": "testuser",
|
||||
"token_balance": 0,
|
||||
"tips_in_last_hour": None,
|
||||
"votes_up": 0,
|
||||
"votes_down": 0,
|
||||
"satisfaction_score": 100,
|
||||
"last_broadcast": -1,
|
||||
"time_online": -1,
|
||||
"num_followers": 0,
|
||||
"num_viewers": 0,
|
||||
"num_registered_viewers": 0,
|
||||
"num_tokened_viewers": 0,
|
||||
"num_ad_anon_viewers": 0, # undocumented extra key must be tolerated
|
||||
}
|
||||
|
||||
AFFILIATE_SAMPLE = {
|
||||
"stats": [{"program": "p", "columns": ["Date"], "rows": [], "totals": {}}] * 9,
|
||||
"range": {"start_date": "2026-09-01", "end_date": "2026-09-15"},
|
||||
"breakdown": "Date",
|
||||
}
|
||||
|
||||
EVENTS_SAMPLE = {
|
||||
"events": [{"method": "tip", "id": "1-0", "object": {"broadcaster": "testuser"}}],
|
||||
"nextUrl": "https://eventsapi.chaturbate.com/events/testuser/T/?i=1-0&timeout=0",
|
||||
}
|
||||
|
||||
|
||||
def transport_responding(handler):
|
||||
return httpx2.MockTransport(handler)
|
||||
|
||||
|
||||
async def test_room_stats_success_and_unknown_keys_tolerated():
|
||||
async def ok(request):
|
||||
return httpx2.Response(200, json=ROOM_SAMPLE)
|
||||
|
||||
data = await fetch_room_stats("testuser", "TOK", transport_responding(ok))
|
||||
assert data["username"] == "testuser"
|
||||
assert "num_ad_anon_viewers" in data # undocumented key passed through
|
||||
|
||||
|
||||
async def test_room_stats_non200_redacts_query_token():
|
||||
async def forbidden(request):
|
||||
return httpx2.Response(403, text="rate limited")
|
||||
|
||||
with pytest.raises(ApiError) as exc:
|
||||
await fetch_room_stats("testuser", "S3CR3T", transport_responding(forbidden))
|
||||
msg = str(exc.value)
|
||||
assert "HTTP 403" in msg
|
||||
assert "S3CR3T" not in msg # token stripped from the URL echoed in the message
|
||||
|
||||
|
||||
async def test_affiliate_default_sends_no_range_params():
|
||||
async def echo(request):
|
||||
return httpx2.Response(200, json={"seen": str(request.url)})
|
||||
|
||||
data = await fetch_affiliate_stats("testuser", "TOK", None, transport_responding(echo))
|
||||
seen = data["seen"]
|
||||
assert "username=testuser" in seen and "token=TOK" in seen
|
||||
assert "start_date" not in seen and "end_date" not in seen and "breakdown" not in seen
|
||||
|
||||
|
||||
async def test_affiliate_appends_only_supplied_extra_params():
|
||||
async def echo(request):
|
||||
return httpx2.Response(200, json={"seen": str(request.url)})
|
||||
|
||||
data = await fetch_affiliate_stats(
|
||||
"testuser", "TOK", {"start_date": "2026-08-01", "end_date": "", "breakdown": None}, # type: ignore[misc]
|
||||
transport_responding(echo),
|
||||
)
|
||||
seen = data["seen"]
|
||||
assert "start_date=2026-08-01" in seen
|
||||
assert "end_date=" not in seen and "breakdown" not in seen # empty/None filtered
|
||||
|
||||
|
||||
async def test_events_feed_success_shape():
|
||||
async def ok(request):
|
||||
return httpx2.Response(200, json=EVENTS_SAMPLE)
|
||||
|
||||
url = events_feed_url("testuser", "TOK", 0)
|
||||
data = await fetch_events_feed(url, transport_responding(ok))
|
||||
assert data["events"][0]["method"] == "tip"
|
||||
assert data["nextUrl"].startswith("https://eventsapi.chaturbate.com/events/")
|
||||
|
||||
|
||||
async def test_events_feed_non200_redacts_path_token():
|
||||
async def nf(request):
|
||||
return httpx2.Response(404, text="gone")
|
||||
|
||||
url = events_feed_url("testuser", "PATHTOKEN", 0)
|
||||
with pytest.raises(ApiError) as exc:
|
||||
await fetch_events_feed(url, transport_responding(nf))
|
||||
assert "PATHTOKEN" not in str(exc.value)
|
||||
|
||||
|
||||
def test_redact_url_query_token():
|
||||
out = redact_url("https://chaturbate.com/statsapi/?username=testuser&token=QUERYTOK")
|
||||
assert "QUERYTOK" not in out
|
||||
assert "token=<redacted>" in out
|
||||
assert "testuser" in out # username kept (not a secret)
|
||||
|
||||
|
||||
def test_redact_url_events_path_token():
|
||||
out = redact_url("https://eventsapi.chaturbate.com/events/testuser/PATHTOK/?i=1-0&timeout=0")
|
||||
assert "PATHTOK" not in out
|
||||
assert "/events/testuser/<token>/" in out
|
||||
|
||||
|
||||
async def test_non_json_body_raises():
|
||||
async def bad(request):
|
||||
return httpx2.Response(200, text="<html>not json</html>")
|
||||
|
||||
with pytest.raises(ApiError, match="non-JSON"):
|
||||
await fetch_room_stats("testuser", "TOK", transport_responding(bad))
|
||||
|
||||
|
||||
def test_module_constants():
|
||||
assert api.STATS_URL == "https://chaturbate.com/statsapi/"
|
||||
assert api.AFFILIATE_URL == "https://chaturbate.com/affiliates/apistats/"
|
||||
assert api.EVENTS_HOST == "https://eventsapi.chaturbate.com"
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Config resolution tests: env overrides, per-call overrides, error messages."""
|
||||
|
||||
import pytest
|
||||
|
||||
from chaturbate_mcp.config import get_config, resolve_account
|
||||
|
||||
|
||||
def test_get_config_empty_when_no_env(monkeypatch):
|
||||
for var in ("CHATURBATE_USERNAME", "CHATURBATE_STATS_TOKEN", "CHATURBATE_EVENTS_TOKEN"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
cfg = get_config()
|
||||
assert cfg.username is None
|
||||
assert cfg.stats_token is None
|
||||
assert cfg.events_token is None
|
||||
assert cfg.stats_configured is False
|
||||
assert cfg.events_configured is False
|
||||
|
||||
|
||||
def test_get_config_reads_env(creds):
|
||||
setvals = creds()
|
||||
cfg = get_config()
|
||||
assert cfg.username == setvals["username"]
|
||||
assert cfg.stats_token == setvals["stats"]
|
||||
assert cfg.events_token == setvals["events"]
|
||||
assert cfg.stats_configured is True
|
||||
assert cfg.events_configured is True
|
||||
|
||||
|
||||
def test_get_config_requires_username_for_configured_flags(creds):
|
||||
creds(username="", stats="S", events="E") # username empty -> not configured
|
||||
cfg = get_config()
|
||||
assert cfg.stats_configured is False
|
||||
assert cfg.events_configured is False
|
||||
|
||||
|
||||
def test_resolve_account_per_call_override_beats_env(creds):
|
||||
creds(username="envuser", stats="ENVSTAT", events="ENVEV")
|
||||
u, t = resolve_account("overrideuser", "overridetok", events=False)
|
||||
assert (u, t) == ("overrideuser", "overridetok")
|
||||
|
||||
|
||||
def test_resolve_account_stats_uses_stats_token(creds):
|
||||
creds(stats="STATS_XYZ", events="EV_XYZ")
|
||||
assert resolve_account(None, None, events=False)[1] == "STATS_XYZ"
|
||||
assert resolve_account(None, None, events=True)[1] == "EV_XYZ"
|
||||
|
||||
|
||||
def test_resolve_account_missing_raises_clear_error(monkeypatch):
|
||||
monkeypatch.delenv("CHATURBATE_USERNAME", raising=False)
|
||||
monkeypatch.delenv("CHATURBATE_STATS_TOKEN", raising=False)
|
||||
with pytest.raises(ValueError, match="CHATURBATE_USERNAME"):
|
||||
resolve_account(None, None, events=False)
|
||||
|
||||
|
||||
def test_resolve_account_missing_events_token_message(creds, monkeypatch):
|
||||
creds(stats="S")
|
||||
monkeypatch.delenv("CHATURBATE_EVENTS_TOKEN") # events token absent
|
||||
with pytest.raises(ValueError, match="CHATURBATE_EVENTS_TOKEN"):
|
||||
resolve_account(None, None, events=True)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Schema-table integrity tests for the 12 events methods."""
|
||||
|
||||
from chaturbate_mcp.events_schema import EVENT_OBJECT_SHAPES, events_schema_markdown
|
||||
|
||||
EXPECTED_METHODS = {
|
||||
"broadcastStart",
|
||||
"broadcastStop",
|
||||
"chatMessage",
|
||||
"fanclubJoin",
|
||||
"follow",
|
||||
"mediaPurchase",
|
||||
"privateMessage",
|
||||
"roomSubjectChange",
|
||||
"tip",
|
||||
"unfollow",
|
||||
"userEnter",
|
||||
"userLeave",
|
||||
}
|
||||
|
||||
|
||||
def test_exactly_twelve_methods():
|
||||
assert set(EVENT_OBJECT_SHAPES) == EXPECTED_METHODS
|
||||
assert len(EVENT_OBJECT_SHAPES) == 12
|
||||
|
||||
|
||||
def test_markdown_has_one_row_per_method():
|
||||
md = events_schema_markdown()
|
||||
assert md.count("| `") == 12
|
||||
assert "| method |" in md
|
||||
assert "object" in md
|
||||
|
||||
|
||||
def test_tip_row_carries_expected_subshapes():
|
||||
row = EVENT_OBJECT_SHAPES["tip"]
|
||||
assert "broadcaster" in row and "tip:" in row and "isAnon" in row and "tokens" in row
|
||||
assert "user:" in row and "recentTips" in row
|
||||
|
||||
|
||||
def test_private_message_carries_from_to_user_note():
|
||||
row = EVENT_OBJECT_SHAPES["privateMessage"]
|
||||
assert "fromUser" in row and "toUser" in row
|
||||
assert "message:" in row
|
||||
|
||||
|
||||
def test_media_purchase_carries_media_shape():
|
||||
row = EVENT_OBJECT_SHAPES["mediaPurchase"]
|
||||
assert "media:" in row and "photos" in row and "video" in row and "tokens" in row
|
||||
|
||||
|
||||
def test_room_subject_change_carries_subject():
|
||||
row = EVENT_OBJECT_SHAPES["roomSubjectChange"]
|
||||
assert "subject" in row
|
||||
|
||||
|
||||
def test_markdown_mentions_public_scope_privacy_note():
|
||||
assert "public" in events_schema_markdown().lower()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tool-level tests through the built MCPServer (network stubbed at the api layer)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from chaturbate_mcp.server import build_server
|
||||
|
||||
# Event feed URLs accepted by validate_events_url (host must match eventsapi.chaturbate.com).
|
||||
GOOD_NEXT_URL = "https://eventsapi.chaturbate.com/events/testuser/EVTOK/?i=1-0&timeout=0"
|
||||
BAD_NEXT_URL = "https://evil.example/events/testuser/EVTOK/"
|
||||
|
||||
|
||||
async def invoke(server, name, args):
|
||||
"""Call a tool and return parsed JSON text, or {'__error__': msg} on a tool error."""
|
||||
try:
|
||||
result = await server.call_tool(name, args)
|
||||
except Exception as exc: # noqa: BLE001 - tool errors surface as exceptions via direct call_tool
|
||||
return {"__error__": str(exc)}
|
||||
text = result.content[0].text if result.content else ""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
return build_server()
|
||||
|
||||
|
||||
async def test_config_status_no_creds(server):
|
||||
out = await invoke(server, "config_status", {})
|
||||
assert out["username"] is None
|
||||
assert out["stats_configured"] is False
|
||||
assert out["events_configured"] is False
|
||||
assert out["env_source"].endswith(".env")
|
||||
|
||||
|
||||
async def test_config_status_never_prints_tokens(server, creds):
|
||||
creds(username="testuser", stats="SECRET_STATS_TOKEN_ABC", events="SECRET_EVENTS_TOKEN_ABC")
|
||||
result = await server.call_tool("config_status", {})
|
||||
text = result.content[0].text
|
||||
out = json.loads(text)
|
||||
assert out["stats_configured"] is True and out["events_configured"] is True
|
||||
assert out["username"] == "testuser"
|
||||
assert "SECRET_STATS_TOKEN_ABC" not in text
|
||||
assert "SECRET_EVENTS_TOKEN_ABC" not in text
|
||||
|
||||
|
||||
async def test_room_stats_happy_path(server, creds, monkeypatch):
|
||||
expected = {"username": "testuser", "num_followers": 3, "num_viewers": 10}
|
||||
captured = {}
|
||||
|
||||
async def stub(username, token, transport=None):
|
||||
captured.update(username=username, token=token)
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr("chaturbate_mcp.server.api_room_stats", stub)
|
||||
creds(stats="REAL_STATS_TOK")
|
||||
out = await invoke(server, "fetch_room_stats", {})
|
||||
assert out == expected
|
||||
assert captured == {"username": "testuser", "token": "REAL_STATS_TOK"}
|
||||
|
||||
|
||||
async def test_room_stats_missing_creds_is_clear_error(server):
|
||||
out = await invoke(server, "fetch_room_stats", {})
|
||||
assert "__error__" in out
|
||||
assert "Missing credentials" in out["__error__"]
|
||||
|
||||
|
||||
async def test_affiliate_default_sends_no_extra(server, creds, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def stub(username, token, extra=None, transport=None):
|
||||
captured["extra"] = extra
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("chaturbate_mcp.server.api_affiliate_stats", stub)
|
||||
creds(stats="REAL_STATS_TOK")
|
||||
out = await invoke(server, "fetch_affiliate_stats", {})
|
||||
assert out == {"ok": True}
|
||||
assert captured["extra"] == {}
|
||||
|
||||
|
||||
async def test_affiliate_forwards_only_supplied_params(server, creds, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def stub(username, token, extra=None, transport=None):
|
||||
captured["extra"] = extra
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("chaturbate_mcp.server.api_affiliate_stats", stub)
|
||||
creds(stats="REAL_STATS_TOK")
|
||||
await invoke(server, "fetch_affiliate_stats", {"start_date": "2026-08-01"})
|
||||
assert captured["extra"] == {"start_date": "2026-08-01"}
|
||||
|
||||
|
||||
async def test_fetch_events_threads_next_url(server, monkeypatch):
|
||||
captured = {}
|
||||
sample = {"events": [], "nextUrl": GOOD_NEXT_URL}
|
||||
|
||||
async def stub(url, transport=None):
|
||||
captured["url"] = url
|
||||
return sample
|
||||
|
||||
monkeypatch.setattr("chaturbate_mcp.server.api_events_feed", stub)
|
||||
out = await invoke(server, "fetch_events", {"next_url": GOOD_NEXT_URL})
|
||||
assert out == {"events": [], "next_url": GOOD_NEXT_URL}
|
||||
assert captured["url"] == GOOD_NEXT_URL
|
||||
|
||||
|
||||
async def test_fetch_events_rejects_foreign_host(server):
|
||||
out = await invoke(server, "fetch_events", {"next_url": BAD_NEXT_URL})
|
||||
assert "eventsapi.chaturbate.com" in out["__error__"]
|
||||
|
||||
|
||||
async def test_fetch_events_timeout_range(server):
|
||||
out = await invoke(server, "fetch_events", {"timeout": 999})
|
||||
assert "0 and 90" in out["__error__"]
|
||||
|
||||
|
||||
async def test_fetch_events_requires_events_creds(server):
|
||||
out = await invoke(server, "fetch_events", {})
|
||||
assert "Missing credentials" in out["__error__"]
|
||||
assert "CHATURBATE_EVENTS_TOKEN" in out["__error__"]
|
||||
|
||||
|
||||
async def test_fetch_events_payload_without_keys_is_error(server, creds, monkeypatch):
|
||||
async def stub(url, transport=None):
|
||||
return {"nope": True}
|
||||
|
||||
monkeypatch.setattr("chaturbate_mcp.server.api_events_feed", stub)
|
||||
creds(events="EV")
|
||||
out = await invoke(server, "fetch_events", {"next_url": GOOD_NEXT_URL})
|
||||
assert "'events' or 'nextUrl'" in out["__error__"]
|
||||
|
||||
|
||||
async def test_build_events_feed_url(server, creds):
|
||||
creds(username="testuser", events="EVTOK_123")
|
||||
out = await invoke(server, "build_events_feed_url", {"timeout": 0})
|
||||
assert out == "https://eventsapi.chaturbate.com/events/testuser/EVTOK_123/?timeout=0"
|
||||
|
||||
|
||||
async def test_get_events_schema_returns_12_rows(server):
|
||||
out = await invoke(server, "get_events_schema", {})
|
||||
assert isinstance(out, str)
|
||||
assert out.count("| `") == 12
|
||||
Reference in New Issue
Block a user