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 <origin>/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
This commit is contained in:
xarmian
2026-09-29 15:45:30 +00:00
parent ef5f7c3701
commit 7b000405c1
9 changed files with 819 additions and 0 deletions
+13
View File
@@ -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)
+11
View File
@@ -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"
+149
View File
@@ -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
}
+122
View File
@@ -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)
}
}
+200
View File
@@ -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)
}
+182
View File
@@ -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)
}
}
+9
View File
@@ -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
+58
View File
@@ -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
}
+75
View File
@@ -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)
}
}