scope tools by page
CI / Test + coverage (push) Successful in 3m34s

This commit is contained in:
2026-09-21 14:01:34 -09:00
parent a53f0c3621
commit bf38f7b1be
37 changed files with 1894 additions and 359 deletions
+96 -20
View File
@@ -48,14 +48,14 @@ docker run --rm -p 3000:3000 tbd
| `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 (`"games"`, `"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 |
| `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 1 :owner-id 7 :title "buy milk" :done false :created-at 1700000000000 :updated-at 1700000000000}]}` |
| `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) |
@@ -95,6 +95,11 @@ Todos are stored in a local [SQLite](https://www.sqlite.org/) file, `data/todo.d
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=...' \
@@ -186,6 +191,7 @@ login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin.
| `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
@@ -211,12 +217,13 @@ restarts), so switch it off when you're done.
`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 three pages, as tabs across the top. Each has its own address in the URL's fragment (`/#/games`,
`/#/todos`, `/#/ai-setup`), so the server only ever serves `/`, and a page can be bookmarked or reloaded.
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 |
|-----|--------------|
| Games | Tic-tac-toe (two players on one board, all client side), and a small calculator form that calls `GET /api/add` and shows the sum (or the server's validation error) |
| 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) |
@@ -267,8 +274,8 @@ what WebMCP's `navigator.modelContext` is to an agent outside the page. The chat
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 #{:games}` is
offered on the Games page and nowhere else (the calculator and tic-tac-toe are games' tools, the
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,
@@ -291,12 +298,14 @@ fails) is a result with the reason, for the model to read and try again from. To
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 (`default-system-prompt` in
`src/tbd/chat/core.clj`) 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 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"*.
@@ -319,29 +328,95 @@ has loaded), `LMSTUDIO_API_KEY` is sent as a bearer token if the server wants on
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 and calculator tools open Games). They are in `ui/src/tbd/ui/webmcp.cljs`,
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 (`games`, `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 |
| `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`, `get_todo` | The todos the page shows (login required, like the todo list itself) |
| `create_todo` | Types a `title` into the new-todo field, presses Add, returns the new todo |
| `set_todo_done` | Ticks or unticks a todo's checkbox (`id`, `done`) |
| `delete_todo` | Presses a todo's Delete button |
| `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`
@@ -409,9 +484,10 @@ src/tbd/
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
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)