diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2d4f792 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.gitignore +.gitea +venv +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +dist +build +*.egg-info +.env +.env.* +helm-chart/*.tgz +*.tgz diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml new file mode 100644 index 0000000..bf4a4ef --- /dev/null +++ b/.gitea/workflows/cicd.yaml @@ -0,0 +1,89 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: my_custom_label + container: node:22 + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Prepare Env for Docker + run: | + whoami + which docker || true + apt update + cat /etc/os-release + apt install -y ca-certificates curl gnupg + mkdir -p /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + focal stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null + apt update + DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + - name: Get Version from version.ini + id: get_version + run: | + # Extract version value from version.ini + VERSION=$(awk -F'=[ \t]*' '/^version[ \t]*=/ {gsub(/[ \t"'\''\r]/, "", $2); print $2}' version.ini) + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Build Dockerfile + run: | + export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`" + docker build -t $BUILDIMGTAG:${{ env.VERSION }} . + docker images + + - name: Tag and Push (Harbor) + run: | + export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`" + export FINALBUILDTAG="`cat Dockerfile | tail -n1 | sed 's/^#//g'`" + docker tag $BUILDIMGTAG:${{ env.VERSION }} $FINALBUILDTAG:${{ env.VERSION }} + docker images + echo $CR_PAT | docker login harbor.freshbrewed.science -u $CR_USER --password-stdin + docker push $FINALBUILDTAG:${{ env.VERSION }} + env: # Or as an environment variable + CR_PAT: ${{ secrets.CR_PAT }} + CR_USER: ${{ secrets.CR_USER }} + + - name: Tag and Push (Dockerhub) + run: | + export BUILDIMGTAG="`cat Dockerfile | tail -n1 | sed 's/^.*\///g'`" + docker tag $BUILDIMGTAG:${{ env.VERSION }} $DHUSER/$BUILDIMGTAG:${{ env.VERSION }} + docker images + echo $DHPAT | docker login -u $DHUSER --password-stdin + docker push $DHUSER/$BUILDIMGTAG:${{ env.VERSION }} + env: # Or as an environment variable + DHPAT: ${{ secrets.DHPAT }} + DHUSER: ${{ secrets.DHUSER }} + + - name: Prepare Env for Helm OCI Push (Helm v3 reference only) + run: | + if [ ! -f /tmp/linux-amd64/helm ]; then + apt update + DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl || true + wget https://get.helm.sh/helm-v3.14.0-linux-amd64.tar.gz -O /tmp/helm.tar.gz || true + cd /tmp + tar xzvf helm.tar.gz + chmod +x linux-amd64/helm + fi + + - name: Package Helm Chart + run: | + set -x + /tmp/linux-amd64/helm package ./helm-chart + export HLMPKG=`ls -tr *.tgz | tail -n1 | tr -d '\n'` + /tmp/linux-amd64/helm registry login harbor.freshbrewed.science -u $CR_USER -p $CR_PAT + /tmp/linux-amd64/helm push ./$HLMPKG oci://harbor.freshbrewed.science/chartrepo/trymemcp-helm + env: # Or as an environment variable + CR_PAT: ${{ secrets.CR_PAT }} + CR_USER: ${{ secrets.CR_USER }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ef0442 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# Stage 1: Builder +FROM python:3.11-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /build + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY pyproject.toml README.md version.ini ./ +COPY app ./app + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir . + +# Stage 2: Production runtime +FROM python:3.11-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" \ + MCP_HOST="0.0.0.0" \ + MCP_PORT="8000" \ + MCP_PATH="/mcp" \ + TRYME_BASE_URL="https://tryme.tpk.pw" + +WORKDIR /app + +RUN groupadd --gid 1000 appuser && \ + useradd --uid 1000 --gid 1000 --create-home --shell /bin/bash appuser + +COPY --from=builder /opt/venv /opt/venv +COPY app ./app +COPY openapi.yaml version.ini README.md ./ + +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4)" || exit 1 + +CMD ["python", "-m", "app.main"] +#harbor.freshbrewed.science/library/trymemcp \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..a1a7b00 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,69 @@ +# Installation Guide (`INSTALL.md`) + +Detailed installation, environment configuration, container build, and operator setup instructions for `trymemcp`. + +## Prerequisites + +- **Python**: `3.11` or newer +- **Docker**: `24.0+` and Docker Compose v2 (for containerized execution) +- **Helm**: `v3.14+` (optional, for Kubernetes deployments) + +## 1. Virtual Environment Installation + +```bash +# Create virtual environment +python3 -m venv venv + +# Upgrade pip and install package + dev dependencies +venv/bin/pip install --upgrade pip +venv/bin/pip install -e ".[dev]" +``` + +## 2. Environment Variables + +Create an optional `.env` file or export variables directly in your shell: + +```bash +export MCP_HOST="0.0.0.0" +export MCP_PORT="8000" +export MCP_PATH="/mcp" +export TRYME_BASE_URL="https://tryme.tpk.pw" +export TRYME_TIMEOUT_SECONDS="30" + +# Optional server-wide default credentials +export TRYME_API_KEY="" +export TRYME_BEARER_TOKEN="" +export TRYME_SESSION_COOKIE="" +``` + +> **Database Note**: `trymemcp` is a stateless MCP HTTP proxy/adapter over the upstream TryMe REST API (`https://tryme.tpk.pw`) and does not require a local database or Alembic migrations. + +## 3. Running the Service + +```bash +# Direct Python execution (serves HTTP on 0.0.0.0:8000) +venv/bin/python -m app.main + +# Run test suite +venv/bin/pytest +``` + +## 4. Docker & Docker Compose Installation + +```bash +# Build production multi-stage image +docker build -t trymemcp:0.1 . + +# Run container on port 8000 +docker run -p 8000:8000 trymemcp:0.1 + +# Or run via Docker Compose +docker compose up --build +``` + +## 5. Gitea CI/CD Secrets Setup + +Before pushing to `main`, operators must configure these secrets in the Gitea repository settings for [`.gitea/workflows/cicd.yaml`](./.gitea/workflows/cicd.yaml): + +- `CR_USER` / `CR_PAT`: Harbor container registry & OCI Helm chart repo (`harbor.freshbrewed.science`) credentials. +- `DHUSER` / `DHPAT`: DockerHub registry credentials. diff --git a/README.md b/README.md index 6fde739..8db6cd6 100644 --- a/README.md +++ b/README.md @@ -1 +1,149 @@ -# TryMe TPK MCP Server +# TryMe TPK MCP Server (`trymemcp`) + +A Python [FastMCP](https://gofastmcp.com) **Streamable HTTP** Model Context Protocol (MCP) server bound to all interfaces (`0.0.0.0:8000`) that exposes all **29** API operations from [`https://tryme.tpk.pw/openapi.json`](https://tryme.tpk.pw/openapi.json) as strongly typed MCP tools. + +## Features + +- **HTTP Transport on All Interfaces (`0.0.0.0`)**: Serves MCP Streamable HTTP at `http://0.0.0.0:8000/mcp` out of the box. +- **Complete OpenAPI Coverage (29 Tools)**: Covers System Health, Auth0 & Dev Authentication, User Profiles & Personal API Keys, Quiz Creation/Play/Voting, Favourites, and Admin Moderation. +- **Flexible Authentication**: Supports per-tool credentials (`api_key`, `authorization`, `tryme_session`), automatic HTTP header/cookie passthrough from MCP clients (`X-API-Key`, `Authorization`, `Cookie: tryme_session=...`), and server environment variable defaults (`TRYME_API_KEY`, `TRYME_BEARER_TOKEN`, `TRYME_SESSION_COOKIE`). +- **Production Container & CI/CD Ready**: Multi-stage `python:3.11-slim` `Dockerfile` with `/health` healthcheck, `docker-compose.yaml`, `helm-chart/`, OpenAPI 3.1 spec (`/openapi.yaml`, `/openapi.json`), and Gitea Actions CI/CD workflow (`.gitea/workflows/cicd.yaml`). + +--- + +## Quick Start + +### Prerequisites + +- Python **3.11+** (or Docker 24+ / Docker Compose v2) +- Optional: A personal TryMe API key (`X-API-Key`) or Bearer token for authenticated endpoints + +### Run Locally + +```bash +python3 -m venv venv +venv/bin/pip install --upgrade pip +venv/bin/pip install -e ".[dev]" + +# Start the FastMCP HTTP server on 0.0.0.0:8000 +venv/bin/python -m app.main +``` + +Or run via Uvicorn directly: + +```bash +venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### Run with Docker / Docker Compose + +```bash +# Docker +docker build -t trymemcp:0.1 . +docker run --rm -p 8000:8000 -e TRYME_API_KEY="your-api-key" trymemcp:0.1 + +# Docker Compose +docker compose up --build -d +``` + +--- + +## Connecting an MCP Client + +Point any Streamable HTTP MCP client at `http://:8000/mcp`: + +```json +{ + "mcpServers": { + "tryme": { + "type": "http", + "url": "http://localhost:8000/mcp", + "headers": { + "X-API-Key": "YOUR_TRYME_API_KEY" + } + } + } +} +``` + +--- + +## HTTP Endpoints + +| Method | Path | Description | +| :--- | :--- | :--- | +| `POST` / `GET` / `DELETE` | `/mcp` | MCP Streamable HTTP endpoint (JSON-RPC 2.0 + SSE) | +| `GET` | `/health` | Container & Kubernetes liveness/readiness healthcheck | +| `GET` | `/openapi.yaml` | OpenAPI 3.1 YAML specification & MCP tool schemas | +| `GET` | `/openapi.json` | OpenAPI 3.1 JSON specification & MCP tool schemas | + +--- + +## Exposed MCP Tools (29 Tools) + +### System +| Tool | Upstream Endpoint | Description | +| :--- | :--- | :--- | +| `healthcheck` | `GET /health` | Check upstream TryMe Gaming App API health | + +### Authentication +| Tool | Upstream Endpoint | Description | +| :--- | :--- | :--- | +| `get_auth_config` | `GET /api/v1/auth/config` | Get Auth0 settings, dev-auth status, and unclaimed admin status | +| `auth0_login` | `GET /api/v1/auth/login` | Initiate Auth0 federated login flow | +| `auth0_callback` | `GET /api/v1/auth/callback` | Complete Auth0 OAuth callback (`code`, `state`) | +| `dev_login` | `POST /api/v1/auth/dev-login` | Authenticate via dev login (`nickname`, `email`, `as_moderator`) | +| `logout` | `POST /api/v1/auth/logout` | Log out the active session | + +### Users & API Keys +| Tool | Upstream Endpoint | Description | +| :--- | :--- | :--- | +| `get_me` | `GET /api/v1/users/me` | Retrieve the authenticated user's profile | +| `update_me` | `PATCH /api/v1/users/me` | Update nickname, name, age, gender, avatar, or consent | +| `get_my_scores` | `GET /api/v1/users/me/scores` | Retrieve quiz attempt counts, latest, best, and global best scores | +| `list_api_keys` | `GET /api/v1/users/me/api-keys` | List personal API keys | +| `create_api_key` | `POST /api/v1/users/me/api-keys` | Create a personal API key (`name`, `expires_in_days`) | +| `expire_api_key` | `POST /api/v1/users/me/api-keys/{key_id}/expire` | Immediately expire/revoke an API key | + +### Quizzes +| Tool | Upstream Endpoint | Description | +| :--- | :--- | :--- | +| `list_quizzes` | `GET /api/v1/quizzes` | Search and list quizzes (`q`, `mode`, `mine_only`) | +| `create_quiz` | `POST /api/v1/quizzes` | Create a new quiz with multiple-choice or write-in questions | +| `get_quiz` | `GET /api/v1/quizzes/{slug_or_id}` | Fetch full quiz details by share slug or ID | +| `update_quiz` | `PUT /api/v1/quizzes/{quiz_id}` | Update an existing quiz | +| `delete_quiz` | `DELETE /api/v1/quizzes/{quiz_id}` | Delete a quiz owned by the authenticated user | +| `submit_quiz` | `POST /api/v1/quizzes/{quiz_id}/submit` | Submit quiz answers and receive graded results & scores | +| `vote_quiz` | `POST /api/v1/quizzes/{quiz_id}/vote` | Upvote (`1`), downvote (`-1`), or clear vote (`0`) on a quiz | + +### Favourites +| Tool | Upstream Endpoint | Description | +| :--- | :--- | :--- | +| `list_favourites` | `GET /api/v1/favourites` | List favourited quizzes in display order | +| `add_favourite` | `POST /api/v1/favourites` | Add a quiz to favourites at an optional `position` | +| `reorder_favourites` | `PUT /api/v1/favourites/reorder` | Reorder favourited quizzes by `quiz_ids` | +| `remove_favourite` | `DELETE /api/v1/favourites/{quiz_id}` | Remove a quiz from favourites | + +### Moderation +| Tool | Upstream Endpoint | Description | +| :--- | :--- | :--- | +| `claim_initial_moderator` | `POST /api/v1/admin/claim` | Claim initial moderator/admin privileges if unclaimed | +| `list_users_for_moderation` | `GET /api/v1/admin/users` | List all users for moderation | +| `ban_user` | `POST /api/v1/admin/users/{user_id}/ban` | Ban a user with a moderation `reason` | +| `unban_user` | `POST /api/v1/admin/users/{user_id}/unban` | Unban a user | +| `list_quizzes_for_moderation` | `GET /api/v1/admin/quizzes` | List all quizzes for moderation | +| `moderator_remove_quiz` | `DELETE /api/v1/admin/quizzes/{quiz_id}` | Remove any quiz as moderator | + +--- + +## Testing + +```bash +venv/bin/pytest +``` + +## Documentation + +- [`SETUP.md`](./SETUP.md) — End-to-end setup, authentication configuration, MCP client setup, Docker/Helm deployment, and Gitea CI/CD guide +- [`INSTALL.md`](./INSTALL.md) — Environment installation & operator reference +- [`SYSTEM.md`](./SYSTEM.md) — System architecture & Mermaid diagrams diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..b35a09c --- /dev/null +++ b/SETUP.md @@ -0,0 +1,177 @@ +# Setup Guide (`SETUP.md`) + +This guide walks through configuring, running, authenticating, and deploying the **TryMe TPK FastMCP HTTP Server (`trymemcp`)**. + +--- + +## 1. Local Development Setup + +1. **Clone the repository and enter the workspace:** + ```bash + git clone trymemcp + cd trymemcp + ``` + +2. **Create an isolated Python virtual environment and install dependencies:** + ```bash + python3 -m venv venv + venv/bin/pip install --upgrade pip + venv/bin/pip install -e ".[dev]" + ``` + +3. **Verify the test suite:** + ```bash + venv/bin/pytest + ``` + +4. **Start the HTTP MCP server on all interfaces (`0.0.0.0:8000`):** + ```bash + venv/bin/python -m app.main + ``` + You can also invoke the installed CLI script: + ```bash + venv/bin/trymemcp + ``` + Or run with Uvicorn directly: + ```bash + venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 + ``` + +--- + +## 2. Environment Variables + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `MCP_HOST` | `0.0.0.0` | Network interface to bind (defaults to all interfaces) | +| `MCP_PORT` | `8000` | TCP port for the HTTP server | +| `MCP_PATH` | `/mcp` | URL path where the MCP Streamable HTTP endpoint is mounted | +| `TRYME_BASE_URL` | `https://tryme.tpk.pw` | Base URL of the upstream TryMe Gaming App API | +| `TRYME_TIMEOUT_SECONDS` | `30` | HTTP request timeout (in seconds) when calling TryMe | +| `TRYME_API_KEY` | *(unset)* | Optional default `X-API-Key` sent on upstream requests | +| `TRYME_BEARER_TOKEN` | *(unset)* | Optional default `Authorization: Bearer ` | +| `TRYME_SESSION_COOKIE` | *(unset)* | Optional default `tryme_session` cookie value | + +--- + +## 3. Authentication Options + +Authenticated TryMe endpoints accept credentials from three sources in order of precedence: + +1. **Per-Tool Arguments** (highest priority): + Pass `api_key`, `authorization`, or `tryme_session` directly in any authenticated MCP tool call. +2. **Incoming MCP HTTP Headers** (recommended for multi-user HTTP deployments): + Configure your MCP client to send `X-API-Key: `, `Authorization: Bearer `, or `Cookie: tryme_session=` when connecting to `http://:8000/mcp`. The server forwards those credentials per request without storing them globally. +3. **Server Environment Variables** (convenient for single-user containers): + Set `TRYME_API_KEY`, `TRYME_BEARER_TOKEN`, or `TRYME_SESSION_COOKIE` in the server environment. + +### Obtaining an API Key via Dev Login (if enabled upstream) + +1. Call the `get_auth_config` tool to check `dev_auth_enabled`. +2. Call `dev_login(nickname="my_agent")` to obtain an `access_token`. +3. Call `create_api_key(name="mcp-client", expires_in_days=90, authorization="")` to generate a persistent `raw_key`. +4. Use `raw_key` in your MCP client's `X-API-Key` header or `TRYME_API_KEY` environment variable. + +--- + +## 4. Connecting MCP Clients + +### Antigravity / Claude Code / Cursor (`mcp_config.json`) + +```json +{ + "mcpServers": { + "trymemcp": { + "type": "http", + "url": "http://localhost:8000/mcp", + "headers": { + "X-API-Key": "YOUR_TRYME_API_KEY" + } + } + } +} +``` + +### Python FastMCP Client + +```python +import asyncio +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport + +async def main(): + transport = StreamableHttpTransport( + url="http://localhost:8000/mcp", + headers={"X-API-Key": "YOUR_TRYME_API_KEY"}, + ) + async with Client(transport) as client: + tools = await client.list_tools() + print(f"Connected! {len(tools)} tools available.") + quizzes = await client.call_tool("list_quizzes", {"q": ""}) + print(quizzes) + +asyncio.run(main()) +``` + +--- + +## 5. Docker & Docker Compose Setup + +### Build and Run with Docker + +```bash +docker build -t trymemcp:0.1 . +docker run -d \ + --name trymemcp \ + -p 8000:8000 \ + -e TRYME_BASE_URL="https://tryme.tpk.pw" \ + -e TRYME_API_KEY="YOUR_TRYME_API_KEY" \ + trymemcp:0.1 +``` + +Check container health: + +```bash +curl http://localhost:8000/health +``` + +### Run with Docker Compose + +```bash +TRYME_API_KEY="YOUR_TRYME_API_KEY" docker compose up --build -d +``` + +--- + +## 6. Helm / Kubernetes Deployment + +A Helm chart is included under [`helm-chart/`](./helm-chart): + +```bash +helm upgrade --install trymemcp ./helm-chart \ + --namespace tryme --create-namespace \ + --set image.tag="0.1" \ + --set secrets.trymeApiKey="YOUR_TRYME_API_KEY" +``` + +--- + +## 7. Gitea Actions CI/CD Configuration + +The workflow at [`.gitea/workflows/cicd.yaml`](./.gitea/workflows/cicd.yaml) triggers automatically on pushes to `main` and performs: + +1. Version extraction from [`version.ini`](./version.ini) (`0.1`). +2. Multi-stage Docker image build tagged from the final line of [`Dockerfile`](./Dockerfile) (`#harbor.freshbrewed.science/library/trymemcp`). +3. Image push to Harbor (`harbor.freshbrewed.science/library/trymemcp:`) and DockerHub (`/trymemcp:`). +4. Helm chart packaging (`./helm-chart`) and OCI push to `oci://harbor.freshbrewed.science/chartrepo/trymemcp-helm`. + +### Required Gitea Repository Secrets + +Configure the following secrets in your Gitea repository (**Settings -> Actions -> Secrets**): + +| Secret | Purpose | +| :--- | :--- | +| `CR_USER` | Harbor registry & OCI chart repository username | +| `CR_PAT` | Harbor registry & OCI chart repository access token / password | +| `DHUSER` | DockerHub username | +| `DHPAT` | DockerHub personal access token | diff --git a/SYSTEM.md b/SYSTEM.md new file mode 100644 index 0000000..ef554a4 --- /dev/null +++ b/SYSTEM.md @@ -0,0 +1,42 @@ +# System Architecture (`SYSTEM.md`) + +## Overview + +`trymemcp` is a stateless Python **FastMCP** server using the **Streamable HTTP** transport bound to `0.0.0.0:8000`. It translates Model Context Protocol (MCP) `tools/call` invocations into authenticated asynchronous HTTP requests against the **TryMe Gaming App API** (`https://tryme.tpk.pw`). + +## Architecture Diagram + +```mermaid +graph TB + Client["MCP Client (IDE / Agent / CLI)"] -->|"Streamable HTTP (0.0.0.0:8000/mcp)"| API["FastMCP HTTP Server (app/main.py)"] + Probe["Docker / K8s Health Probe"] -->|"GET /health"| API + API --> Tools["29 Typed MCP Tools"] + Tools --> Service["TryMeClient & Auth Resolver (app/client.py)"] + Service -->|"HTTPS REST (X-API-Key / Bearer / Cookie)"| External["TryMe Gaming App API (https://tryme.tpk.pw)"] +``` + +## Request & Authentication Flow + +```mermaid +sequenceDiagram + participant C as MCP HTTP Client + participant M as FastMCP Server (0.0.0.0:8000) + participant A as TryMeClient Auth Resolver + participant T as TryMe API (tryme.tpk.pw) + + C->>M: POST /mcp (tools/call + optional HTTP headers) + M->>A: Validate tool args via Pydantic models + A->>A: Resolve auth (1: Tool Arg -> 2: MCP HTTP Header -> 3: Env Var) + A->>T: Async HTTPS request (httpx.AsyncClient) + T-->>A: JSON / 204 No Content / 302 Redirect + A-->>M: Structured Python dict/list (or TryMeApiError) + M-->>C: MCP JSON-RPC CallToolResult +``` + +## Module Breakdown + +- **[`app/config.py`](./app/config.py)**: Reads [`version.ini`](./version.ini) and environment variables (`MCP_HOST=0.0.0.0`, `MCP_PORT=8000`, `MCP_PATH=/mcp`, `TRYME_BASE_URL=https://tryme.tpk.pw`, timeouts, and default credentials). +- **[`app/models.py`](./app/models.py)**: Pydantic v2 models (`QuestionOptionSchema`, `QuestionIn`) enforcing OpenAPI 3.1 field constraints and generating rich JSON Schemas for MCP tool discovery. +- **[`app/client.py`](./app/client.py)**: Async `httpx.AsyncClient` wrapper handling three-tier credential resolution, Bearer header normalization, `tryme_session` cookie extraction, redirect reporting, and structured upstream error reporting (`TryMeApiError`). +- **[`app/main.py`](./app/main.py)**: FastMCP server instance, custom HTTP endpoints (`/health`, `/openapi.yaml`, `/openapi.json`), all 29 MCP tool definitions, ASGI `app`, and `run_server()` CLI entrypoint. +- **[`helm-chart/`](./helm-chart)** & **[`.gitea/workflows/cicd.yaml`](./.gitea/workflows/cicd.yaml)**: Automated Docker multi-stage build, Harbor & DockerHub image publishing, and Helm OCI chart delivery. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..44b41ea --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,5 @@ +"""TryMe TPK FastMCP HTTP Server package.""" + +from app.config import get_version + +__version__ = get_version() diff --git a/app/client.py b/app/client.py new file mode 100644 index 0000000..2389c89 --- /dev/null +++ b/app/client.py @@ -0,0 +1,178 @@ +"""Async HTTP client for the TryMe Gaming App API.""" + +from __future__ import annotations + +from http.cookies import SimpleCookie +from typing import Any + +import httpx +from fastmcp.server.dependencies import get_http_headers + +from app.config import Settings, settings + + +class TryMeApiError(RuntimeError): + """Raised when the upstream TryMe API returns an error response.""" + + def __init__(self, status_code: int, detail: Any, path: str) -> None: + self.status_code = status_code + self.detail = detail + self.path = path + super().__init__(f"TryMe API error ({status_code}) on {path}: {detail}") + + +def _normalize_authorization(value: str) -> str: + """Ensure raw tokens are prefixed with 'Bearer ' while preserving full auth headers.""" + cleaned = value.strip() + if not cleaned: + return "" + if " " in cleaned: + return cleaned + return f"Bearer {cleaned}" + + +def _extract_cookie_from_header(cookie_header: str | None, name: str) -> str | None: + """Extract a named cookie from an incoming HTTP Cookie header string.""" + if not cookie_header: + return None + cookie = SimpleCookie() + try: + cookie.load(cookie_header) + except Exception: + return None + morsel = cookie.get(name) + return morsel.value if morsel else None + + +def _safe_incoming_headers() -> dict[str, str]: + """Read incoming MCP HTTP headers if running inside an HTTP request context.""" + try: + raw = get_http_headers(include_all=True) or {} + return {k.lower(): v for k, v in raw.items() if isinstance(v, str)} + except Exception: + return {} + + +class TryMeClient: + """HTTP client wrapper for interacting with https://tryme.tpk.pw.""" + + def __init__( + self, + config: Settings | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.config = config or settings + self._transport = transport + + def build_auth( + self, + *, + api_key: str | None = None, + authorization: str | None = None, + tryme_session: str | None = None, + ) -> tuple[dict[str, str], dict[str, str]]: + """Resolve authentication headers and cookies with precedence: + 1. Explicit tool parameters + 2. Incoming MCP HTTP request headers/cookies + 3. Server environment variables + """ + incoming = _safe_incoming_headers() + headers: dict[str, str] = {} + cookies: dict[str, str] = {} + + resolved_api_key = ( + api_key + or incoming.get("x-api-key") + or self.config.tryme_api_key + ) + if resolved_api_key: + headers["X-API-Key"] = resolved_api_key.strip() + + resolved_auth = ( + authorization + or incoming.get("authorization") + or self.config.tryme_bearer_token + ) + if resolved_auth: + norm = _normalize_authorization(resolved_auth) + if norm: + headers["Authorization"] = norm + + resolved_session = ( + tryme_session + or _extract_cookie_from_header(incoming.get("cookie"), "tryme_session") + or self.config.tryme_session_cookie + ) + if resolved_session: + cookies["tryme_session"] = resolved_session.strip() + + return headers, cookies + + async def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: Any = None, + api_key: str | None = None, + authorization: str | None = None, + tryme_session: str | None = None, + follow_redirects: bool = False, + ) -> Any: + """Send an async HTTP request to the TryMe API and parse the response.""" + headers, cookies = self.build_auth( + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + headers.setdefault("Accept", "application/json") + + cleaned_params: dict[str, Any] | None = None + if params: + cleaned_params = {k: v for k, v in params.items() if v is not None} + + async with httpx.AsyncClient( + base_url=self.config.tryme_base_url, + timeout=self.config.tryme_timeout_seconds, + follow_redirects=follow_redirects, + cookies=cookies, + transport=self._transport, + ) as http: + response = await http.request( + method=method.upper(), + url=path, + params=cleaned_params, + json=json_body, + headers=headers, + ) + + if 300 <= response.status_code < 400: + result: dict[str, Any] = { + "status_code": response.status_code, + "redirect_url": response.headers.get("location", ""), + } + set_cookie = response.headers.get("set-cookie") + if set_cookie: + result["cookies_set"] = True + return result + + if response.status_code == 204 or not response.content: + return None + + if response.status_code >= 400: + try: + err_payload = response.json() + detail = err_payload.get("detail", err_payload) if isinstance(err_payload, dict) else err_payload + except Exception: + detail = response.text + raise TryMeApiError(response.status_code, detail, path) + + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + return response.json() + + try: + return response.json() + except Exception: + return {"status_code": response.status_code, "text": response.text} diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f96603e --- /dev/null +++ b/app/config.py @@ -0,0 +1,65 @@ +"""Configuration management for the TryMe FastMCP HTTP server.""" + +from __future__ import annotations + +import configparser +import os +from dataclasses import dataclass +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +def get_version() -> str: + """Read application version from version.ini.""" + version_file = PROJECT_ROOT / "version.ini" + if version_file.exists(): + parser = configparser.ConfigParser() + parser.read(version_file, encoding="utf-8") + if parser.has_option("version", "version"): + return parser.get("version", "version").strip() + return "0.1" + + +@dataclass(slots=True) +class Settings: + """Runtime settings populated from environment variables.""" + + version: str + mcp_host: str + mcp_port: int + mcp_path: str + tryme_base_url: str + tryme_timeout_seconds: float + tryme_api_key: str | None + tryme_bearer_token: str | None + tryme_session_cookie: str | None + + @classmethod + def from_env(cls) -> Settings: + """Load settings from current process environment.""" + raw_path = os.getenv("MCP_PATH", "/mcp").strip() or "/mcp" + if not raw_path.startswith("/"): + raw_path = f"/{raw_path}" + + api_key = os.getenv("TRYME_API_KEY", "").strip() or None + bearer_token = os.getenv("TRYME_BEARER_TOKEN", "").strip() or None + session_cookie = os.getenv("TRYME_SESSION_COOKIE", "").strip() or None + + return cls( + version=get_version(), + mcp_host=os.getenv("MCP_HOST", "0.0.0.0").strip() or "0.0.0.0", + mcp_port=int(os.getenv("MCP_PORT", "8000")), + mcp_path=raw_path, + tryme_base_url=( + os.getenv("TRYME_BASE_URL", "https://tryme.tpk.pw").strip().rstrip("/") + or "https://tryme.tpk.pw" + ), + tryme_timeout_seconds=float(os.getenv("TRYME_TIMEOUT_SECONDS", "30")), + tryme_api_key=api_key, + tryme_bearer_token=bearer_token, + tryme_session_cookie=session_cookie, + ) + + +settings = Settings.from_env() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3fdd4e2 --- /dev/null +++ b/app/main.py @@ -0,0 +1,801 @@ +"""FastMCP HTTP server exposing all tools from https://tryme.tpk.pw/openapi.json.""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +import yaml +from fastmcp import FastMCP +from pydantic import Field +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse, Response + +from app.client import TryMeClient +from app.config import PROJECT_ROOT, settings +from app.models import QuestionIn + +_client = TryMeClient(config=settings) + + +def get_client() -> TryMeClient: + """Return the active TryMe API client instance.""" + return _client + + +def set_client(client: TryMeClient) -> None: + """Override the active TryMe API client instance (primarily for testing).""" + global _client + _client = client + + +mcp = FastMCP( + name="TryMe TPK MCP Server", + version=settings.version, + instructions=( + "MCP HTTP server for the TryMe Gaming App API (https://tryme.tpk.pw). " + "Provides tools for student quiz creation, discovery, gameplay/submission, " + "voting, favourites, leaderboards/scores, Auth0/dev authentication, personal " + "API keys, and admin moderation. Authenticated endpoints accept optional " + "`api_key`, `authorization` (Bearer token), or `tryme_session` parameters, " + "and also inherit incoming HTTP headers or server environment variables " + "(`TRYME_API_KEY`, `TRYME_BEARER_TOKEN`, `TRYME_SESSION_COOKIE`)." + ), +) + +# --------------------------------------------------------------------------- +# Custom HTTP Endpoints (Healthcheck & OpenAPI Specification) +# --------------------------------------------------------------------------- + +OPENAPI_PATH = PROJECT_ROOT / "openapi.yaml" + + +@mcp.custom_route("/health", methods=["GET"], name="server_health") +async def server_health(_request: Request) -> Response: + """Container and service healthcheck endpoint.""" + return JSONResponse( + { + "status": "ok", + "service": "trymemcp", + "version": settings.version, + "transport": "http", + "host": settings.mcp_host, + "port": settings.mcp_port, + "mcp_endpoint": settings.mcp_path, + "upstream_url": settings.tryme_base_url, + } + ) + + +@mcp.custom_route("/openapi.yaml", methods=["GET"], name="openapi_yaml") +async def serve_openapi_yaml(_request: Request) -> Response: + """Serve the server's OpenAPI 3.1 YAML specification.""" + if not OPENAPI_PATH.exists(): + return JSONResponse({"detail": "openapi.yaml not found"}, status_code=404) + content = OPENAPI_PATH.read_text(encoding="utf-8") + return PlainTextResponse(content, media_type="application/yaml") + + +@mcp.custom_route("/openapi.json", methods=["GET"], name="openapi_json") +async def serve_openapi_json(_request: Request) -> Response: + """Serve the server's OpenAPI 3.1 JSON specification.""" + if not OPENAPI_PATH.exists(): + return JSONResponse({"detail": "openapi.yaml not found"}, status_code=404) + spec = yaml.safe_load(OPENAPI_PATH.read_text(encoding="utf-8")) + return JSONResponse(spec) + + +# --------------------------------------------------------------------------- +# Shared Auth Parameter Type Aliases +# --------------------------------------------------------------------------- + +ApiKeyParam = Annotated[ + str | None, + Field( + default=None, + description="Optional TryMe API key (`X-API-Key` header). Overrides server default if provided.", + ), +] +AuthorizationParam = Annotated[ + str | None, + Field( + default=None, + description="Optional Bearer token or `Authorization` header value.", + ), +] +SessionCookieParam = Annotated[ + str | None, + Field( + default=None, + description="Optional `tryme_session` cookie value.", + ), +] + + +# --------------------------------------------------------------------------- +# System Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(tags={"System"}) +async def healthcheck() -> dict[str, Any]: + """Check the health status of the upstream TryMe Gaming App API (`GET /health`).""" + return await get_client().request("GET", "/health") + + +# --------------------------------------------------------------------------- +# Authentication Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(tags={"Authentication"}) +async def get_auth_config() -> dict[str, Any]: + """Retrieve Auth0 federated login settings, dev-auth status, and admin claim status (`GET /api/v1/auth/config`).""" + return await get_client().request("GET", "/api/v1/auth/config") + + +@mcp.tool(tags={"Authentication"}) +async def auth0_login( + return_to: Annotated[ + str, + Field(default="/", description="Relative path to redirect to after login."), + ] = "/", +) -> dict[str, Any]: + """Initiate the Auth0 federated login flow (`GET /api/v1/auth/login`).""" + return await get_client().request( + "GET", + "/api/v1/auth/login", + params={"return_to": return_to}, + follow_redirects=False, + ) + + +@mcp.tool(tags={"Authentication"}) +async def auth0_callback( + code: Annotated[str, Field(description="Authorization code returned by Auth0.")], + state: Annotated[str, Field(description="CSRF state parameter returned by Auth0.")], +) -> dict[str, Any]: + """Handle the Auth0 OAuth callback (`GET /api/v1/auth/callback`).""" + return await get_client().request( + "GET", + "/api/v1/auth/callback", + params={"code": code, "state": state}, + follow_redirects=False, + ) + + +@mcp.tool(tags={"Authentication"}) +async def dev_login( + nickname: Annotated[ + str, + Field(min_length=2, max_length=48, description="User nickname (2-48 chars)."), + ], + email: Annotated[ + str | None, + Field(default=None, max_length=255, description="Optional email address."), + ] = None, + as_moderator: Annotated[ + bool, + Field(default=False, description="Request moderator privileges in dev mode."), + ] = False, +) -> dict[str, Any]: + """Authenticate using the development login endpoint (`POST /api/v1/auth/dev-login`). + + Returns an `access_token`, `token_type`, and `user` profile when dev auth is enabled. + """ + payload: dict[str, Any] = { + "nickname": nickname, + "email": email, + "as_moderator": as_moderator, + } + return await get_client().request("POST", "/api/v1/auth/dev-login", json_body=payload) + + +@mcp.tool(tags={"Authentication"}) +async def logout( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Log out the current authenticated session (`POST /api/v1/auth/logout`).""" + await get_client().request( + "POST", + "/api/v1/auth/logout", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + return {"status": "logged_out"} + + +# --------------------------------------------------------------------------- +# Users & API Keys Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(tags={"Users & API Keys"}) +async def get_me( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Retrieve the authenticated user's profile (`GET /api/v1/users/me`).""" + return await get_client().request( + "GET", + "/api/v1/users/me", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Users & API Keys"}) +async def update_me( + nickname: Annotated[ + str | None, + Field(default=None, min_length=2, max_length=48, description="Updated nickname."), + ] = None, + first_name: Annotated[ + str | None, + Field(default=None, max_length=80, description="Updated first name."), + ] = None, + last_name: Annotated[ + str | None, + Field(default=None, max_length=80, description="Updated last name."), + ] = None, + age: Annotated[ + int | None, + Field(default=None, ge=4, le=120, description="Updated age (4-120)."), + ] = None, + clear_age: Annotated[ + bool, + Field(default=False, description="Set to true to clear the stored age."), + ] = False, + gender: Annotated[ + str | None, + Field(default=None, description="Updated gender value."), + ] = None, + gender_custom: Annotated[ + str | None, + Field(default=None, max_length=80, description="Custom gender text."), + ] = None, + avatar_animal: Annotated[ + str | None, + Field(default=None, description="Avatar animal identifier."), + ] = None, + avatar_color: Annotated[ + str | None, + Field(default=None, description="Avatar color identifier."), + ] = None, + public_website_consent: Annotated[ + bool | None, + Field(default=None, description="Public website display consent flag."), + ] = None, + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Update the authenticated user's profile (`PATCH /api/v1/users/me`).""" + payload: dict[str, Any] = {} + optional_fields = { + "nickname": nickname, + "first_name": first_name, + "last_name": last_name, + "age": age, + "gender": gender, + "gender_custom": gender_custom, + "avatar_animal": avatar_animal, + "avatar_color": avatar_color, + "public_website_consent": public_website_consent, + } + for key, value in optional_fields.items(): + if value is not None: + payload[key] = value + if clear_age: + payload["clear_age"] = True + + return await get_client().request( + "PATCH", + "/api/v1/users/me", + json_body=payload, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Users & API Keys"}) +async def get_my_scores( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """Retrieve quiz score summaries and attempt counts for the authenticated user (`GET /api/v1/users/me/scores`).""" + return await get_client().request( + "GET", + "/api/v1/users/me/scores", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Users & API Keys"}) +async def list_api_keys( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """List personal API keys belonging to the authenticated user (`GET /api/v1/users/me/api-keys`).""" + return await get_client().request( + "GET", + "/api/v1/users/me/api-keys", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Users & API Keys"}) +async def create_api_key( + name: Annotated[ + str, + Field(min_length=1, max_length=80, description="Descriptive name for the API key."), + ], + expires_in_days: Annotated[ + int | None, + Field( + default=90, + ge=1, + le=3650, + description="Number of days until expiration (1-3650), or null for non-expiring if supported.", + ), + ] = 90, + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Create a new personal API key (`POST /api/v1/users/me/api-keys`). Returns `raw_key` once.""" + payload = {"name": name, "expires_in_days": expires_in_days} + return await get_client().request( + "POST", + "/api/v1/users/me/api-keys", + json_body=payload, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Users & API Keys"}) +async def expire_api_key( + key_id: Annotated[int, Field(description="Numeric ID of the API key to expire/revoke.")], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Expire/revoke an existing personal API key (`POST /api/v1/users/me/api-keys/{key_id}/expire`).""" + return await get_client().request( + "POST", + f"/api/v1/users/me/api-keys/{key_id}/expire", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +# --------------------------------------------------------------------------- +# Quizzes Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(tags={"Quizzes"}) +async def list_quizzes( + q: Annotated[ + str | None, + Field(default=None, max_length=120, description="Optional search query string."), + ] = None, + mode: Annotated[ + str | None, + Field( + default=None, + description="Optional quiz mode filter (e.g. 'multiple_choice', 'write_in', 'mixed').", + ), + ] = None, + mine_only: Annotated[ + bool, + Field(default=False, description="If true, return only quizzes authored by the authenticated user."), + ] = False, + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """Search and list quizzes (`GET /api/v1/quizzes`).""" + return await get_client().request( + "GET", + "/api/v1/quizzes", + params={"q": q, "mode": mode, "mine_only": mine_only}, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Quizzes"}) +async def create_quiz( + title: Annotated[ + str, + Field(min_length=1, max_length=140, description="Quiz title (1-140 chars)."), + ], + description: Annotated[ + str, + Field(default="", max_length=1000, description="Quiz description (up to 1000 chars)."), + ] = "", + is_public: Annotated[ + bool, + Field(default=True, description="Whether the quiz is publicly listed."), + ] = True, + questions: Annotated[ + list[QuestionIn] | None, + Field(default=None, max_length=40, description="Up to 40 quiz questions."), + ] = None, + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Create a new quiz (`POST /api/v1/quizzes`).""" + payload: dict[str, Any] = { + "title": title, + "description": description, + "is_public": is_public, + "questions": [q.model_dump() for q in (questions or [])], + } + return await get_client().request( + "POST", + "/api/v1/quizzes", + json_body=payload, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Quizzes"}) +async def get_quiz( + slug_or_id: Annotated[ + str, + Field(description="Quiz share slug or numeric quiz ID (passed as string)."), + ], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Retrieve full details and questions for a quiz (`GET /api/v1/quizzes/{slug_or_id}`).""" + return await get_client().request( + "GET", + f"/api/v1/quizzes/{slug_or_id}", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Quizzes"}) +async def update_quiz( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to update.")], + title: Annotated[ + str, + Field(min_length=1, max_length=140, description="Updated quiz title (1-140 chars)."), + ], + description: Annotated[ + str, + Field(default="", max_length=1000, description="Updated quiz description (up to 1000 chars)."), + ] = "", + is_public: Annotated[ + bool, + Field(default=True, description="Whether the quiz is publicly listed."), + ] = True, + questions: Annotated[ + list[QuestionIn] | None, + Field(default=None, max_length=40, description="Full updated list of up to 40 quiz questions."), + ] = None, + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Update an existing quiz (`PUT /api/v1/quizzes/{quiz_id}`).""" + payload: dict[str, Any] = { + "title": title, + "description": description, + "is_public": is_public, + } + if questions is not None: + payload["questions"] = [q.model_dump() for q in questions] + return await get_client().request( + "PUT", + f"/api/v1/quizzes/{quiz_id}", + json_body=payload, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Quizzes"}) +async def delete_quiz( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to delete.")], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Delete a quiz authored by the authenticated user (`DELETE /api/v1/quizzes/{quiz_id}`).""" + await get_client().request( + "DELETE", + f"/api/v1/quizzes/{quiz_id}", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + return {"status": "deleted", "quiz_id": quiz_id} + + +@mcp.tool(tags={"Quizzes"}) +async def submit_quiz( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to submit answers for.")], + answers: Annotated[ + dict[str, str], + Field( + default_factory=dict, + description="Mapping of question ID (as string) to submitted option ID or write-in answer.", + ), + ], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Submit answers for a quiz and receive graded results & scores (`POST /api/v1/quizzes/{quiz_id}/submit`).""" + return await get_client().request( + "POST", + f"/api/v1/quizzes/{quiz_id}/submit", + json_body={"answers": answers}, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Quizzes"}) +async def vote_quiz( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to vote on.")], + value: Annotated[ + Literal[-1, 0, 1], + Field(description="Vote value: `1` (upvote), `-1` (downvote), or `0` (remove vote)."), + ], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Upvote, downvote, or clear vote on a quiz (`POST /api/v1/quizzes/{quiz_id}/vote`).""" + return await get_client().request( + "POST", + f"/api/v1/quizzes/{quiz_id}/vote", + json_body={"value": value}, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +# --------------------------------------------------------------------------- +# Favourites Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(tags={"Favourites"}) +async def list_favourites( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """List the authenticated user's favourited quizzes (`GET /api/v1/favourites`).""" + return await get_client().request( + "GET", + "/api/v1/favourites", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Favourites"}) +async def add_favourite( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to favourite.")], + position: Annotated[ + int | None, + Field(default=None, ge=0, description="Optional 0-based position in the favourites list."), + ] = None, + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """Add a quiz to the authenticated user's favourites (`POST /api/v1/favourites`).""" + payload: dict[str, Any] = {"quiz_id": quiz_id} + if position is not None: + payload["position"] = position + return await get_client().request( + "POST", + "/api/v1/favourites", + json_body=payload, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Favourites"}) +async def reorder_favourites( + quiz_ids: Annotated[ + list[int], + Field(description="Ordered list of favourited quiz IDs representing the desired order."), + ], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """Reorder the authenticated user's favourited quizzes (`PUT /api/v1/favourites/reorder`).""" + return await get_client().request( + "PUT", + "/api/v1/favourites/reorder", + json_body={"quiz_ids": quiz_ids}, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Favourites"}) +async def remove_favourite( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to remove from favourites.")], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """Remove a quiz from the authenticated user's favourites (`DELETE /api/v1/favourites/{quiz_id}`).""" + return await get_client().request( + "DELETE", + f"/api/v1/favourites/{quiz_id}", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +# --------------------------------------------------------------------------- +# Moderation Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(tags={"Moderation"}) +async def claim_initial_moderator( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Claim initial moderator/admin status when no admin exists (`POST /api/v1/admin/claim`).""" + return await get_client().request( + "POST", + "/api/v1/admin/claim", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Moderation"}) +async def list_users_for_moderation( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """List all users for admin moderation (`GET /api/v1/admin/users`). Requires moderator privileges.""" + return await get_client().request( + "GET", + "/api/v1/admin/users", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Moderation"}) +async def ban_user( + user_id: Annotated[int, Field(description="Numeric ID of the user to ban.")], + reason: Annotated[ + str, + Field( + default="Community guidelines violation", + max_length=255, + description="Reason for banning the user.", + ), + ] = "Community guidelines violation", + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Ban a user (`POST /api/v1/admin/users/{user_id}/ban`). Requires moderator privileges.""" + return await get_client().request( + "POST", + f"/api/v1/admin/users/{user_id}/ban", + json_body={"reason": reason}, + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Moderation"}) +async def unban_user( + user_id: Annotated[int, Field(description="Numeric ID of the user to unban.")], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Unban a previously banned user (`POST /api/v1/admin/users/{user_id}/unban`). Requires moderator privileges.""" + return await get_client().request( + "POST", + f"/api/v1/admin/users/{user_id}/unban", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Moderation"}) +async def list_quizzes_for_moderation( + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> list[dict[str, Any]]: + """List all quizzes for admin moderation (`GET /api/v1/admin/quizzes`). Requires moderator privileges.""" + return await get_client().request( + "GET", + "/api/v1/admin/quizzes", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + + +@mcp.tool(tags={"Moderation"}) +async def moderator_remove_quiz( + quiz_id: Annotated[int, Field(description="Numeric ID of the quiz to remove.")], + api_key: ApiKeyParam = None, + authorization: AuthorizationParam = None, + tryme_session: SessionCookieParam = None, +) -> dict[str, Any]: + """Remove any quiz as a moderator (`DELETE /api/v1/admin/quizzes/{quiz_id}`). Requires moderator privileges.""" + await get_client().request( + "DELETE", + f"/api/v1/admin/quizzes/{quiz_id}", + api_key=api_key, + authorization=authorization, + tryme_session=tryme_session, + ) + return {"status": "removed", "quiz_id": quiz_id} + + +# ASGI application for Uvicorn / Gunicorn (`uvicorn app.main:app --host 0.0.0.0 --port 8000`) +app = mcp.http_app(path=settings.mcp_path, transport="http") + + +def run_server() -> None: + """Start the FastMCP HTTP server bound to 0.0.0.0 (all interfaces) by default.""" + mcp.run( + transport="http", + host=settings.mcp_host, + port=settings.mcp_port, + path=settings.mcp_path, + ) + + +if __name__ == "__main__": + run_server() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..4bb8053 --- /dev/null +++ b/app/models.py @@ -0,0 +1,53 @@ +"""Pydantic input schemas aligned with https://tryme.tpk.pw/openapi.json.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class QuestionOptionSchema(BaseModel): + """Multiple-choice option for a quiz question.""" + + id: str = Field( + ..., + min_length=1, + max_length=32, + description="Option identifier (e.g. 'a', 'b', 'opt1').", + ) + text: str = Field( + ..., + min_length=1, + max_length=300, + description="Display text of the answer option.", + ) + + +class QuestionIn(BaseModel): + """Input schema for creating or updating a quiz question.""" + + question_type: Literal["multiple_choice", "write_in"] = Field( + ..., + description="Type of question: 'multiple_choice' or 'write_in'.", + ) + prompt: str = Field( + ..., + min_length=1, + max_length=1000, + description="Question prompt displayed to quiz players.", + ) + options: list[QuestionOptionSchema] = Field( + default_factory=list, + description="List of selectable options (used when question_type is 'multiple_choice').", + ) + correct_answer: str = Field( + ..., + min_length=1, + max_length=500, + description="Option ID (for multiple_choice) or canonical answer text (for write_in).", + ) + accepted_answers: list[str] = Field( + default_factory=list, + description="Additional accepted write-in answers.", + ) diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..dd9fabb --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + trymemcp: + build: + context: . + dockerfile: Dockerfile + container_name: trymemcp + ports: + - "8000:8000" + environment: + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" + MCP_PATH: "/mcp" + TRYME_BASE_URL: "${TRYME_BASE_URL:-https://tryme.tpk.pw}" + TRYME_TIMEOUT_SECONDS: "${TRYME_TIMEOUT_SECONDS:-30}" + TRYME_API_KEY: "${TRYME_API_KEY:-}" + TRYME_BEARER_TOKEN: "${TRYME_BEARER_TOKEN:-}" + TRYME_SESSION_COOKIE: "${TRYME_SESSION_COOKIE:-}" + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml new file mode 100644 index 0000000..22fae5b --- /dev/null +++ b/helm-chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: trymemcp +description: A Helm chart for TryMe TPK FastMCP HTTP Server +type: application +version: 0.1.0 +appVersion: "0.1" diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..cbc7b30 --- /dev/null +++ b/helm-chart/templates/_helpers.tpl @@ -0,0 +1,28 @@ +{{- define "trymemcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "trymemcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "trymemcp.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{ include "trymemcp.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "trymemcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "trymemcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/helm-chart/templates/deployment.yaml b/helm-chart/templates/deployment.yaml new file mode 100644 index 0000000..fe5043e --- /dev/null +++ b/helm-chart/templates/deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "trymemcp.fullname" . }} + labels: + {{- include "trymemcp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "trymemcp.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "trymemcp.selectorLabels" . | nindent 8 }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: MCP_HOST + value: {{ .Values.env.MCP_HOST | quote }} + - name: MCP_PORT + value: {{ .Values.env.MCP_PORT | quote }} + - name: MCP_PATH + value: {{ .Values.env.MCP_PATH | quote }} + - name: TRYME_BASE_URL + value: {{ .Values.env.TRYME_BASE_URL | quote }} + - name: TRYME_TIMEOUT_SECONDS + value: {{ .Values.env.TRYME_TIMEOUT_SECONDS | quote }} + {{- if .Values.secrets.trymeApiKey }} + - name: TRYME_API_KEY + value: {{ .Values.secrets.trymeApiKey | quote }} + {{- end }} + {{- if .Values.secrets.trymeBearerToken }} + - name: TRYME_BEARER_TOKEN + value: {{ .Values.secrets.trymeBearerToken | quote }} + {{- end }} + {{- if .Values.secrets.trymeSessionCookie }} + - name: TRYME_SESSION_COOKIE + value: {{ .Values.secrets.trymeSessionCookie | quote }} + {{- end }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/helm-chart/templates/service.yaml b/helm-chart/templates/service.yaml new file mode 100644 index 0000000..f98284f --- /dev/null +++ b/helm-chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "trymemcp.fullname" . }} + labels: + {{- include "trymemcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "trymemcp.selectorLabels" . | nindent 4 }} diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml new file mode 100644 index 0000000..ee47ca5 --- /dev/null +++ b/helm-chart/values.yaml @@ -0,0 +1,30 @@ +replicaCount: 1 + +image: + repository: harbor.freshbrewed.science/library/trymemcp + pullPolicy: IfNotPresent + tag: "0.1" + +service: + type: ClusterIP + port: 8000 + +env: + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" + MCP_PATH: "/mcp" + TRYME_BASE_URL: "https://tryme.tpk.pw" + TRYME_TIMEOUT_SECONDS: "30" + +secrets: + trymeApiKey: "" + trymeBearerToken: "" + trymeSessionCookie: "" + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..3a21b94 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,1249 @@ +openapi: 3.1.0 +info: + title: TryMe TPK FastMCP HTTP Server + version: '0.1' + description: Streamable HTTP Model Context Protocol (MCP) server exposing all 29 + TryMe Gaming App API operations (https://tryme.tpk.pw/openapi.json) as MCP tools. +servers: +- url: http://0.0.0.0:8000 + description: Default local/container HTTP bind +paths: + /mcp: + post: + tags: + - MCP + summary: MCP Streamable HTTP Endpoint (JSON-RPC 2.0) + operationId: mcp_post + description: Accepts MCP JSON-RPC 2.0 messages (`initialize`, `tools/list`, + `tools/call`, etc.). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JsonRpcRequest' + responses: + '200': + description: JSON-RPC response or SSE event stream + content: + application/json: + schema: + type: object + text/event-stream: + schema: + type: string + get: + tags: + - MCP + summary: MCP Streamable HTTP SSE Listener + operationId: mcp_get + responses: + '200': + description: SSE event stream for server-initiated notifications + content: + text/event-stream: + schema: + type: string + delete: + tags: + - MCP + summary: Terminate MCP HTTP Session + operationId: mcp_delete + responses: + '200': + description: Session terminated + /health: + get: + tags: + - System + summary: MCP Server Container Healthcheck + operationId: server_health + responses: + '200': + description: Server health status + content: + application/json: + schema: + $ref: '#/components/schemas/ServerHealthOut' + /openapi.yaml: + get: + tags: + - System + summary: OpenAPI 3.1 YAML Specification + operationId: openapi_yaml + responses: + '200': + description: OpenAPI YAML document + content: + application/yaml: + schema: + type: string + /openapi.json: + get: + tags: + - System + summary: OpenAPI 3.1 JSON Specification + operationId: openapi_json + responses: + '200': + description: OpenAPI JSON document + content: + application/json: + schema: + type: object +x-mcp-tools: +- name: healthcheck + description: Check the health status of the upstream TryMe Gaming App API (`GET + /health`). + tags: + - System + inputSchemaRef: '#/components/schemas/Tool_healthcheck_Input' +- name: get_auth_config + description: Retrieve Auth0 federated login settings, dev-auth status, and admin + claim status (`GET /api/v1/auth/config`). + tags: + - Authentication + inputSchemaRef: '#/components/schemas/Tool_get_auth_config_Input' +- name: auth0_login + description: Initiate the Auth0 federated login flow (`GET /api/v1/auth/login`). + tags: + - Authentication + inputSchemaRef: '#/components/schemas/Tool_auth0_login_Input' +- name: auth0_callback + description: Handle the Auth0 OAuth callback (`GET /api/v1/auth/callback`). + tags: + - Authentication + inputSchemaRef: '#/components/schemas/Tool_auth0_callback_Input' +- name: dev_login + description: 'Authenticate using the development login endpoint (`POST /api/v1/auth/dev-login`). + + + Returns an `access_token`, `token_type`, and `user` profile when dev auth is enabled.' + tags: + - Authentication + inputSchemaRef: '#/components/schemas/Tool_dev_login_Input' +- name: logout + description: Log out the current authenticated session (`POST /api/v1/auth/logout`). + tags: + - Authentication + inputSchemaRef: '#/components/schemas/Tool_logout_Input' +- name: get_me + description: Retrieve the authenticated user's profile (`GET /api/v1/users/me`). + tags: + - Users & API Keys + inputSchemaRef: '#/components/schemas/Tool_get_me_Input' +- name: update_me + description: Update the authenticated user's profile (`PATCH /api/v1/users/me`). + tags: + - Users & API Keys + inputSchemaRef: '#/components/schemas/Tool_update_me_Input' +- name: get_my_scores + description: Retrieve quiz score summaries and attempt counts for the authenticated + user (`GET /api/v1/users/me/scores`). + tags: + - Users & API Keys + inputSchemaRef: '#/components/schemas/Tool_get_my_scores_Input' +- name: list_api_keys + description: List personal API keys belonging to the authenticated user (`GET /api/v1/users/me/api-keys`). + tags: + - Users & API Keys + inputSchemaRef: '#/components/schemas/Tool_list_api_keys_Input' +- name: create_api_key + description: Create a new personal API key (`POST /api/v1/users/me/api-keys`). Returns + `raw_key` once. + tags: + - Users & API Keys + inputSchemaRef: '#/components/schemas/Tool_create_api_key_Input' +- name: expire_api_key + description: Expire/revoke an existing personal API key (`POST /api/v1/users/me/api-keys/{key_id}/expire`). + tags: + - Users & API Keys + inputSchemaRef: '#/components/schemas/Tool_expire_api_key_Input' +- name: list_quizzes + description: Search and list quizzes (`GET /api/v1/quizzes`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_list_quizzes_Input' +- name: create_quiz + description: Create a new quiz (`POST /api/v1/quizzes`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_create_quiz_Input' +- name: get_quiz + description: Retrieve full details and questions for a quiz (`GET /api/v1/quizzes/{slug_or_id}`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_get_quiz_Input' +- name: update_quiz + description: Update an existing quiz (`PUT /api/v1/quizzes/{quiz_id}`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_update_quiz_Input' +- name: delete_quiz + description: Delete a quiz authored by the authenticated user (`DELETE /api/v1/quizzes/{quiz_id}`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_delete_quiz_Input' +- name: submit_quiz + description: Submit answers for a quiz and receive graded results & scores (`POST + /api/v1/quizzes/{quiz_id}/submit`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_submit_quiz_Input' +- name: vote_quiz + description: Upvote, downvote, or clear vote on a quiz (`POST /api/v1/quizzes/{quiz_id}/vote`). + tags: + - Quizzes + inputSchemaRef: '#/components/schemas/Tool_vote_quiz_Input' +- name: list_favourites + description: List the authenticated user's favourited quizzes (`GET /api/v1/favourites`). + tags: + - Favourites + inputSchemaRef: '#/components/schemas/Tool_list_favourites_Input' +- name: add_favourite + description: Add a quiz to the authenticated user's favourites (`POST /api/v1/favourites`). + tags: + - Favourites + inputSchemaRef: '#/components/schemas/Tool_add_favourite_Input' +- name: reorder_favourites + description: Reorder the authenticated user's favourited quizzes (`PUT /api/v1/favourites/reorder`). + tags: + - Favourites + inputSchemaRef: '#/components/schemas/Tool_reorder_favourites_Input' +- name: remove_favourite + description: Remove a quiz from the authenticated user's favourites (`DELETE /api/v1/favourites/{quiz_id}`). + tags: + - Favourites + inputSchemaRef: '#/components/schemas/Tool_remove_favourite_Input' +- name: claim_initial_moderator + description: Claim initial moderator/admin status when no admin exists (`POST /api/v1/admin/claim`). + tags: + - Moderation + inputSchemaRef: '#/components/schemas/Tool_claim_initial_moderator_Input' +- name: list_users_for_moderation + description: List all users for admin moderation (`GET /api/v1/admin/users`). Requires + moderator privileges. + tags: + - Moderation + inputSchemaRef: '#/components/schemas/Tool_list_users_for_moderation_Input' +- name: ban_user + description: Ban a user (`POST /api/v1/admin/users/{user_id}/ban`). Requires moderator + privileges. + tags: + - Moderation + inputSchemaRef: '#/components/schemas/Tool_ban_user_Input' +- name: unban_user + description: Unban a previously banned user (`POST /api/v1/admin/users/{user_id}/unban`). + Requires moderator privileges. + tags: + - Moderation + inputSchemaRef: '#/components/schemas/Tool_unban_user_Input' +- name: list_quizzes_for_moderation + description: List all quizzes for admin moderation (`GET /api/v1/admin/quizzes`). + Requires moderator privileges. + tags: + - Moderation + inputSchemaRef: '#/components/schemas/Tool_list_quizzes_for_moderation_Input' +- name: moderator_remove_quiz + description: Remove any quiz as a moderator (`DELETE /api/v1/admin/quizzes/{quiz_id}`). + Requires moderator privileges. + tags: + - Moderation + inputSchemaRef: '#/components/schemas/Tool_moderator_remove_quiz_Input' +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + SessionCookie: + type: apiKey + in: cookie + name: tryme_session + schemas: + JsonRpcRequest: + type: object + required: + - jsonrpc + - method + properties: + jsonrpc: + type: string + const: '2.0' + id: + anyOf: + - type: string + - type: integer + method: + type: string + params: + type: object + ServerHealthOut: + type: object + required: + - status + - service + - version + - transport + - mcp_endpoint + - upstream_url + properties: + status: + type: string + service: + type: string + version: + type: string + transport: + type: string + host: + type: string + port: + type: integer + mcp_endpoint: + type: string + upstream_url: + type: string + Tool_healthcheck_Input: + additionalProperties: false + properties: {} + type: object + Tool_get_auth_config_Input: + additionalProperties: false + properties: {} + type: object + Tool_auth0_login_Input: + additionalProperties: false + properties: + return_to: + default: / + description: Relative path to redirect to after login. + type: string + type: object + Tool_auth0_callback_Input: + additionalProperties: false + properties: + code: + description: Authorization code returned by Auth0. + type: string + state: + description: CSRF state parameter returned by Auth0. + type: string + required: + - code + - state + type: object + Tool_dev_login_Input: + additionalProperties: false + properties: + nickname: + description: User nickname (2-48 chars). + maxLength: 48 + minLength: 2 + type: string + email: + anyOf: + - maxLength: 255 + type: string + - type: 'null' + default: null + description: Optional email address. + as_moderator: + default: false + description: Request moderator privileges in dev mode. + type: boolean + required: + - nickname + type: object + Tool_logout_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_get_me_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_update_me_Input: + additionalProperties: false + properties: + nickname: + anyOf: + - maxLength: 48 + minLength: 2 + type: string + - type: 'null' + default: null + description: Updated nickname. + first_name: + anyOf: + - maxLength: 80 + type: string + - type: 'null' + default: null + description: Updated first name. + last_name: + anyOf: + - maxLength: 80 + type: string + - type: 'null' + default: null + description: Updated last name. + age: + anyOf: + - maximum: 120 + minimum: 4 + type: integer + - type: 'null' + default: null + description: Updated age (4-120). + clear_age: + default: false + description: Set to true to clear the stored age. + type: boolean + gender: + anyOf: + - type: string + - type: 'null' + default: null + description: Updated gender value. + gender_custom: + anyOf: + - maxLength: 80 + type: string + - type: 'null' + default: null + description: Custom gender text. + avatar_animal: + anyOf: + - type: string + - type: 'null' + default: null + description: Avatar animal identifier. + avatar_color: + anyOf: + - type: string + - type: 'null' + default: null + description: Avatar color identifier. + public_website_consent: + anyOf: + - type: boolean + - type: 'null' + default: null + description: Public website display consent flag. + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_get_my_scores_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_list_api_keys_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_create_api_key_Input: + additionalProperties: false + properties: + name: + description: Descriptive name for the API key. + maxLength: 80 + minLength: 1 + type: string + expires_in_days: + anyOf: + - maximum: 3650 + minimum: 1 + type: integer + - type: 'null' + default: 90 + description: Number of days until expiration (1-3650), or null for non-expiring + if supported. + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - name + type: object + Tool_expire_api_key_Input: + additionalProperties: false + properties: + key_id: + description: Numeric ID of the API key to expire/revoke. + type: integer + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - key_id + type: object + Tool_list_quizzes_Input: + additionalProperties: false + properties: + q: + anyOf: + - maxLength: 120 + type: string + - type: 'null' + default: null + description: Optional search query string. + mode: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional quiz mode filter (e.g. 'multiple_choice', 'write_in', + 'mixed'). + mine_only: + default: false + description: If true, return only quizzes authored by the authenticated + user. + type: boolean + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_create_quiz_Input: + additionalProperties: false + properties: + title: + description: Quiz title (1-140 chars). + maxLength: 140 + minLength: 1 + type: string + description: + default: '' + description: Quiz description (up to 1000 chars). + maxLength: 1000 + type: string + is_public: + default: true + description: Whether the quiz is publicly listed. + type: boolean + questions: + anyOf: + - items: + description: Input schema for creating or updating a quiz question. + properties: + question_type: + description: 'Type of question: ''multiple_choice'' or ''write_in''.' + enum: + - multiple_choice + - write_in + type: string + prompt: + description: Question prompt displayed to quiz players. + maxLength: 1000 + minLength: 1 + type: string + options: + description: List of selectable options (used when question_type + is 'multiple_choice'). + items: + description: Multiple-choice option for a quiz question. + properties: + id: + description: Option identifier (e.g. 'a', 'b', 'opt1'). + maxLength: 32 + minLength: 1 + type: string + text: + description: Display text of the answer option. + maxLength: 300 + minLength: 1 + type: string + required: + - id + - text + type: object + type: array + correct_answer: + description: Option ID (for multiple_choice) or canonical answer + text (for write_in). + maxLength: 500 + minLength: 1 + type: string + accepted_answers: + description: Additional accepted write-in answers. + items: + type: string + type: array + required: + - question_type + - prompt + - correct_answer + type: object + maxItems: 40 + type: array + - type: 'null' + default: null + description: Up to 40 quiz questions. + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - title + type: object + Tool_get_quiz_Input: + additionalProperties: false + properties: + slug_or_id: + description: Quiz share slug or numeric quiz ID (passed as string). + type: string + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - slug_or_id + type: object + Tool_update_quiz_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to update. + type: integer + title: + description: Updated quiz title (1-140 chars). + maxLength: 140 + minLength: 1 + type: string + description: + default: '' + description: Updated quiz description (up to 1000 chars). + maxLength: 1000 + type: string + is_public: + default: true + description: Whether the quiz is publicly listed. + type: boolean + questions: + anyOf: + - items: + description: Input schema for creating or updating a quiz question. + properties: + question_type: + description: 'Type of question: ''multiple_choice'' or ''write_in''.' + enum: + - multiple_choice + - write_in + type: string + prompt: + description: Question prompt displayed to quiz players. + maxLength: 1000 + minLength: 1 + type: string + options: + description: List of selectable options (used when question_type + is 'multiple_choice'). + items: + description: Multiple-choice option for a quiz question. + properties: + id: + description: Option identifier (e.g. 'a', 'b', 'opt1'). + maxLength: 32 + minLength: 1 + type: string + text: + description: Display text of the answer option. + maxLength: 300 + minLength: 1 + type: string + required: + - id + - text + type: object + type: array + correct_answer: + description: Option ID (for multiple_choice) or canonical answer + text (for write_in). + maxLength: 500 + minLength: 1 + type: string + accepted_answers: + description: Additional accepted write-in answers. + items: + type: string + type: array + required: + - question_type + - prompt + - correct_answer + type: object + maxItems: 40 + type: array + - type: 'null' + default: null + description: Full updated list of up to 40 quiz questions. + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + - title + type: object + Tool_delete_quiz_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to delete. + type: integer + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + type: object + Tool_submit_quiz_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to submit answers for. + type: integer + answers: + additionalProperties: + type: string + description: Mapping of question ID (as string) to submitted option ID or + write-in answer. + type: object + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + type: object + Tool_vote_quiz_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to vote on. + type: integer + value: + description: 'Vote value: `1` (upvote), `-1` (downvote), or `0` (remove + vote).' + enum: + - -1 + - 0 + - 1 + type: integer + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + - value + type: object + Tool_list_favourites_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_add_favourite_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to favourite. + type: integer + position: + anyOf: + - minimum: 0 + type: integer + - type: 'null' + default: null + description: Optional 0-based position in the favourites list. + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + type: object + Tool_reorder_favourites_Input: + additionalProperties: false + properties: + quiz_ids: + description: Ordered list of favourited quiz IDs representing the desired + order. + items: + type: integer + type: array + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_ids + type: object + Tool_remove_favourite_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to remove from favourites. + type: integer + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + type: object + Tool_claim_initial_moderator_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_list_users_for_moderation_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_ban_user_Input: + additionalProperties: false + properties: + user_id: + description: Numeric ID of the user to ban. + type: integer + reason: + default: Community guidelines violation + description: Reason for banning the user. + maxLength: 255 + type: string + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - user_id + type: object + Tool_unban_user_Input: + additionalProperties: false + properties: + user_id: + description: Numeric ID of the user to unban. + type: integer + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - user_id + type: object + Tool_list_quizzes_for_moderation_Input: + additionalProperties: false + properties: + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + type: object + Tool_moderator_remove_quiz_Input: + additionalProperties: false + properties: + quiz_id: + description: Numeric ID of the quiz to remove. + type: integer + api_key: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional TryMe API key (`X-API-Key` header). Overrides server + default if provided. + authorization: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional Bearer token or `Authorization` header value. + tryme_session: + anyOf: + - type: string + - type: 'null' + default: null + description: Optional `tryme_session` cookie value. + required: + - quiz_id + type: object diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..453e9d7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "trymemcp" +version = "0.1.0" +description = "FastMCP HTTP server exposing tools for the TryMe Gaming App API (https://tryme.tpk.pw)" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +authors = [ + { name = "TryMe Team" } +] +dependencies = [ + "fastmcp>=2.0.0", + "httpx>=0.27.0", + "pydantic>=2.0.0", + "pyyaml>=6.0.0", + "uvicorn>=0.30.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[project.scripts] +trymemcp = "app.main:run_server" + +[tool.setuptools.packages.find] +include = ["app*"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..63bf916 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e557870 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for TryMe TPK FastMCP HTTP Server.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c6bbbb0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,144 @@ +"""Shared pytest fixtures for testing the TryMe FastMCP HTTP server.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +import httpx +import pytest + +from app.client import TryMeClient +from app.config import Settings +from app.main import get_client, set_client + + +@dataclass +class RecordedCall: + """Captured upstream HTTP request made by TryMeClient.""" + + method: str + path: str + query: dict[str, str] + headers: dict[str, str] + body: Any + + +@dataclass +class UpstreamMockState: + """Stateful recorder and configurable responder for MockTransport.""" + + calls: list[RecordedCall] = field(default_factory=list) + next_status: int | None = None + next_json: Any = None + next_headers: dict[str, str] = field(default_factory=dict) + + +@pytest.fixture +def mock_upstream() -> Any: + """Install a MockTransport-backed TryMeClient and restore original client afterward.""" + state = UpstreamMockState() + original_client = get_client() + + async def handler(request: httpx.Request) -> httpx.Response: + raw_body = request.content.decode("utf-8") if request.content else "" + parsed_body: Any = None + if raw_body: + try: + parsed_body = json.loads(raw_body) + except Exception: + parsed_body = raw_body + + state.calls.append( + RecordedCall( + method=request.method, + path=request.url.path, + query=dict(request.url.params), + headers={k.lower(): v for k, v in request.headers.items()}, + body=parsed_body, + ) + ) + + if state.next_status is not None: + status = state.next_status + payload = state.next_json + resp_headers = dict(state.next_headers) + state.next_status = None + state.next_json = None + state.next_headers.clear() + if status == 204 or (300 <= status < 400 and payload is None): + return httpx.Response(status_code=status, headers=resp_headers) + return httpx.Response(status_code=status, json=payload, headers=resp_headers) + + # Default realistic responses by method + path + path = request.url.path + method = request.method.upper() + + if path == "/health": + return httpx.Response(200, json={"status": "ok", "service": "trymeapp", "version": "0.2"}) + if path == "/api/v1/auth/config": + return httpx.Response( + 200, + json={ + "auth0_enabled": True, + "auth0_domain": "example.us.auth0.com", + "auth0_client_id": "client123", + "auth0_audience": "", + "dev_auth_enabled": True, + "admin_unclaimed": False, + }, + ) + if path in ("/api/v1/auth/login", "/api/v1/auth/callback"): + return httpx.Response( + 302, + headers={"location": "https://example.us.auth0.com/authorize"}, + ) + if path == "/api/v1/auth/dev-login": + return httpx.Response( + 200, + json={ + "access_token": "dev-jwt-token", + "token_type": "bearer", + "user": {"id": 1, "nickname": parsed_body.get("nickname", "tester")}, + }, + ) + if method in ("DELETE", "POST") and path in ( + "/api/v1/auth/logout", + ): + return httpx.Response(204) + if method == "DELETE" and ( + path.startswith("/api/v1/quizzes/") or path.startswith("/api/v1/admin/quizzes/") + ): + return httpx.Response(204) + if path in ( + "/api/v1/users/me/scores", + "/api/v1/users/me/api-keys", + "/api/v1/quizzes", + "/api/v1/favourites", + "/api/v1/admin/users", + "/api/v1/admin/quizzes", + ) and method == "GET": + return httpx.Response(200, json=[{"ok": True, "path": path}]) + if path.startswith("/api/v1/favourites") and method in ("POST", "PUT", "DELETE"): + return httpx.Response(200, json=[{"favourite_id": 1, "position": 0, "quiz": {"id": 10}}]) + + return httpx.Response(200, json={"ok": True, "method": method, "path": path, "body": parsed_body}) + + test_settings = Settings( + version="0.1", + mcp_host="0.0.0.0", + mcp_port=8000, + mcp_path="/mcp", + tryme_base_url="https://tryme.tpk.pw", + tryme_timeout_seconds=10.0, + tryme_api_key="env-default-key", + tryme_bearer_token=None, + tryme_session_cookie=None, + ) + client = TryMeClient(config=test_settings, transport=httpx.MockTransport(handler)) + set_client(client) + try: + yield state + finally: + set_client(original_client) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..be01642 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,353 @@ +"""Unit and integration tests for the TryMe TPK FastMCP HTTP server.""" + +from __future__ import annotations + +import asyncio +import json +import socket +from typing import Any + +import httpx +import pytest +import uvicorn +import yaml +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.exceptions import ToolError + +from app.config import Settings, get_version +from app.main import app, mcp +from tests.conftest import UpstreamMockState + +EXPECTED_TOOLS = { + # System + "healthcheck", + # Authentication + "get_auth_config", + "auth0_login", + "auth0_callback", + "dev_login", + "logout", + # Users & API Keys + "get_me", + "update_me", + "get_my_scores", + "list_api_keys", + "create_api_key", + "expire_api_key", + # Quizzes + "list_quizzes", + "create_quiz", + "get_quiz", + "update_quiz", + "delete_quiz", + "submit_quiz", + "vote_quiz", + # Favourites + "list_favourites", + "add_favourite", + "reorder_favourites", + "remove_favourite", + # Moderation + "claim_initial_moderator", + "list_users_for_moderation", + "ban_user", + "unban_user", + "list_quizzes_for_moderation", + "moderator_remove_quiz", +} + + +def _unwrap_result(result: Any) -> Any: + """Extract structured/JSON payload from a FastMCP CallToolResult.""" + if getattr(result, "structured_content", None) is not None: + sc = result.structured_content + if isinstance(sc, dict) and set(sc.keys()) == {"result"}: + return sc["result"] + return sc + if getattr(result, "data", None) is not None: + return result.data + for item in getattr(result, "content", []): + if hasattr(item, "text"): + return json.loads(item.text) + return None + + +def test_default_settings_bind_all_interfaces(monkeypatch: pytest.MonkeyPatch) -> None: + """Default configuration binds HTTP server to 0.0.0.0:8000 on /mcp.""" + for var in ("MCP_HOST", "MCP_PORT", "MCP_PATH", "TRYME_BASE_URL"): + monkeypatch.delenv(var, raising=False) + + cfg = Settings.from_env() + assert cfg.mcp_host == "0.0.0.0" + assert cfg.mcp_port == 8000 + assert cfg.mcp_path == "/mcp" + assert cfg.tryme_base_url == "https://tryme.tpk.pw" + assert get_version() == "0.1" + + +async def test_custom_http_routes_health_and_openapi() -> None: + """Verify /health, /openapi.yaml, and /openapi.json on the HTTP ASGI app.""" + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as http: + health_resp = await http.get("/health") + assert health_resp.status_code == 200 + health_data = health_resp.json() + assert health_data["status"] == "ok" + assert health_data["service"] == "trymemcp" + assert health_data["transport"] == "http" + assert health_data["host"] == "0.0.0.0" + + yaml_resp = await http.get("/openapi.yaml") + assert yaml_resp.status_code == 200 + parsed_yaml = yaml.safe_load(yaml_resp.text) + assert parsed_yaml["openapi"] == "3.1.0" + assert len(parsed_yaml["x-mcp-tools"]) == 29 + + json_resp = await http.get("/openapi.json") + assert json_resp.status_code == 200 + assert json_resp.json()["info"]["title"] == "TryMe TPK FastMCP HTTP Server" + + +async def test_all_29_openapi_tools_registered() -> None: + """Ensure all 29 operations from https://tryme.tpk.pw/openapi.json are exposed.""" + async with Client(mcp) as client: + tools = await client.list_tools() + names = {t.name for t in tools} + assert names == EXPECTED_TOOLS + assert len(names) == 29 + + +async def test_system_and_auth_tools(mock_upstream: UpstreamMockState) -> None: + """Test system healthcheck and authentication tools.""" + async with Client(mcp) as client: + res = _unwrap_result(await client.call_tool("healthcheck", {})) + assert res["status"] == "ok" + assert mock_upstream.calls[-1].path == "/health" + + cfg = _unwrap_result(await client.call_tool("get_auth_config", {})) + assert cfg["auth0_enabled"] is True + + login_res = _unwrap_result(await client.call_tool("auth0_login", {"return_to": "/dashboard"})) + assert login_res["status_code"] == 302 + assert "authorize" in login_res["redirect_url"] + assert mock_upstream.calls[-1].query == {"return_to": "/dashboard"} + + cb_res = _unwrap_result( + await client.call_tool("auth0_callback", {"code": "abc", "state": "xyz"}) + ) + assert cb_res["status_code"] == 302 + assert mock_upstream.calls[-1].query == {"code": "abc", "state": "xyz"} + + dev_res = _unwrap_result( + await client.call_tool( + "dev_login", + {"nickname": "quizmaster", "email": "qm@example.com", "as_moderator": True}, + ) + ) + assert dev_res["access_token"] == "dev-jwt-token" + assert mock_upstream.calls[-1].body == { + "nickname": "quizmaster", + "email": "qm@example.com", + "as_moderator": True, + } + + logout_res = _unwrap_result( + await client.call_tool("logout", {"authorization": "raw-jwt-token"}) + ) + assert logout_res == {"status": "logged_out"} + assert mock_upstream.calls[-1].headers["authorization"] == "Bearer raw-jwt-token" + + +async def test_users_and_api_keys_tools(mock_upstream: UpstreamMockState) -> None: + """Test user profile, scores, and API key lifecycle tools.""" + async with Client(mcp) as client: + # Uses env fallback X-API-Key when no explicit key is passed + await client.call_tool("get_me", {}) + assert mock_upstream.calls[-1].path == "/api/v1/users/me" + assert mock_upstream.calls[-1].headers["x-api-key"] == "env-default-key" + + # Explicit api_key overrides env default + await client.call_tool( + "update_me", + { + "nickname": "new_nick", + "age": 16, + "public_website_consent": True, + "api_key": "override-key", + }, + ) + last = mock_upstream.calls[-1] + assert last.method == "PATCH" + assert last.headers["x-api-key"] == "override-key" + assert last.body == { + "nickname": "new_nick", + "age": 16, + "public_website_consent": True, + } + + scores = _unwrap_result(await client.call_tool("get_my_scores", {})) + assert isinstance(scores, list) + assert mock_upstream.calls[-1].path == "/api/v1/users/me/scores" + + keys = _unwrap_result(await client.call_tool("list_api_keys", {})) + assert isinstance(keys, list) + assert mock_upstream.calls[-1].path == "/api/v1/users/me/api-keys" + + await client.call_tool("create_api_key", {"name": "ci-agent", "expires_in_days": 30}) + assert mock_upstream.calls[-1].method == "POST" + assert mock_upstream.calls[-1].body == {"name": "ci-agent", "expires_in_days": 30} + + await client.call_tool("expire_api_key", {"key_id": 42}) + assert mock_upstream.calls[-1].path == "/api/v1/users/me/api-keys/42/expire" + + +async def test_quiz_tools(mock_upstream: UpstreamMockState) -> None: + """Test quiz listing, CRUD, submission, and voting tools.""" + async with Client(mcp) as client: + await client.call_tool( + "list_quizzes", + {"q": "science", "mode": "multiple_choice", "mine_only": True}, + ) + assert mock_upstream.calls[-1].query == { + "q": "science", + "mode": "multiple_choice", + "mine_only": "true", + } + + await client.call_tool( + "create_quiz", + { + "title": "Space Trivia", + "description": "Planets and stars", + "is_public": True, + "questions": [ + { + "question_type": "multiple_choice", + "prompt": "Closest planet to the Sun?", + "options": [ + {"id": "a", "text": "Mercury"}, + {"id": "b", "text": "Venus"}, + ], + "correct_answer": "a", + "accepted_answers": [], + } + ], + }, + ) + created_call = mock_upstream.calls[-1] + assert created_call.method == "POST" + assert created_call.path == "/api/v1/quizzes" + assert created_call.body["title"] == "Space Trivia" + assert created_call.body["questions"][0]["correct_answer"] == "a" + + await client.call_tool("get_quiz", {"slug_or_id": "space-trivia"}) + assert mock_upstream.calls[-1].path == "/api/v1/quizzes/space-trivia" + + await client.call_tool( + "update_quiz", + {"quiz_id": 7, "title": "Updated Space Trivia", "is_public": False}, + ) + assert mock_upstream.calls[-1].method == "PUT" + assert mock_upstream.calls[-1].path == "/api/v1/quizzes/7" + + del_res = _unwrap_result(await client.call_tool("delete_quiz", {"quiz_id": 7})) + assert del_res == {"status": "deleted", "quiz_id": 7} + assert mock_upstream.calls[-1].method == "DELETE" + + await client.call_tool( + "submit_quiz", + {"quiz_id": 7, "answers": {"101": "a", "102": "42"}}, + ) + assert mock_upstream.calls[-1].path == "/api/v1/quizzes/7/submit" + assert mock_upstream.calls[-1].body == {"answers": {"101": "a", "102": "42"}} + + await client.call_tool("vote_quiz", {"quiz_id": 7, "value": 1}) + assert mock_upstream.calls[-1].path == "/api/v1/quizzes/7/vote" + assert mock_upstream.calls[-1].body == {"value": 1} + + +async def test_favourites_and_moderation_tools(mock_upstream: UpstreamMockState) -> None: + """Test favourites management and admin moderation tools.""" + async with Client(mcp) as client: + await client.call_tool("list_favourites", {}) + assert mock_upstream.calls[-1].path == "/api/v1/favourites" + + await client.call_tool("add_favourite", {"quiz_id": 12, "position": 0}) + assert mock_upstream.calls[-1].body == {"quiz_id": 12, "position": 0} + + await client.call_tool("reorder_favourites", {"quiz_ids": [12, 5, 9]}) + assert mock_upstream.calls[-1].method == "PUT" + assert mock_upstream.calls[-1].body == {"quiz_ids": [12, 5, 9]} + + await client.call_tool("remove_favourite", {"quiz_id": 12}) + assert mock_upstream.calls[-1].method == "DELETE" + assert mock_upstream.calls[-1].path == "/api/v1/favourites/12" + + await client.call_tool("claim_initial_moderator", {}) + assert mock_upstream.calls[-1].path == "/api/v1/admin/claim" + + await client.call_tool("list_users_for_moderation", {}) + assert mock_upstream.calls[-1].path == "/api/v1/admin/users" + + await client.call_tool("ban_user", {"user_id": 99, "reason": "Spamming quizzes"}) + assert mock_upstream.calls[-1].path == "/api/v1/admin/users/99/ban" + assert mock_upstream.calls[-1].body == {"reason": "Spamming quizzes"} + + await client.call_tool("unban_user", {"user_id": 99}) + assert mock_upstream.calls[-1].path == "/api/v1/admin/users/99/unban" + + await client.call_tool("list_quizzes_for_moderation", {}) + assert mock_upstream.calls[-1].path == "/api/v1/admin/quizzes" + + mod_del = _unwrap_result(await client.call_tool("moderator_remove_quiz", {"quiz_id": 55})) + assert mod_del == {"status": "removed", "quiz_id": 55} + assert mock_upstream.calls[-1].path == "/api/v1/admin/quizzes/55" + + +async def test_upstream_error_raises_tool_error(mock_upstream: UpstreamMockState) -> None: + """Verify upstream 4xx/5xx responses surface cleanly as MCP ToolErrors.""" + mock_upstream.next_status = 401 + mock_upstream.next_json = {"detail": "Invalid or expired API key"} + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="401"): + await client.call_tool("get_me", {}) + + +async def test_live_http_server_on_all_interfaces_and_header_passthrough( + mock_upstream: UpstreamMockState, +) -> None: + """Start HTTP server bound to 0.0.0.0 and verify MCP HTTP client & header forwarding.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("0.0.0.0", 0)) + port = sock.getsockname()[1] + + http_app = mcp.http_app(path="/mcp", transport="http") + config = uvicorn.Config(http_app, host="0.0.0.0", port=port, log_level="error") + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve(sockets=[sock])) + + try: + while not server.started: + await asyncio.sleep(0.02) + + transport = StreamableHttpTransport( + url=f"http://127.0.0.1:{port}/mcp", + headers={ + "X-API-Key": "client-header-api-key", + "Cookie": "tryme_session=session-cookie-abc", + }, + ) + async with Client(transport) as client: + tools = await client.list_tools() + assert len(tools) == 29 + + await client.call_tool("get_me", {}) + last_call = mock_upstream.calls[-1] + assert last_call.headers["x-api-key"] == "client-header-api-key" + assert "tryme_session=session-cookie-abc" in last_call.headers.get("cookie", "") + finally: + server.should_exit = True + await task + sock.close() diff --git a/version.ini b/version.ini new file mode 100644 index 0000000..1a9b0ac --- /dev/null +++ b/version.ini @@ -0,0 +1,2 @@ +[version] +version = 0.1