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