// 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 // 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 one write tool, registered only when a write backend is wired. It is // not read-only or idempotent — proposing twice opens two proposals — so it // carries neither 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 }) } 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 } }