~bigbes/sr-ht-ecore

ref: b352133b3cfcde8afeb58127bf01d31735faa2fe sr-ht-ecore/mcphttp/mcphttp.go -rw-r--r-- 5.1 KiB
b352133b — Eugene Blikh bearer: silence the std logger the benchmarks were timing 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// Package mcphttp is the HTTP-level plumbing an MCP endpoint on this instance
// needs around the Go SDK's streamable transport: the Host allowlist that
// replaces the SDK's own DNS-rebinding guard, the cache directives that keep a
// private repository's data out of every cache, and the two transport options
// both services set.
//
// It is deliberately three functions and not a framework. cov.sr.ht and
// bench.sr.ht each mount an MCP surface, and an audit of the two found the
// genuinely shared part to be about forty lines — everything else differs
// because it was meant to. Their tool sets are their own, their service
// interfaces are their own, and above all their authentication gates are their
// own: cov refuses a caller with no read grant outright, bench serves an
// anonymous caller everything PUBLIC. Unifying those would not be deduplication,
// it would be a policy change smuggled in as one. So the gates stay in the
// services and only what is identical in both, or identical once one of the two
// stances is chosen, lives here.
//
// The three:
//
//   - [HostGuard], the Host-header allowlist. The predicate was already
//     byte-for-byte identical in both donors.
//   - [PrivateCache], the response wrapper that writes the cache directives at
//     the moment the response is committed, because the SDK writes its own on
//     the way out and a middleware that set them on the way in loses.
//   - [StreamableOptions], the transport options: stateless sessions, SDK
//     rebinding guard off because [HostGuard] replaces it.
//
// A service composes them around its own gate, outermost first:
//
//	h := mcp.NewStreamableHTTPHandler(
//		func(*http.Request) *mcp.Server { return srv },
//		mcphttp.StreamableOptions(),
//	)
//	guarded, err := mcphttp.HostGuard(myAuthGate(h), origin)
//	if err != nil {
//		return err
//	}
//	r.Handle("/mcp", mcphttp.PrivateCache(guarded))
//
// The cache wrapper goes outside the Host guard on purpose: a 403 by hostname is
// as unstorable as an answer, and it is written before the SDK is reached at all.
package mcphttp

import (
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

// StreamableOptions is the streamable transport's configuration for an MCP
// endpoint deployed the way this instance deploys them. Both donors set exactly
// these two fields and nothing else, and each of them is load-bearing.
//
// # Stateless, because a stateful session is itself a credential
//
// In the SDK's stateful mode a session spans requests, and a tool handler runs
// under the context of the request that *initialised* the session — not the one
// that carried the call. Every surface here reads the caller off that context.
// So the session id becomes the credential: whoever presents it is answered as
// whoever opened the session, having presented nothing themselves. A session
// opened anonymously keeps answering as anonymous even when a later request
// carries a token, and a token revoked mid-session keeps working until the
// client reconnects. A session id is not a secret in the way a token is — it
// travels in a plain header, lands in proxy logs, crash reports and a client's
// state file — and none of the machinery that mints, scopes, expires and revokes
// tokens applies to it.
//
// Stateless mode gives each POST its own throwaway session and its own context,
// so the identity a tool sees is the identity of the request that carried it, by
// construction rather than by care. What it costs is the server-to-client half
// of the protocol: no standalone SSE stream, therefore no server-initiated
// requests, and a bare GET is answered 405. Both services' tools are reads that
// answer in one response and none of them samples, elicits or reports progress,
// so there is nothing to give up.
//
// # DisableLocalhostProtection, because the SDK's guard cannot see this proxy
//
// Turning off a security default is usually a mistake, so: the SDK's guard
// refuses any request that arrives on a loopback address while carrying a
// non-loopback Host. That is precisely this deployment — every daemon binds
// 127.0.0.1 behind nginx, which forwards with the instance's public Host — so
// every genuine request would be a 403, and only in production, since a local
// client sends a loopback Host and passes.
//
// The guard does have something real to catch: a browser on the daemon's own
// host can reach the loopback port directly with an attacker's Host. It simply
// cannot tell that request from nginx's, because both arrive from loopback with
// a non-loopback Host, and the SDK exposes no allowlist to separate them. So it
// is disabled and *replaced* by [HostGuard], which requires Host to name this
// instance where the SDK asked only whether it was loopback. Disabling it
// without that replacement would be a regression rather than a formality, which
// is why the two are documented as one decision.
//
// A fresh value is returned on every call: the SDK takes a pointer, and a shared
// one would let any caller reconfigure every other endpoint.
func StreamableOptions() *mcp.StreamableHTTPOptions {
	return &mcp.StreamableHTTPOptions{
		Stateless:                  true,
		DisableLocalhostProtection: true,
	}
}