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

+27
View File
@@ -1,5 +1,6 @@
import {
APIKey,
OAuthAuthorizeInfo,
AuthStatus,
DashboardStats,
Document,
@@ -1322,6 +1323,32 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
// --- OAUTH CONSENT (claude.ai, ChatGPT connect to MCP) ---
/** query: the search string of /oauth/authorize as the client sent it */
async getOAuthAuthorizeInfo(query: string): Promise<OAuthAuthorizeInfo> {
return request<OAuthAuthorizeInfo>(`/api/v1/oauth/authorize${query}`);
},
/** Allow (with the chosen rights) or refuse; returns where to send the browser */
async answerOAuthAuthorize(query: string, approve: boolean, scopes: ApiKeyScope[]): Promise<{ redirect: string }> {
const q = new URLSearchParams(query);
return request<{ redirect: string }>('/api/v1/oauth/authorize', {
method: 'POST',
body: JSON.stringify({
responseType: q.get('response_type') || '',
clientId: q.get('client_id') || '',
redirectUri: q.get('redirect_uri') || '',
codeChallenge: q.get('code_challenge') || '',
codeChallengeMethod: q.get('code_challenge_method') || '',
state: q.get('state') || '',
scope: q.get('scope') || '',
resource: q.get('resource') || '',
approve,
scopes,
}),
});
},
/** Simulator: sends like an external system would, with the given key or else the own session. */
async testWebhookIngest(
apiKey: string | null,
+20
View File
@@ -0,0 +1,20 @@
import { ApiKeyScope } from './types';
// 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.
export type ScopeGroup = 'tasks' | 'lists' | 'inbox';
export 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' },
];
export const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
export const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
+3 -2
View File
@@ -20,8 +20,9 @@ export const isOffline = () => typeof navigator !== 'undefined' && navigator.onL
const DB_NAME = 'mindsift-offline';
const STORE = 'responses';
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
// Endpoints whose answers are worth keeping; streams, long polls, note sync, downloads and the OAuth
// consent are not
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/, /^\/api\/v1\/oauth/];
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
+13
View File
@@ -17,6 +17,8 @@ export interface AuthStatus {
sharingMode?: SharingMode;
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
passwordReset?: 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
demoNextReset?: string;
demoResetInSeconds?: number;
@@ -674,6 +676,17 @@ export interface APIKey {
scopes: string; // comma separated ApiKeyScope values
lastUsedAt?: string;
createdAt: string;
// 'oauth': a connection agreed to on the consent page (claude.ai, ChatGPT), no secret to show
kind?: 'key' | 'oauth';
}
// What the consent page (/oauth/authorize) shows; redirect alone means "go back to the client"
export interface OAuthAuthorizeInfo {
redirect?: string;
clientName?: string;
redirectHost?: string;
scopes?: ApiKeyScope[];
offeredScopes?: ApiKeyScope[];
}
export interface DashboardStats {