Update
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 3m5s Details

This commit is contained in:
Isaac Johnson 2026-11-11 21:23:16 -06:00
parent d58010c735
commit b267cf09ed
25 changed files with 3649 additions and 1 deletions

21
.dockerignore Normal file
View File

@ -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

View File

@ -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 }}

46
Dockerfile Normal file
View File

@ -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

69
INSTALL.md Normal file
View File

@ -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.

150
README.md
View File

@ -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://<server-ip>: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

177
SETUP.md Normal file
View File

@ -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 <your-gitea-repo-url> 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 <token>` |
| `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: <raw_key>`, `Authorization: Bearer <token>`, or `Cookie: tryme_session=<cookie>` when connecting to `http://<host>: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="<access_token>")` 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:<version>`) and DockerHub (`<DHUSER>/trymemcp:<version>`).
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 |

42
SYSTEM.md Normal file
View File

@ -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.

5
app/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""TryMe TPK FastMCP HTTP Server package."""
from app.config import get_version
__version__ = get_version()

178
app/client.py Normal file
View File

@ -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}

65
app/config.py Normal file
View File

@ -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()

801
app/main.py Normal file
View File

@ -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()

53
app/models.py Normal file
View File

@ -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.",
)

24
docker-compose.yaml Normal file
View File

@ -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

6
helm-chart/Chart.yaml Normal file
View File

@ -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"

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

30
helm-chart/values.yaml Normal file
View File

@ -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

1249
openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

33
pyproject.toml Normal file
View File

@ -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*"]

8
pytest.ini Normal file
View File

@ -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

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Test suite for TryMe TPK FastMCP HTTP Server."""

144
tests/conftest.py Normal file
View File

@ -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)

353
tests/test_main.py Normal file
View File

@ -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()

2
version.ini Normal file
View File

@ -0,0 +1,2 @@
[version]
version = 0.1