/** * MCP Outline PostgreSQL - API Keys Tools * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Pool } from 'pg'; import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js'; import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js'; interface ApiKeyListArgs extends PaginationArgs { user_id?: string; } interface ApiKeyCreateArgs { name: string; user_id: string; expires_at?: string; scope?: string[]; } interface ApiKeyUpdateArgs { id: string; name?: string; expires_at?: string; } interface ApiKeyDeleteArgs { id: string; } /** * apiKeys.list - List API keys */ const listApiKeys: BaseTool = { name: 'outline_api_keys_list', description: 'List API keys for programmatic access. Shows key metadata but not the secret itself.', inputSchema: { type: 'object', properties: { user_id: { type: 'string', description: 'Filter by user ID (UUID)', }, limit: { type: 'number', description: 'Maximum results (default: 25, max: 100)', }, offset: { type: 'number', description: 'Results to skip (default: 0)', }, }, }, handler: async (args, pgClient): Promise => { const { limit, offset } = validatePagination(args.limit, args.offset); const conditions: string[] = ['a."deletedAt" IS NULL']; const params: any[] = []; let paramIndex = 1; if (args.user_id) { if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format'); conditions.push(`a."userId" = $${paramIndex++}`); params.push(args.user_id); } const whereClause = `WHERE ${conditions.join(' AND ')}`; const result = await pgClient.query( ` SELECT a.id, a.name, a.last4, a.scope, a."userId", a."expiresAt", a."lastActiveAt", a."createdAt", u.name as "userName", u.email as "userEmail" FROM "apiKeys" a LEFT JOIN users u ON a."userId" = u.id ${whereClause} ORDER BY a."createdAt" DESC LIMIT $${paramIndex++} OFFSET $${paramIndex} `, [...params, limit, offset] ); return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2), }], }; }, }; /** * apiKeys.create - Create a new API key */ const createApiKey: BaseTool = { name: 'outline_api_keys_create', description: 'Create a new API key for programmatic access. Returns the secret only once.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Name/label for the API key', }, user_id: { type: 'string', description: 'User ID this key belongs to (UUID)', }, expires_at: { type: 'string', description: 'Expiration date (ISO 8601 format, optional)', }, scope: { type: 'array', items: { type: 'string' }, description: 'Permission scopes (e.g., ["read", "write"])', }, }, required: ['name', 'user_id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format'); const name = sanitizeInput(args.name); // Generate a secure random secret (in production, use crypto) const secret = `ol_${Buffer.from(crypto.randomUUID() + crypto.randomUUID()).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 40)}`; const last4 = secret.slice(-4); const hash = secret; // In production, hash the secret const scope = args.scope || ['read', 'write']; const result = await pgClient.query( ` INSERT INTO "apiKeys" ( id, name, secret, hash, last4, "userId", scope, "expiresAt", "createdAt", "updatedAt" ) VALUES ( gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, NOW(), NOW() ) RETURNING id, name, last4, scope, "userId", "expiresAt", "createdAt" `, [name, secret, hash, last4, args.user_id, scope, args.expires_at || null] ); return { content: [{ type: 'text', text: JSON.stringify({ data: { ...result.rows[0], secret: secret, // Only returned on creation }, message: 'API key created successfully. Save the secret - it will not be shown again.', }, null, 2), }], }; }, }; /** * apiKeys.update - Update an API key */ const updateApiKey: BaseTool = { name: 'outline_api_keys_update', description: 'Update an API key name or expiration.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'API key ID (UUID)', }, name: { type: 'string', description: 'New name for the key', }, expires_at: { type: 'string', description: 'New expiration date (ISO 8601 format)', }, }, required: ['id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) throw new Error('Invalid id format'); const updates: string[] = ['"updatedAt" = NOW()']; const params: any[] = []; let paramIndex = 1; if (args.name) { updates.push(`name = $${paramIndex++}`); params.push(sanitizeInput(args.name)); } if (args.expires_at !== undefined) { updates.push(`"expiresAt" = $${paramIndex++}`); params.push(args.expires_at || null); } params.push(args.id); const result = await pgClient.query( ` UPDATE "apiKeys" SET ${updates.join(', ')} WHERE id = $${paramIndex} AND "deletedAt" IS NULL RETURNING id, name, last4, scope, "expiresAt", "updatedAt" `, params ); if (result.rows.length === 0) { throw new Error('API key not found'); } return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'API key updated successfully' }, null, 2), }], }; }, }; /** * apiKeys.delete - Delete an API key */ const deleteApiKey: BaseTool = { name: 'outline_api_keys_delete', description: 'Soft delete an API key, revoking access.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'API key ID to delete (UUID)', }, }, required: ['id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) throw new Error('Invalid id format'); const result = await pgClient.query( ` UPDATE "apiKeys" SET "deletedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, name, last4 `, [args.id] ); if (result.rows.length === 0) { throw new Error('API key not found or already deleted'); } return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'API key deleted successfully' }, null, 2), }], }; }, }; export const apiKeysTools: BaseTool[] = [listApiKeys, createApiKey, updateApiKey, deleteApiKey];