feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s

MindSift becomes the OAuth authorization server for its own MCP
endpoint: discovery (RFC 9728/8414), dynamic client registration,
authorization code with PKCE (S256), rotating refresh tokens with reuse
detection. The consent page lives in the app at /oauth/authorize. A
grant is an api_keys entry (kind 'oauth'), so scopes, origin badges,
renaming and revoking work as for keys; tokens are valid for /mcp only.
Unused connections expire after OAUTH_IDLE_DAYS (default 90). Requires
BASE_URL; off in demo mode. Release 0.51.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 20:01:32 +01:00
1 parent 994f6dc554
commit 75aea4a7d4
31 files changed
+1581 -49

No files matched your search

+63 -32
View File
@@ -12,6 +12,7 @@ import {
Bot,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS, scopeChip } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
@@ -19,27 +20,8 @@ interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
type McpClient = 'connector' | 'claude' | 'codex' | 'json';
type Access = 'rest' | 'mcp';
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
@@ -50,7 +32,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
const [mcpClient, setMcpClient] = useState<McpClient>('connector');
// Address for claude.ai / ChatGPT connectors; empty while the server has no OAuth (no BASE_URL)
const [connectorUrl, setConnectorUrl] = useState('');
const [copiedConnector, setCopiedConnector] = useState(false);
const [access, setAccess] = useState<Access>('rest');
// Live Webhook Simulator State
@@ -72,6 +57,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
api
.getAuthStatus()
.then((st) => setConnectorUrl(st.mcpConnectorUrl || ''))
.catch(() => {});
}, []);
// Editing an existing key: name and rights (the secret stays the same)
@@ -145,10 +134,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
const handleDeleteKey = async (k: APIKey) => {
if (!window.confirm(k.kind === 'oauth' ? `Die Verbindung „${k.name}“ wirklich trennen?` : 'Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(id);
await api.deleteAPIKey(k.id);
await loadKeys();
};
@@ -312,8 +301,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</div>
{renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)}
<p className="text-[11px] text-faint">
Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen
Rechte gelten ab sofort.
{k.kind === 'oauth'
? 'Die Verbindung bleibt bestehen; die neuen Rechte gelten ab sofort.'
: 'Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen Rechte gelten ab sofort.'}
</p>
{editError && <p className="text-[11px] text-status-err">{editError}</p>}
</div>
@@ -321,7 +311,13 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.kind === 'oauth' ? (
<span className="px-1.5 py-0.5 rounded-btn bg-accent/15 text-accent text-[10px] font-semibold" title="Verbunden über OAuth (claude.ai, ChatGPT …)">
OAuth
</span>
) : (
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
)}
{k.scopes
.split(',')
.filter(Boolean)
@@ -346,7 +342,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteKey(k.id)}
onClick={() => handleDeleteKey(k)}
className="btn-icon btn-icon-danger w-7 h-7"
aria-label={`API-Key ${k.name} löschen`}
>
@@ -561,7 +557,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten. claude.ai und ChatGPT verbinden sich ohne
Schlüssel: MindSift fragt beim Verbinden, was der Agent darf.
</p>
</div>
</div>
@@ -569,6 +566,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['connector', 'claude.ai / ChatGPT'],
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
@@ -588,7 +586,41 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
{mcpClient === 'connector' ? (
connectorUrl ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 p-3 bg-canvas border border-border font-mono text-xs text-main break-all">{connectorUrl}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(connectorUrl);
setCopiedConnector(true);
setTimeout(() => setCopiedConnector(false), 2000);
}}
className="btn-icon w-11 h-11 sm:w-9 sm:h-9 flex-shrink-0"
aria-label="Adresse kopieren"
title="Adresse kopieren"
>
{copiedConnector ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted">
In claude.ai unter <span className="text-main">Einstellungen → Connectors → Eigenen Connector hinzufügen</span>{' '}
diese Adresse eintragen (in ChatGPT entsprechend als Connector bzw. App). Beim Verbinden öffnet sich MindSift:
anmelden, Rechte wählen, erlauben. Die Verbindung erscheint oben in der Liste und lässt sich dort ändern oder
trennen; unbenutzt endet sie nach einiger Zeit von selbst.
</p>
</div>
) : (
<p className="p-3 bg-canvas border border-border text-xs text-muted">
Verbinden über claude.ai und ChatGPT braucht OAuth, und das setzt <code className="font-mono">BASE_URL</code> in
der Server-Konfiguration voraus. Bitte die Person, die MindSift betreibt, sie zu setzen.
</p>
)
) : (
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
)}
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
@@ -598,8 +630,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
</p>
</div>
)}