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:
John Smith
2026-09-02 23:33:25 -04:00
co-authored by Claude
commit 2d6b57be77
22 changed files with 2478 additions and 0 deletions
+77
View File
@@ -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.
+13
View File
@@ -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
View File
@@ -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/
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"chaturbate": {
"type": "stdio",
"command": "uv",
"args": ["run", "--project", "${CLAUDE_PROJECT_DIR:-.}", "chaturbate-mcp"],
"timeout": 120000
}
}
}
+1
View File
@@ -0,0 +1 @@
3.14
+35
View File
@@ -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.
+113
View File
@@ -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.
+40
View File
@@ -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
+165
View File
@@ -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()))
+3
View File
@@ -0,0 +1,3 @@
"""chaturbate-mcp: read-only MCP tools for the Chaturbate stats, affiliate, and events APIs."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
"""Support ``python -m chaturbate_mcp``."""
from .server import main
main()
+106
View File
@@ -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)
+86
View File
@@ -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
+48
View File
@@ -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."
)
+38
View File
@@ -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
+190
View File
@@ -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()
+33
View File
@@ -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
+135
View File
@@ -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"
+59
View File
@@ -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)
+56
View File
@@ -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()
+148
View File
@@ -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
Generated
+1100
View File
File diff suppressed because it is too large Load Diff