# dolt.sr.ht — the MCP surface (`mcpsrv/`) A read-only Model Context Protocol endpoint on the web listener, so that an agent reads a hosted Dolt database — and the beads tracker inside it — by calling tools instead of scraping HTML. This document is normative for the work; `docs/DESIGN.md` remains the service's architecture document and is not superseded by it. ## 1. Why Every beads tracker on this instance already has a companion Dolt database here, and the browse UI already knows how to read one without a SQL engine. What is missing is a machine-readable door to the same reading. Today an agent that wants "what is ready to work in ~bigbes/sr-ht-artifacts" either runs `bd` in a checkout it may not have, or fetches an HTML board and parses it. Both are worse than the answer this service can already compute. The family has settled this shape four times over — `sourcehut-coverage`, `-specs`, `-bench` and `-artifacts` each ship an `mcpsrv/` package on `github.com/modelcontextprotocol/go-sdk v1.6.1`. This service copies that pattern rather than inventing a fifth one. `sourcehut-coverage/mcpsrv` is the donor: it is the newest, and its package doc carries the reasoning the sections below reuse. ## 2. What it must not become The whole pure-Go build (`gms_pure_go`, no CGO, no ICU, no zstd) stands on one fact: this service never starts the SQL engine. Bare NBS stores have no working set, so they cannot be opened by `sqle` at all (`browse/open.go`). The MCP surface changes nothing about that: - **No SQL tool.** There is no `query(sql)` tool and there will not be one. It would require the engine, the engine requires a working set, and the working set is what a bare store does not have. An agent that needs an arbitrary projection reads rows and projects them itself. - **No writes.** No `dolt_commit`, no issue mutation. Writing means a working set, a commit and a push — the dependency the build is deliberately free of. Mutation stays with `bd` in a checkout (and see `DESIGN.views.md` ch. 6, which gives a human the exact command to paste). - **No second reading of any schema.** Visibility is `core.Allowed`, the beads fingerprint is the one in `beads/`, the row cap is the one constant. Two surfaces that each grew their own copy is how they start disagreeing quietly. ## 3. Placement and mounting New package `mcpsrv/` at the module root, beside `browse/`, `web/` and `remoteapi/`. It is mounted at `/mcp` on the **web listener** (there is no new port): the daemon binds loopback and Traefik/nginx forwards, exactly as the web UI is reached today. `web.Register` claims `"/"` and wraps everything it mounts in the same-origin CSRF group (`web/router.go`). `/mcp` must therefore be registered by `cmd/doltsrht/main.go` on the router group **before** `web.Register`, and outside that group: ```go r.Handle(mcpRoute, agents) // "/mcp", registered before web.Register(r, cfg) ``` `Handle` and not `Mount`: the streamable handler serves the exact path, and `Mount` would both rewrite the path to the empty remainder and claim `/mcp/*` (this is the donor's note, `cmd/coversrht/main.go`). The surface is bearer-only, carries no `Origin` and no cookie, and must not be inside the CSRF group — the guard would refuse every call. ## 4. Identity ### 4.1 The credential This service has three credential planes today: the unified-login cookie (`authn/cookie.go`), a meta personal access token presented as HTTP Basic for `dolt clone`/`push` (`authn/token.go`), and dolt's Ed25519 keypair JWT on the gRPC side (`authn/jwt.go`). None of them is what an agent should carry. The instance has a token service — `tokens.sr.ht` (`~/data/home/sourcehut-tokens`) — whose whole purpose is this case: a long-lived parent token a human puts in an agent's config once, exchanged for short-lived **working tokens** with narrowed grants. `sr-ht-ecore/bearer` is the one copy of the validation, and `sr-ht-ecore/grants` the one parser of the grant string; `cov.sr.ht` already consumes both (`authn/instance.go`, `authn/resolver.go`). `sr-ht-ecore` is already a direct dependency of this module, so adoption costs no new dependency. **`Authorization: Bearer ` is the only machine credential `/mcp` accepts.** No cookie plane here (an MCP client is not a browser), no Basic (that is dolt's remote flow, and its username-must-match rule has no meaning without a presented username). Both bearer shapes the instance issues are `auth.BearerToken` sealed with the same instance key, and only the `ClientID` tells them apart: | ClientID | What it is | Gate | |---|---|---| | `tokens.sr.ht` | a tokens.sr.ht working token | must carry `dolt:read` (`grants`), plus the live check for a registered token | | anything else | a meta.sr.ht PAT | `authn.TokenGrantsAllow(ac, core.AccessRO)`, the check the clone path already applies | A token that fails verification is a **refusal**, never a downgrade to anonymous. An absent `Authorization` header is anonymous, and anonymous is a normal caller: it sees what anonymity may see. That is `authn.Resolver`'s rule in the donor and it is this surface's too. ### 4.2 The grant vocabulary `tokens.sr.ht` does not know any service's vocabulary by design — the service declares it. Add to `core/`: ```go // GrantRead is the tokens.sr.ht grant an instance working token must carry to // read anything through /mcp. const GrantRead = "dolt:read" ``` `dolt:write` is deliberately not defined: nothing on this surface writes, and a grant nobody checks is a promise to an operator that no code keeps. The grant is checked once, in front of the SDK handler, exactly as the donor does (`requireReadGrant`): every tool on this surface is a read, so there is no per-tool grant to derive. A meta PAT and an anonymous caller carry no tokens.sr.ht grants and pass this gate; their access is decided by §4.3. ### 4.3 Visibility is not re-implemented Every tool resolves the database through the same two calls the browse handlers make (`web/router.go:loadRepoForBrowse`): ```go repo := Repos.GetRepoByOwnerAndName(ctx, owner, name) mode := Repos.EffectiveAccess(ctx, caller.UserID, repo.ID) core.Allowed(caller, repo, mode, core.OpBrowse) ``` and answers a refusal the way the web does: a PRIVATE database the caller may not see is **not found**, indistinguishable from one that does not exist (`core.NotFoundForPrivate`). A tool that reported "forbidden" would rebuild the distinction the 404 exists to erase. `list_databases` enumerates only what the caller may *list* — an UNLISTED database is absent from the listing and readable when named directly, which is the rule the dashboard already implements. ## 5. Stateless, and why that is an authentication decision The streamable HTTP transport runs **stateless**. The donor documents the measurement against the same pinned SDK: in stateful mode the SDK binds a session to the context of the `initialize` request, so every later `tools/call` on that session answers as the principal who opened it — a call presenting *no* credential, and a call presenting a *stranger's*, both read as the opener. The session id becomes a bearer credential this service never meant to issue. Stateless mode connects a temporary session per POST, so a tool handler's context descends from the request that carried the call and the principal is per call. The cost is the server→client half of the protocol: no standalone SSE stream, `GET /mcp` answers 405. Every tool here is a read that answers in one response; none samples, elicits or reports progress. **The measurement is the donor's, not ours.** Port its identity test with the matrix (no credential / stranger's token / owner's token) and re-run it here against our own pinned SDK version, rather than citing this paragraph. ## 6. The Host allowlist The SDK's DNS-rebinding guard refuses a request arriving on loopback with a non-loopback `Host` — which is every genuine request in this deployment, since the daemon binds `localhost:5307` behind a proxy that forwards the public host. Disable it (`DisableLocalhostProtection: true`) and **replace** it in the same constructor with an allowlist requiring `Host` to be this instance's own hostname, derived from `[dolt.sr.ht]origin` via `instconf.OriginHost`. An empty host from a malformed origin is a constructor error, never a guessed `localhost`. Refusals carry the private-cache directives too. ## 7. Package shape ``` mcpsrv/ mcpsrv.go — Server, New, tool registration, the HTTP handler chain ports.go — the seams this package calls (interfaces declared here) read.go — the tool handlers and their argument/result shapes errors.go — sentinel → tool result / protocol error mapping *_test.go — in-process client over fakes; no Postgres, no store on disk ``` `ports.go` declares consumer-side interfaces, as `web/deps.go` does, and names exactly what the tools call: ```go type Repos interface { GetRepoByOwnerAndName(ctx, owner, name string) (*core.Repo, error) ListReposForDashboard(ctx, userID int) ([]*core.Repo, error) ListReposByOwner(ctx, owner string, viewer *core.Caller) ([]*core.Repo, error) EffectiveAccess(ctx, userID, repoID int) (*core.AccessMode, error) } type BrowseOpener interface { Open(ctx context.Context, diskPath string) (BrowseSession, error) } ``` `BrowseSession` is the same method set `web` declares (`Branches`, `Log`, `Tables`, `Rows`, `CommitSummary`, `Close`, plus `TableHash` from `DESIGN.views.md` ch. 2). What is **absent** from these seams is the design made structural: no `StoreManager` (nothing here creates or deletes a store), no `UserResolver`, no ACL mutation, no key management. A handler cannot write what its seam cannot reach. Sessions are per call and closed by the handler (`defer sess.Close()`), which is the browse discipline: a fresh manifest read per request, no stale cache. ## 8. The shared beads projection (`beads/`) Both `/mcp` and the web views must read the beads schema the same way. Today that reading lives in `web/beads.go` (1083 lines: the fingerprint, the lane bucketing, the ready rule, the status categories, the dependency walk, the event humanizer) where a second consumer cannot reach it. **Extract it into a new root package `beads/`** before writing the tools: - `beads/` depends on `browse/` and the standard library, nothing else. It is a projection: rows in, view model out, no HTML, no `net/http`, no `core`. - Moves as-is: `Applies` (the fingerprint), `BeadCard`/`BeadIssue`/`BeadEdge`/…, the lane bucketing, the ready rule, `statusCategory`, the dep-tree walk, `humanizeEvent`, the filter model, the row helpers (`cell`, `indexCols`, `truthy`, `readRows`, `readRowsOptional`), the milestone rollup. - `web/beads.go` keeps `beadsView` (the `View` implementation) and whatever is genuinely presentational; `web/milestones.go` likewise. - The move is mechanical, and its test suites (`web/beads_test.go`, `web/milestones_test.go`, 900 lines together) move with it. A behaviour change smuggled into this commit is what makes the whole refactor unreviewable — the commit must be a pure move plus export renames. This extraction is a prerequisite of both this document and `DESIGN.views.md` ch. 4/5, and is the first phase in §11. ## 9. The tools Names are `snake_case`; every schema is derived from a Go struct via the generic `mcp.AddTool`, never hand-written JSON. A database is addressed as `{owner, name}` (both without the `~`), which is what the URL says and what an agent can copy from a link. MCP has no per-resource tool registry: the tool list is static per server. The beads tools are therefore always advertised and refuse per database — a database whose tables do not carry the beads fingerprint answers "this database is not a beads tracker", naming the generic tools as the way to read it anyway. ### 9.1 Generic (any hosted database) | Tool | Arguments | Answers | |---|---|---| | `list_databases` | — | every database the caller may list: owner, name, visibility, description, default branch, head hash + commit time, `is_beads` | | `list_branches` | db | branches with head hashes | | `list_tables` | db, ref? | table names, columns (name/type/pk/nullable), row counts | | `read_rows` | db, table, ref?, offset?, limit? | a page of rows as strings-or-null (a cell holding no value is JSON `null`, so a real NULL is not the string `"NULL"`), plus the table total | | `get_commit_log` | db, ref?, from?, limit? | commits: hash, author, date, message, parents | | `get_commit_diff` | db, hash | the per-table summary `browse.CommitSummary` computes | `ref` defaults to the repository's default branch (`browse.DefaultBranch`), so an agent that does not care about branches never has to name one. ### 9.2 Beads-aware (a database whose tables carry the fingerprint) | Tool | Arguments | Answers | |---|---|---| | `list_issues` | db, ref?, filter{status/category, type, priority, assignee, label, q, ready}, limit? | cards: id, title, type, priority, assignee, labels, blocked-by/blocks counts, ready, lane — **no long-text bodies** | | `get_issue` | db, id, ref? | the whole issue: every modelled field, both dependency directions, the transitive trees, subtasks + rollup for an epic, comments, the merged history | | `list_milestones` | db, ref? | the `milestone:` rollups: totals, done/in-progress/open, members | | `list_memories` | db, ref?, q? | `kv.memory.*` from `config`: slug, text, and the revision it was last written at (`DESIGN.views.md` ch. 2) | | `ready_work` | db?, limit? | the ready set; **db omitted ⇒ across every beads database the caller may see** (the cross-database aggregation of `DESIGN.views.md` ch. 4, one implementation serving both surfaces) | The list/detail split is deliberate and is this surface's version of the family's "no source text" rule: a board of 78 issues carrying every description, design note and acceptance criterion is the agent's context window spent on text it did not ask for. Lists carry identity and metadata; `get_issue` carries bodies. ### 9.3 Caps - `read_rows`: `limit` ≤ 500, default 100. - `get_commit_log`: `limit` ≤ 100, default 25. - beads tools read at most `beads.Max` (2000) rows per table, the constant the board already uses, and report truncation in the result rather than paging. - `list_issues`: `limit` ≤ 500, default 200, applied after filtering. A cap that silently truncates is a lie to a caller that cannot see the table: every truncated result carries `truncated: true` and the true total. ## 10. Configuration | Key | Meaning | |---|---| | `[dolt.sr.ht]origin` | already required; also the `/mcp` Host allowlist | | `[tokens.sr.ht]origin` | enables the working-token plane (revocation check). Absent ⇒ no such daemon on this instance ⇒ working tokens are refused, meta PATs and anonymity still work | No `mcp-enabled` switch. A surface that is off in production and on in a test is a surface nobody tests; `/mcp` exists wherever the daemon does. ## 11. Phases 1. **`beads/` extraction** (§8). Pure move, tests move with it, no behaviour change. Blocks everything else here and in `DESIGN.views.md` ch. 4–5. 2. **The bearer plane in `authn/`**: `ParseBearer`, the `tokens.sr.ht` plane over `sr-ht-ecore/bearer` (the `InstanceValidator` seam), the meta-PAT arm over the existing decode path, `core.GrantRead`. Table-driven classification tests, no network. 3. **`mcpsrv/` skeleton**: `New`, the Host allowlist, the grant gate, stateless transport, `list_databases` only. The identity matrix test lands here. 4. **Generic tools** (§9.1) over the browse seam. 5. **Beads tools** (§9.2) over `beads/`, minus `ready_work`'s cross-database arm. 6. **Wiring in `cmd/doltsrht/main.go`** (§3) + `config.example.ini` + `README.md`. 7. **`ready_work` across databases**, after `DESIGN.views.md` ch. 4 lands its aggregator and cache. Phases 4 and 5 are disjoint file sets over a committed §8 and a committed §3 skeleton, so they can run in parallel; 1→2→3 is serial. ## 12. Tests The donor's suites are the model, and the ones worth naming here: - **Identity matrix** (§5): no credential / stranger's token / owner's token, same session, asserting the answer follows the *call*. - **Visibility matrix**: for each of PUBLIC/UNLISTED/PRIVATE × anonymous / stranger / grantee / owner, every tool either answers or reports not-found — never "forbidden", never a distinguishable failure. - **Host allowlist**: the instance host passes, a rebinding host is 403 before the SDK is reached. - **Grant gate**: a working token without `dolt:read` is 403; a meta PAT and an anonymous caller are not gated by it. - **Caps**: a table larger than the cap answers truncated, with the true total. - **In-process client over fakes**: no Postgres, no store on disk — the fakes `web`'s tests already carry are the pattern.