~bigbes/sr-ht-spec

ref: 5a10600a93d35781d59b978d6b1c0f850ff43292 sr-ht-spec/mcpsrv/mcpsrv.go -rw-r--r-- 10.6 KiB
5a10600a — Eugene Blikh feat: service.Archive — one accessor, one tree walk, one link graph 27 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
// 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 tools (spec_propose, spec_comment) are Phase 3 and are deliberately
// absent; nothing here writes.
//
// # 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"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"strings"

	"github.com/modelcontextprotocol/go-sdk/mcp"

	"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
}

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

	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.
func Handler(b Backend, version, origin string) (http.Handler, error) {
	srv, err := New(b, version)
	if err != nil {
		return nil, err
	}
	h := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return srv },
		&mcp.StreamableHTTPOptions{DisableLocalhostProtection: true},
	)
	return allowHosts(h, origin), 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.
//
// An empty or unparseable origin leaves the endpoint unguarded, so it says so
// loudly. A misconfigured origin must not quietly become the difference between
// protected and open — that is the class of failure nobody discovers.
func allowHosts(next http.Handler, origin string) http.Handler {
	want := originHost(origin)
	if want == "" {
		slog.Warn("mcpsrv: no usable origin configured; Host validation on /mcp is DISABLED")
		return next
	}
	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)
	})
}

// 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
	}
}

// originHost extracts the hostname from a configured origin URL.
func originHost(origin string) string {
	if origin == "" {
		return ""
	}
	u, err := url.Parse(origin)
	if err != nil {
		return ""
	}
	return u.Hostname()
}

// 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
	}
}