~bigbes/sr-ht-spec

ref: 5bb0bb134d608263da3197df9fb4f1d8a3fe42db sr-ht-spec/mcpsrv/mcpsrv.go -rw-r--r-- 18.1 KiB
5bb0bb13 — Eugene Blikh graph: serve /query on the anonymous router with a bearer credential 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
// Package mcpsrv is spec.sr.ht's Model Context Protocol surface: the tools an
// agent calls to find and read approved documents.
//
// It is warren's mcpsrv/ absorbed, and it keeps warren's two structural
// choices — a narrow backend interface the tools are written against, and the
// SDK's generic AddTool deriving every schema from a Go struct — while
// discarding everything that assumed a local vault: the vault-wide id space,
// the parent/child tools, and the hybrid keyword+semantic mode argument (vector
// search is Phase 5 here).
//
// # A surface, not a wrapper
//
// The design's rule is that MCP is a first-class surface and that its tools
// call the same resolver layer as REST, GraphQL and the web UI rather than a
// parallel implementation. That is what this package is: every lookup goes
// through service/, every addressing decision through doc.Archive, and every
// query through search.Index. Nothing here re-derives which document an id
// names or which spaces a project covers, because a second implementation of
// those rules is how two surfaces start answering the same question
// differently — silently, and months later.
//
// # The read contract is the reason this exists
//
// Bots need "the approved text of SPEC-0007", not "whatever a branch points at
// while another agent rewrites it". So:
//
//   - Omitting rev reads the space's approved head. That is the default,
//     and it is service.ApprovedRev — the same default every other surface has.
//   - Passing rev pins the read to one immutable revision, and rev must be an
//     object name: 7-64 lowercase hex, the same grammar the design pins for
//     X-Agent-Base. Ref names are refused outright, which is what makes
//     "read a proposal branch" impossible to reach by accident or by
//     mistyping — an agent can only reach unapproved content by naming a
//     commit sha it had to obtain deliberately, since neither spec_search nor
//     spec_list ever reports one.
//
// Serving drafts by default would poison every downstream agent context with
// unreviewed text, which is the exact failure the service exists to prevent.
//
// # The write plane
//
// Two tools write, and both are registered only when a write backend is wired:
// spec_propose opens or extends a proposal, and spec_comment reads a proposal's
// review threads and replies to them. Neither can approve, merge, open a review
// thread or resolve one — those are the owner's, in service/, and the
// interfaces here do not name them.
//
// # Layering
//
// Reader and Searcher are declared here rather than imported as concrete types
// so the tools can be tested without a repository, a Postgres instance or a
// bleve index. *service.Service satisfies Reader and *search.Index satisfies
// Searcher, structurally, with no adapter.
package mcpsrv

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"strings"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
)

// ServerName is the implementation name reported in the MCP handshake. It is
// the service's config-section name, so a client listing several SourceHut MCP
// endpoints sees which one it is talking to.
const ServerName = "spec.sr.ht"

// maxSearchLimit caps how many hits one call may ask for.
const maxSearchLimit = 100

// Backend is everything the tools read through. Both halves are required: a
// nil one is a wiring mistake, and New reports it at startup rather than
// letting the first tool call panic inside a request.
type Backend struct {
	// Docs is the orchestration layer — in production *service.Service.
	Docs Reader
	// Index is the one global bleve index — in production *search.Index.
	Index Searcher
	// Write is the write side — in production *service.Service. It is optional:
	// when nil the server registers only the read tools, so a read-only
	// deployment or a test needs no mutable backend. When set, spec_propose is
	// registered and every write goes through the same service.Propose the REST
	// PUT calls.
	Write Writer
}

