New modules (22 tools): - Stars (3): list, create, delete - bookmarks - Pins (3): list, create, delete - highlighted docs - Views (2): list, create - view tracking - Reactions (3): list, create, delete - emoji on comments - API Keys (4): list, create, update, delete - Webhooks (4): list, create, update, delete - Backlinks (1): list - read-only view - Search Queries (2): list, stats - analytics Total tools: 86 -> 108 (+22) All 22 new tools validated against Outline v0.78 schema. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
277 lines
7.2 KiB
TypeScript
277 lines
7.2 KiB
TypeScript
/**
|
|
* 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<ApiKeyListArgs> = {
|
|
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<ToolResponse> => {
|
|
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<ApiKeyCreateArgs> = {
|
|
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<ToolResponse> => {
|
|
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<ApiKeyUpdateArgs> = {
|
|
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<ToolResponse> => {
|
|
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<ApiKeyDeleteArgs> = {
|
|
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<ToolResponse> => {
|
|
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<any>[] = [listApiKeys, createApiKey, updateApiKey, deleteApiKey];
|