Update
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 3m5s
Details
Build and Publish Docker Image / Build and Push Docker Image (push) Successful in 3m5s
Details
This commit is contained in:
parent
d58010c735
commit
b267cf09ed
|
|
@ -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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
150
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://<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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"""TryMe TPK FastMCP HTTP Server package."""
|
||||
|
||||
from app.config import get_version
|
||||
|
||||
__version__ = get_version()
|
||||
|
|
@ -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}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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.",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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*"]
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Test suite for TryMe TPK FastMCP HTTP Server."""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[version]
|
||||
version = 0.1
|
||||
Loading…
Reference in New Issue