coverage~bigbes/sr-ht-spec3cb1c03dmcpsrv/mcpsrv.go

Coverage
96.6% 57/59 statements
Δ
Blob
3eb235b
Uncovered L179-L181
1 // Package mcpsrv is spec.sr.ht's Model Context Protocol surface: the tools an
2 // agent calls to find and read approved documents.
3 //
4 // It is warren's mcpsrv/ absorbed, and it keeps warren's two structural
5 // choices — a narrow backend interface the tools are written against, and the
6 // SDK's generic AddTool deriving every schema from a Go struct — while
7 // discarding everything that assumed a local vault: the vault-wide id space,
8 // the parent/child tools, and the hybrid keyword+semantic mode argument (vector
9 // search is Phase 5 here).
10 //
11 // # A surface, not a wrapper
12 //
13 // The design's rule is that MCP is a first-class surface and that its tools
14 // call the same resolver layer as REST, GraphQL and the web UI rather than a
15 // parallel implementation. That is what this package is: every lookup goes
16 // through service/, every addressing decision through doc.Archive, and every
17 // query through search.Index. Nothing here re-derives which document an id
18 // names or which spaces a project covers, because a second implementation of
19 // those rules is how two surfaces start answering the same question
20 // differently — silently, and months later.
21 //
22 // # The read contract is the reason this exists
23 //
24 // Bots need "the approved text of SPEC-0007", not "whatever a branch points at
25 // while another agent rewrites it". So:
26 //
27 // - Omitting rev reads the space's approved head. That is the default,
28 // and it is service.ApprovedRev — the same default every other surface has.
29 // - Passing rev pins the read to one immutable revision, and rev must be an
30 // object name: 7-64 lowercase hex, the same grammar the design pins for
31 // X-Agent-Base. Ref names are refused outright, which is what makes
32 // "read a proposal branch" impossible to reach by accident or by
33 // mistyping — an agent can only reach unapproved content by naming a
34 // commit sha it had to obtain deliberately, since neither spec_search nor
35 // spec_list ever reports one.
36 //
37 // Serving drafts by default would poison every downstream agent context with
38 // unreviewed text, which is the exact failure the service exists to prevent.
39 //
40 // # The write plane
41 //
42 // Two tools write, and both are registered only when a write backend is wired:
43 // spec_propose opens or extends a proposal, and spec_comment reads a proposal's
44 // review threads and replies to them. Neither can approve, merge, open a review
45 // thread or resolve one — those are the owner's, in service/, and the
46 // interfaces here do not name them.
47 //
48 // # Layering
49 //
50 // Reader and Searcher are declared here rather than imported as concrete types
51 // so the tools can be tested without a repository, a Postgres instance or a
52 // bleve index. *service.Service satisfies Reader and *search.Index satisfies
53 // Searcher, structurally, with no adapter.
54 package mcpsrv
55
56 import (
57 "context"
58 "errors"
59 "fmt"
60 "net"
61 "net/http"
62 "strings"
63
64 "github.com/modelcontextprotocol/go-sdk/mcp"
65 "sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
66
67 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
68 "sourcecraft.dev/bigbes/sr-ht-spec/search"
69 )
70
71 // ServerName is the implementation name reported in the MCP handshake. It is
72 // the service's config-section name, so a client listing several SourceHut MCP
73 // endpoints sees which one it is talking to.
74 const ServerName = "spec.sr.ht"
75
76 // maxSearchLimit caps how many hits one call may ask for.
77 const maxSearchLimit = 100
78
79 // Backend is everything the tools read through. Both halves are required: a
80 // nil one is a wiring mistake, and New reports it at startup rather than
81 // letting the first tool call panic inside a request.
82 type Backend struct {
83 // Docs is the orchestration layer — in production *service.Service.
84 Docs Reader
85 // Index is the one global bleve index — in production *search.Index.
86 Index Searcher
87 // Write is the write side — in production *service.Service. It is optional:
88 // when nil the server registers only the read tools, so a read-only
89 // deployment or a test needs no mutable backend. When set, spec_propose is
90 // registered and every write goes through the same service.Propose the REST
91 // PUT calls.
92 Write Writer
93 }
94
95 // New builds the MCP server with the Phase 2 read tools registered. version is
96 // reported as the implementation version in the handshake.
97 //
98 // The returned server is not connected to a transport; Handler wires it to
99 // streamable HTTP, and a caller that wants stdio can call Run itself.
100 47 func New(b Backend, version string) (*mcp.Server, error) {
101 47 if b.Docs == nil {
102 2 return nil, errors.New("mcpsrv: backend has no document reader")
103 2 }
104 45 if b.Index == nil {
105 1 return nil, errors.New("mcpsrv: backend has no search index")
106 1 }
107
108 44 srv := mcp.NewServer(&mcp.Implementation{Name: ServerName, Version: version}, nil)
109 44 readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true}
110 44
111 44 mcp.AddTool(srv, &mcp.Tool{
112 44 Name: "spec_search",
113 44 Annotations: readOnly,
114 44 Description: "Search every approved document on this instance, ranked. Each hit carries " +
115 44 "the space, the document id, its path, the revision it was indexed at, its title " +
116 44 "and a plain-text snippet — enough to fetch it with spec_read without a second " +
117 44 "lookup.\n\n" +
118 44 "Pass `spaces` to restrict the search to a set of spaces: that set is what a " +
119 44 "project is on this service — a saved filter over one global index, not a " +
120 44 "container. Omitting it searches everything, which is the meta-project.\n\n" +
121 44 "Results come from the approved revision of each space. Proposal branches are " +
122 44 "not indexed and never appear here.",
123 44 }, func(ctx context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
124 10 out, err := searchHandler(ctx, b, in)
125 10 return nil, out, err
126 10 })
127
128 44 mcp.AddTool(srv, &mcp.Tool{
129 44 Name: "spec_read",
130 44 Annotations: readOnly,
131 44 Description: "Read one document's markdown, frontmatter included, exactly as it is stored.\n\n" +
132 44 "By default this returns the space's APPROVED text — the reviewed, canonical " +
133 44 "revision — and reports the revision it resolved to in `rev`. Pass that value " +
134 44 "back as the `rev` argument later to re-read the identical bytes; a revision, " +
135 44 "once named, is immutable.\n\n" +
136 44 "Address the document by its frontmatter id (\"SPEC-0007\") when it has a " +
137 44 "well-formed one that no other document in the space claims, and otherwise by " +
138 44 "its path, with or without the \".md\" extension. A document whose id is " +
139 44 "duplicated within its space resolves to neither document and is reported as " +
140 44 "ambiguous rather than guessed at.",
141 44 }, func(ctx context.Context, _ *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, readOutput, error) {
142 24 out, err := readHandler(ctx, b, in)
143 24 return nil, out, err
144 24 })
145
146 44 mcp.AddTool(srv, &mcp.Tool{
147 44 Name: "spec_list",
148 44 Annotations: readOnly,
149 44 Description: "List spaces, or list the documents in one space.\n\n" +
150 44 "Omit `space` to get every space on the instance — those names are what " +
151 44 "spec_search's `spaces` filter takes. Pass `space` to get that space's " +
152 44 "documents at its approved head, each with the id spec_read addresses it by, " +
153 44 "its path, title, section and authored status. Pass `rev` as well to list a " +
154 44 "pinned revision instead.",
155 44 }, func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) {
156 5 out, err := listHandler(ctx, b, in)
157 5 return nil, out, err
158 5 })
159
160 // The write tools, registered only when a write backend is wired. Neither is
161 // read-only or idempotent — proposing twice opens two proposals, replying
162 // twice says it twice — so neither carries a hint, which is how a client
163 // tells a tool it can retry freely from one it cannot.
164 44 if b.Write != nil {
165 4 mcp.AddTool(srv, &mcp.Tool{
166 4 Name: "spec_propose",
167 4 Description: "Propose a change to a space: upload whole documents and get back a proposal " +
168 4 "and a URL to hand a human for review.\n\n" +
169 4 "Pass `if_match` as the `rev` you read the approved head at (from spec_read) — it becomes " +
170 4 "the proposal's base, and a base the approved branch has moved off is rejected so you " +
171 4 "refetch and re-propose. Each document in `documents` is the WHOLE markdown, frontmatter " +
172 4 "included; there are no patches.\n\n" +
173 4 "Omit `proposal` to open a new one (give it a `title`); pass an existing proposal id to add " +
174 4 "more documents to it, sending the same `if_match` you opened it with.\n\n" +
175 4 "The response always carries the proposal `url`. Surface it: the human reviews there, and a " +
176 4 "proposal whose link you never mention is invisible. When `merged` is true the space's " +
177 4 "auto_merge policy landed the change immediately; otherwise it is open and waiting.",
178 4 }, func(ctx context.Context, _ *mcp.CallToolRequest, in proposeInput) (*mcp.CallToolResult, proposeOutput, error) {
179 0 out, err := proposeHandler(ctx, b.Write, in)
180 0 return nil, out, err
181 0 })
182
183 4 mcp.AddTool(srv, &mcp.Tool{
184 4 Name: "spec_comment",
185 4 Description: "Read the review threads on a proposal, and reply to one.\n\n" +
186 4 "Pass only `proposal` to list its threads: each carries the owner's critique, its " +
187 4 "replies, the document and heading path it is anchored to, and whether it is still " +
188 4 "open.\n\n" +
189 4 "Read `state` before acting on a thread. \"anchored\" means the block you were " +
190 4 "criticised for is still there verbatim; \"edited\" means the block is still in that " +
191 4 "position but its text changed after the comment was written, so the critique may " +
192 4 "already be addressed; \"outdated\" means the anchor lost its block entirely and the " +
193 4 "comment describes text that is no longer in the proposal. Fixing what an outdated " +
194 4 "comment asks for edits something else.\n\n" +
195 4 "Pass `thread` and `body` to reply to that thread. Replying does not close it — only " +
196 4 "the owner resolves a thread, and an open thread holds back auto-merge. So answer the " +
197 4 "critique and push the revision with spec_propose; do not expect the reply itself to " +
198 4 "unblock the proposal.",
199 4 }, func(ctx context.Context, _ *mcp.CallToolRequest, in commentInput) (*mcp.CallToolResult, commentOutput, error) {
200 1 out, err := commentHandler(ctx, b.Write, in)
201 1 return nil, out, err
202 1 })
203 }
204
205 44 return srv, nil
206 }
207
208 // Handler mounts the server on streamable HTTP. cmd/specsrht hangs it off the
209 // chi router at /mcp, which is what keeps MCP to one listener and one nginx
210 // block rather than a second port.
211 //
212 // The SDK's DNS-rebinding guard is disabled deliberately, and the reason is
213 // worth stating because disabling a security default usually is not.
214 //
215 // The guard rejects any request arriving on a loopback address that carries a
216 // non-loopback Host header. That is precisely our deployment: the daemon binds
217 // 127.0.0.1:5091 and nginx forwards with `proxy_set_header Host $host`, so
218 // every genuine request would 403 — and it would 403 only in production,
219 // because a local client sends a loopback Host and passes.
220 //
221 // It is not that the guard has nothing to catch. A browser running on the
222 // daemon's own host could reach 127.0.0.1:5091 directly with an attacker's
223 // Host header, which is the attack the guard is for. The problem is that the
224 // guard cannot tell that request from nginx's: both arrive from loopback
225 // carrying a Host that is not loopback, and the SDK exposes no allowlist to
226 // separate them.
227 //
228 // So the guard is disabled and REPLACED, in the same constructor, by allowHosts
229 // below — a stricter check than the one removed. The SDK asks only "is Host
230 // loopback?"; we require Host to equal this instance's configured origin. A
231 // rebinding attack carries the attacker's name in Host and fails that; nginx
232 // forwards our real hostname and passes. Disabling the SDK guard without this
233 // replacement would be a genuine regression, not a formality.
234 //
235 // origin is therefore required, and an origin with no host in it is refused
236 // here rather than warned about and then served unguarded. This used to warn:
237 // it logged that Host validation was disabled and returned the bare handler,
238 // on the reasoning that refusing to start over a config typo is worse than
239 // running without the guard. That trade is the wrong way round. The guard is
240 // the *only* thing protecting /mcp once the SDK's own is disabled, so the warn
241 // path turned one unparseable config value into a silently open endpoint —
242 // discoverable, in principle, from a log line nobody reads, and indistinguishable
243 // in every functional test from a correctly guarded one. A daemon cannot reach
244 // this call with such an origin anyway: service.Config.Validate already refuses
245 // to start unless [spec.sr.ht] origin parses and carries a host. A caller that
246 // got here without one is not an operator to be warned, it is a bug.
247 23 func Handler(b Backend, version, origin string) (http.Handler, error) {
248 23 srv, err := New(b, version)
249 23 if err != nil {
250 1 return nil, err
251 1 }
252 // instconf.OriginHost and not a local parse: this is the reading of an
253 // origin, and it is the same one authn's mailbox derivation and service's
254 // config validation make. An origin nobody can extract a host from answers
255 // "" here — never a guess such as "localhost", which would silently make
256 // every malformed origin agree with a local client on the one code path
257 // where that decides an allowlist.
258 22 want := instconf.OriginHost(origin)
259 22 if want == "" {
260 5 return nil, fmt.Errorf("mcpsrv: origin %q has no host to guard /mcp with", origin)
261 5 }
262 17 h := mcp.NewStreamableHTTPHandler(
263 17 func(*http.Request) *mcp.Server { return srv },
264 &mcp.StreamableHTTPOptions{DisableLocalhostProtection: true},
265 )
266 // The cache directives wrap the Host allowlist rather than the other way
267 // round, so that the 403 carries them too: a refusal by hostname is as
268 // unstorable as an answer, and it is written before the SDK is reached at all.
269 17 return privateCache(allowHosts(h, want)), nil
270 }
271
272 // allowHosts is this endpoint's DNS-rebinding protection, in the form the
273 // deployment actually needs: Host must be the service's own origin hostname, or
274 // a loopback name for local development.
275 //
276 // want is the hostname already extracted from the origin by Handler, which is
277 // also where an origin that yields none is refused. It is a resolved host and
278 // never an origin, so there is no path through this function that leaves the
279 // guard off.
280 17 func allowHosts(next http.Handler, want string) http.Handler {
281 18 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282 18 if !hostAllowed(r.Host, want) {
283 4 http.Error(w, "Forbidden: unexpected Host header", http.StatusForbidden)
284 4 return
285 4 }
286 14 next.ServeHTTP(w, r)
287 })
288 }
289
290 // Gate refuses a caller with no read authority before any MCP method — not just
291 // tools/call, but initialize and tools/list too — reaches the server. The ACL
292 // is authn.Principal.CanRead — the owner and its agents may read and nobody else
293 // may — the same predicate graph's /query and the web UI apply, so the three
294 // read surfaces cannot drift into three policies, which is how a corpus leaks.
295 //
296 // It reads the principal the resolver middleware set, so it must be mounted
297 // INSIDE that middleware:
298 //
299 // mcp = resolver.Middleware()(mcpsrv.Gate(handler))
300 //
301 // Without it, every read tool served approved content to anyone who cleared the
302 // Host allowlist — spec_propose was already fail-closed in service.Propose, but
303 // spec_search/spec_read/spec_list checked nothing. The refusal is a 401 with a
304 // line of plain text and never a login redirect: every caller here is a machine.
305 //
306 // It checks identity and not grants, deliberately. This one endpoint carries
307 // both the read tools and the write ones, and the tool being called is in the
308 // JSON-RPC body, not the request — so a surface-wide spec:read would refuse a
309 // tokens.sr.ht token minted for spec:propose alone at `initialize`, before it
310 // ever named a tool. The grant is therefore checked per tool, by requireRead and
311 // by service.Propose, each of which knows what is being attempted.
312 //
313 // The refusal carries the cache directives itself, which privateCache would
314 // otherwise have written for it. It has to: Gate runs inside the resolver
315 // middleware and Handler runs inside Gate, so a 401 written here never reaches
316 // the wrapper Handler installs. A shared cache free to keep this 401 and replay
317 // it to the next caller would refuse a credential this service never saw.
318 10 func Gate(next http.Handler) http.Handler {
319 10 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
320 10 if !authn.PrincipalFromContext(r.Context()).CanRead() {
321 4 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
322 4 w.Header().Set("WWW-Authenticate", authn.Challenge())
323 4 w.Header().Set("Cache-Control", cacheControl)
324 4 w.Header().Set("Vary", cacheVary)
325 4 http.Error(w, "authentication required", http.StatusUnauthorized)
326 4 return
327 4 }
328 6 next.ServeHTTP(w, r)
329 })
330 }
331
332 // requireRead is the grant half of the read ACL, for the tools that serve
333 // content: Gate has already established that the caller may read at all, and
334 // this asks whether the credential they used was minted for it.
335 //
336 // It is a no-op for the owner's cookie and for spec's own agent token, neither
337 // of which carries grants — so every client that works today keeps working — and
338 // refuses a tokens.sr.ht working token that lacks spec:read.
339 63 func requireRead(ctx context.Context) error {
340 63 return authn.PrincipalFromContext(ctx).Authorize(authn.ActionRead)
341 63 }
342
343 // hostAllowed compares a request's Host against the expected hostname, ignoring
344 // any port and IPv6 brackets. Loopback names stay allowed so `make run-dev` and
345 // a local MCP client keep working.
346 18 func hostAllowed(reqHost, want string) bool {
347 18 h := reqHost
348 18 if stripped, _, err := net.SplitHostPort(h); err == nil {
349 8 h = stripped
350 8 }
351 18 h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
352 18 switch {
353 7 case strings.EqualFold(h, want):
354 7 return true
355 7 case h == "localhost", h == "127.0.0.1", h == "::1":
356 7 return true
357 4 default:
358 4 return false
359 }
360 }
361
362 // clampLimit applies the hit-count policy: unset defers to search's own
363 // default rather than restating it, and anything larger than maxSearchLimit is
364 // clamped. A tool result is a context window, so an agent asking for a thousand
365 // hits is asking for something it cannot use.
366 8 func clampLimit(n int) int {
367 8 switch {
368 6 case n <= 0:
369 6 return search.DefaultLimit
370 1 case n > maxSearchLimit:
371 1 return maxSearchLimit
372 1 default:
373 1 return n
374 }
375 }