Quick start
Copy as MarkdownRun the example attendance app, put it behind HTTPS, add it to your team, and check that Theorem reaches it.
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, 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:
[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:
"""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
-
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. -
On your server, in the folder with the two files, start the app with your ID and host:
APP_ID=app_01KA3B3C4D5E6F7G8H9JKMNPQR APP_HOST=attendance.example.com uv run server.pyIt listens on
127.0.0.1:8787, on this machine only.APP_PORTchanges the port. -
Put HTTPS in front of it, so that
https://attendance.example.com/mcpreacheshttp://127.0.0.1:8787/mcp, and passes the request's ownHostheader through. Use the web server or proxy you already run. -
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:
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
TheoremTokenchecks every request before MCP sees it (The token).team_hoursdecides by the team and role in the token (Roles in your app).- The annotations on each tool decide which ones ask first (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.