From 7b000405c186447fdc84567dd7152d3a31e01068 Mon Sep 17 00:00:00 2001 From: xarmian Date: Tue, 29 Sep 2026 15:45:30 +0000 Subject: [PATCH] feat(mcp): resolve MCP addressing from config; mcp_enabled setting with an env lock (TASK-3302) PLAN-2310 unit 1 (DR-2, DR-3, the data behind DR-7). No route changes: nothing is gated or exposed by this unit, and U2 wires the predicate into the routes. Addressing (internal/config/mcp.go, ResolveMCPEndpoints): - The origin is cfg.URL (PAD_URL, toml url, --url), else PUBLIC_URL, never the Host:Port fallback and never a request's Host. - The MCP URL, which is also the OAuth audience, is PAD_MCP_PUBLIC_URL, else /mcp. The auth-server URL is PAD_AUTH_SERVER_URL, else the origin. - A value must be an absolute http(s) URL without user info, query or fragment; an origin also has no path. An unusable value never fails startup (PAD_URL also serves the CLI). It is logged, leaves its field empty, and keeps MCP unavailable. An unusable override does not fall back to the origin. - HTTPS() is the auth-server URL's scheme: https means OAuth plus PATs, http means PATs only. The setting (internal/server/handlers_admin_mcp.go): - platform_settings.mcp_enabled, default off, read per request with no cache (the webmcp_enabled precedent). A read error counts as off. - PAD_MCP_ENABLED (true/false/1/0/yes/no/on/off) forces it and locks the toggle. An unparseable value forces nothing and is logged. Cloud is always on. - mcpAvailable() is DR-1's predicate: cloud, or the setting on and the addressing usable. - GET/PUT /api/v1/admin/mcp, admin-only. GET reports enabled, source (setting|environment|cloud), locked and the readiness: origin, MCP URL, auth-server URL, https, auth methods, problems, state on|off|blocked with the reason, and the resume counts. PUT answers 409 set_by_environment under the env lock (the decision-provider shape), 403 managed_by_operator on cloud, and 400 without {enabled}. It is audit-logged as settings_changed. - The resume counts (internal/store/mcp_resume.go) count live OAuth chains the way the Connected Apps page does (request_id with an active access or refresh row), and PATs the way /mcp accepts them (has a user_id; expiry parsed as a time, as ValidateToken does). Tests: - config resolution table (11 cases); PAD_MCP_ENABLED parsing and Load. - Store counts: a rotated chain counted once, a revoked chain excluded, a refresh-only chain counted; the legacy and expired PATs excluded. - Router tests: default off (the upgrade case); https on; http PAT-only; blocked without or with an unusable origin; the env lock with a stored value underneath; cloud; admin-only and body validation; a setting read error is off, with a control leg. - Mutants, each BUILD OK and killed by the test aimed at it: env override ignored; availability ignoring addressing; PUT skipping the env lock; origin path allowed; PUT allowed on cloud; read error counted as on (error branch only); invalid override falling back; expired PATs counted; refresh-only chains missed. Docs for the variables and the endpoint land with PLAN-2310 U7. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9 --- cmd/pad/cmd_server.go | 13 ++ internal/config/config.go | 11 ++ internal/config/mcp.go | 149 +++++++++++++++ internal/config/mcp_test.go | 122 +++++++++++++ internal/server/handlers_admin_mcp.go | 200 +++++++++++++++++++++ internal/server/handlers_admin_mcp_test.go | 182 +++++++++++++++++++ internal/server/server.go | 9 + internal/store/mcp_resume.go | 58 ++++++ internal/store/mcp_resume_test.go | 75 ++++++++ 9 files changed, 819 insertions(+) create mode 100644 internal/config/mcp.go create mode 100644 internal/config/mcp_test.go create mode 100644 internal/server/handlers_admin_mcp.go create mode 100644 internal/server/handlers_admin_mcp_test.go create mode 100644 internal/store/mcp_resume.go create mode 100644 internal/store/mcp_resume_test.go diff --git a/cmd/pad/cmd_server.go b/cmd/pad/cmd_server.go index cfc1b553e..6eaef9059 100644 --- a/cmd/pad/cmd_server.go +++ b/cmd/pad/cmd_server.go @@ -268,6 +268,19 @@ func serveCmd() *cobra.Command { srv.SetBaseURL(cfg.PublicLinkBaseURL()) srv.SetCORSOrigins(cfg.CORSOrigins) srv.SetSecureCookies(cfg.SecureCookies) + + // MCP addressing and the PAD_MCP_ENABLED override (PLAN-2310 DR-2, + // DR-3), on every install. An unusable value never stops startup, + // because PAD_URL also serves the CLI: it is logged, and it keeps + // MCP unavailable until fixed. + mcpEndpoints := cfg.ResolveMCPEndpoints() + for _, problem := range mcpEndpoints.Problems() { + slog.Warn("mcp: configuration value not usable", "problem", problem) + } + if cfg.MCPEnabledEnvRaw != "" && cfg.MCPEnabledEnv == nil { + slog.Warn("mcp: PAD_MCP_ENABLED is not true/false; ignoring it", "value", cfg.MCPEnabledEnvRaw) + } + srv.SetMCPConfig(mcpEndpoints, cfg.MCPEnabledEnv) srv.SetTrustedProxies(cfg.TrustedProxies) srv.SetMetricsToken(cfg.MetricsToken) srv.SetIPChangeEnforce(cfg.IPChangeEnforce) diff --git a/internal/config/config.go b/internal/config/config.go index de1f05ecf..3978b28e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,6 +89,13 @@ type Config struct { MCPPublicURL string `toml:"mcp_public_url"` // Canonical URL clients paste into their MCP client (e.g. https://mcp.getpad.dev — matches mcp.stripe.com / mcp.linear.app convention). Published verbatim as RFC 9728 `resource` and used as the OAuth audience binding; pad mounts the transport internally at /mcp and pad-cloud's nginx router transparently rewrites mcp.* root → /mcp so external clients see a single canonical URL regardless of internal path. AuthServerURL string `toml:"auth_server_url"` // Canonical URL of the OAuth authorization server (TASK-951), e.g. https://app.getpad.dev. Embedded in protected-resource metadata's authorization_servers field. + // MCPEnabledEnv is PAD_MCP_ENABLED parsed (PLAN-2310 DR-2): nil when + // unset, otherwise the forced value of the MCP setting, which also + // locks the console toggle. MCPEnabledEnvRaw keeps an unparseable value + // so the server can warn about it; such a value forces nothing. + MCPEnabledEnv *bool `toml:"-"` + MCPEnabledEnvRaw string `toml:"-"` + // Encryption EncryptionKey string `toml:"encryption_key"` // 32-byte hex-encoded AES-256 key for encrypting sensitive fields EncryptionKeySource string `toml:"-"` // "env", "file", "generated", or "" (unset); populated by EnsureEncryptionKey @@ -444,6 +451,10 @@ func Load() (*Config, error) { if v := os.Getenv("PAD_AUTH_SERVER_URL"); v != "" { cfg.AuthServerURL = v } + if v := os.Getenv("PAD_MCP_ENABLED"); v != "" { + cfg.MCPEnabledEnvRaw = v + cfg.MCPEnabledEnv = parseMCPEnabledEnv(v) + } if v := os.Getenv("PAD_ENCRYPTION_KEY"); v != "" { cfg.EncryptionKey = v cfg.EncryptionKeySource = "env" diff --git a/internal/config/mcp.go b/internal/config/mcp.go new file mode 100644 index 000000000..680aeb7fa --- /dev/null +++ b/internal/config/mcp.go @@ -0,0 +1,149 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +// MCP public addressing (PLAN-2310 DR-3). Every URL an MCP client or an +// OAuth flow sees is derived from configuration here, never from a +// request's Host: on a LAN box the Host header is chosen by whoever sent the +// request (DNS rebinding), and a guessed "https://"+Host on an http box +// points clients at a URL that does not exist. +// +// All three inputs are startup-time configuration (environment, the toml +// file, or a flag). None is a runtime-writable setting, so a value derived +// from them, such as the OAuth audience fixed at construction, cannot go +// stale while the process runs (PLAN-2310 DR-4). + +// MCPEndpoints is the resolved addressing, with the reason for each value +// that could not be used. +type MCPEndpoints struct { + // Origin is the deployment's public origin: cfg.URL (PAD_URL, toml + // `url`, or --url), else PUBLIC_URL. Empty when neither is set or the + // value is unusable; OriginErr then says why. + Origin string + OriginVar string // the variable the origin came from, for messages + OriginErr string + + // ResourceURL is the MCP URL clients connect to, and the OAuth + // audience: PAD_MCP_PUBLIC_URL, else Origin + "/mcp". + ResourceURL string + ResourceURLErr string + + // AuthServerURL is the OAuth issuer: PAD_AUTH_SERVER_URL, else Origin. + AuthServerURL string + AuthServerURLErr string +} + +// Usable reports whether MCP can be addressed at all: an origin and the +// two URLs derived from it all resolved. +func (e MCPEndpoints) Usable() bool { + return e.Origin != "" && e.ResourceURL != "" && e.AuthServerURL != "" +} + +// HTTPS reports whether the auth-server URL is https, which is what makes +// OAuth available (PLAN-2310 DR-4, Dave's ruling: http deployments are +// PAT-only). +func (e MCPEndpoints) HTTPS() bool { + return e.Usable() && strings.HasPrefix(e.AuthServerURL, "https://") +} + +// Problems lists every configured value that could not be used, as +// "VARIABLE: reason" lines for a startup warning. An unset origin is not a +// problem; it is the default, and MCP simply stays unavailable. +func (e MCPEndpoints) Problems() []string { + var out []string + for _, p := range []string{e.OriginErr, e.ResourceURLErr, e.AuthServerURLErr} { + if p != "" { + out = append(out, p) + } + } + return out +} + +// ResolveMCPEndpoints derives the MCP addressing from the configuration. It +// never fails: PAD_URL also tells the CLI where the server is, so an install +// that set it for that purpose must keep booting whatever it contains. An +// unusable value leaves the matching field empty with a reason instead. +func (c *Config) ResolveMCPEndpoints() MCPEndpoints { + var e MCPEndpoints + + raw, name := c.URL, "PAD_URL" + if raw == "" { + raw, name = c.PublicURL, "PUBLIC_URL" + } + if raw != "" { + e.OriginVar = name + if origin, err := parseMCPURL(raw, false); err != nil { + e.OriginErr = fmt.Sprintf("%s %q is not usable as the public origin: %v", name, raw, err) + } else { + e.Origin = origin + } + } + + if c.MCPPublicURL != "" { + if u, err := parseMCPURL(c.MCPPublicURL, true); err != nil { + e.ResourceURLErr = fmt.Sprintf("PAD_MCP_PUBLIC_URL %q is not usable: %v", c.MCPPublicURL, err) + } else { + e.ResourceURL = u + } + } else if e.Origin != "" { + e.ResourceURL = e.Origin + "/mcp" + } + + if c.AuthServerURL != "" { + if u, err := parseMCPURL(c.AuthServerURL, true); err != nil { + e.AuthServerURLErr = fmt.Sprintf("PAD_AUTH_SERVER_URL %q is not usable: %v", c.AuthServerURL, err) + } else { + e.AuthServerURL = u + } + } else if e.Origin != "" { + e.AuthServerURL = e.Origin + } + + // An override without an origin still leaves MCP unavailable, because + // the other value falls back to the origin. Usable() is the gate. + return e +} + +// parseMCPURL accepts an absolute http(s) URL with a host, no user info, no +// query and no fragment, and returns it without a trailing slash. An origin +// must also have no path; the MCP and auth-server URLs may have one. +func parseMCPURL(raw string, pathAllowed bool) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", fmt.Errorf("does not parse as a URL") + } + switch { + case u.Scheme != "http" && u.Scheme != "https": + return "", fmt.Errorf("the scheme must be http or https") + case u.Host == "" || u.Hostname() == "": + return "", fmt.Errorf("it has no host") + case u.User != nil: + return "", fmt.Errorf("it must not carry user info") + case u.RawQuery != "" || u.ForceQuery: + return "", fmt.Errorf("it must not have a query") + case u.Fragment != "": + return "", fmt.Errorf("it must not have a fragment") + case !pathAllowed && strings.Trim(u.Path, "/") != "": + return "", fmt.Errorf("an origin must not have a path (got %q)", u.Path) + } + out := u.Scheme + "://" + u.Host + strings.TrimRight(u.EscapedPath(), "/") + return out, nil +} + +// parseMCPEnabledEnv reads PAD_MCP_ENABLED: true/1/yes/on and +// false/0/no/off, case-insensitive. Anything else forces nothing (nil); the +// raw value is kept on the config so the server warns about it. +func parseMCPEnabledEnv(v string) *bool { + t, f := true, false + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on": + return &t + case "false", "0", "no", "off": + return &f + } + return nil +} diff --git a/internal/config/mcp_test.go b/internal/config/mcp_test.go new file mode 100644 index 000000000..dc0d90e25 --- /dev/null +++ b/internal/config/mcp_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "strings" + "testing" +) + +// PLAN-2310 DR-3: MCP addressing comes from configuration only, never from +// a request, and an unusable value is reported, never fatal. +func TestResolveMCPEndpoints(t *testing.T) { + cases := []struct { + name string + cfg Config + origin, resource, authServer string + usable, https bool + problem string // substring of the only expected problem, "" for none + }{ + {name: "nothing configured", cfg: Config{Host: "0.0.0.0", Port: 7777}}, + { + name: "PAD_URL https", cfg: Config{URL: "https://pad.example.com/"}, + origin: "https://pad.example.com", resource: "https://pad.example.com/mcp", authServer: "https://pad.example.com", + usable: true, https: true, + }, + { + name: "PUBLIC_URL http when PAD_URL is unset", cfg: Config{PublicURL: "http://pad.lan:7777"}, + origin: "http://pad.lan:7777", resource: "http://pad.lan:7777/mcp", authServer: "http://pad.lan:7777", + usable: true, + }, + { + name: "PAD_URL wins over PUBLIC_URL", cfg: Config{URL: "https://a.example", PublicURL: "https://b.example"}, + origin: "https://a.example", resource: "https://a.example/mcp", authServer: "https://a.example", + usable: true, https: true, + }, + { + name: "cloud's three variables", cfg: Config{PublicURL: "https://app.getpad.dev", MCPPublicURL: "https://mcp.getpad.dev", AuthServerURL: "https://app.getpad.dev"}, + origin: "https://app.getpad.dev", resource: "https://mcp.getpad.dev", authServer: "https://app.getpad.dev", + usable: true, https: true, + }, + { + name: "https origin but an http auth server is PAT-only", cfg: Config{URL: "https://pad.example.com", AuthServerURL: "http://auth.internal"}, + origin: "https://pad.example.com", resource: "https://pad.example.com/mcp", authServer: "http://auth.internal", + usable: true, + }, + { + name: "an origin with a path is unusable", cfg: Config{URL: "https://pad.example.com/pad"}, + problem: "PAD_URL", + }, + { + name: "a non-http scheme is unusable", cfg: Config{PublicURL: "ftp://pad.example.com"}, + problem: "PUBLIC_URL", + }, + { + name: "a query is unusable", cfg: Config{URL: "https://pad.example.com?x=1"}, + problem: "query", + }, + { + name: "an unusable override does not fall back to the origin", cfg: Config{URL: "https://pad.example.com", MCPPublicURL: "mcp.example.com"}, + origin: "https://pad.example.com", authServer: "https://pad.example.com", + problem: "PAD_MCP_PUBLIC_URL", + }, + { + name: "an override without an origin is still unavailable", cfg: Config{MCPPublicURL: "https://mcp.example.com"}, + resource: "https://mcp.example.com", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := tc.cfg.ResolveMCPEndpoints() + if e.Origin != tc.origin || e.ResourceURL != tc.resource || e.AuthServerURL != tc.authServer { + t.Errorf("got (%q, %q, %q), want (%q, %q, %q)", e.Origin, e.ResourceURL, e.AuthServerURL, tc.origin, tc.resource, tc.authServer) + } + if e.Usable() != tc.usable || e.HTTPS() != tc.https { + t.Errorf("usable/https = %v/%v, want %v/%v", e.Usable(), e.HTTPS(), tc.usable, tc.https) + } + probs := e.Problems() + switch { + case tc.problem == "" && len(probs) != 0: + t.Errorf("problems = %v, want none", probs) + case tc.problem != "" && (len(probs) != 1 || !strings.Contains(probs[0], tc.problem)): + t.Errorf("problems = %v, want one naming %q", probs, tc.problem) + } + }) + } +} + +func TestParseMCPEnabledEnv(t *testing.T) { + for v, want := range map[string]string{ + "true": "true", "1": "true", "YES": "true", " on ": "true", + "false": "false", "0": "false", "no": "false", "Off": "false", + "maybe": "nil", "": "nil", + } { + got := "nil" + if b := parseMCPEnabledEnv(v); b != nil { + got = map[bool]string{true: "true", false: "false"}[*b] + } + if got != want { + t.Errorf("parseMCPEnabledEnv(%q) = %s, want %s", v, got, want) + } + } +} + +// Load reads PAD_MCP_ENABLED, keeping an unparseable value for the warning. +func TestLoadReadsPadMCPEnabled(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("PAD_MCP_ENABLED", "true") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.MCPEnabledEnv == nil || !*cfg.MCPEnabledEnv { + t.Fatalf("MCPEnabledEnv = %v, want true", cfg.MCPEnabledEnv) + } + + t.Setenv("PAD_MCP_ENABLED", "sometimes") + cfg, err = Load() + if err != nil { + t.Fatal(err) + } + if cfg.MCPEnabledEnv != nil || cfg.MCPEnabledEnvRaw != "sometimes" { + t.Fatalf("unparseable value: env=%v raw=%q, want nil and the raw value", cfg.MCPEnabledEnv, cfg.MCPEnabledEnvRaw) + } +} diff --git a/internal/server/handlers_admin_mcp.go b/internal/server/handlers_admin_mcp.go new file mode 100644 index 000000000..2de3b997c --- /dev/null +++ b/internal/server/handlers_admin_mcp.go @@ -0,0 +1,200 @@ +package server + +import ( + "log/slog" + "net/http" + + "github.com/PerpetualSoftware/pad/internal/config" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// The MCP capability's setting and addressing (PLAN-2310 DR-1, DR-2, DR-3, +// DR-7). This unit only reads and reports them; route gating arrives with +// PLAN-2310 U2. + +// settingMCPEnabled is the platform_settings key behind the console toggle. +// It is deliberately NOT in adminManagedSettings: the generic settings PATCH +// cannot express the environment lock or the cloud refusal. +const settingMCPEnabled = "mcp_enabled" + +// mcpSettingSource says where the effective MCP setting comes from. +const ( + mcpSourceSetting = "setting" + mcpSourceEnvironment = "environment" + mcpSourceCloud = "cloud" +) + +// SetMCPConfig installs the resolved MCP addressing and the PAD_MCP_ENABLED +// override. Called once at startup on every install, before the router is +// built; neither value changes while the process runs. +func (s *Server) SetMCPConfig(endpoints config.MCPEndpoints, enabledEnv *bool) { + s.mcpEndpoints = endpoints + s.mcpEnabledEnv = enabledEnv +} + +// mcpSetting returns the effective value of the MCP setting and its source. +// Cloud is always on. Otherwise PAD_MCP_ENABLED wins, then the stored +// setting, read per request with no cache, following webmcp_enabled. A read +// error counts as off: the capability fails closed. +func (s *Server) mcpSetting() (bool, string) { + if s.cloudMode { + return true, mcpSourceCloud + } + if s.mcpEnabledEnv != nil { + return *s.mcpEnabledEnv, mcpSourceEnvironment + } + v, err := s.store.GetPlatformSetting(settingMCPEnabled) + if err != nil { + slog.Warn("mcp: reading the mcp_enabled setting failed; treating MCP as off", "error", err) + return false, mcpSourceSetting + } + return v == "true", mcpSourceSetting +} + +// mcpAvailable is PLAN-2310 DR-1's predicate: cloud, or the setting on and +// a usable public origin configured. A setting that is on without an origin +// leaves MCP unavailable; the readiness panel says why. +func (s *Server) mcpAvailable() bool { + if s.cloudMode { + return true + } + on, _ := s.mcpSetting() + return on && s.mcpEndpoints.Usable() +} + +type mcpReadiness struct { + Origin string `json:"origin"` + OriginVar string `json:"origin_var,omitempty"` + MCPURL string `json:"mcp_url"` + AuthServerURL string `json:"auth_server_url"` + HTTPS bool `json:"https"` + AuthMethods []string `json:"auth_methods"` + Problems []string `json:"problems"` + // State is "on", "off", or "blocked": on in the setting, but not + // available because the addressing is not usable. Blocked says why. + State string `json:"state"` + Blocked string `json:"blocked,omitempty"` + // Resume counts what would work again if MCP were turned on. Turning + // it off revokes nothing (PLAN-2310 DR-5). + Resume struct { + OAuthConnections int `json:"oauth_connections"` + PATs int `json:"pats"` + } `json:"resume"` +} + +type mcpSettingsResponse struct { + Enabled bool `json:"enabled"` + Source string `json:"source"` + Locked bool `json:"locked"` + Readiness mcpReadiness `json:"readiness"` +} + +func (s *Server) buildMCPSettingsResponse() (mcpSettingsResponse, error) { + on, source := s.mcpSetting() + ep := s.mcpEndpoints + resp := mcpSettingsResponse{ + Enabled: on, + Source: source, + Locked: source != mcpSourceSetting, + } + rd := &resp.Readiness + rd.Origin, rd.OriginVar = ep.Origin, ep.OriginVar + rd.MCPURL, rd.AuthServerURL = ep.ResourceURL, ep.AuthServerURL + rd.HTTPS = ep.HTTPS() + rd.Problems = ep.Problems() + if rd.Problems == nil { + rd.Problems = []string{} + } + switch { + case !ep.Usable(): + rd.AuthMethods = []string{} + case rd.HTTPS: + rd.AuthMethods = []string{"oauth", "pat"} + default: + rd.AuthMethods = []string{"pat"} + } + + // The state is the DR-1 predicate itself, so the panel reports exactly + // what gates the routes. + switch { + case !on: + rd.State = "off" + case s.mcpAvailable(): + rd.State = "on" + default: + rd.State = "blocked" + if len(rd.Problems) > 0 { + rd.Blocked = rd.Problems[0] + } else { + rd.Blocked = "No public origin is configured. Set PAD_URL (or PUBLIC_URL) to the URL this server is reached at." + } + } + + var err error + if rd.Resume.OAuthConnections, err = s.store.CountLiveOAuthConnections(); err != nil { + return resp, err + } + if rd.Resume.PATs, err = s.store.CountMCPUsablePATs(); err != nil { + return resp, err + } + return resp, nil +} + +// handleGetMCPSettings: GET /api/v1/admin/mcp. Admin-only. +func (s *Server) handleGetMCPSettings(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil || user.Role != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "Admin access required") + return + } + resp, err := s.buildMCPSettingsResponse() + if err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} + +// handleUpdateMCPSettings: PUT /api/v1/admin/mcp {enabled}. Admin-only. +// Refused on Pad Cloud (always on) and when PAD_MCP_ENABLED forces the +// value: a write that could not take effect must not answer 200. +func (s *Server) handleUpdateMCPSettings(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil || user.Role != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "Admin access required") + return + } + if s.cloudMode { + writeError(w, http.StatusForbidden, "managed_by_operator", "MCP is always on for this instance and is managed by the operator.") + return + } + var in struct { + Enabled *bool `json:"enabled"` + } + if err := decodeJSON(r, &in); err != nil || in.Enabled == nil { + writeError(w, http.StatusBadRequest, "bad_request", "Body must be {\"enabled\": true|false}") + return + } + if s.mcpEnabledEnv != nil { + writeError2(w, http.StatusConflict, "set_by_environment", + "Set by the server environment (PAD_MCP_ENABLED), which overrides this setting: enabled", + map[string]interface{}{"fields": []string{"enabled"}}) + return + } + value := "false" + if *in.Enabled { + value = "true" + } + if err := s.store.SetPlatformSetting(settingMCPEnabled, value); err != nil { + writeInternalError(w, err) + return + } + s.logAuditEvent(models.ActionSettingsChanged, r, settingsChangedMeta([]string{settingMCPEnabled})) + + resp, err := s.buildMCPSettingsResponse() + if err != nil { + writeInternalError(w, err) + return + } + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/server/handlers_admin_mcp_test.go b/internal/server/handlers_admin_mcp_test.go new file mode 100644 index 000000000..1ca775c82 --- /dev/null +++ b/internal/server/handlers_admin_mcp_test.go @@ -0,0 +1,182 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/PerpetualSoftware/pad/internal/config" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// PLAN-2310 U1: GET/PUT /api/v1/admin/mcp, through the router. + +func decodeMCPSettings(t *testing.T, rr *httptest.ResponseRecorder) mcpSettingsResponse { + t.Helper() + if rr.Code != http.StatusOK { + t.Fatalf("status %d, want 200: %s", rr.Code, rr.Body.String()) + } + var out mcpSettingsResponse + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v: %s", err, rr.Body.String()) + } + return out +} + +func mcpServer(t *testing.T, cfg config.Config, env *bool) (*Server, string) { + t.Helper() + srv := testServer(t) + token := bootstrapFirstUser(t, srv, "admin@example.com", "Admin") + srv.SetMCPConfig(cfg.ResolveMCPEndpoints(), env) + return srv, token +} + +// An install that never touched the setting reads as off, from the stored +// setting, unlocked. This is the upgrade case: nothing turns on by itself. +func TestAdminMCP_DefaultIsOff(t *testing.T) { + srv, token := mcpServer(t, config.Config{URL: "https://pad.example.com"}, nil) + got := decodeMCPSettings(t, doRequestWithCookie(srv, "GET", "/api/v1/admin/mcp", nil, token)) + if got.Enabled || got.Source != mcpSourceSetting || got.Locked || got.Readiness.State != "off" { + t.Fatalf("default = %+v, want off from the setting, unlocked", got) + } + if srv.mcpAvailable() { + t.Fatal("mcpAvailable() is true on an install that never enabled MCP") + } +} + +// Turning it on with a usable https origin: on, OAuth and PATs, and the +// DR-1 predicate agrees with the panel. +func TestAdminMCP_EnableHTTPS(t *testing.T) { + srv, token := mcpServer(t, config.Config{URL: "https://pad.example.com"}, nil) + got := decodeMCPSettings(t, doRequestWithCookie(srv, "PUT", "/api/v1/admin/mcp", map[string]any{"enabled": true}, token)) + rd := got.Readiness + if !got.Enabled || rd.State != "on" || !rd.HTTPS || rd.MCPURL != "https://pad.example.com/mcp" { + t.Fatalf("after enable = %+v", got) + } + if len(rd.AuthMethods) != 2 || rd.AuthMethods[0] != "oauth" || rd.AuthMethods[1] != "pat" { + t.Fatalf("auth methods = %v, want [oauth pat]", rd.AuthMethods) + } + if !srv.mcpAvailable() { + t.Fatal("mcpAvailable() is false with the setting on and an https origin") + } + // Persisted, not just echoed. + if v, _ := srv.store.GetPlatformSetting(settingMCPEnabled); v != "true" { + t.Fatalf("stored mcp_enabled = %q, want true", v) + } +} + +func TestAdminMCP_HTTPIsPATOnly(t *testing.T) { + srv, token := mcpServer(t, config.Config{PublicURL: "http://pad.lan:7777"}, nil) + got := decodeMCPSettings(t, doRequestWithCookie(srv, "PUT", "/api/v1/admin/mcp", map[string]any{"enabled": true}, token)) + if got.Readiness.State != "on" || got.Readiness.HTTPS || len(got.Readiness.AuthMethods) != 1 || got.Readiness.AuthMethods[0] != "pat" { + t.Fatalf("http deployment = %+v, want on with [pat]", got.Readiness) + } +} + +// On without a usable origin is blocked, with the reason, and not available. +func TestAdminMCP_BlockedWithoutOrigin(t *testing.T) { + for name, cfg := range map[string]config.Config{ + "no origin": {Host: "0.0.0.0", Port: 7777}, + "unusable origin": {URL: "https://pad.example.com/sub"}, + } { + t.Run(name, func(t *testing.T) { + srv, token := mcpServer(t, cfg, nil) + got := decodeMCPSettings(t, doRequestWithCookie(srv, "PUT", "/api/v1/admin/mcp", map[string]any{"enabled": true}, token)) + if got.Readiness.State != "blocked" || got.Readiness.Blocked == "" { + t.Fatalf("readiness = %+v, want blocked with a reason", got.Readiness) + } + if srv.mcpAvailable() { + t.Fatal("mcpAvailable() is true without a usable origin") + } + }) + } +} + +// PAD_MCP_ENABLED forces the value and locks the toggle: GET reports it, +// PUT is refused 409 set_by_environment and stores nothing. +func TestAdminMCP_EnvironmentLock(t *testing.T) { + off := false + srv, token := mcpServer(t, config.Config{URL: "https://pad.example.com"}, &off) + if err := srv.store.SetPlatformSetting(settingMCPEnabled, "true"); err != nil { + t.Fatal(err) + } + got := decodeMCPSettings(t, doRequestWithCookie(srv, "GET", "/api/v1/admin/mcp", nil, token)) + if got.Enabled || got.Source != mcpSourceEnvironment || !got.Locked { + t.Fatalf("env-forced off over a stored on = %+v, want off, environment, locked", got) + } + if srv.mcpAvailable() { + t.Fatal("mcpAvailable() ignores PAD_MCP_ENABLED=false") + } + + rr := doRequestWithCookie(srv, "PUT", "/api/v1/admin/mcp", map[string]any{"enabled": true}, token) + if rr.Code != http.StatusConflict { + t.Fatalf("PUT under the env lock: %d, want 409: %s", rr.Code, rr.Body.String()) + } + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &body) + if body.Error.Code != "set_by_environment" { + t.Fatalf("code = %q, want set_by_environment", body.Error.Code) + } +} + +// Cloud is always on, and the toggle is the operator's. +func TestAdminMCP_Cloud(t *testing.T) { + srv, token := mcpServer(t, config.Config{PublicURL: "https://app.getpad.dev", MCPPublicURL: "https://mcp.getpad.dev"}, nil) + srv.SetCloudMode("cloud-secret") + got := decodeMCPSettings(t, doRequestWithCookie(srv, "GET", "/api/v1/admin/mcp", nil, token)) + if !got.Enabled || got.Source != mcpSourceCloud || !got.Locked || got.Readiness.State != "on" { + t.Fatalf("cloud = %+v, want on, cloud, locked", got) + } + rr := doRequestWithCookie(srv, "PUT", "/api/v1/admin/mcp", map[string]any{"enabled": false}, token) + if rr.Code != http.StatusForbidden { + t.Fatalf("PUT on cloud: %d, want 403: %s", rr.Code, rr.Body.String()) + } +} + +// A setting that cannot be read counts as off (DR-2): the capability fails +// closed. The control leg shows the stored value reads as on first, so the +// off after the failure is caused by the failure. +func TestAdminMCP_SettingReadErrorIsOff(t *testing.T) { + srv, _ := mcpServer(t, config.Config{URL: "https://pad.example.com"}, nil) + if err := srv.store.SetPlatformSetting(settingMCPEnabled, "true"); err != nil { + t.Fatal(err) + } + if on, _ := srv.mcpSetting(); !on || !srv.mcpAvailable() { + t.Fatal("control: the stored setting does not read as on") + } + if _, err := srv.store.DB().Exec(`ALTER TABLE platform_settings RENAME TO platform_settings_unreadable`); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = srv.store.DB().Exec(`ALTER TABLE platform_settings_unreadable RENAME TO platform_settings`) + }) + if on, _ := srv.mcpSetting(); on || srv.mcpAvailable() { + t.Fatal("a setting read error counted as on") + } +} + +func TestAdminMCP_RequiresAdminAndABody(t *testing.T) { + srv, token := mcpServer(t, config.Config{URL: "https://pad.example.com"}, nil) + if _, err := srv.store.CreateUser(models.UserCreate{Email: "member@example.com", Name: "Member", Password: "correct-horse-battery-staple", Role: "member"}); err != nil { + t.Fatal(err) + } + member := loginUser(t, srv, "member@example.com", "correct-horse-battery-staple") + for _, method := range []string{"GET", "PUT"} { + rr := doRequestWithCookie(srv, method, "/api/v1/admin/mcp", map[string]any{"enabled": true}, member) + if rr.Code != http.StatusForbidden { + t.Errorf("%s as a member: %d, want 403", method, rr.Code) + } + } + rr := doRequestWithCookie(srv, "PUT", "/api/v1/admin/mcp", map[string]any{}, token) + if rr.Code != http.StatusBadRequest { + t.Fatalf("PUT without enabled: %d, want 400", rr.Code) + } + if v, _ := srv.store.GetPlatformSetting(settingMCPEnabled); v != "" { + t.Fatalf("a refused PUT stored %q", v) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ac02bca98..bf7b98021 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -31,6 +31,7 @@ import ( "github.com/PerpetualSoftware/pad/internal/attachments" "github.com/PerpetualSoftware/pad/internal/billing" "github.com/PerpetualSoftware/pad/internal/collab" + "github.com/PerpetualSoftware/pad/internal/config" "github.com/PerpetualSoftware/pad/internal/email" "github.com/PerpetualSoftware/pad/internal/events" "github.com/PerpetualSoftware/pad/internal/metrics" @@ -291,6 +292,12 @@ type Server struct { // question sets, and serialises reconfiguration (TASK-3121). decisionSettings decisionSettingsState + // mcpEndpoints and mcpEnabledEnv are the MCP capability's addressing and + // the PAD_MCP_ENABLED override (PLAN-2310), set once at startup by + // SetMCPConfig. See handlers_admin_mcp.go. + mcpEndpoints config.MCPEndpoints + mcpEnabledEnv *bool + // inFlightUploadHashes tracks content_hash values for uploads // that have called AttachmentStore.Put but not yet inserted the // attachments row. Without this, the orphan GC could delete a @@ -1608,6 +1615,8 @@ func (s *Server) setupRouter() { r.Patch("/settings", s.handleUpdatePlatformSettings) r.Get("/decision-provider", s.handleGetDecisionSettings) r.Put("/decision-provider", s.handleUpdateDecisionSettings) + r.Get("/mcp", s.handleGetMCPSettings) + r.Put("/mcp", s.handleUpdateMCPSettings) r.Post("/test-email", s.handleTestEmail) // Cloud sidecar endpoints — only exist in cloud mode. requireCloudMode diff --git a/internal/store/mcp_resume.go b/internal/store/mcp_resume.go new file mode 100644 index 000000000..914c92896 --- /dev/null +++ b/internal/store/mcp_resume.go @@ -0,0 +1,58 @@ +package store + +import "fmt" + +// Counts for the console's MCP readiness panel (PLAN-2310 DR-7). Turning +// MCP off revokes nothing; these are what would start working again if it +// were turned back on. + +// CountLiveOAuthConnections counts grant chains with an active access or +// refresh token across the instance. A chain is a request_id, the unit the +// Connected Apps page lists (ListUserOAuthConnections walks both tables the +// same way), so the panel's number matches what users see there. +func (s *Store) CountLiveOAuthConnections() (int, error) { + var n int + err := s.db.QueryRow(s.q(` + SELECT COUNT(*) FROM ( + SELECT request_id FROM oauth_access_tokens WHERE active = ? + UNION + SELECT request_id FROM oauth_refresh_tokens WHERE active = ? + ) chains`), true, true).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count live oauth connections: %w", err) + } + return n, nil +} + +// CountMCPUsablePATs counts personal access tokens that /mcp would accept. +// It applies the same two rules the request path does, in the same way: +// MCPBearerAuth refuses a token with no user_id (a legacy workspace token, +// middleware_mcp_auth.go), and ValidateToken refuses one whose parsed +// expires_at is before now (api_tokens.go). Expiry is compared as a time +// here too, not as a string in SQL, so the two cannot disagree about a +// stored format. Revoking a token deletes its row. +func (s *Store) CountMCPUsablePATs() (int, error) { + rows, err := s.db.Query(s.q(` + SELECT expires_at FROM api_tokens + WHERE user_id IS NOT NULL AND user_id <> ''`)) + if err != nil { + return 0, fmt.Errorf("count mcp-usable personal access tokens: %w", err) + } + defer rows.Close() + cutoff := parseTime(now()) + n := 0 + for rows.Next() { + var expiresAt *string + if err := rows.Scan(&expiresAt); err != nil { + return 0, fmt.Errorf("count mcp-usable personal access tokens: %w", err) + } + if t := parseTimePtr(expiresAt); t != nil && t.Before(cutoff) { + continue + } + n++ + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("count mcp-usable personal access tokens: %w", err) + } + return n, nil +} diff --git a/internal/store/mcp_resume_test.go b/internal/store/mcp_resume_test.go new file mode 100644 index 000000000..ecda9430e --- /dev/null +++ b/internal/store/mcp_resume_test.go @@ -0,0 +1,75 @@ +package store + +import ( + "testing" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// PLAN-2310 DR-7 resume counts. A connection is a grant chain (request_id) +// with an active access OR refresh row, the unit the Connected Apps page +// lists. So one chain with several rows counts once, a chain that is only +// refresh-active counts, and a revoked chain does not. +func TestCountLiveOAuthConnections(t *testing.T) { + t.Parallel() + s := testStore(t) + client := seedClient(t, s, "resume-client") + user := seedUser(t, s, "resume@example.com") + now := time.Now().UTC() + + // Chain A: two access rows (a refresh rotation), counted once. + seedAccess(t, s, "chain-a", client, user, now, "{}", "pad:read", true) + seedAccess(t, s, "chain-a", client, user, now.Add(time.Minute), "{}", "pad:read", true) + // Chain B: revoked, not counted. + seedAccess(t, s, "chain-b", client, user, now, "{}", "pad:read", false) + // Chain C: only an active refresh row (its access token not yet + // minted or already expired and gone), counted. + if err := s.CreateRefreshToken(models.OAuthRequest{ + Signature: newID(), RequestID: "chain-c", RequestedAt: now, + ClientID: client, GrantedScopes: "pad:read", SessionData: "{}", Subject: user, + }); err != nil { + t.Fatalf("CreateRefreshToken: %v", err) + } + + n, err := s.CountLiveOAuthConnections() + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("live connections = %d, want 2 (chains a and c)", n) + } +} + +// A PAT counts when /mcp would accept it: it has a user_id and has not +// expired. A legacy workspace token (no user) and an expired user token do +// not count. +func TestCountMCPUsablePATs(t *testing.T) { + t.Parallel() + s := testStore(t) + u := createTestUser(t, s, "pat-owner@example.com", "PAT Owner", "s3cret") + + if _, err := s.CreateAPIToken(u.ID, models.APITokenCreate{Name: "live"}, 90, 0); err != nil { + t.Fatal(err) + } + expired, err := s.CreateAPIToken(u.ID, models.APITokenCreate{Name: "expired"}, 90, 0) + if err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(s.q(`UPDATE api_tokens SET expires_at = ? WHERE id = ?`), + time.Now().UTC().Add(-time.Hour).Format(time.RFC3339), expired.ID); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(s.q(`INSERT INTO api_tokens (id, workspace_id, user_id, name, token_hash, prefix, scopes, created_at) + VALUES (?, NULL, NULL, 'legacy', ?, 'pad_legacy', '["*"]', ?)`), newID(), newID(), now()); err != nil { + t.Fatal(err) + } + + n, err := s.CountMCPUsablePATs() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("usable PATs = %d, want 1 (live only)", n) + } +}