// New builds the MCP server with the Phase 2 read tools registered. version is
// reported as the implementation version in the handshake.
//
// The returned server is not connected to a transport; Handler wires it to
// streamable HTTP, and a caller that wants stdio can call Run itself.
func New(b Backend, version string) (*mcp.Server, error) {
	if b.Docs == nil {
		return nil, errors.New("mcpsrv: backend has no document reader")
	}
	if b.Index == nil {
		return nil, errors.New("mcpsrv: backend has no search index")
	}

	srv := mcp.NewServer(&mcp.Implementation{Name: ServerName, Version: version}, nil)
	readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true}

	mcp.AddTool(srv, &mcp.Tool{
		Name:        "spec_search",
		Annotations: readOnly,
		Description: "Search every approved document on this instance, ranked. Each hit carries " +
			"the space, the document id, its path, the revision it was indexed at, its title " +
			"and a plain-text snippet — enough to fetch it with spec_read without a second " +
			"lookup.\n\n" +
			"Pass `spaces` to restrict the search to a set of spaces: that set is what a " +
			"project is on this service — a saved filter over one global index, not a " +
			"container. Omitting it searches everything, which is the meta-project.\n\n" +
			"Results come from the approved revision of each space. Proposal branches are " +
			"not indexed and never appear here.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in searchInput) (*mcp.CallToolResult, searchOutput, error) {
		out, err := searchHandler(ctx, b, in)
		return nil, out, err
	})

	mcp.AddTool(srv, &mcp.Tool{
		Name:        "spec_read",
		Annotations: readOnly,
		Description: "Read one document's markdown, frontmatter included, exactly as it is stored.\n\n" +
			"By default this returns the space's APPROVED text — the reviewed, canonical " +
			"revision — and reports the revision it resolved to in `rev`. Pass that value " +
			"back as the `rev` argument later to re-read the identical bytes; a revision, " +
			"once named, is immutable.\n\n" +
			"Address the document by its frontmatter id (\"SPEC-0007\") when it has a " +
			"well-formed one that no other document in the space claims, and otherwise by " +
			"its path, with or without the \".md\" extension. A document whose id is " +
			"duplicated within its space resolves to neither document and is reported as " +
			"ambiguous rather than guessed at.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in readInput) (*mcp.CallToolResult, readOutput, error) {
		out, err := readHandler(ctx, b, in)
		return nil, out, err
	})

	mcp.AddTool(srv, &mcp.Tool{
		Name:        "spec_list",
		Annotations: readOnly,
		Description: "List spaces, or list the documents in one space.\n\n" +
			"Omit `space` to get every space on the instance — those names are what " +
			"spec_search's `spaces` filter takes. Pass `space` to get that space's " +
			"documents at its approved head, each with the id spec_read addresses it by, " +
			"its path, title, section and authored status. Pass `rev` as well to list a " +
			"pinned revision instead.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in listInput) (*mcp.CallToolResult, listOutput, error) {
		out, err := listHandler(ctx, b, in)
		return nil, out, err
	})

	// The write tools, registered only when a write backend is wired. Neither is
	// read-only or idempotent — proposing twice opens two proposals, replying
	// twice says it twice — so neither carries a hint, which is how a client
	// tells a tool it can retry freely from one it cannot.
	if b.Write != nil {
		mcp.AddTool(srv, &mcp.Tool{
			Name: "spec_propose",
			Description: "Propose a change to a space: upload whole documents and get back a proposal " +
				"and a URL to hand a human for review.\n\n" +
				"Pass `if_match` as the `rev` you read the approved head at (from spec_read) — it becomes " +
				"the proposal's base, and a base the approved branch has moved off is rejected so you " +
				"refetch and re-propose. Each document in `documents` is the WHOLE markdown, frontmatter " +
				"included; there are no patches.\n\n" +
				"Omit `proposal` to open a new one (give it a `title`); pass an existing proposal id to add " +
				"more documents to it, sending the same `if_match` you opened it with.\n\n" +
				"The response always carries the proposal `url`. Surface it: the human reviews there, and a " +
				"proposal whose link you never mention is invisible. When `merged` is true the space's " +
				"auto_merge policy landed the change immediately; otherwise it is open and waiting.",
		}, func(ctx context.Context, _ *mcp.CallToolRequest, in proposeInput) (*mcp.CallToolResult, proposeOutput, error) {
			out, err := proposeHandler(ctx, b.Write, in)
			return nil, out, err
		})

		mcp.AddTool(srv, &mcp.Tool{
			Name: "spec_comment",
			Description: "Read the review threads on a proposal, and reply to one.\n\n" +
				"Pass only `proposal` to list its threads: each carries the owner's critique, its " +
				"replies, the document and heading path it is anchored to, and whether it is still " +
				"open.\n\n" +
				"Read `state` before acting on a thread. \"anchored\" means the block you were " +
				"criticised for is still there verbatim; \"edited\" means the block is still in that " +
				"position but its text changed after the comment was written, so the critique may " +
				"already be addressed; \"outdated\" means the anchor lost its block entirely and the " +
				"comment describes text that is no longer in the proposal. Fixing what an outdated " +
				"comment asks for edits something else.\n\n" +
				"Pass `thread` and `body` to reply to that thread. Replying does not close it — only " +
				"the owner resolves a thread, and an open thread holds back auto-merge. So answer the " +
				"critique and push the revision with spec_propose; do not expect the reply itself to " +
				"unblock the proposal.",
		}, func(ctx context.Context, _ *mcp.CallToolRequest, in commentInput) (*mcp.CallToolResult, commentOutput, error) {
			out, err := commentHandler(ctx, b.Write, in)
			return nil, out, err
		})
	}

	return srv, nil
}

