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
@@ -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
|
||||
|
||||
+4
-2
@@ -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 `<BASE_URL>/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 `<BASE_URL>/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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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_<hex>`, 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.
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -160,6 +160,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
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<AdminSettingsViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI agents over MCP (and claude.ai / ChatGPT over OAuth) */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">KI-Agenten (MCP) erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.mcpEnabled !== false}
|
||||
disabled={Boolean(settings.mcpConfiguredViaEnv)}
|
||||
onChange={(mcpEnabled) => handleSaveSystemSettings({ mcpEnabled })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Who may register without an invite */}
|
||||
{(() => {
|
||||
const mode = settings.registrationMode || 'closed';
|
||||
|
||||
@@ -37,6 +37,8 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
const [connectorUrl, setConnectorUrl] = useState('');
|
||||
const [copiedConnector, setCopiedConnector] = useState(false);
|
||||
const [access, setAccess] = useState<Access>('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<ApiKeysPanelProps> = ({ 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 */}
|
||||
<div className="pt-4 border-t border-border space-y-4">
|
||||
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
|
||||
{(
|
||||
[
|
||||
['rest', 'curl / Smart Home', Terminal],
|
||||
['mcp', 'KI-Agenten (MCP)', Bot],
|
||||
] as const
|
||||
).map(([value, label, Icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={access === value}
|
||||
onClick={() => setAccess(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
|
||||
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mcpEnabled && (
|
||||
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
|
||||
{(
|
||||
[
|
||||
['rest', 'curl / Smart Home', Terminal],
|
||||
['mcp', 'KI-Agenten (MCP)', Bot],
|
||||
] as const
|
||||
).map(([value, label, Icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={access === value}
|
||||
onClick={() => setAccess(value)}
|
||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
|
||||
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{access === 'rest' && (
|
||||
{(access === 'rest' || !mcpEnabled) && (
|
||||
<div className="space-y-5">
|
||||
{/* Reading: e.g. a smart home sensor for what is due today */}
|
||||
<div>
|
||||
@@ -547,7 +554,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
</div>
|
||||
)}
|
||||
|
||||
{access === 'mcp' && (
|
||||
{access === 'mcp' && mcpEnabled && (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||
|
||||
@@ -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(<ApiKeysPanel />);
|
||||
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(<ApiKeysPanel />);
|
||||
expect(await screen.findByText('Claude (OAuth)')).toBeTruthy();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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