504 lines
35 KiB
Markdown
504 lines
35 KiB
Markdown
# tbd
|
|
|
|
A small Clojure HTTP API built on [Ring](https://github.com/ring-clojure/ring) (Jetty) and
|
|
[reitit](https://github.com/metosin/reitit), with a ClojureScript
|
|
[re-frame](https://github.com/day8/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](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (22.22+ for the UI tests; with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report`
|
|
|
|
## Running
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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](#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 |
|
|
| `POST` | `/api/chat` | **Login required.** Body `{"messages": [{"role": "user", "content": "hi"}]}` (the conversation so far, 1 to 40 messages of up to 8000 characters, roles `user`, `assistant` or `tool`; and optionally `"page"`, the id of the UI page the user is on (`"tic-tac-toe"`, `"ai-setup"`: a lowercase slug of up to 64 characters), which the LLM is told in a system message, and `"tools"`, the page's tools as MCP describes them: see [the chatbot's tools](#the-chatbots-tools)) sends it to the [chatbot's](#chatbot) LLM and **streams the reply back** as server-sent events (`text/event-stream`): `data: {"thinking":"Let me"}` for each piece of the model's reasoning, `data: {"content":"Hel"}` for each piece of the reply, `data: {"tool-calls":[{"id":"call_1","name":"add","arguments":"{\"x\":1,\"y\":2}"}]}` if the model asks for tools to be called, and last `data: {"done":true}` or `data: {"error":"..."}`. Failures before the reply starts are ordinary errors: 400 for an invalid conversation, 502 if the LLM server can't be reached or refuses, 503 if `LMSTUDIO_URL` isn't set |
|
|
| `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 "3f2b8c1e-5a47-4d0e-9b6a-1c2d3e4f5a6b" :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}` |
|
|
|
|
```bash
|
|
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`. The operations
|
|
themselves (ownership, validation, storage) live in `src/tbd/todo/service.clj`, which the HTTP routes
|
|
and the [MCP tools](#mcp-server) both call, so they behave the same and are tested once; a route or
|
|
tool only translates its request into a service call and the result into a response, and never
|
|
touches the database.
|
|
|
|
Todos are stored in a local [SQLite](https://www.sqlite.org/) 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.
|
|
|
|
Every id the app issues (a todo's, an OAuth grant's) is a UUID, never a number: a model that reads `7` as a
|
|
todo's id mixes it up with a 7 in a title. (`owner-id` is the user's id in Gitea, which it issues, so it stays
|
|
a number.) A database from before that is migrated when it is opened: rows keep their order and get UUIDs, so
|
|
the ids in URLs or notes from before no longer work.
|
|
|
|
```bash
|
|
# 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](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`; it should call the group's service (or pure `core`
|
|
functions), not a database, and if the group has no service yet, that is the thing to add, so the
|
|
HTTP routes can share it. 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.
|
|
|
|
(The UI has tools of its own for a browser agent, [see below](#browser-mcp-tools-webmcp); they are not part of this server.)
|
|
|
|
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.
|
|
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 |
|
|
| `LMSTUDIO_URL`, `LMSTUDIO_MODEL`, `LMSTUDIO_API_KEY`, `LMSTUDIO_TIMEOUT_SECONDS` | The LM Studio server behind the [chatbot](#chatbot). Only the URL is needed |
|
|
| `CHAT_TRACE_FILE` | Optional. A file to append a JSON trace of every chatbot turn to, [with the model's full reasoning and replies](#watching-the-chatbot-think). Off by default |
|
|
| `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
|
|
|
|
```bash
|
|
# 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](https://github.com/day8/re-frame) app (ClojureScript, React 19 via
|
|
[Reagent](https://reagent-project.github.io/)) built with [shadow-cljs](https://github.com/thheller/shadow-cljs).
|
|
It has four pages, as tabs across the top. Each has its own address in the URL's fragment (`/#/tic-tac-toe`,
|
|
`/#/calculator`, `/#/todos`, `/#/ai-setup`), so the server only ever serves `/`, and a page can be bookmarked or reloaded.
|
|
|
|
| Tab | What's on it |
|
|
|-----|--------------|
|
|
| Tic-tac-toe | Two players on one board, all client side |
|
|
| Calculator | A small form that calls `GET /api/add` and shows the sum (or the server's validation error) |
|
|
| Todos | Your todo list (add, tick off, delete), backed by `/api/todos`. The tab only shows once you're logged in; its address shows a "Log in" prompt otherwise |
|
|
| AI setup | 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; and how to connect an MCP client to the page's own [browser tools](#browser-mcp-tools-webmcp) |
|
|
|
|
A "Log in" / "Log out" link sits in the footer row next to the API docs links.
|
|
|
|
A green square in the bottom left corner (on every page, opposite the blue WebMCP one) opens the
|
|
[chatbot](#chatbot).
|
|
|
|
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. The UI's tests run
|
|
in another stage of the same Dockerfile (`--target ui-test`), which the server image doesn't include.
|
|
|
|
### Chatbot
|
|
|
|
The green square opens a small chat panel. What you type goes to `POST /api/chat`, and the backend
|
|
forwards the conversation to an [LM Studio](https://lmstudio.ai) server (its OpenAI-compatible
|
|
`/v1/chat/completions`) and returns the reply. The browser never talks to the LLM directly, so the
|
|
server's address (and key) stay on the backend. The reply is streamed: the panel shows "Thinking…" until the
|
|
model's first word (a reasoning model can take a while), then the reply appears as it is generated. What
|
|
the model reasons is never shown, only that it is thinking; the tool calls (see below) show as small grey
|
|
lines between the replies, in full, however long, and the conversation scrolls. The conversation lives in the page only (a reload or logging out clears it), and the UI sends
|
|
the last 40 messages of it. Chat needs a login, like the todos: every message costs the LLM server's time.
|
|
|
|
The conversation scrolls. It follows the reply while you are at its end; scroll up to read back and it
|
|
stays put until you send a message or scroll down again. Drag the panel's top right corner to make it
|
|
wider or taller (the size is kept while the page is open, through Clear and closing the panel).
|
|
|
|
`curl -N` shows the stream (`-N` turns off curl's own buffering; use your session cookie):
|
|
|
|
```bash
|
|
curl -N -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
|
|
-d '{"messages":[{"role":"user","content":"hi"}]}' http://localhost:3000/api/chat
|
|
```
|
|
|
|
#### The chatbot's tools
|
|
|
|
The chatbot can use the page. The [browser tools](#browser-mcp-tools-webmcp) (the calculator, the todos,
|
|
tic-tac-toe) are registered in the page's *model context*, `ui/src/tbd/ui/model_context.cljs`, which is
|
|
what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat is that agent, embedded:
|
|
|
|
1. Each `POST /api/chat` carries `"page"`, the page the user is on, and `"tools"`: the tools' metadata, exactly what an MCP server answers
|
|
`tools/list` with (`{"name", "description", "inputSchema"}`, the schema being JSON Schema).
|
|
The backend gives it to the LLM as OpenAI-style `tools` (LM Studio's own tool use), and stays stateless.
|
|
Only the tools of the page being shown are listed: a tool registered with `:pages #{:calculator}` is
|
|
offered on the Calculator page and nowhere else (tic-tac-toe's tools are the Tic-tac-toe page's, the
|
|
todos' are the Todos page's), and one with no `:pages` (`ping`, `whoami`, `navigate`) on every page. The
|
|
list follows the page the user is on each time the conversation is sent (so does `"page"`, which the
|
|
backend puts in the system message ahead of the conversation for the LLM to know where the user is,
|
|
with the names of the tools it has),
|
|
and a call for a tool the page doesn't offer is refused with an error the LLM reads. The LLM
|
|
can move between pages with `navigate`; its next request then carries the new page and that
|
|
page's tools. An agent outside the page, through WebMCP, is still offered all of them.
|
|
2. The LLM never runs anything. When it wants a tool, it says so: the reply ends with a
|
|
`{"tool-calls": [...]}` event (the calls are put together from the pieces the model writes, the
|
|
`arguments` being the JSON text it wrote). The backend only relays them.
|
|
3. The page intercepts them and calls the tools right there (one after the other), through the same
|
|
handlers WebMCP uses, so they act on the page as a user would and you see it happen. The chat panel
|
|
shows each call and its result in small print between the messages.
|
|
4. The results go back as messages with the role `tool` (`{"role": "tool", "tool-call-id": "call_1",
|
|
"content": "..."}`, after the assistant message that asked, which carries `"tool-calls"`), in
|
|
another request with the conversation so far, for the LLM to go on from, until it answers with words.
|
|
|
|
A call that can't be made (a tool that isn't there, arguments that aren't a JSON object, a tool that
|
|
fails) is a result with the reason, for the model to read and try again from. To keep a model that never
|
|
stops calling tools from acting on your page for ever, one message of yours gets it at most 12 calls;
|
|
then the chat stops, and says so. Results are cut to the 8000 characters the server accepts.
|
|
|
|
The chatbot is told to be an agent, not a conversationalist: the system message (`tbd.agent.prompt/chat`, in
|
|
`shared/tbd/agent/prompt.cljc`, which `default-system-prompt` in `src/tbd/chat/core.clj` is) says that what it
|
|
does shows on your screen at once, so it should call a tool in the same turn rather than offer to, chain
|
|
calls (navigate, list, then act), take obvious defaults instead of asking, and answer in one short
|
|
sentence, since the call and its result are already shown. If a model still answers requests with offers,
|
|
that prompt (and its four examples, which name tools) is what to tune for it; the tool descriptions in
|
|
`webmcp.cljs` are the other lever. The same file gives an MCP client outside the page the same
|
|
instructions, [see below](#browser-mcp-tools-webmcp).
|
|
|
|
To try it, use a model that supports tool use in LM Studio, log in, and ask the chat something the page
|
|
can do: *"add 2 and 3 with the calculator"*, *"add a todo to buy milk"*, *"play tic-tac-toe with me, you're O"*.
|
|
|
|
A reverse proxy in front of tbd must not buffer responses, or the reply arrives all at once at the end
|
|
(the server sends `X-Accel-Buffering: no`, which nginx obeys; for others, turn buffering off for `/api/chat`).
|
|
The `thinking` events (the reasoning) also keep the connection from looking idle to a proxy while the model reasons; the UI drops them.
|
|
Anything that goes wrong is logged on the server's stdout, with a `[tbd] chat:` prefix.
|
|
|
|
Point the backend at your instance with environment variables (see [.env-template](.env-template)):
|
|
|
|
```bash
|
|
LMSTUDIO_URL=http://192.168.1.20:1234 script/server
|
|
```
|
|
|
|
Only `LMSTUDIO_URL` is required (the address of LM Studio's server, without `/v1`; for a remote instance
|
|
turn on "Serve on Local Network" there). `LMSTUDIO_MODEL` picks a model by id (default: the one LM Studio
|
|
has loaded), `LMSTUDIO_API_KEY` is sent as a bearer token if the server wants one, and
|
|
`LMSTUDIO_TIMEOUT_SECONDS` (default 120) is how long the LLM server may stay silent, before it starts
|
|
answering or between two pieces of it, before the server gives up. Without `LMSTUDIO_URL` the server
|
|
still starts (it says so), and the chat panel shows "chat is not configured".
|
|
|
|
#### Watching the chatbot think
|
|
|
|
Every request to `/api/chat` is one turn of the model, and every turn is logged on stdout: one
|
|
`turn-start` line when it goes out and one `turn-end` line when it ends. The tool loop runs in the browser,
|
|
so the server sees each tool result as a fresh request; the turns of one loop are tied together by a `run`
|
|
id (a hash of the user's last message), with `step` counting them from 0.
|
|
|
|
```
|
|
[tbd] chat turn-start req=9c1e4a07 run=d4a6cdb3 step=3 page=tic-tac-toe model=qwen messages=8 tools=6 prompt-chars=1180 run-calls=3
|
|
[tbd] chat loop-suspect req=9c1e4a07 run=d4a6cdb3 step=3 kind=repeated-call tool=get_tic_tac_toe count=3 args={}
|
|
[tbd] chat loop-suspect req=9c1e4a07 run=d4a6cdb3 step=3 kind=repeated-thinking count=8 text="Let me check the board again."
|
|
[tbd] chat turn-end req=9c1e4a07 run=d4a6cdb3 step=3 outcome=tool-calls duration-ms=18040 ttft-ms=2210 think-ms=15100 events=1830 thinking-chars=9210 content-chars=0 calls="get_tic_tac_toe{}" flags=repeated-thinking
|
|
```
|
|
|
|
`turn-end` says how it ended (`outcome`: `done`, `tool-calls`, `error`, `upstream-error`, or `aborted` when the
|
|
browser went away), how long it took (`ttft-ms` is the wait for the first piece, which includes the LLM
|
|
reading the prompt; `think-ms` the time spent reasoning before answering), how much it wrote (`events` is
|
|
close to a token count), and `finish-reason` and token counts when the LLM server sends them. `flags` is
|
|
the grep handle for a stuck model, with a `loop-suspect` line for each trip as it happens:
|
|
|
|
| Flag | Means |
|
|
| --- | --- |
|
|
| `repeated-call` | The same tool was called with the same arguments 3 or more times in one run (checked when the turn starts) |
|
|
| `repeated-thinking` | The reasoning said the same sentence again, ignoring case and punctuation: at the 4th time, then the 8th, 16th, ... |
|
|
| `long-reasoning` | The reasoning passed 8000 characters, then 16000, ... (only the ones without an answer yet are worrying) |
|
|
| `truncated` | The LLM stopped with `finish_reason: length`: it ran out of tokens, typically in a reasoning loop |
|
|
|
|
These are heuristics for where to look, not verdicts, and nothing is stopped: the server's timeout only
|
|
counts *silence*, so a model that reasons forever, out loud, is never cut off by it.
|
|
|
|
The log has no text of the conversation. To read what the model actually thought, set `CHAT_TRACE_FILE`
|
|
(say `data/chat-trace.jsonl`, which git ignores): each turn's entries are appended there as JSON lines, with
|
|
the full `reasoning`, `reply` and (in `turn-start`) the `input` messages of the run, the tool results the
|
|
model was reacting to. It holds what the user typed, so treat it like the database.
|
|
|
|
```bash
|
|
# the runs that got stuck, and how
|
|
jq -c 'select((.flags // []) | length > 0) | {run, step, flags, thinking_chars: ."thinking-chars"}' data/chat-trace.jsonl
|
|
# what the model said in one turn
|
|
jq -r 'select(.req == "9c1e4a07" and .event == "turn-end") | .reasoning' data/chat-trace.jsonl
|
|
```
|
|
|
|
### Browser MCP tools (WebMCP)
|
|
|
|
The page also offers tools of its own to your MCP client (Claude), through [WebMCP](https://webmcp.dev):
|
|
a small library that adds a widget to the page and lets the page register tools with it. They are the browser-side twin of the
|
|
[server's MCP tools](#mcp-server): those work on your data on the server, these work *the page*, doing
|
|
what you could do with the mouse and keyboard, so you see it happen. A tool that changes something opens the tab it acts on first (the todo tools open Todos, the game tools open Tic-tac-toe, the calculator tools open Calculator). They are in `ui/src/tbd/ui/webmcp.cljs`,
|
|
registered with `registerTool` on a `WebMCP` instance (the `@jason.today/webmcp` npm package):
|
|
|
|
| Tool | What it does |
|
|
|------|--------------|
|
|
| `ping`, `whoami` | Health check; who is logged in on the page, if anyone |
|
|
| `navigate` | Opens a page (`tic-tac-toe`, `calculator`, `todos` or `ai-setup`) as a click on its tab would; `todos` needs a login, like its tab. The chatbot is then offered the new page's tools |
|
|
| `get_calculator` | What the calculator form holds: its two fields and the result it shows |
|
|
| `add` | Types `x` and `y` into the calculator, presses Add, and returns the sum the page shows |
|
|
| `list_todos` | The todos the page shows (login required, like the todo list itself) |
|
|
| `get_todo` | Reads todos by id (`todos`: a list of `{id}`) |
|
|
| `create_todo` | Types each title into the new-todo field and presses Add (`todos`: a list of `{title}`), returns the new todos |
|
|
| `set_todo_done` | Ticks or unticks the checkboxes of several todos in one call (`todos`: a list of `{id, done}`), so a bulk change is one tool call |
|
|
| `delete_todo` | Presses the Delete button of several todos (`todos`: a list of `{id}`) |
|
|
|
|
The todo tools take their work as a list, so a batch ("untick all of them") is one tool call: the chatbot
|
|
is capped in the tool calls it may make in a turn. A batch is checked in full before its first change is made,
|
|
and then made one todo at a time; if one fails part way, the error says how many were done.
|
|
|
|
Each tool dispatches the same re-frame events the views do and waits for the result to show up in
|
|
the app-db, so what the agent sees is what the page shows, server errors included. There is no
|
|
`update_todo` with a title as on the server, because the page can't edit one.
|
|
|
|
Next to the tools the page registers **prompts** and **resources** (`prompts` and `resources` in `webmcp.cljs`),
|
|
so a client's lists of them are not empty. They carry the same prompt engineering as the chatbot, from
|
|
`shared/tbd/agent/prompt.cljc` (a `.cljc`, on the classpath of both the server and the UI build): act instead
|
|
of offering, chain the calls, take obvious defaults, report in a sentence, and play tic-tac-toe by the turn.
|
|
The WebMCP bridge sends no server-level instructions, so this is how Claude or Codex learns them:
|
|
|
|
| Prompt | What it is |
|
|
|--------|-----------|
|
|
| `drive_the_app` | The instructions, then the `task` you gave it (Claude Code lists it as a slash command). Without a task, the agent asks what you want |
|
|
| `play_tic_tac_toe` | Plays a game with you, one move on its own turn, as `side` (`x` or `o`, default `o`) |
|
|
|
|
| Resource | What it is |
|
|
|----------|-----------|
|
|
| `webmcp://app/guide` | The instructions, as Markdown: read this first |
|
|
| `webmcp://app/state` | JSON: the page that is open, the pages (which need a login) and who is logged in |
|
|
|
|
The bridge prefixes their names with the page's channel, like the tools'. A client does not read a
|
|
resource on its own: mention it (`@`) or ask the agent to read the guide.
|
|
|
|
To connect (the page's "Browser tools (WebMCP)" section says the same):
|
|
|
|
1. Add the WebMCP server to your MCP client, once: `npx -y @jason.today/webmcp@latest --config claude`
|
|
(restart Claude Desktop afterwards).
|
|
2. Ask the client to *make a webmcp token*.
|
|
3. Click the blue square in the bottom right corner of the page, paste the token, and press Connect.
|
|
The token works once, and the page has no field for it: the widget is where it goes.
|
|
4. The tools show up in the client, prefixed with the page's host.
|
|
|
|
## 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:
|
|
|
|
```bash
|
|
script/repl
|
|
```
|
|
|
|
### Tests
|
|
|
|
| Task | What it does |
|
|
|------|--------------|
|
|
| `script/unit-test` | Runs the Clojure tests in `test/` |
|
|
| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node, in a [jsdom](https://github.com/jsdom/jsdom) window (needs Node and a JDK) |
|
|
| `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down |
|
|
| `script/test` | UI tests, 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) |
|
|
| `script/docker-ui-test` | Same as `script/ui-test`, but inside a Node + JDK image (Docker only) |
|
|
|
|
The UI tests render the real app in jsdom against a fake server and drive it like a user (type, submit,
|
|
click), next to plain tests of the re-frame events and subscriptions, the game rules and the WebMCP tools
|
|
(`ui/test/tbd/ui/`; the shared fakes are in `test_util.cljs`). `ui/test/dom-setup.cjs` gives them a
|
|
`window` and the real WebMCP library, and fails a run that hangs, rejects a promise nobody handles, or ends
|
|
without a result (a test that never calls `done` would otherwise pass or stall silently). `npm test` in
|
|
`ui/` preloads it, so use that (or the scripts) rather than running the build's output directly.
|
|
|
|
### Coverage
|
|
|
|
Line coverage of the Clojure code via [Cloverage](https://github.com/cloverage/cloverage), and of the UI via
|
|
[c8](https://github.com/bcoe/c8):
|
|
|
|
| Task | Output |
|
|
|------|--------|
|
|
| `script/unit-coverage` | `target/coverage/unit/` |
|
|
| `script/integration-coverage` | `target/coverage/integration/` |
|
|
| `script/ui-coverage` | `target/coverage/ui/` (V8 coverage of the compiled UI, mapped back to `ui/src/**/*.cljs` through the source maps) |
|
|
| `script/coverage` | Runs all three, then writes the merged Clojure report to `target/coverage/merged/` (the UI's stays separate) |
|
|
| `script/docker-unit-coverage`, `script/docker-ui-coverage`, `script/docker-coverage-report` | Dockerized versions of the unit run, the UI run and the merge step, so `script/docker-ui-coverage`, `script/docker-unit-coverage`, `script/integration-coverage`, `script/docker-coverage-report` is `script/coverage` with no local Clojure, Node or Babashka (this is what CI runs) |
|
|
|
|
Each report directory has an `index.html` and an `lcov.info`. The UI's numbers are approximate where one
|
|
ClojureScript form compiles to several JavaScript blocks (a `case` or `cond`, say): a line can show as
|
|
uncovered when only part of the form ran. See
|
|
[integration-test/README.md](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)
|
|
chat/ The chatbot: request to the LM Studio server, tool calls included (core), the request's Malli schema (schema), `/api/chat` route, per-turn logging and loop detection (trace)
|
|
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
|
|
shared/ .cljc read by both the server and the UI (tbd.agent.prompt: the instructions an LLM is given)
|
|
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,model_context,core}.cljs, tests in ui/test/
|
|
.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, ui-test, 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`.
|