mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user