# Quick start

This runs a small attendance app for your team. It has three tools: **My hours**, **Clock
in** and **Team hours**, which only the team's leaders and the holders of a role called
Finance may use.

You need [uv](https://docs.astral.sh/uv/), a server on the internet, and a host name for it
with HTTPS in front, such as `attendance.example.com`. You must be a leader of the team.

## The files

`pyproject.toml`:

```toml file=examples/team-app/pyproject.toml
[project]
name = "team-app"
version = "0.1.0"
description = "A small example of a team's app for Theorem: an MCP server that trusts Theorem's token."
requires-python = ">=3.11"
dependencies = [
    "mcp>=1.20,<2",
    "pyjwt[crypto]>=2.10",
    "uvicorn>=0.34",
]

[dependency-groups]
dev = ["pytest>=8", "httpx>=0.28"]

[tool.uv]
package = false

[tool.pytest.ini_options]
testpaths = ["."]
```

`server.py`:

```python file=examples/team-app/server.py
"""A small example of a team's app for Theorem: an MCP server that trusts Theorem's token.

Every request must carry `Authorization: Bearer <token>`. The token is a JWT signed by
Theorem (EdDSA). This server checks its signature against Theorem's public keys, that
Theorem issued it, and that it was made for this app's ID. It then knows who is calling
and which of the team's roles they hold, and decides for itself what each role may do.

Settings, from the environment:
  APP_ID      this app's ID at Theorem (`app_...`), shown where the app was added
  APP_HOST    the host name people reach this server by
  APP_PORT    the port to listen on, on this machine only (default 8787)
  THEOREM     Theorem's address (default https://theorem.sa)
  THEOREM_KEYS_FILE  Theorem's public keys saved to a file, for a server that cannot reach Theorem
"""

import contextvars
import os

import jwt
import uvicorn
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from starlette.responses import JSONResponse

APP_ID = os.environ["APP_ID"]
APP_HOST = os.environ["APP_HOST"]
PORT = int(os.environ.get("APP_PORT", "8787"))
THEOREM = os.environ.get("THEOREM", "https://theorem.sa")

keys = jwt.PyJWKClient(f"{THEOREM}/api/identity/jwks")
# For a server that cannot reach Theorem: the same keys, saved to a file.
KEYS_FILE = os.environ.get("THEOREM_KEYS_FILE")
caller = contextvars.ContextVar("caller")

HOURS = {}


def checked(token):
    """The token's claims, or an error: Theorem's signature, issuer, this app, not ended."""
    if KEYS_FILE:
        kid = jwt.get_unverified_header(token)["kid"]
        key = jwt.PyJWKSet.from_json(open(KEYS_FILE).read())[kid]
    else:
        key = keys.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        key.key,
        algorithms=["EdDSA"],
        audience=APP_ID,
        issuer=THEOREM,
        options={"require": ["exp", "iss", "aud", "sub"]},
    )


class TheoremToken:
    """Lets a request in only with a token Theorem made for this app."""

    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] == "lifespan":
            return await self.app(scope, receive, send)
        if scope["type"] != "http":
            return  # this app serves HTTP only; anything else would get past the check
        header = dict(scope["headers"]).get(b"authorization", b"").decode()
        try:
            kind, _, token = header.partition(" ")
            if kind.lower() != "bearer" or not token:
                raise jwt.InvalidTokenError("no bearer token")
            claims = checked(token)
        except Exception as error:  # every failure is the same answer: not let in
            print(f"refused: {type(error).__name__}: {error}", flush=True)
            response = JSONResponse({"error": "A token from Theorem for this app is needed."}, status_code=401,
                                    headers={"WWW-Authenticate": "Bearer"})
            return await response(scope, receive, send)
        marker = caller.set(claims)
        try:
            print(f"let in: {claims['email']} roles={[r['name'] for r in claims['team']['roles']]} "
                  f"leader={claims['team']['leader']}", flush=True)
            await self.app(scope, receive, send)
        finally:
            caller.reset(marker)


mcp = FastMCP(
    "Attendance",
    stateless_http=True,
    json_response=True,
    transport_security=TransportSecuritySettings(
        enable_dns_rebinding_protection=True, allowed_hosts=[APP_HOST], allowed_origins=[f"https://{APP_HOST}"]
    ),
)


@mcp.tool(title="My hours", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def my_hours() -> str:
    """The hours the person has clocked in this week."""
    me = caller.get()
    return f"{me.get('name') or me['email']} has {HOURS.get(me['sub'], 0)} hours this week."


@mcp.tool(title="Clock in", annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=True))
def clock_in(hours: int) -> str:
    """Adds hours worked today for the person."""
    me = caller.get()
    HOURS[me["sub"]] = HOURS.get(me["sub"], 0) + hours
    return f"Added {hours} hours. The week's total is {HOURS[me['sub']]}."


@mcp.tool(title="Team hours", annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def team_hours() -> str:
    """Everyone's hours this week. Only for the team's leaders and the Finance role."""
    team = caller.get()["team"]
    if not team["leader"] and "Finance" not in [role["name"] for role in team["roles"]]:
        return "Only the team's leaders and people in Finance may see everyone's hours."
    return f"{len(HOURS)} people have clocked in, {sum(HOURS.values())} hours in all, in the team {team['name']}."


if __name__ == "__main__":
    uvicorn.run(TheoremToken(mcp.streamable_http_app()), host="127.0.0.1", port=PORT, log_level="warning")
```

## Run it

1. In Theorem, open **Teams**, your team, **Connections**, and press **New connection**.
   Name it Attendance, give the address `https://attendance.example.com/mcp`, choose
   **Everyone** for now, so that you get it too, and press **Save**. Copy the **App ID**. The first check fails: the app is not
   running yet.
2. On your server, in the folder with the two files, start the app with your ID and host:

   ```sh
   APP_ID=app_01KA3B3C4D5E6F7G8H9JKMNPQR APP_HOST=attendance.example.com uv run server.py
   ```

   It listens on `127.0.0.1:8787`, on this machine only. `APP_PORT` changes the port.
3. Put HTTPS in front of it, so that `https://attendance.example.com/mcp` reaches
   `http://127.0.0.1:8787/mcp`, and passes the request's own `Host` header through. Use
   the web server or proxy you already run.
4. In the app's dialog in Theorem, press **Check again**. It says **Connected. 3 tools
   found.**, with **My hours** and **Team hours** marked **runs freely** and **Clock in**
   marked **asks first**.

Now ask your assistant "How many hours have I clocked this week?". Your app's log shows
the person it let in, with their roles.

## Try a request without Theorem

A request with no token is refused with 401:

```sh
curl -i -X POST http://127.0.0.1:8787/mcp \
  -H 'Host: attendance.example.com' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

It answers `{"error":"A token from Theorem for this app is needed."}`.

## Settings

| Variable | What it is |
|---|---|
| `APP_ID` | Your app's ID, from the **Connections** tab. |
| `APP_HOST` | The host name people reach the app by. Requests for any other host are refused. |
| `APP_PORT` | The port on this machine. 8787 unless you set it. |
| `THEOREM_KEYS_FILE` | Theorem's public keys saved to a file, for a server that cannot reach theorem.sa. Save them from `https://theorem.sa/api/identity/jwks`, and save them again if every token starts to be refused: the app reads this file and never asks Theorem. |
| `THEOREM` | Theorem's address, which is also who must have issued a token. Leave it unset: it is `https://theorem.sa`. |

## What the example shows

- `TheoremToken` checks every request before MCP sees it ([The token](/developers/token)).
- `team_hours` decides by the team and role in the token
  ([Roles in your app](/developers/permissions)).
- The annotations on each tool decide which ones ask first ([Tools](/developers/tools)).
- Hours are kept in memory and lost when the app stops: a real app keeps them in its own
  store, keyed by the person's `sub`.
