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
}