// 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"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"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.
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)
})
}
// 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.
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")
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
}
}
// 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
}
}