This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user