~bigbes/sr-ht-dolt

ref: cd0b0c0fb427bff6060c3f785d67784b999610f3 sr-ht-dolt/docs/DESIGN.mcp.md -rw-r--r-- 17.1 KiB
cd0b0c0f — Eugene Blikh web: rename a database from its settings page 3 days ago

#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:

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 <token> 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/:

// 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):

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:

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. An id absent from a clipped read answers issue: null with table_truncated, which is "not among the rows read" and not "does not exist"
list_milestones db, ref? the milestone:<name> 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. That holds for the projection's own row cap as well, which every beads tool reports as table_truncated + table_total — including get_issue and list_milestones, where a partial read is otherwise indistinguishable from a complete one: a missing id reads as absence, and a rollup reads as arithmetic about the whole tracker.

#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.