coverage~bigbes/sr-ht-ecore89fa694cmcphttp/mcphttp.go

Coverage
100.0% 1/1 statements
Δ
+0.0
Blob
f850020
Uncovered nothing — every instrumented line ran
1 // Package mcphttp is the HTTP-level plumbing an MCP endpoint on this instance
2 // needs around the Go SDK's streamable transport: the Host allowlist that
3 // replaces the SDK's own DNS-rebinding guard, the cache directives that keep a
4 // private repository's data out of every cache, and the two transport options
5 // both services set.
6 //
7 // It is deliberately three functions and not a framework. cov.sr.ht and
8 // bench.sr.ht each mount an MCP surface, and an audit of the two found the
9 // genuinely shared part to be about forty lines — everything else differs
10 // because it was meant to. Their tool sets are their own, their service
11 // interfaces are their own, and above all their authentication gates are their
12 // own: cov refuses a caller with no read grant outright, bench serves an
13 // anonymous caller everything PUBLIC. Unifying those would not be deduplication,
14 // it would be a policy change smuggled in as one. So the gates stay in the
15 // services and only what is identical in both, or identical once one of the two
16 // stances is chosen, lives here.
17 //
18 // The three:
19 //
20 // - [HostGuard], the Host-header allowlist. The predicate was already
21 // byte-for-byte identical in both donors.
22 // - [PrivateCache], the response wrapper that writes the cache directives at
23 // the moment the response is committed, because the SDK writes its own on
24 // the way out and a middleware that set them on the way in loses.
25 // - [StreamableOptions], the transport options: stateless sessions, SDK
26 // rebinding guard off because [HostGuard] replaces it.
27 //
28 // A service composes them around its own gate, outermost first:
29 //
30 // h := mcp.NewStreamableHTTPHandler(
31 // func(*http.Request) *mcp.Server { return srv },
32 // mcphttp.StreamableOptions(),
33 // )
34 // guarded, err := mcphttp.HostGuard(myAuthGate(h), origin)
35 // if err != nil {
36 // return err
37 // }
38 // r.Handle("/mcp", mcphttp.PrivateCache(guarded))
39 //
40 // The cache wrapper goes outside the Host guard on purpose: a 403 by hostname is
41 // as unstorable as an answer, and it is written before the SDK is reached at all.
42 package mcphttp
43
44 import (
45 "github.com/modelcontextprotocol/go-sdk/mcp"
46 )
47
48 // StreamableOptions is the streamable transport's configuration for an MCP
49 // endpoint deployed the way this instance deploys them. Both donors set exactly
50 // these two fields and nothing else, and each of them is load-bearing.
51 //
52 // # Stateless, because a stateful session is itself a credential
53 //
54 // In the SDK's stateful mode a session spans requests, and a tool handler runs
55 // under the context of the request that *initialised* the session — not the one
56 // that carried the call. Every surface here reads the caller off that context.
57 // So the session id becomes the credential: whoever presents it is answered as
58 // whoever opened the session, having presented nothing themselves. A session
59 // opened anonymously keeps answering as anonymous even when a later request
60 // carries a token, and a token revoked mid-session keeps working until the
61 // client reconnects. A session id is not a secret in the way a token is — it
62 // travels in a plain header, lands in proxy logs, crash reports and a client's
63 // state file — and none of the machinery that mints, scopes, expires and revokes
64 // tokens applies to it.
65 //
66 // Stateless mode gives each POST its own throwaway session and its own context,
67 // so the identity a tool sees is the identity of the request that carried it, by
68 // construction rather than by care. What it costs is the server-to-client half
69 // of the protocol: no standalone SSE stream, therefore no server-initiated
70 // requests, and a bare GET is answered 405. Both services' tools are reads that
71 // answer in one response and none of them samples, elicits or reports progress,
72 // so there is nothing to give up.
73 //
74 // # DisableLocalhostProtection, because the SDK's guard cannot see this proxy
75 //
76 // Turning off a security default is usually a mistake, so: the SDK's guard
77 // refuses any request that arrives on a loopback address while carrying a
78 // non-loopback Host. That is precisely this deployment — every daemon binds
79 // 127.0.0.1 behind nginx, which forwards with the instance's public Host — so
80 // every genuine request would be a 403, and only in production, since a local
81 // client sends a loopback Host and passes.
82 //
83 // The guard does have something real to catch: a browser on the daemon's own
84 // host can reach the loopback port directly with an attacker's Host. It simply
85 // cannot tell that request from nginx's, because both arrive from loopback with
86 // a non-loopback Host, and the SDK exposes no allowlist to separate them. So it
87 // is disabled and *replaced* by [HostGuard], which requires Host to name this
88 // instance where the SDK asked only whether it was loopback. Disabling it
89 // without that replacement would be a regression rather than a formality, which
90 // is why the two are documented as one decision.
91 //
92 // A fresh value is returned on every call: the SDK takes a pointer, and a shared
93 // one would let any caller reconfigure every other endpoint.
94 4 func StreamableOptions() *mcp.StreamableHTTPOptions {
95 4 return &mcp.StreamableHTTPOptions{
96 4 Stateless: true,
97 4 DisableLocalhostProtection: true,
98 4 }
99 4 }