ajet aa4f8ea86a
CI / Test + coverage (push) Successful in 1m3s
add validation and unified schemas
2026-09-19 12:48:41 -09:00
2026-09-19 06:03:35 -09:00
2026-09-19 06:49:28 -09:00
2026-09-19 07:21:19 -09:00
2026-09-19 12:48:41 -09:00
2026-09-19 07:46:05 -09:00
2026-09-19 12:48:41 -09:00
2026-09-19 05:42:17 -09:00
2026-09-19 12:48:41 -09:00
2026-09-19 12:48:41 -09:00
2026-09-19 12:48:41 -09:00

tbd

A small Clojure HTTP API built on Ring (Jetty) and reitit, with a ClojureScript re-frame front end (ui/), Swagger UI docs, unit tests, 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 (tools.deps) with JDK 21+, Node 20+ (with a JDK) for the UI build, and Babashka for script/coverage-report

Running

script/ui-build        # compile the UI (once, and after UI changes)
script/server [port]

The server listens on port 3000 by default; pass a port argument or set PORT to change it (the argument wins):

script/server 8080
PORT=8080 script/server

This is a thin wrapper around clojure -X:run, which also honors PORT. Then open http://localhost:3000 for the UI. Without script/ui-build the server still starts, but / shows a placeholder.

Or build and run the container:

docker build -t tbd .
docker run --rm -p 3000:3000 tbd

API

Method Path Description
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) (400 if either is missing or not a number)
POST, DELETE /mcp The 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 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)
GET /auth/callback OAuth redirect target: completes the login, sets the session cookie, redirects to /
POST /auth/logout Ends the session
GET /auth/me The logged-in user, {:login "alice" :id 7 :admin? true} (401 if not logged in)
GET /api/todos Login required. Your todos: {:todos [{:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1700000000000 :updated-at 1700000000000}]}
POST /api/todos Login required. Body {"title": "buy milk"} creates a todo and returns it with 201 (400 if the title is blank or over 200 characters)
GET /api/todos/:id Login required. One todo (404 if it doesn't exist or isn't yours)
PATCH /api/todos/:id Login required. Body with title and/or done ({"done": true}) updates the todo and returns it (400 for an empty or invalid change, 404 as above)
DELETE /api/todos/:id Login required. Deletes the todo: {:deleted true} (404 as above)
GET /nrepl Admin only. Whether the nREPL debug server is on: {:enabled false}, or {:enabled true :bind "127.0.0.1" :port 7888}
POST /nrepl Admin only. Body {"enabled": true} (JSON or EDN) starts the nREPL debug server, false stops it; returns {:enabled true :bind "127.0.0.1" :port 7888}
curl localhost:3000/api/ping           # pong
curl 'localhost:3000/api/add?x=2&y=3'  # {:result 5}
curl 'localhost:3000/api/add?x=2&y=3' -H 'Accept: application/json'  # {"result":5}

Formats

The API speaks EDN, JSON and Transit (application/transit+json, application/transit+msgpack). Responses follow the request's Accept header, and request bodies are read by their Content-Type. EDN is the default: with no Accept (or */*, or a type the API can't produce) you get EDN, and a body sent with no Content-Type is read as EDN. Errors (400, 401, 403, ...) use the same format as everything else. Two exceptions: /api/ping is always text/plain, and /swagger.json is always JSON.

Handlers return plain data ((tbd.http/respond 200 {:result 5})), and the muuntaja middleware in tbd.core encodes it; don't pr-str a body yourself.

Todos

Each logged-in user has their own todo list, keyed by their Gitea user id. Every query is filtered by that id, so users can't see or change each other's todos; someone else's todo answers 404, exactly like a missing one. Without a login the todo routes answer 401.

Todos are stored in a local SQLite file, data/todo.db by default (git-ignored), which is created and migrated on the first todo request. Set TODO_DB_PATH to put it elsewhere. In Docker, mount a volume at that path (e.g. docker run -v tbd-data:/app/data ...), or the todos are lost with the container.

