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:
sandrodachandClaude Opus 5.5 committed 2026-11-01 21:11:53 +01:00
1 parent 39a04082fc
commit c6f28aa816
17 files changed
+197 -30

No files matched your search

+9
View File
@@ -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())
+2
View File
@@ -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() {
+8
View File
@@ -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)")
+67
View File
@@ -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)
}
}
+1 -1
View File
@@ -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, "/") }
+5
View File
@@ -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)