@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user