// Handler mounts the server on streamable HTTP. cmd/specsrht hangs it off the
// chi router at /mcp, which is what keeps MCP to one listener and one nginx
// block rather than a second port.
//
// The SDK's DNS-rebinding guard is disabled deliberately, and the reason is
// worth stating because disabling a security default usually is not.
//
// The guard rejects any request arriving on a loopback address that carries a
// non-loopback Host header. That is precisely our deployment: the daemon binds
// 127.0.0.1:5091 and nginx forwards with `proxy_set_header Host $host`, so
// every genuine request would 403 — and it would 403 only in production,
// because a local client sends a loopback Host and passes.
//
// It is not that the guard has nothing to catch. A browser running on the
// daemon's own host could reach 127.0.0.1:5091 directly with an attacker's
// Host header, which is the attack the guard is for. The problem is that the
// guard cannot tell that request from nginx's: both arrive from loopback
// carrying a Host that is not loopback, and the SDK exposes no allowlist to
// separate them.
//
// So the guard is disabled and REPLACED, in the same constructor, by allowHosts
// below — a stricter check than the one removed. The SDK asks only "is Host
// loopback?"; we require Host to equal this instance's configured origin. A
// rebinding attack carries the attacker's name in Host and fails that; nginx
// forwards our real hostname and passes. Disabling the SDK guard without this
// replacement would be a genuine regression, not a formality.
//
// origin is therefore required, and an origin with no host in it is refused
// here rather than warned about and then served unguarded. This used to warn:
// it logged that Host validation was disabled and returned the bare handler,
// on the reasoning that refusing to start over a config typo is worse than
// running without the guard. That trade is the wrong way round. The guard is
// the *only* thing protecting /mcp once the SDK's own is disabled, so the warn
// path turned one unparseable config value into a silently open endpoint —
// discoverable, in principle, from a log line nobody reads, and indistinguishable
// in every functional test from a correctly guarded one. A daemon cannot reach
// this call with such an origin anyway: service.Config.Validate already refuses
// to start unless [spec.sr.ht] origin parses and carries a host. A caller that
// got here without one is not an operator to be warned, it is a bug.
func Handler(b Backend, version, origin string) (http.Handler, error) {
	srv, err := New(b, version)
	if err != nil {
		return nil, err
	}
	// instconf.OriginHost and not a local parse: this is the reading of an
	// origin, and it is the same one authn's mailbox derivation and service's
	// config validation make. An origin nobody can extract a host from answers
	// "" here — never a guess such as "localhost", which would silently make
	// every malformed origin agree with a local client on the one code path
	// where that decides an allowlist.
	want := instconf.OriginHost(origin)
	if want == "" {
		return nil, fmt.Errorf("mcpsrv: origin %q has no host to guard /mcp with", origin)
	}
	h := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return srv },
		&mcp.StreamableHTTPOptions{DisableLocalhostProtection: true},
	)
	// The cache directives wrap the Host allowlist rather than the other way
	// round, so that the 403 carries them too: a refusal by hostname is as
	// unstorable as an answer, and it is written before the SDK is reached at all.
	return privateCache(allowHosts(h, want)), nil
}

