feat(mcp): operators can switch MCP (and OAuth) off
Admin switch in the system settings, MCP_ENABLED fixes it. Off: the endpoint answers 404, OAuth is off, the API key settings hide the MCP tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39a04082fc
commit
c6f28aa816
17 files changed
+197
-30
No files matched your search
@@ -345,6 +345,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
|
||||
out.RetentionAllowNever = &allow
|
||||
out.RetentionNeverViaEnv = true
|
||||
}
|
||||
if s.cfg.MCPEnabled != nil {
|
||||
on := *s.cfg.MCPEnabled
|
||||
out.MCPEnabled = &on
|
||||
out.MCPViaEnv = true
|
||||
}
|
||||
out.EmailLinksAvailable = s.emailLinksAvailable()
|
||||
return &out
|
||||
}
|
||||
@@ -381,6 +386,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
settings.RetentionAllowNever = nil // fixed by RETENTION_ALLOW_NEVER, the stored value stays
|
||||
}
|
||||
settings.RetentionNeverViaEnv = false
|
||||
if s.cfg.MCPEnabled != nil {
|
||||
settings.MCPEnabled = nil // fixed by MCP_ENABLED, the stored value stays
|
||||
}
|
||||
settings.MCPViaEnv = false
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -48,6 +48,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
"passwordReset": s.passwordResetAvailable(),
|
||||
// AI agents may connect over MCP (MCP_ENABLED or the admin's switch)
|
||||
"mcpEnabled": s.mcpEnabled(),
|
||||
// Address to enter as connector in claude.ai / ChatGPT (OAuth); empty while OAuth is off
|
||||
"mcpConnectorUrl": func() string {
|
||||
if s.oauthEnabled() {
|
||||
|
||||
@@ -190,6 +190,14 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleMCPNoStream: the endpoint is stateless and has no server-initiated stream (GET) or session to
|
||||
// end (DELETE).
|
||||
// mcpEnabled: MCP_ENABLED if set, else the admin's switch in the system settings (default: on).
|
||||
func (s *Server) mcpEnabled() bool {
|
||||
if s.cfg.MCPEnabled != nil {
|
||||
return *s.cfg.MCPEnabled
|
||||
}
|
||||
return s.store.MCPEnabledSetting()
|
||||
}
|
||||
|
||||
func handleMCPNoStream(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", "POST")
|
||||
writeError(w, http.StatusMethodNotAllowed, "Der MCP-Endpunkt nimmt nur POST an (kein Server-Stream)")
|
||||
|
||||
@@ -288,3 +288,70 @@ func TestMCPCreateDeleteAndOrigin(t *testing.T) {
|
||||
t.Fatalf("webhook:task key tools: %s", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Operators can switch MCP off: in the admin settings, or fixed by MCP_ENABLED. OAuth goes with it.
|
||||
func TestMCPSwitchOff(t *testing.T) {
|
||||
srv, _, session := setupTestServer(t)
|
||||
srv.cfg.BaseURL = "https://ms.example/"
|
||||
srv.cfg.BaseURLSet = true
|
||||
router := srv.Router()
|
||||
const list = `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||
status := func() map[string]interface{} {
|
||||
_, _, st := oauthDo(t, router, http.MethodGet, "/api/v1/auth/status", "", "", "")
|
||||
return st
|
||||
}
|
||||
|
||||
if code, _ := rpc(t, router, session, list); code != http.StatusOK {
|
||||
t.Fatalf("MCP should be on by default, got %d", code)
|
||||
}
|
||||
if st := status(); st["mcpEnabled"] != true || st["mcpConnectorUrl"] == "" {
|
||||
t.Fatalf("status with MCP on: %v", st)
|
||||
}
|
||||
|
||||
if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"mcpEnabled":false}`); code != http.StatusOK {
|
||||
t.Fatalf("switch off: %d", code)
|
||||
}
|
||||
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
|
||||
t.Fatalf("MCP switched off must answer 404, got %d", code)
|
||||
}
|
||||
code, h, _ := oauthDo(t, router, http.MethodPost, mcpPath, "", "application/json", list)
|
||||
if code != http.StatusNotFound || h.Get("WWW-Authenticate") != "" {
|
||||
t.Fatalf("no login hint while MCP is off: %d %q", code, h.Get("WWW-Authenticate"))
|
||||
}
|
||||
if code, _, _ := oauthDo(t, router, http.MethodGet, "/.well-known/oauth-authorization-server", "", "", ""); code != http.StatusNotFound {
|
||||
t.Fatalf("OAuth must be off with MCP, got %d", code)
|
||||
}
|
||||
if st := status(); st["mcpEnabled"] != false || st["mcpConnectorUrl"] != "" {
|
||||
t.Fatalf("status with MCP off: %v", st)
|
||||
}
|
||||
// Saving other settings (without the field) leaves the switch as it is
|
||||
if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"allowPrivateWebhooks":true}`); code != http.StatusOK {
|
||||
t.Fatalf("save other settings: %d", code)
|
||||
}
|
||||
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
|
||||
t.Fatalf("MCP must stay off, got %d", code)
|
||||
}
|
||||
|
||||
// MCP_ENABLED wins over the stored switch and locks it
|
||||
on := true
|
||||
srv.cfg.MCPEnabled = &on
|
||||
if code, _ := rpc(t, router, session, list); code != http.StatusOK {
|
||||
t.Fatalf("MCP_ENABLED=true must win, got %d", code)
|
||||
}
|
||||
_, _, adm := oauthDo(t, router, http.MethodGet, "/api/v1/admin/settings", session, "", "")
|
||||
if adm["mcpEnabled"] != true || adm["mcpConfiguredViaEnv"] != true {
|
||||
t.Fatalf("admin settings must show the environment's value: %v", adm)
|
||||
}
|
||||
off := false
|
||||
srv.cfg.MCPEnabled = &off
|
||||
if code, _, _ := oauthDo(t, router, http.MethodPut, "/api/v1/admin/settings", session, "application/json", `{"mcpEnabled":true}`); code != http.StatusOK {
|
||||
t.Fatalf("save: %d", code)
|
||||
}
|
||||
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
|
||||
t.Fatalf("MCP_ENABLED=false must win, got %d", code)
|
||||
}
|
||||
srv.cfg.MCPEnabled = nil
|
||||
if code, _ := rpc(t, router, session, list); code != http.StatusNotFound {
|
||||
t.Fatalf("the locked switch must not have changed the stored value, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ var oauthOfferedScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrit
|
||||
// Preselected when the client asks for nothing in particular (claude.ai usually does not)
|
||||
var oauthDefaultScopes = []string{storage.ScopeTasksRead, storage.ScopeTasksWrite, storage.ScopeListsRead, storage.ScopeListsWrite}
|
||||
|
||||
func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode }
|
||||
func (s *Server) oauthEnabled() bool { return s.cfg.BaseURLSet && !s.cfg.DemoMode && s.mcpEnabled() }
|
||||
|
||||
func (s *Server) oauthIssuer() string { return strings.TrimRight(s.cfg.BaseURL, "/") }
|
||||
|
||||
|
||||
@@ -536,6 +536,11 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||
} else {
|
||||
token = r.Header.Get("X-API-Key")
|
||||
}
|
||||
// MCP switched off: the endpoint answers as if it did not exist, with or without login
|
||||
if r.URL.Path == mcpPath && !s.mcpEnabled() {
|
||||
writeError(w, http.StatusNotFound, "MCP ist auf diesem Server ausgeschaltet")
|
||||
return
|
||||
}
|
||||
user, key := s.authenticateToken(token)
|
||||
if user == nil {
|
||||
// Tells MCP clients where to start the OAuth flow (RFC 9728)
|
||||
|
||||
@@ -60,6 +60,9 @@ type Config struct {
|
||||
// RetentionAllowNever: RETENTION_ALLOW_NEVER=true/false fixes whether people may keep finished and
|
||||
// deleted things for good; unset (nil) leaves it to the system settings
|
||||
RetentionAllowNever *bool
|
||||
// MCPEnabled: MCP_ENABLED=true/false fixes whether the MCP endpoint (and OAuth for connectors) is on;
|
||||
// unset (nil) leaves it to the system settings
|
||||
MCPEnabled *bool
|
||||
// Speech recognition, configured only by environment (STT_*)
|
||||
Speech SpeechConfig
|
||||
|
||||
@@ -261,6 +264,7 @@ func Load() *Config {
|
||||
DemoResetMinutes: demoResetMinutes(),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
RetentionAllowNever: envBoolPtr("RETENTION_ALLOW_NEVER"),
|
||||
MCPEnabled: envBoolPtr("MCP_ENABLED"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
OAuthIdleDays: envInt("OAUTH_IDLE_DAYS", 90, 0, 36500),
|
||||
SMTPHost: smtpHost,
|
||||
|
||||
@@ -418,6 +418,10 @@ type SystemSettings struct {
|
||||
// update leaves it unchanged. RetentionNeverViaEnv: fixed by RETENTION_ALLOW_NEVER, not editable.
|
||||
RetentionAllowNever *bool `json:"retentionAllowNever,omitempty"`
|
||||
RetentionNeverViaEnv bool `json:"retentionNeverViaEnv,omitempty"`
|
||||
// MCPEnabled: AI agents may connect over MCP (and claude.ai / ChatGPT over OAuth); nil in an update
|
||||
// leaves it unchanged. MCPViaEnv: fixed by MCP_ENABLED, not editable.
|
||||
MCPEnabled *bool `json:"mcpEnabled,omitempty"`
|
||||
MCPViaEnv bool `json:"mcpConfiguredViaEnv,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole
|
||||
|
||||
@@ -179,6 +179,9 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
case "retention_allow_never":
|
||||
allow := v != "false"
|
||||
settings.RetentionAllowNever = &allow
|
||||
case "mcp_enabled":
|
||||
on := v != "false"
|
||||
settings.MCPEnabled = &on
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +197,10 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
allow := true
|
||||
settings.RetentionAllowNever = &allow
|
||||
}
|
||||
if settings.MCPEnabled == nil {
|
||||
on := true
|
||||
settings.MCPEnabled = &on
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
@@ -280,5 +287,16 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
_, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('retention_allow_never', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.RetentionAllowNever))
|
||||
}
|
||||
if err == nil && settings.MCPEnabled != nil {
|
||||
_, err = s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('mcp_enabled', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.FormatBool(*settings.MCPEnabled))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MCPEnabledSetting: the admin's switch for the MCP endpoint in the system settings (default: on).
|
||||
func (s *Store) MCPEnabledSetting() bool {
|
||||
var v string
|
||||
_ = s.db.QueryRow(`SELECT value FROM system_settings WHERE key = 'mcp_enabled'`).Scan(&v)
|
||||
return v != "false"
|
||||
}
|
||||
Reference in new issue
Block a user