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.
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.
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:
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.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).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.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.
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.
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.
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.
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.
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.
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.
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.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.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.
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.
| 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, 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.
| 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:<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.
read_rows: limit ≤ 500, default 100.get_commit_log: limit ≤ 100, default 25.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.
| 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.
beads/ extraction (§8). Pure move, tests move with it, no behaviour
change. Blocks everything else here and in DESIGN.views.md ch. 4–5.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.mcpsrv/ skeleton: New, the Host allowlist, the grant gate, stateless
transport, list_databases only. The identity matrix test lands here.beads/, minus ready_work's cross-database
arm.cmd/doltsrht/main.go (§3) + config.example.ini +
README.md.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.
The donor's suites are the model, and the ones worth naming here:
dolt:read is 403; a meta PAT and an
anonymous caller are not gated by it.web's tests already carry are the pattern.