add validation and unified schemas
CI / Test + coverage (push) Successful in 1m3s

This commit is contained in:
2026-09-19 12:48:41 -09:00
parent 64d1a35da2
commit aa4f8ea86a
30 changed files with 2027 additions and 77 deletions
+74 -4
View File
@@ -8,7 +8,7 @@ dockerized integration tests, and joint line coverage.
## Requirements
- Docker, for the integration tests and the `script/docker-*` tasks (all CI needs)
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 11+, [Node](https://nodejs.org/) 20+ (with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
## Running
@@ -43,6 +43,11 @@ docker run --rm -p 3000:3000 tbd
| `GET` | `/` | The UI: an HTML shell that loads the compiled ClojureScript from `/js/main.js` |
| `GET` | `/api/ping` | Health check, returns `pong` |
| `GET` | `/api/add?x=2&y=3` | Adds two numbers: `{:result 5}` (EDN by default, see [Formats](#formats)) (400 if either is missing or not a number) |
| `POST`, `DELETE` | `/mcp` | The [MCP server](#mcp-server) (JSON-RPC over Streamable HTTP; `DELETE` ends a session; `GET` is `405`). **Needs an OAuth access token** (`401` without one) |
| `GET` | `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server` | The [MCP OAuth](#connecting-an-mcp-client) discovery documents (RFC 9728 and 8414) |
| `POST` | `/oauth/register` | Dynamic client registration (RFC 7591) for MCP clients |
| `GET`, `POST` | `/oauth/authorize` | Where an MCP client sends the user's browser: Gitea login if needed, then a consent page |
| `POST` | `/oauth/token` | Trades an authorization code (with its PKCE verifier) or a refresh token for tokens |
| `GET` | `/api-docs` | Swagger UI |
| `GET` | `/swagger.json` | OpenAPI/Swagger spec |
| `GET` | `/auth/login` | Starts the Gitea OAuth login (redirects to Gitea) |
@@ -92,6 +97,65 @@ curl -XPOST localhost:3000/api/todos -H 'Cookie: tbd-session=...' \
curl localhost:3000/api/todos -H 'Cookie: tbd-session=...'
```
## MCP server
The server also speaks [MCP](https://modelcontextprotocol.io/) (Model Context Protocol) at `/mcp`,
built on [PluMCP](https://github.com/plumce/plumcp) and running inside this same process; there is
nothing extra to start. It uses the Streamable HTTP transport, and, like the todo API, it acts **as
the logged-in user**: a client has to be approved by a user first (see below), and from then on
every tool call knows who is calling.
```bash
claude mcp add --transport http tbd https://tbd.example.com/mcp # <BASE_URL>/mcp
```
The first time the client connects it gets a `401`, opens your browser, you log in with Gitea
as usual and approve the client, and the client keeps its tokens from then on. It needs the
[Gitea login](#authentication-and-the-admin-api) to be configured, including `BASE_URL` (which must
be the address the client uses; without them `/mcp` answers `503` rather than being open).
It offers these tools: `ping`, `add` (the same logic as `/api/add`), `whoami` (which user this
connection acts as, and whether they are an admin), and, on the caller's own todos (the same ones
as `/api/todos`), `list_todos`, `get_todo`, `create_todo`, `update_todo` and `delete_todo`. To add a
tool, add a `tool` to `src/tbd/mcp/tools.clj`. Its arguments are described by the group's
[Malli](https://github.com/metosin/malli) schema in `src/tbd/<group>/schema.clj`, the same schema the
HTTP routes use for their parameters, so a shape of data is written down once: the tool's
input schema is generated from it, and the arguments a client sends are coerced and validated against
it (plumcp only checks that required ones are present). A tool that touches a user's data or does
something admin-only gets the caller from `current-user` there (`{:login :id :admin? :client-id}`),
and must not trust anything else about who is asking.
Sessions are kept in memory, so they end when the server restarts (clients just re-initialize; their
tokens are in the database and keep working). Server-to-client streaming (`GET /mcp`) is deliberately
off: nothing here pushes messages, and the response would hang under Jetty. PluMCP needs JDK 21+.
### Connecting an MCP client
tbd is its own OAuth 2.1 authorization server for `/mcp` (in `src/tbd/oauth/`), with the Gitea login
as the way a user proves who they are. Gitea's own tokens never reach an MCP client, and the tokens
tbd issues are worth nothing anywhere else. The flow is the one the
[MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
describes, so any compliant client does it by itself:
1. `POST /mcp` with no token gets `401` and `WWW-Authenticate: Bearer resource_metadata="<BASE_URL>/.well-known/oauth-protected-resource/mcp"`.
2. The client reads that document, then `/.well-known/oauth-authorization-server`, and registers itself at
`/oauth/register` (dynamic client registration, public clients only).
3. It opens `/oauth/authorize` in the browser (authorization-code flow, PKCE `S256` required). If you aren't
logged in you go through the Gitea login first and come back; then a consent page names the client and
the address you'll be sent back to, and asks you to **Approve** or **Deny**.
4. The client trades the code at `/oauth/token` for an access token (1 hour) and a refresh token (30 days,
rotated on every use), and sends `Authorization: Bearer ...` on every `/mcp` request.
The consent page is what stops a stranger's client (anyone can register one) from getting a token by
sending you a link: nothing is issued without your click. Redirect URIs must be `https`, `http` on
`localhost`/`127.0.0.1`/`[::1]` (the port may vary), or an app's own scheme (`cursor://...`).
What's stored is in `OAUTH_DB_PATH`: registered clients, and for each approval the user and a *hash* of the
current access and refresh tokens. To cut a client off, delete its row from `grants`. Admin-ness isn't in
the token: it is looked up in `ADMIN_USERS` on every request, so removing someone from the list takes
effect at once. A Gitea account that is disabled keeps working until its refresh token expires (up to 30
days without use) or its grant is deleted, since Gitea isn't asked again after the login.
## Authentication and the admin API
Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance.
@@ -107,10 +171,11 @@ login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin.
| Variable | Purpose |
|----------|---------|
| `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` | The Gitea OAuth2 application. Without all three (and `BASE_URL`), `/auth/login` answers 503 |
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure` |
| `BASE_URL` | Public URL of this app; an `https://` one also marks the session cookie `Secure`. It is also the address of the MCP server: the OAuth issuer, and what MCP clients are told to use (`<BASE_URL>/mcp`) |
| `ADMIN_USERS` | Comma-separated Gitea usernames (case-insensitive) allowed to use the admin API |
| `SESSION_SECRET` | Signs and encrypts session cookies. Unset = a random key per start, so logins don't survive a restart |
| `TODO_DB_PATH` | SQLite file for the todo group (default `data/todo.db`) |
| `OAUTH_DB_PATH` | SQLite file for the MCP OAuth server: registered clients, codes and tokens (default `data/oauth.db`). Mount a volume for it in Docker too, or every MCP client has to be approved again after each restart |
| `NREPL_PORT`, `NREPL_BIND` | Where the debug nREPL listens (default `7888` on `127.0.0.1`) |
Sessions live in an encrypted, signed, `HttpOnly`, `SameSite=Lax` cookie and expire after 8 hours
@@ -138,7 +203,9 @@ restarts), so switch it off when you're done.
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
It's a small calculator form that calls `GET /api/add` and shows the sum (or the server's
validation error). Below it, once you're logged in, is a todo list (add, tick off, delete) backed by
`/api/todos`; it stays hidden when you're not. A "Log in" / "Log out" link sits in the footer row
`/api/todos`; it stays hidden when you're not. At the bottom is a section with the
`claude mcp add ...` command to copy (with this page's own address in it) and the steps to sign in, for
adding the [MCP server](#mcp-server) to Claude Code. A "Log in" / "Log out" link sits in the footer row
next to the API docs links.
shadow-cljs compiles it into `resources/public/js/` (git-ignored). The server serves that
@@ -192,9 +259,12 @@ src/tbd/
core.clj Router, Ring app, and server startup
<group>/routes.clj Each route group (health, math, ...) owns its route subtree
<group>/core.clj Business logic for the group, with no HTTP concerns
<group>/schema.clj Malli schemas of the group's input, shared by its routes and the MCP tools
auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes
mcp/ MCP server (PluMCP) mounted at `/mcp`: tools (tools.clj), Malli-to-tool glue (schema.clj) and the Ring handler (routes.clj)
oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware)
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
todo/ Per-user todos: pure rules (core), SQLite storage (db), `/api/todos` routes
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)