import type Database from 'better-sqlite3'; import { encrypt, decrypt, loadKeyFromEnv } from './crypto.js'; import type { AuthKind, McpServerPublic, McpServerRecord, McpDiscoveryMetadata, } from './types.js'; import { logger } from '../logger.js'; const ID_REGEX = /^[a-z0-9_-]{1,64}$/; // RFC 7230 header field-name token chars. Guards against header injection // (CR/LF, colon, spaces) when a custom auth header name is supplied. const HEADER_NAME_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; export interface UpsertInput { id: string; name: string; url: string; authKind: AuthKind; ownerId: string | null; // null = global/admin-managed // OAuth fields (required when authKind === 'oauth') oauthClientId?: string; oauthClientSecret?: string; oauthScopes?: string | null; // API key field (required when authKind === 'api_key') staticToken?: string; // Optional custom auth header name for api_key servers (e.g. 'xc-mcp-token'). authHeaderName?: string | null; enabled?: boolean; createdBy?: string | null; } interface Row { id: string; name: string; url: string; oauth_client_id: string; oauth_client_secret_enc: Buffer; oauth_scopes: string | null; issuer: string | null; authorization_endpoint: string | null; token_endpoint: string | null; discovery_fingerprint: string | null; enabled: number; created_by: string | null; created_at: string; updated_at: string; // Phase 8 columns auth_kind: string; static_token_enc: Buffer | null; owner_id: string | null; auth_header_name: string | null; } function rowToPublic(r: Row): McpServerPublic { return { id: r.id, name: r.name, url: r.url, authKind: (r.auth_kind ?? 'oauth') as AuthKind, ownerId: r.owner_id ?? null, authHeaderName: r.auth_header_name ?? null, oauthClientId: r.oauth_client_id, oauthScopes: r.oauth_scopes, issuer: r.issuer, authorizationEndpoint: r.authorization_endpoint, tokenEndpoint: r.token_endpoint, discoveryFingerprint: r.discovery_fingerprint, enabled: r.enabled === 1, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, }; } export function createRegistry(db: Database.Database) { return { upsert(input: UpsertInput): void { if (!ID_REGEX.test(input.id)) { throw new Error(`id must match ${ID_REGEX}: got "${input.id}"`); } const key = loadKeyFromEnv(); const authKind = input.authKind; // Validate per-kind requirements if (authKind === 'oauth') { if (!input.oauthClientId || !input.oauthClientSecret) { throw new Error(`authKind 'oauth' requires oauthClientId and oauthClientSecret`); } } else if (authKind === 'api_key') { if (!input.staticToken) { throw new Error(`authKind 'api_key' requires staticToken`); } } else { throw new Error(`Unknown authKind: ${authKind as string}`); } // Optional custom auth header name (api_key only). Validated against the // RFC 7230 token grammar to prevent header injection. const authHeaderName = input.authHeaderName?.trim() || null; if (authHeaderName !== null) { if (authKind !== 'api_key') { throw new Error(`authHeaderName is only valid for authKind 'api_key'`); } if (!HEADER_NAME_REGEX.test(authHeaderName)) { throw new Error(`authHeaderName must be a valid HTTP header token: got "${authHeaderName}"`); } } // For oauth: encrypt the real secret; static_token_enc stays NULL. // For api_key: oauth_client_id = '' and oauth_client_secret_enc = encrypt('', key) // because those columns are NOT NULL in the original schema. They are never read // for api_key servers. static_token_enc = encrypt(realToken, key). const oauthClientId = authKind === 'oauth' ? input.oauthClientId! : ''; const oauthSecretEnc = encrypt(authKind === 'oauth' ? input.oauthClientSecret! : '', key); const staticTokenEnc = authKind === 'api_key' ? encrypt(input.staticToken!, key) : null; db.prepare( `INSERT INTO mcp_servers (id, name, url, oauth_client_id, oauth_client_secret_enc, oauth_scopes, auth_kind, owner_id, static_token_enc, auth_header_name, enabled, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, url=excluded.url, oauth_client_id=excluded.oauth_client_id, oauth_client_secret_enc=excluded.oauth_client_secret_enc, oauth_scopes=excluded.oauth_scopes, auth_kind=excluded.auth_kind, owner_id=excluded.owner_id, static_token_enc=excluded.static_token_enc, auth_header_name=excluded.auth_header_name, enabled=excluded.enabled, updated_at=datetime('now')`, ).run( input.id, input.name, input.url, oauthClientId, oauthSecretEnc, input.oauthScopes ?? null, authKind, input.ownerId ?? null, staticTokenEnc, authHeaderName, input.enabled === false ? 0 : 1, input.createdBy ?? null, ); logger.info(`[mcp:registry] upsert server id=${input.id} url=${input.url} authKind=${authKind}`); }, delete(id: string): void { db.prepare('DELETE FROM mcp_servers WHERE id = ?').run(id); logger.info(`[mcp:registry] delete server id=${id}`); }, listPublic(): McpServerPublic[] { const rows = db.prepare('SELECT * FROM mcp_servers ORDER BY id').all() as Row[]; return rows.map(rowToPublic); }, /** * Returns all enabled servers visible to the given user: * - Global (owner_id IS NULL) servers are visible to everyone. * - User-owned (owner_id = userId) servers are only visible to that user. */ listEnabledForUser(userId: string): McpServerPublic[] { const rows = db .prepare('SELECT * FROM mcp_servers WHERE enabled = 1 AND (owner_id IS NULL OR owner_id = ?) ORDER BY id') .all(userId) as Row[]; return rows.map(rowToPublic); }, /** * Returns only servers owned by the given user (for owner/admin scoped listing). */ listEnabledForOwner(ownerId: string): McpServerPublic[] { const rows = db .prepare('SELECT * FROM mcp_servers WHERE enabled = 1 AND owner_id = ? ORDER BY id') .all(ownerId) as Row[]; return rows.map(rowToPublic); }, /** @deprecated Use listEnabledForUser(userId) to include user-owned servers. */ listEnabledPublic(): McpServerPublic[] { const rows = db .prepare('SELECT * FROM mcp_servers WHERE enabled = 1 ORDER BY id') .all() as Row[]; return rows.map(rowToPublic); }, getDecrypted(id: string): McpServerRecord | null { const row = db.prepare('SELECT * FROM mcp_servers WHERE id = ?').get(id) as | Row | undefined; if (!row) return null; const key = loadKeyFromEnv(); const authKind = (row.auth_kind ?? 'oauth') as AuthKind; const oauthClientSecret = authKind === 'oauth' ? decrypt(row.oauth_client_secret_enc, key) : ''; // not used for api_key servers const staticToken = (authKind === 'api_key' && row.static_token_enc) ? decrypt(row.static_token_enc, key) : null; return { ...rowToPublic(row), oauthClientSecret, staticToken, }; }, setDiscovery(id: string, meta: McpDiscoveryMetadata): void { db.prepare( `UPDATE mcp_servers SET issuer=?, authorization_endpoint=?, token_endpoint=?, discovery_fingerprint=?, updated_at=datetime('now') WHERE id=?`, ).run(meta.issuer, meta.authorizationEndpoint, meta.tokenEndpoint, meta.fingerprint, id); }, setEnabled(id: string, enabled: boolean): void { db.prepare( `UPDATE mcp_servers SET enabled=?, updated_at=datetime('now') WHERE id=?`, ).run(enabled ? 1 : 0, id); }, }; } export type McpRegistry = ReturnType;