mcpsrv: mark /mcp uncacheable, fail closed on origin, split tool errors from faults Three gaps between this surface and the cov/dolt pattern the siblings settled on. The write tools are untouched, the per-tool grant scheme is untouched, and the transport stays stateful. The endpoint set no Cache-Control and no Vary at all. Every answer here depends entirely on the credential the request carried and says nothing about it in its URL, and some of them are the whole approved corpus, so a shared cache was free to keep one and replay it to the next caller. cache.go is a local copy of ecore/mcphttp.PrivateCache, byte for byte on the header values so the retrofit is a delete and an import once that commit is published. It commits the headers on Write and Flush as well as WriteHeader: the SDK answers a POST with an event stream that never calls WriteHeader, so a wrapper hooking only that one sets nothing on the response an agent actually gets, while passing every other header test. Measured — with only WriteHeader hooked the streamed answer leaves with the SDK's own `no-cache, no-transform` and no Vary. Vary names both planes, where dolt.sr.ht names Authorization alone. dolt is right for dolt: its /mcp is bearer-only, so naming Cookie would promise a cache a dependency the surface never reads. It is wrong here. authn.Resolver.Resolve prefers a bearer token when one is present but falls through to login.UsernameFromRequest when none is, and an owner cookie resolves to KindOwner — which is exactly what Gate admits. On this service the cookie is the difference between the whole corpus and a 401. This is the one string a future mcphttp retrofit has to reconcile between the two services. An origin with no host in it was a warning and then an unguarded endpoint. The Host allowlist is the only thing protecting /mcp once the SDK's own rebinding guard is disabled, so that path turned one unparseable config value into a silently open endpoint indistinguishable in every functional test from a correctly guarded one. It is a construction error now. The daemon cannot reach it either way: service.Config.Validate already refuses to start unless the origin parses and carries a host. Errors from below travelled to the agent as tool results carrying their own text, so a dead git object store and a missing document were the same kind of answer. A tool result means "the call was understood and the thing you asked for is not there", so an agent reading one for a store outage concludes the document does not exist and rewrites a specification around a document that is perfectly real — and the store's own words reached it. errors.go splits the two: service.ErrNotFound is a tool result whose sentence is built from the call's own arguments, and everything else is a jsonrpc protocol error saying "internal server error" with the detail logged. The old tests asserted the behaviour being removed — that the agent was shown the words "on fire" — and are replaced by ones that pin the split in both directions.
authn: accept tokens.sr.ht working tokens beside the agent token A second agent credential plane, next to the existing one rather than in place of it. The agent_token table, every agent configured with it, and the refs rule and provenance requirement around it are untouched; the local plane is removed in a later phase, not this one. The resolver tries the instance plane first and falls back to the local store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs. spec's local token has no prefix to discriminate on — it is 32 random bytes in base64, which is precisely what "did not decode as one of ours" looks like — so the fallback replaces the shape test bench and cover can afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a withdrawn credential must not get a second chance at the old door, and an unreachable daemon must not silently degrade into the legacy plane. Grants ride on the principal and are checked where the action is known, never in the middleware, which runs upstream of the router: spec:propose in service.Propose, below both write surfaces, and spec:read in each read surface's gate. /mcp checks per tool rather than at its Gate, because one endpoint carries both kinds and a surface-wide read grant would refuse a propose-only token at initialize. Principal.Authorize is a no-op off the instance plane, which is what keeps the local token working. The instance plane brings an owner where the local token had none, so a working token belonging to anybody but [sr.ht] owner-name is refused rather than admitted as a second identity: Principal.Owner is read by the provenance committer, the refs rule's principal kind and the coreauth AuthContext, all written for one human. StatusFor is the one status table. ErrUnavailable is 503 and never 401 — reading "I could not ask tokens.sr.ht" as "revoked" would refuse every live instance token while a daemon that is deliberately off the hot path restarts. An instance with no [tokens.sr.ht] section builds no instance plane and starts anyway, serving its own agent token as before.
refactor: make the filter-polarity trap inexpressible service.SpaceFilter meant "empty membership selects nothing" — a newly created project has no members — while search.Query.Spaces was a bare []core.SpaceRef whose empty case meant every space. Passing a project's members into a query therefore turned an empty project into the whole corpus: a silent scope inversion, invisible when it happens, and passing every test written with a non-empty project. The filter moves to core.SpaceFilter with unexported fields, and search.Query takes it whole. There is no slice to hand over any more, so the inversion cannot be written. Its three states are distinct: every space (EverythingFilter), exactly these — possibly none — (SpacesFilter), and the zero value, which is neither. Search refuses the zero value rather than defaulting it, because both plausible defaults are wrong for one of the two callers that can produce one, and returns no hits for a filter that selects no space without asking the index. service.SpaceFilter is now an alias for the core type, so ResolveProject and its callers keep their names. Tests that built a Query without a scope now say core.EverythingFilter(), which is what they always meant.
feat(mcpsrv): MCP read tools, with Host validation replacing the SDK guard Adds the Phase 2 read tools (spec_search, spec_read, spec_list) over the service layer. Two security fixes came out of building them. The read plane could serve proposal content. gitx resolves ref names, and service.resolveRev passed any string through, so rev=proposals/42 made the READ plane hand back unreviewed text — which would then flow into agent context as though approved, the single failure this service exists to prevent. ValidateReadRev now admits only the approved-head sentinel or a full 40-character object name, at the layer all three surfaces share. Abbreviations are refused too: one that is unique today can become ambiguous later, so a pinned revision would silently stop meaning one thing. The MCP SDK's DNS-rebinding guard rejects a loopback listener whose Host is not loopback, which is exactly nginx forwarding to 127.0.0.1 — it would 403 only in production, passing every local test. The SDK offers no allowlist, so the guard is disabled and replaced by a stricter check: Host must equal the configured origin, or a loopback name for development. A rebinding attack carries the attacker's name in Host and fails it. An unusable origin logs loudly rather than quietly unguarding the endpoint.