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