diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cfd1f..939dd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Administration → Server & Isolation: Schalter „KI-Agenten (MCP) erlauben“. Ausgeschaltet ist der MCP-Endpunkt abgeschaltet, ebenso das Verbinden über claude.ai und ChatGPT; bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. In den API-Schlüssel-Einstellungen verschwindet dann der Reiter „KI-Agenten (MCP)“. Mit `MCP_ENABLED=false` bzw. `true` legt der Betreiber das fest, der Schalter ist dann gesperrt. + ## [0.52.0] - 2026-11-01 ### Added diff --git a/Doku/api/rest.md b/Doku/api/rest.md index ecd9709..736e6b7 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,7 +7,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`) | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`), `mcpEnabled`, `mcpConnectorUrl` | | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | @@ -83,6 +83,8 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus. MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text. +Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Administration, Feld `mcpEnabled` in `/admin/settings`). Dann antwortet der Endpunkt mit `404`, OAuth ist ebenfalls aus, und `GET /auth/status` liefert `mcpEnabled: false`. + | Werkzeug | Scope | Zweck | |---|---|---| | `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission`, offenen Aufgaben und Board-Spalten (`columns`: gültige Werte für `kanbanStatus`) | @@ -108,7 +110,7 @@ Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht änd ### OAuth für claude.ai und ChatGPT -Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL` und im Demo-Modus antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist). +Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL`, im Demo-Modus und bei abgeschaltetem MCP antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist). | Methode | Pfad (ohne `/api/v1`) | Zweck | |---|---|---| diff --git a/Doku/architecture.md b/Doku/architecture.md index c5b3b8f..8e79d89 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -78,6 +78,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB | | `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) | | `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB | +| `MCP_ENABLED` | leer | `false` schaltet `/api/v1/mcp` ab (antwortet `404`, auch ohne Anmeldung) und damit OAuth; `true` lässt es fest an. Leer: Schalter „KI-Agenten (MCP) erlauben“ in Administration → Server & Isolation (Standard: an) | | `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Server & Isolation (Standard: erlaubt) | | `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser | | `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt | @@ -101,7 +102,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in - **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse. - **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`; Notizen sind mit Keys nie erreichbar. -- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus. +- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus, ebenso wenn MCP abgeschaltet ist (`MCP_ENABLED` bzw. Admin-Schalter). - **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile. - **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator. diff --git a/README.md b/README.md index 06e7182..af3db33 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert | | `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut | | `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) | +| `MCP_ENABLED` | leer (Admin-UI entscheidet) | `false` schaltet den MCP-Endpunkt für KI-Agenten ab, damit auch die Connector-Anmeldung (OAuth) für claude.ai und ChatGPT; `true` lässt ihn fest an. API-Schlüssel funktionieren per curl weiter | | `OAUTH_IDLE_DAYS` | `90` | Verbindungen von claude.ai, ChatGPT & Co. (OAuth), die so viele Tage nicht benutzt wurden, werden getrennt. `0` = nie. OAuth selbst gibt es nur mit `BASE_URL` | | `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) | diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index d7cec05..881d1ae 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -160,6 +160,8 @@ export const AdminSettingsView: React.FC = ({ backupRetentionDays: sys.backupRetentionDays || 3, retentionAllowNever: sys.retentionAllowNever ?? true, retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv), + mcpEnabled: sys.mcpEnabled ?? true, + mcpConfiguredViaEnv: Boolean(sys.mcpConfiguredViaEnv), }); } } catch (err: any) { @@ -1334,6 +1336,24 @@ export const AdminSettingsView: React.FC = ({ /> + {/* AI agents over MCP (and claude.ai / ChatGPT over OAuth) */} +
+
+

KI-Agenten (MCP) erlauben

+

+ Eingeschaltet können Claude Code, Codex & Co. per API-Schlüssel und claude.ai oder ChatGPT als Connector + auf MindSift zugreifen. Ausgeschaltet ist der MCP-Endpunkt samt Connector-Anmeldung abgeschaltet; + bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter. + {settings.mcpConfiguredViaEnv && ' Festgelegt per MCP_ENABLED.'} +

