230 lines
8.0 KiB
TypeScript
230 lines
8.0 KiB
TypeScript
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<typeof createRegistry>;
|