// allowHosts is this endpoint's DNS-rebinding protection, in the form the
// deployment actually needs: Host must be the service's own origin hostname, or
// a loopback name for local development.
//
// want is the hostname already extracted from the origin by Handler, which is
// also where an origin that yields none is refused. It is a resolved host and
// never an origin, so there is no path through this function that leaves the
// guard off.
func allowHosts(next http.Handler, want string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !hostAllowed(r.Host, want) {
			http.Error(w, "Forbidden: unexpected Host header", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// Gate refuses a caller with no read authority before any MCP method — not just
// tools/call, but initialize and tools/list too — reaches the server. The ACL
// is authn.Principal.CanRead — the owner and its agents may read and nobody else
// may — the same predicate graph's /query and the web UI apply, so the three
// read surfaces cannot drift into three policies, which is how a corpus leaks.
//
// It reads the principal the resolver middleware set, so it must be mounted
// INSIDE that middleware:
//
//	mcp = resolver.Middleware()(mcpsrv.Gate(handler))
//
// Without it, every read tool served approved content to anyone who cleared the
// Host allowlist — spec_propose was already fail-closed in service.Propose, but
// spec_search/spec_read/spec_list checked nothing. The refusal is a 401 with a
// line of plain text and never a login redirect: every caller here is a machine.
//
// It checks identity and not grants, deliberately. This one endpoint carries
// both the read tools and the write ones, and the tool being called is in the
// JSON-RPC body, not the request — so a surface-wide spec:read would refuse a
// tokens.sr.ht token minted for spec:propose alone at `initialize`, before it
// ever named a tool. The grant is therefore checked per tool, by requireRead and
// by service.Propose, each of which knows what is being attempted.
//
// The refusal carries the cache directives itself, which privateCache would
// otherwise have written for it. It has to: Gate runs inside the resolver
// middleware and Handler runs inside Gate, so a 401 written here never reaches
// the wrapper Handler installs. A shared cache free to keep this 401 and replay
// it to the next caller would refuse a credential this service never saw.
func Gate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !authn.PrincipalFromContext(r.Context()).CanRead() {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			w.Header().Set("WWW-Authenticate", authn.Challenge())
			w.Header().Set("Cache-Control", cacheControl)
			w.Header().Set("Vary", cacheVary)
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// requireRead is the grant half of the read ACL, for the tools that serve
// content: Gate has already established that the caller may read at all, and
// this asks whether the credential they used was minted for it.
//
// It is a no-op for the owner's cookie and for spec's own agent token, neither
// of which carries grants — so every client that works today keeps working — and
// refuses a tokens.sr.ht working token that lacks spec:read.
func requireRead(ctx context.Context) error {
	return authn.PrincipalFromContext(ctx).Authorize(authn.ActionRead)
}

// hostAllowed compares a request's Host against the expected hostname, ignoring
// any port and IPv6 brackets. Loopback names stay allowed so `make run-dev` and
// a local MCP client keep working.
func hostAllowed(reqHost, want string) bool {
	h := reqHost
	if stripped, _, err := net.SplitHostPort(h); err == nil {
		h = stripped
	}
	h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
	switch {
	case strings.EqualFold(h, want):
		return true
	case h == "localhost", h == "127.0.0.1", h == "::1":
		return true
	default:
		return false
	}
}

// clampLimit applies the hit-count policy: unset defers to search's own
// default rather than restating it, and anything larger than maxSearchLimit is
// clamped. A tool result is a context window, so an agent asking for a thousand
// hits is asking for something it cannot use.
func clampLimit(n int) int {
	switch {
	case n <= 0:
		return search.DefaultLimit
	case n > maxSearchLimit:
		return maxSearchLimit
	default:
		return n
	}
}