+
+ handleSaveSystemSettings({ mcpEnabled })} + /> +
+ {/* Who may register without an invite */} {(() => { const mode = settings.registrationMode || 'closed'; diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index a9d7640..6d735a0 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -37,6 +37,8 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { const [connectorUrl, setConnectorUrl] = useState(''); const [copiedConnector, setCopiedConnector] = useState(false); const [access, setAccess] = useState('rest'); + // The operator can switch MCP off; then only the curl / smart home access is shown + const [mcpEnabled, setMcpEnabled] = useState(true); // Live Webhook Simulator State const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); @@ -59,7 +61,10 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { api.getTaskLists().then(setTaskLists).catch(() => {}); api .getAuthStatus() - .then((st) => setConnectorUrl(st.mcpConnectorUrl || '')) + .then((st) => { + setConnectorUrl(st.mcpConnectorUrl || ''); + setMcpEnabled(st.mcpEnabled !== false); + }) .catch(() => {}); }, []); @@ -358,30 +363,32 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` {/* How to connect: one API, two accesses */}
-
- {( - [ - ['rest', 'curl / Smart Home', Terminal], - ['mcp', 'KI-Agenten (MCP)', Bot], - ] as const - ).map(([value, label, Icon]) => ( - - ))} -
+ {mcpEnabled && ( +
+ {( + [ + ['rest', 'curl / Smart Home', Terminal], + ['mcp', 'KI-Agenten (MCP)', Bot], + ] as const + ).map(([value, label, Icon]) => ( + + ))} +
+ )} - {access === 'rest' && ( + {(access === 'rest' || !mcpEnabled) && (
{/* Reading: e.g. a smart home sensor for what is due today */}
@@ -547,7 +554,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
)} - {access === 'mcp' && ( + {access === 'mcp' && mcpEnabled && (
diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index 9ebc041..a34d08c 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -3,13 +3,16 @@ import { render, screen, fireEvent, act, within } from '@testing-library/react'; const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' })); const updateAPIKey = vi.fn(async () => ({})); +const getAuthStatus = vi.fn(async (): Promise<{ mcpConnectorUrl?: string; mcpEnabled?: boolean }> => ({ + mcpConnectorUrl: 'https://ms.example/api/v1/mcp', +})); vi.mock('../../../lib/api', () => ({ api: { getAPIKeys: vi.fn(async () => [ { id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' }, { id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' }, ]), - getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })), + getAuthStatus: (...args: unknown[]) => getAuthStatus(...(args as [])), getAllLists: vi.fn(async () => []), getTaskLists: vi.fn(async () => []), createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])), @@ -59,6 +62,14 @@ describe('ApiKeysPanel', () => { expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy(); }); + it('hides the MCP tab when the operator switched MCP off', async () => { + getAuthStatus.mockResolvedValueOnce({ mcpConnectorUrl: '', mcpEnabled: false }); + render(); + await act(async () => {}); + expect(screen.queryByRole('tab', { name: /KI-Agenten/ })).toBeNull(); + expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0); + }); + it('marks OAuth connections instead of showing a key prefix', async () => { render(); expect(await screen.findByText('Claude (OAuth)')).toBeTruthy(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4b9799e..9976d8c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -17,6 +17,8 @@ export interface AuthStatus { sharingMode?: SharingMode; // "Passwort vergessen?" works (SMTP and BASE_URL configured) passwordReset?: boolean; + // AI agents may connect over MCP (the operator can switch it off) + mcpEnabled?: boolean; // MCP address for claude.ai / ChatGPT connectors (OAuth); empty when the server has no BASE_URL mcpConnectorUrl?: string; // Demo mode with periodic reset: next reset (ISO) and seconds until then @@ -301,6 +303,9 @@ export interface SystemSettings { // People may keep finished and deleted things for good ("nie"); fixed when set via RETENTION_ALLOW_NEVER retentionAllowNever?: boolean; retentionNeverViaEnv?: boolean; + // AI agents may connect over MCP (and claude.ai / ChatGPT over OAuth); fixed when set via MCP_ENABLED + mcpEnabled?: boolean; + mcpConfiguredViaEnv?: boolean; } /** One file of a server backup listing. */ diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index b0c0068..a483909 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -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()) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index acb9295..145e404 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -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() { diff --git a/internal/api/mcp.go b/internal/api/mcp.go index aeb8394..ae5298e 100644 --- a/internal/api/mcp.go +++ b/internal/api/mcp.go @@ -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)") diff --git a/internal/api/mcp_test.go b/internal/api/mcp_test.go index 09b2e3f..9c14810 100644 --- a/internal/api/mcp_test.go +++ b/internal/api/mcp_test.go @@ -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) + } +} diff --git a/internal/api/oauth.go b/internal/api/oauth.go index 202cb13..aa5f00e 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -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, "/") } diff --git a/internal/api/server.go b/internal/api/server.go index 2421874..6b1c087 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 4335aeb..c720b8a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, diff --git a/internal/domain/models.go b/internal/domain/models.go index 031d7cf..3c90c32 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -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 diff --git a/internal/storage/system.go b/internal/storage/system.go index b44b3d0..ac15fe0 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -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" +}