mcp connection
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-09-22 10:58:02 +05:30
parent 4e1f746b22
commit f2aa3b3ad8
53 changed files with 12515 additions and 37 deletions

View File

@@ -12,6 +12,7 @@ package config
import (
"fmt"
"net/netip"
"net/url"
"os"
"strconv"
@@ -58,8 +59,44 @@ type Config struct {
Agents AgentsConfig
Model ModelConfig
Knowledge KnowledgeConfig
OAuth OAuthConfig
}
// OAuthConfig is the MCP surface's OAuth 2.1 identity.
//
// EMPTY IS THE DEFAULT AND IT MEANS "OFF". A deployment that sets neither
// OAUTH_ISSUER nor MCP_RESOURCE does not serve OAuth or MCP at all, and that is
// the correct default for every deployment that exists today — the routes are
// simply not registered, exactly as routeRuns is skipped without a model
// credential.
//
// NO PRODUCTION DOMAIN IS HARDCODED. Both values are URLs the operator supplies,
// because the issuer identifies the deployment and a default would be one
// deployment's identity baked into every other one.
//
// Issuer and Resource look similar and are not the same thing: the ISSUER
// identifies the authorization server ("who minted this token"), the RESOURCE
// identifies what the token is good for ("which MCP server may spend it"). A
// token's audience is checked against Resource. Conflating them is how a token
// for one service becomes spendable at another.
type OAuthConfig struct {
// Issuer is the authorization server's base URL, e.g.
// https://api.example.com. No trailing slash.
Issuer string
// Resource is the canonical MCP endpoint URI, e.g.
// https://api.example.com/mcp. This becomes an issued token's audience.
Resource string
// LoginPath is where the authorization endpoint sends somebody who is not
// signed in. A same-origin path, never an absolute URL — an absolute one
// would be an open redirect waiting for a misconfiguration.
LoginPath string
}
// Enabled reports whether this deployment serves OAuth and MCP.
func (c OAuthConfig) Enabled() bool { return c.Issuer != "" && c.Resource != "" }
// KnowledgeConfig routes the retrieval layer's embedding provider.
//
// The chat provider does not serve embeddings, so the dense half of hybrid
@@ -213,6 +250,42 @@ type HTTPConfig struct {
// this API, and once authentication exists that becomes a real hole rather
// than a theoretical one.
CORSOrigins []string
// TrustedProxies are the networks a forwarded client address may be
// believed from. Empty by default, and empty means "believe nothing".
//
// WHY THIS EXISTS
//
// Several limits on this API are keyed by the caller's network address:
// failed logins, OAuth registration, and OAuth authorization before the
// caller has signed in. Behind a reverse proxy every request arrives from
// the proxy, so RemoteAddr is one constant value and those per-address
// budgets silently become one budget for the entire deployment. The
// symptom is users rate-limiting each other — one person retrying a
// connector exhausts everybody's allowance.
//
// WHY IT IS NOT SIMPLY "READ X-FORWARDED-FOR"
//
// That header is client-supplied. A caller reaching the API directly can
// invent one and mint a fresh budget per request, which is strictly worse
// than sharing a bucket: it removes the limit entirely. The header is
// meaningful only when the immediate peer is a proxy that is known to
// rewrite it, which is what this list names.
//
// WHY THE DEFAULT IS EMPTY
//
// So that a missing or misspelt setting cannot open the spoofing hole. An
// unconfigured deployment behaves exactly as it did before this setting
// existed: RemoteAddr, and X-Forwarded-For ignored. The failure mode of
// forgetting to set it is the old shared bucket, which is an availability
// problem an operator will notice, rather than an unmetered endpoint which
// they will not.
//
// Entries are CIDR blocks or bare addresses (a bare address is treated as
// a single-host block). Both families are accepted. Set it to the network
// the load balancer or ingress talks to the API from — see
// .env.example and infrastructure/.env.docker.example.
TrustedProxies []netip.Prefix
}
type DBConfig struct {
@@ -278,6 +351,12 @@ func Load() (*Config, error) {
return v
}
// Parsed before the literal below because it can fail, and a malformed
// entry has to stop startup rather than be dropped: an operator who
// mistypes the proxy network gets the shared-bucket behaviour back, and
// silently is the one way they will not find out.
trustedProxies, trustedProxiesErr := parseTrustedProxies(os.Getenv("HTTP_TRUSTED_PROXIES"))
cfg := &Config{
AppEnv: withDefault("APP_ENV", "development"),
Log: LogConfig{Level: withDefault("LOG_LEVEL", "info")},
@@ -293,6 +372,7 @@ func Load() (*Config, error) {
// the server derive it from the CORS posture", and an explicit value
// overrides that derivation. See Server.sessionSameSite.
CookieSameSite: strings.ToLower(strings.TrimSpace(os.Getenv("HTTP_COOKIE_SAMESITE"))),
TrustedProxies: trustedProxies,
},
Seed: SeedConfig{
FixturePath: withDefault("SEED_FIXTURE_PATH", "./seed/fixtures/seed.json"),
@@ -300,6 +380,14 @@ func Load() (*Config, error) {
Agents: AgentsConfig{
CuratedPath: withDefault("CURATED_AGENTS_PATH", "./agents"),
},
OAuth: OAuthConfig{
// Trailing slashes trimmed here rather than at every use: the
// canonical form of a resource URI has none, and a token minted
// against ".../mcp/" would fail to validate against ".../mcp".
Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OAUTH_ISSUER")), "/"),
Resource: strings.TrimRight(strings.TrimSpace(os.Getenv("MCP_RESOURCE")), "/"),
LoginPath: withDefault("OAUTH_LOGIN_PATH", "/login"),
},
Knowledge: KnowledgeConfig{
EmbedProvider: strings.ToLower(strings.TrimSpace(os.Getenv("EMBED_PROVIDER"))),
EmbedAPIKey: strings.TrimSpace(os.Getenv("VOYAGE_API_KEY")),
@@ -347,6 +435,9 @@ func Load() (*Config, error) {
},
}
if trustedProxiesErr != nil {
return nil, trustedProxiesErr
}
if len(missing) > 0 {
return nil, fmt.Errorf("missing required environment variables: %s "+
"(copy .env.example to .env and fill them in)", strings.Join(missing, ", "))
@@ -531,6 +622,9 @@ func (c *Config) validate() error {
if c.Knowledge.EmbedDims < 0 {
return fmt.Errorf("EMBED_DIMENSIONS cannot be negative, got %d", c.Knowledge.EmbedDims)
}
if err := c.validateOAuth(); err != nil {
return err
}
for name, model := range map[string]string{
"MODEL_FAST": c.Model.Fast, "MODEL_BALANCED": c.Model.Balanced, "MODEL_DEEP": c.Model.Deep,
} {
@@ -621,6 +715,49 @@ func corsOrigins(appEnv string) []string {
return out
}
// parseTrustedProxies reads HTTP_TRUSTED_PROXIES, a comma-separated list of
// CIDR blocks or bare addresses.
//
// Unset or empty yields nil, which means no proxy is trusted and forwarded
// client addresses are ignored entirely. That is the safe default and the
// behaviour this API had before the setting existed.
//
// A bare address is accepted and widened to a single-host prefix, because
// "10.0.0.7" is what an operator reaches for when there is exactly one ingress
// and requiring them to write "10.0.0.7/32" only invites a mistake.
//
// Malformed entries are an error rather than a skip. Skipping one would leave
// the deployment quietly trusting a shorter list than the operator wrote, and
// the consequence — a proxy that is not believed, so every user shares one
// rate-limit bucket again — is precisely the fault this setting exists to fix.
func parseTrustedProxies(raw string) ([]netip.Prefix, error) {
var out []netip.Prefix
for _, part := range strings.Split(raw, ",") {
entry := strings.TrimSpace(part)
if entry == "" {
continue
}
if prefix, err := netip.ParsePrefix(entry); err == nil {
// Masked so that a block written with host bits set — 10.0.0.7/8,
// which is easy to write and easy to misread — still contains what
// its author meant. Unmasked, Prefix.Contains always reports false.
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return nil, fmt.Errorf("HTTP_TRUSTED_PROXIES entry %q is not an IP address "+
"or CIDR block (for example 10.0.0.0/8, 172.17.0.1 or fd00::/8)", entry)
}
// Unmap first: ::ffff:10.0.0.1 and 10.0.0.1 are the same host, and a
// /128 around the mapped form would not match the peer address Go
// reports for an IPv4 connection.
addr = addr.Unmap()
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
func withDefault(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
@@ -764,3 +901,45 @@ func applyDotEnv(content string) {
}
}
}
// validateOAuth checks the MCP surface's OAuth identity.
//
// Both values empty is the ordinary case and means the surface is off. Setting
// exactly one is always a mistake — a deployment that named an issuer but no
// resource would serve discovery documents pointing at a resource that does not
// exist — so it is refused at boot rather than at the first client connection.
func (c *Config) validateOAuth() error {
issuer, resource := c.OAuth.Issuer, c.OAuth.Resource
if issuer == "" && resource == "" {
return nil
}
if issuer == "" || resource == "" {
return fmt.Errorf("OAUTH_ISSUER and MCP_RESOURCE must be set together; " +
"one without the other serves discovery documents that point nowhere")
}
for name, raw := range map[string]string{"OAUTH_ISSUER": issuer, "MCP_RESOURCE": resource} {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return fmt.Errorf("%s must be an absolute URL, got %q", name, raw)
}
// HTTPS everywhere except a loopback development host. OAuth 2.1
// requires every authorization server endpoint to be served over
// HTTPS; a token or code sent over plain http is a token on the wire.
if parsed.Scheme != "https" && !isLoopback(raw) {
return fmt.Errorf("%s must use https (http is permitted only on loopback), got %q", name, raw)
}
if parsed.Fragment != "" {
return fmt.Errorf("%s must not contain a fragment, got %q", name, raw)
}
}
// A same-origin path, never an absolute URL: the authorization endpoint
// redirects here, and an operator-supplied absolute URL would be an open
// redirect one config mistake away.
if !strings.HasPrefix(c.OAuth.LoginPath, "/") || strings.HasPrefix(c.OAuth.LoginPath, "//") {
return fmt.Errorf("OAUTH_LOGIN_PATH must be a same-origin path beginning with a single '/', got %q",
c.OAuth.LoginPath)
}
return nil
}

View File

@@ -0,0 +1,134 @@
package config
// HTTP_TRUSTED_PROXIES parsing.
//
// The setting decides whether a client-supplied header is believed, so the
// tests worth having are about what happens when it is WRONG: unset, empty,
// mistyped. Every one of those must end in "trust nothing", because the
// alternative — trusting something the operator did not write — is the whole
// risk this setting carries.
import (
"net/netip"
"testing"
)
func TestTrustedProxiesUnsetTrustsNothing(t *testing.T) {
for _, raw := range []string{"", " ", ",", " , , "} {
got, err := parseTrustedProxies(raw)
if err != nil {
t.Errorf("parseTrustedProxies(%q): unexpected error %v", raw, err)
}
if len(got) != 0 {
t.Errorf("parseTrustedProxies(%q) = %v, want empty — an unset value must trust nothing", raw, got)
}
}
}
func TestTrustedProxiesParsesCIDRsAndBareAddresses(t *testing.T) {
got, err := parseTrustedProxies(" 10.0.0.0/8 , 172.17.0.1 , fd00::/8 , ::1 ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"10.0.0.0/8", "172.17.0.1/32", "fd00::/8", "::1/128"}
if len(got) != len(want) {
t.Fatalf("parsed %d entries (%v), want %d", len(got), got, len(want))
}
for i, w := range want {
if got[i].String() != w {
t.Errorf("entry %d = %q, want %q", i, got[i].String(), w)
}
}
}
// A bare address must become a single-host block that contains that host and
// nothing else — the operator wrote one proxy, not a network.
func TestTrustedProxyBareAddressIsOneHost(t *testing.T) {
got, err := parseTrustedProxies("172.17.0.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got[0].Contains(netip.MustParseAddr("172.17.0.1")) {
t.Error("the host itself is not in its own single-host block")
}
if got[0].Contains(netip.MustParseAddr("172.17.0.2")) {
t.Error("a bare address was widened beyond one host")
}
}
// A block written with host bits set is common and easy to misread. Masking it
// at parse time makes it mean what its author meant; unmasked, netip.Prefix
// .Contains reports false for everything.
func TestTrustedProxyCIDRWithHostBitsIsMasked(t *testing.T) {
got, err := parseTrustedProxies("10.1.2.3/8")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if want := "10.0.0.0/8"; got[0].String() != want {
t.Fatalf("got %q, want %q", got[0].String(), want)
}
if !got[0].Contains(netip.MustParseAddr("10.9.9.9")) {
t.Error("the masked block does not contain an address inside it")
}
}
// An IPv4-mapped address names an IPv4 host, and must match the peer address
// Go reports for an IPv4 connection.
func TestTrustedProxyIPv4MappedIsUnmapped(t *testing.T) {
got, err := parseTrustedProxies("::ffff:10.0.0.1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got[0].Contains(netip.MustParseAddr("10.0.0.1")) {
t.Errorf("%q does not contain 10.0.0.1", got[0].String())
}
}
// Malformed entries stop startup. Skipping one would leave the deployment
// trusting a shorter list than the operator wrote, and the consequence — every
// user sharing one rate-limit bucket — is silent.
func TestTrustedProxiesRejectMalformedEntries(t *testing.T) {
for _, raw := range []string{
"banana",
"10.0.0.0/33",
"10.0.0.0/8, banana",
"300.1.2.3",
"10.0.0.1:8080",
"*",
"https://proxy.internal",
"fd00::/200",
} {
if _, err := parseTrustedProxies(raw); err == nil {
t.Errorf("parseTrustedProxies(%q) was accepted; it must refuse and stop startup", raw)
}
}
}
// The error has to name the entry and show the shape expected, because it is
// read by an operator at 3am with a container that will not boot.
func TestTrustedProxiesErrorNamesTheEntry(t *testing.T) {
_, err := parseTrustedProxies("10.0.0.0/8, banana")
if err == nil {
t.Fatal("expected an error")
}
for _, want := range []string{"HTTP_TRUSTED_PROXIES", "banana"} {
if !contains(err.Error(), want) {
t.Errorf("error %q does not mention %q", err.Error(), want)
}
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && (haystack == needle ||
len(needle) == 0 || indexOf(haystack, needle) >= 0)
}
func indexOf(haystack, needle string) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return i
}
}
return -1
}