# after logging in in a browser, copy the `tbd-session` cookie value:
curl -XPOST localhost:3000/api/todos -H 'Cookie: tbd-session=...' \
     -H 'Content-Type: application/json' -d '{"title": "buy milk"}'
curl localhost:3000/api/todos -H 'Cookie: tbd-session=...'

MCP server

The server also speaks MCP (Model Context Protocol) at /mcp, built on 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.

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 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 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 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 instance. Anyone with a Gitea account can log in, but routes marked :admin? true (currently GET and POST /nrepl) also require the user to be on the ADMIN_USERS allowlist: 401 without a login, 403 for a logged-in non-admin. With no allowlist, nobody is an admin.

  1. In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with the redirect URI <BASE_URL>/auth/callback.
  2. cp .env-template .env and fill it in. script/server loads .env; for Docker use docker run --env-file .env. .env is git-ignored and kept out of the image.
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. 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 (enforced server-side). The cookie is stateless, so POST /auth/logout clears it in the browser but can't revoke a copy someone already captured; it just expires on its own.

The nREPL endpoint

# after logging in in a browser, copy the `tbd-session` cookie value:
curl -XPOST localhost:3000/nrepl -H 'Cookie: tbd-session=...' \
     -H 'Content-Type: application/json' -d '{"enabled": true}'
clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.2.0"}}}' -M -m nrepl.cmdline --connect --host 127.0.0.1 --port 7888

nREPL has no authentication and evaluates arbitrary code, so enabling it is effectively remote code execution for whoever can reach the port. It therefore binds to loopback by default; reach it through an SSH tunnel (or docker exec) rather than setting NREPL_BIND=0.0.0.0. Each enable/disable is logged with the admin's Gitea username. It stays on until an admin disables it (or the server restarts), so switch it off when you're done.

UI

ui/ is a re-frame app (ClojureScript, React 19 via Reagent) built with 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. 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 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 directory at /js, and serves resources/public/index.html, the shell that mounts the app, at /.

Task What it does
script/ui-build Optimized release build into resources/public/js
script/ui-watch Rebuilds on every change, with hot reload; run script/server alongside and refresh the page

The Docker image builds the UI in its own stage, so docker build needs no local Node.

Development

Every task is a bash script in script/. Start an nREPL server (with CIDER middleware) on the port in .nrepl-port, or 3000 if that file doesn't exist:

script/repl

Tests

Task What it does
script/unit-test Runs the Clojure tests in test/
script/integration-test Builds the app image, starts it on a random port, runs the integration suite against it, then tears it down
script/test Unit tests, then integration tests (stops at the first failure)
script/docker-unit-test Same as script/unit-test, but inside the app image (Docker only)

Coverage

Line coverage via Cloverage:

Task Output
script/unit-coverage target/coverage/unit/
script/integration-coverage target/coverage/integration/
script/coverage Runs both, then writes the merged report to target/coverage/merged/
script/docker-unit-coverage, script/docker-coverage-report Dockerized versions of the unit run and the merge step, so script/docker-unit-coverage, script/integration-coverage, script/docker-coverage-report is script/coverage with no local Clojure or Babashka (this is what CI runs)

Each report directory has an index.html and an lcov.info. See integration-test/README.md for how integration coverage is collected.

Project layout

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), 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)
test/                 Unit tests
integration-test/     Dockerized integration tests and coverage harness
script/               Bash entry points (server, ui-build, test, coverage, repl, ...) and coverage helpers

To add a new group, create tbd.<group>.routes with a routes fn that returns a reitit route vector, then add it to the router in src/tbd/core.clj. To make a route admin-only, put :admin? true in its route data (admin allowlist), or :login? true (any logged-in user; the user is passed to the handler as :user). A group with storage keeps it in its own tbd.<group>.db, apart from its business logic in core.clj.

Description
Languages
Clojure 97.5%
Shell 1.6%
JavaScript 0.5%
Dockerfile 0.3%