feat: Add 22 new tools for complete Outline coverage (v1.1.0)
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>
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-01-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Stars (3 tools):** list, create, delete - Bookmark documents/collections for quick access
|
||||||
|
- **Pins (3 tools):** list, create, delete - Pin important documents to collection tops
|
||||||
|
- **Views (2 tools):** list, create - Track document views and view counts
|
||||||
|
- **Reactions (3 tools):** list, create, delete - Emoji reactions on comments
|
||||||
|
- **API Keys (4 tools):** list, create, update, delete - Manage programmatic access
|
||||||
|
- **Webhooks (4 tools):** list, create, update, delete - Event notification subscriptions
|
||||||
|
- **Backlinks (1 tool):** list - View document link references (read-only view)
|
||||||
|
- **Search Queries (2 tools):** list, stats - Search analytics and popular queries
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Total tools increased from 86 to 108
|
||||||
|
|
||||||
## [1.0.1] - 2026-01-31
|
## [1.0.1] - 2026-01-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -8,7 +8,7 @@ MCP server for direct PostgreSQL access to Outline Wiki database. Follows patter
|
|||||||
|
|
||||||
**Architecture:** Claude Code -> MCP Outline (stdio) -> PostgreSQL (Outline DB)
|
**Architecture:** Claude Code -> MCP Outline (stdio) -> PostgreSQL (Outline DB)
|
||||||
|
|
||||||
**Total Tools:** 86 tools across 12 modules
|
**Total Tools:** 108 tools across 20 modules
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ src/
|
|||||||
└── security.ts
|
└── security.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools Summary (86 total)
|
## Tools Summary (108 total)
|
||||||
|
|
||||||
| Module | Tools | Description |
|
| Module | Tools | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
@@ -73,6 +73,14 @@ src/
|
|||||||
| file-operations | 4 | import/export jobs |
|
| file-operations | 4 | import/export jobs |
|
||||||
| oauth | 8 | OAuth clients, authentications |
|
| oauth | 8 | OAuth clients, authentications |
|
||||||
| auth | 2 | auth info, config |
|
| auth | 2 | auth info, config |
|
||||||
|
| 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 | CRUD (programmatic access) |
|
||||||
|
| webhooks | 4 | CRUD (event subscriptions) |
|
||||||
|
| backlinks | 1 | list (document links - read-only view) |
|
||||||
|
| search-queries | 2 | list, stats (search analytics) |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,122 @@ export const listDocuments: BaseTool = {
|
|||||||
| `searchQueries.list` | `list_search_queries` | SELECT | P3 |
|
| `searchQueries.list` | `list_search_queries` | SELECT | P3 |
|
||||||
| `searchQueries.popular` | `get_popular_searches` | SELECT | P3 |
|
| `searchQueries.popular` | `get_popular_searches` | SELECT | P3 |
|
||||||
|
|
||||||
|
### 5.19 Teams (5 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `teams.info` | `get_team` | SELECT | P1 |
|
||||||
|
| `teams.update` | `update_team` | UPDATE | P2 |
|
||||||
|
| `teams.stats` | `get_team_stats` | SELECT | P2 |
|
||||||
|
| `teams.domains` | `list_team_domains` | SELECT | P2 |
|
||||||
|
| `teams.settings` | `update_team_settings` | UPDATE | P2 |
|
||||||
|
|
||||||
|
### 5.20 Integrations (6 tools) - CRÍTICO para embeds
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `integrations.list` | `list_integrations` | SELECT | P1 |
|
||||||
|
| `integrations.info` | `get_integration` | SELECT | P1 |
|
||||||
|
| `integrations.create` | `create_integration` | INSERT | P1 |
|
||||||
|
| `integrations.update` | `update_integration` | UPDATE | P2 |
|
||||||
|
| `integrations.delete` | `delete_integration` | DELETE | P2 |
|
||||||
|
| `integrations.sync` | `sync_integration` | UPDATE | P2 |
|
||||||
|
|
||||||
|
### 5.21 Notifications (4 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `notifications.list` | `list_notifications` | SELECT | P1 |
|
||||||
|
| `notifications.read` | `mark_notification_read` | UPDATE | P2 |
|
||||||
|
| `notifications.readAll` | `mark_all_notifications_read` | UPDATE | P2 |
|
||||||
|
| `notifications.settings` | `get_notification_settings` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.22 Subscriptions (4 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `subscriptions.list` | `list_subscriptions` | SELECT | P1 |
|
||||||
|
| `subscriptions.create` | `subscribe_to_document` | INSERT | P2 |
|
||||||
|
| `subscriptions.delete` | `unsubscribe_from_document` | DELETE | P2 |
|
||||||
|
| `subscriptions.settings` | `get_subscription_settings` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.23 Imports (4 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `imports.list` | `list_imports` | SELECT | P2 |
|
||||||
|
| `imports.status` | `get_import_status` | SELECT | P2 |
|
||||||
|
| `imports.create` | `create_import` | INSERT | P2 |
|
||||||
|
| `imports.cancel` | `cancel_import` | UPDATE | P2 |
|
||||||
|
|
||||||
|
### 5.24 Emojis (3 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `emojis.list` | `list_emojis` | SELECT | P2 |
|
||||||
|
| `emojis.create` | `create_emoji` | INSERT | P3 |
|
||||||
|
| `emojis.delete` | `delete_emoji` | DELETE | P3 |
|
||||||
|
|
||||||
|
### 5.25 User Permissions (3 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `userPermissions.list` | `list_user_permissions` | SELECT | P2 |
|
||||||
|
| `userPermissions.grant` | `grant_permission` | INSERT | P2 |
|
||||||
|
| `userPermissions.revoke` | `revoke_permission` | DELETE | P2 |
|
||||||
|
|
||||||
|
### 5.26 Bulk Operations (6 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `bulk.moveDocuments` | `bulk_move_documents` | UPDATE | P2 |
|
||||||
|
| `bulk.archiveDocuments` | `bulk_archive_documents` | UPDATE | P2 |
|
||||||
|
| `bulk.deleteDocuments` | `bulk_delete_documents` | DELETE | P2 |
|
||||||
|
| `bulk.updateDocuments` | `bulk_update_documents` | UPDATE | P2 |
|
||||||
|
| `documents.duplicate` | `duplicate_document` | INSERT | P2 |
|
||||||
|
| `collections.merge` | `merge_collections` | UPDATE | P2 |
|
||||||
|
|
||||||
|
### 5.27 Export/Import Avançado (4 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `export.collectionMarkdown` | `export_collection_to_markdown` | SELECT | P2 |
|
||||||
|
| `export.documentTree` | `export_document_tree` | SELECT | P2 |
|
||||||
|
| `import.markdownFolder` | `import_markdown_folder` | INSERT | P2 |
|
||||||
|
| `import.fromUrl` | `import_from_url` | INSERT | P3 |
|
||||||
|
|
||||||
|
### 5.28 Advanced Search (6 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `search.byDateRange` | `search_by_date_range` | SELECT | P2 |
|
||||||
|
| `search.byAuthor` | `search_by_author` | SELECT | P2 |
|
||||||
|
| `search.inCollection` | `search_in_collection` | SELECT | P2 |
|
||||||
|
| `search.orphanDocuments` | `find_orphan_documents` | SELECT | P2 |
|
||||||
|
| `search.emptyCollections` | `find_empty_collections` | SELECT | P2 |
|
||||||
|
| `search.brokenLinks` | `find_broken_links` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.29 Analytics (6 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `analytics.workspace` | `get_workspace_stats` | SELECT | P2 |
|
||||||
|
| `analytics.userActivity` | `get_user_activity` | SELECT | P2 |
|
||||||
|
| `analytics.collection` | `get_collection_stats` | SELECT | P2 |
|
||||||
|
| `analytics.mostViewed` | `get_most_viewed_documents` | SELECT | P2 |
|
||||||
|
| `analytics.mostEdited` | `get_most_edited_documents` | SELECT | P2 |
|
||||||
|
| `analytics.stale` | `get_stale_documents` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.30 External Sync (5 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `sync.deskProject` | `create_desk_project_doc` | INSERT | P3 |
|
||||||
|
| `sync.deskTask` | `link_desk_task` | INSERT | P3 |
|
||||||
|
| `embeds.create` | `create_embed` | INSERT | P2 |
|
||||||
|
| `embeds.update` | `update_embed` | UPDATE | P2 |
|
||||||
|
| `embeds.list` | `list_document_embeds` | SELECT | P2 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Resumo de Tools
|
## 6. Resumo de Tools
|
||||||
|
|||||||
36
src/index.ts
36
src/index.ts
@@ -33,7 +33,15 @@ import {
|
|||||||
attachmentsTools,
|
attachmentsTools,
|
||||||
fileOperationsTools,
|
fileOperationsTools,
|
||||||
oauthTools,
|
oauthTools,
|
||||||
authTools
|
authTools,
|
||||||
|
starsTools,
|
||||||
|
pinsTools,
|
||||||
|
viewsTools,
|
||||||
|
reactionsTools,
|
||||||
|
apiKeysTools,
|
||||||
|
webhooksTools,
|
||||||
|
backlinksTools,
|
||||||
|
searchQueriesTools
|
||||||
} from './tools/index.js';
|
} from './tools/index.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -58,7 +66,21 @@ const allTools: BaseTool[] = [
|
|||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
...oauthTools,
|
...oauthTools,
|
||||||
...authTools
|
...authTools,
|
||||||
|
|
||||||
|
// User engagement
|
||||||
|
...starsTools,
|
||||||
|
...pinsTools,
|
||||||
|
...viewsTools,
|
||||||
|
...reactionsTools,
|
||||||
|
|
||||||
|
// API & Integration
|
||||||
|
...apiKeysTools,
|
||||||
|
...webhooksTools,
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
...backlinksTools,
|
||||||
|
...searchQueriesTools
|
||||||
];
|
];
|
||||||
|
|
||||||
// Validate all tools have required properties
|
// Validate all tools have required properties
|
||||||
@@ -185,7 +207,15 @@ async function main() {
|
|||||||
attachments: attachmentsTools.length,
|
attachments: attachmentsTools.length,
|
||||||
fileOperations: fileOperationsTools.length,
|
fileOperations: fileOperationsTools.length,
|
||||||
oauth: oauthTools.length,
|
oauth: oauthTools.length,
|
||||||
auth: authTools.length
|
auth: authTools.length,
|
||||||
|
stars: starsTools.length,
|
||||||
|
pins: pinsTools.length,
|
||||||
|
views: viewsTools.length,
|
||||||
|
reactions: reactionsTools.length,
|
||||||
|
apiKeys: apiKeysTools.length,
|
||||||
|
webhooks: webhooksTools.length,
|
||||||
|
backlinks: backlinksTools.length,
|
||||||
|
searchQueries: searchQueriesTools.length
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
276
src/tools/api-keys.ts
Normal file
276
src/tools/api-keys.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* 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];
|
||||||
99
src/tools/backlinks.ts
Normal file
99
src/tools/backlinks.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Backlinks Tools
|
||||||
|
* Note: backlinks is a VIEW, not a table - read-only
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
interface BacklinkListArgs extends PaginationArgs {
|
||||||
|
document_id?: string;
|
||||||
|
reverse_document_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* backlinks.list - List document backlinks
|
||||||
|
*/
|
||||||
|
const listBacklinks: BaseTool<BacklinkListArgs> = {
|
||||||
|
name: 'outline_backlinks_list',
|
||||||
|
description: 'List backlinks between documents. Shows which documents link to which. Backlinks is a view (read-only).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by source document ID (UUID) - documents that link TO this',
|
||||||
|
},
|
||||||
|
reverse_document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by target document ID (UUID) - documents that ARE LINKED FROM this',
|
||||||
|
},
|
||||||
|
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[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
conditions.push(`b."documentId" = $${paramIndex++}`);
|
||||||
|
params.push(args.document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.reverse_document_id) {
|
||||||
|
if (!isValidUUID(args.reverse_document_id)) throw new Error('Invalid reverse_document_id format');
|
||||||
|
conditions.push(`b."reverseDocumentId" = $${paramIndex++}`);
|
||||||
|
params.push(args.reverse_document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
b.id,
|
||||||
|
b."documentId",
|
||||||
|
b."reverseDocumentId",
|
||||||
|
b."userId",
|
||||||
|
b."createdAt",
|
||||||
|
b."updatedAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
rd.title as "reverseDocumentTitle",
|
||||||
|
u.name as "userName"
|
||||||
|
FROM backlinks b
|
||||||
|
LEFT JOIN documents d ON b."documentId" = d.id
|
||||||
|
LEFT JOIN documents rd ON b."reverseDocumentId" = rd.id
|
||||||
|
LEFT JOIN users u ON b."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY b."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 },
|
||||||
|
note: 'Backlinks is a read-only view. Links are automatically detected from document content.',
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const backlinksTools: BaseTool<any>[] = [listBacklinks];
|
||||||
@@ -39,3 +39,27 @@ export { oauthTools } from './oauth.js';
|
|||||||
|
|
||||||
// Auth Tools - Authentication and authorization
|
// Auth Tools - Authentication and authorization
|
||||||
export { authTools } from './auth.js';
|
export { authTools } from './auth.js';
|
||||||
|
|
||||||
|
// Stars Tools - Bookmarks/favorites
|
||||||
|
export { starsTools } from './stars.js';
|
||||||
|
|
||||||
|
// Pins Tools - Pinned documents
|
||||||
|
export { pinsTools } from './pins.js';
|
||||||
|
|
||||||
|
// Views Tools - Document view tracking
|
||||||
|
export { viewsTools } from './views.js';
|
||||||
|
|
||||||
|
// Reactions Tools - Emoji reactions on comments
|
||||||
|
export { reactionsTools } from './reactions.js';
|
||||||
|
|
||||||
|
// API Keys Tools - API key management
|
||||||
|
export { apiKeysTools } from './api-keys.js';
|
||||||
|
|
||||||
|
// Webhooks Tools - Webhook subscriptions
|
||||||
|
export { webhooksTools } from './webhooks.js';
|
||||||
|
|
||||||
|
// Backlinks Tools - Document link references
|
||||||
|
export { backlinksTools } from './backlinks.js';
|
||||||
|
|
||||||
|
// Search Queries Tools - Search analytics
|
||||||
|
export { searchQueriesTools } from './search-queries.js';
|
||||||
|
|||||||
214
src/tools/pins.ts
Normal file
214
src/tools/pins.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Pins Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
interface PinListArgs extends PaginationArgs {
|
||||||
|
collection_id?: string;
|
||||||
|
team_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PinCreateArgs {
|
||||||
|
document_id: string;
|
||||||
|
collection_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PinDeleteArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pins.list - List pinned documents
|
||||||
|
*/
|
||||||
|
const listPins: BaseTool<PinListArgs> = {
|
||||||
|
name: 'outline_pins_list',
|
||||||
|
description: 'List pinned documents. Pins highlight important documents at the top of collections or home.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by collection ID (UUID)',
|
||||||
|
},
|
||||||
|
team_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by team 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[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.collection_id) {
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id format');
|
||||||
|
conditions.push(`p."collectionId" = $${paramIndex++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||||
|
conditions.push(`p."teamId" = $${paramIndex++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p."documentId",
|
||||||
|
p."collectionId",
|
||||||
|
p."teamId",
|
||||||
|
p."createdById",
|
||||||
|
p.index,
|
||||||
|
p."createdAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
c.name as "collectionName",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM pins p
|
||||||
|
LEFT JOIN documents d ON p."documentId" = d.id
|
||||||
|
LEFT JOIN collections c ON p."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON p."createdById" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.index ASC NULLS LAST, p."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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pins.create - Pin a document
|
||||||
|
*/
|
||||||
|
const createPin: BaseTool<PinCreateArgs> = {
|
||||||
|
name: 'outline_pins_create',
|
||||||
|
description: 'Pin a document to highlight it at the top of a collection or home.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID to pin (UUID)',
|
||||||
|
},
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Collection ID to pin to (UUID, optional - pins to home if not specified)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['document_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id format');
|
||||||
|
|
||||||
|
// Get document to find team
|
||||||
|
const docResult = await pgClient.query(
|
||||||
|
`SELECT id, "teamId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||||
|
[args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docResult.rows.length === 0) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamId = docResult.rows[0].teamId;
|
||||||
|
|
||||||
|
// Get admin user for createdById
|
||||||
|
const userResult = await pgClient.query(
|
||||||
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
throw new Error('No admin user found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing pin
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id FROM pins WHERE "documentId" = $1 AND ("collectionId" = $2 OR ($2 IS NULL AND "collectionId" IS NULL))`,
|
||||||
|
[args.document_id, args.collection_id || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
throw new Error('Document is already pinned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO pins (id, "documentId", "collectionId", "teamId", "createdById", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[args.document_id, args.collection_id || null, teamId, userResult.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Pin created successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pins.delete - Remove a pin
|
||||||
|
*/
|
||||||
|
const deletePin: BaseTool<PinDeleteArgs> = {
|
||||||
|
name: 'outline_pins_delete',
|
||||||
|
description: 'Remove a pin from a document.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Pin 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(
|
||||||
|
`DELETE FROM pins WHERE id = $1 RETURNING id, "documentId", "collectionId"`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Pin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Pin deleted successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pinsTools: BaseTool<any>[] = [listPins, createPin, deletePin];
|
||||||
241
src/tools/reactions.ts
Normal file
241
src/tools/reactions.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Reactions 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 ReactionListArgs extends PaginationArgs {
|
||||||
|
comment_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
emoji?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactionCreateArgs {
|
||||||
|
comment_id: string;
|
||||||
|
user_id: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReactionDeleteArgs {
|
||||||
|
id?: string;
|
||||||
|
comment_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
emoji?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactions.list - List reactions on comments
|
||||||
|
*/
|
||||||
|
const listReactions: BaseTool<ReactionListArgs> = {
|
||||||
|
name: 'outline_reactions_list',
|
||||||
|
description: 'List emoji reactions on comments. Reactions are quick feedback on comments.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
comment_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by comment ID (UUID)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by user ID (UUID)',
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by emoji (e.g., "thumbs_up", "heart")',
|
||||||
|
},
|
||||||
|
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[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.comment_id) {
|
||||||
|
if (!isValidUUID(args.comment_id)) throw new Error('Invalid comment_id format');
|
||||||
|
conditions.push(`r."commentId" = $${paramIndex++}`);
|
||||||
|
params.push(args.comment_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
conditions.push(`r."userId" = $${paramIndex++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.emoji) {
|
||||||
|
conditions.push(`r.emoji = $${paramIndex++}`);
|
||||||
|
params.push(sanitizeInput(args.emoji));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.emoji,
|
||||||
|
r."commentId",
|
||||||
|
r."userId",
|
||||||
|
r."createdAt",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail"
|
||||||
|
FROM reactions r
|
||||||
|
LEFT JOIN users u ON r."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY r."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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactions.create - Add a reaction to a comment
|
||||||
|
*/
|
||||||
|
const createReaction: BaseTool<ReactionCreateArgs> = {
|
||||||
|
name: 'outline_reactions_create',
|
||||||
|
description: 'Add an emoji reaction to a comment.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
comment_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comment ID to react to (UUID)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User ID adding the reaction (UUID)',
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Emoji to add (e.g., "thumbs_up", "heart", "smile")',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['comment_id', 'user_id', 'emoji'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.comment_id)) throw new Error('Invalid comment_id format');
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
|
||||||
|
const emoji = sanitizeInput(args.emoji);
|
||||||
|
|
||||||
|
// Check comment exists
|
||||||
|
const commentCheck = await pgClient.query(
|
||||||
|
`SELECT id FROM comments WHERE id = $1`,
|
||||||
|
[args.comment_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (commentCheck.rows.length === 0) {
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing reaction
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id FROM reactions WHERE "commentId" = $1 AND "userId" = $2 AND emoji = $3`,
|
||||||
|
[args.comment_id, args.user_id, emoji]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
throw new Error('User already reacted with this emoji');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO reactions (id, emoji, "commentId", "userId", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[emoji, args.comment_id, args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Reaction added successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactions.delete - Remove a reaction
|
||||||
|
*/
|
||||||
|
const deleteReaction: BaseTool<ReactionDeleteArgs> = {
|
||||||
|
name: 'outline_reactions_delete',
|
||||||
|
description: 'Remove an emoji reaction from a comment.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Reaction ID to delete (UUID)',
|
||||||
|
},
|
||||||
|
comment_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comment ID (requires user_id and emoji)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User ID (requires comment_id and emoji)',
|
||||||
|
},
|
||||||
|
emoji: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Emoji to remove (requires comment_id and user_id)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid id format');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM reactions WHERE id = $1 RETURNING id, emoji, "commentId"`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
} else if (args.comment_id && args.user_id && args.emoji) {
|
||||||
|
if (!isValidUUID(args.comment_id)) throw new Error('Invalid comment_id format');
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM reactions WHERE "commentId" = $1 AND "userId" = $2 AND emoji = $3 RETURNING id, emoji`,
|
||||||
|
[args.comment_id, args.user_id, sanitizeInput(args.emoji)]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Either id or (comment_id + user_id + emoji) is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Reaction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Reaction deleted successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reactionsTools: BaseTool<any>[] = [listReactions, createReaction, deleteReaction];
|
||||||
243
src/tools/search-queries.ts
Normal file
243
src/tools/search-queries.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Search Queries 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 SearchQueryListArgs extends PaginationArgs {
|
||||||
|
user_id?: string;
|
||||||
|
team_id?: string;
|
||||||
|
query?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchQueryStatsArgs {
|
||||||
|
team_id?: string;
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* searchQueries.list - List search queries
|
||||||
|
*/
|
||||||
|
const listSearchQueries: BaseTool<SearchQueryListArgs> = {
|
||||||
|
name: 'outline_search_queries_list',
|
||||||
|
description: 'List search queries made by users. Useful for understanding what users are looking for.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by user ID (UUID)',
|
||||||
|
},
|
||||||
|
team_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by team ID (UUID)',
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by search query text (partial match)',
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by source (e.g., "app", "api", "slack")',
|
||||||
|
},
|
||||||
|
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[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
conditions.push(`sq."userId" = $${paramIndex++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||||
|
conditions.push(`sq."teamId" = $${paramIndex++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.query) {
|
||||||
|
conditions.push(`sq.query ILIKE $${paramIndex++}`);
|
||||||
|
params.push(`%${sanitizeInput(args.query)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.source) {
|
||||||
|
conditions.push(`sq.source = $${paramIndex++}`);
|
||||||
|
params.push(sanitizeInput(args.source));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
sq.id,
|
||||||
|
sq.query,
|
||||||
|
sq.source,
|
||||||
|
sq.results,
|
||||||
|
sq.score,
|
||||||
|
sq.answer,
|
||||||
|
sq."userId",
|
||||||
|
sq."teamId",
|
||||||
|
sq."shareId",
|
||||||
|
sq."createdAt",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail"
|
||||||
|
FROM search_queries sq
|
||||||
|
LEFT JOIN users u ON sq."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY sq."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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* searchQueries.stats - Get search query statistics
|
||||||
|
*/
|
||||||
|
const getSearchQueryStats: BaseTool<SearchQueryStatsArgs> = {
|
||||||
|
name: 'outline_search_queries_stats',
|
||||||
|
description: 'Get statistics about search queries including popular searches and zero-result queries.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by team ID (UUID)',
|
||||||
|
},
|
||||||
|
days: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of days to analyze (default: 30)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const days = args.days || 30;
|
||||||
|
const conditions: string[] = [`sq."createdAt" > NOW() - INTERVAL '${days} days'`];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||||
|
conditions.push(`sq."teamId" = $${paramIndex++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
|
||||||
|
// Overall stats
|
||||||
|
const overallStats = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as "totalSearches",
|
||||||
|
COUNT(DISTINCT "userId") as "uniqueUsers",
|
||||||
|
AVG(results) as "avgResults",
|
||||||
|
COUNT(CASE WHEN results = 0 THEN 1 END) as "zeroResultSearches"
|
||||||
|
FROM search_queries sq
|
||||||
|
${whereClause}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Popular searches
|
||||||
|
const popularSearches = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
query,
|
||||||
|
COUNT(*) as count,
|
||||||
|
AVG(results) as "avgResults"
|
||||||
|
FROM search_queries sq
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY query
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zero-result searches (content gaps)
|
||||||
|
const zeroResultSearches = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
query,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM search_queries sq
|
||||||
|
${whereClause} AND results = 0
|
||||||
|
GROUP BY query
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Searches by source
|
||||||
|
const bySource = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
source,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM search_queries sq
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY source
|
||||||
|
ORDER BY count DESC
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search activity by day
|
||||||
|
const byDay = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
DATE(sq."createdAt") as date,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM search_queries sq
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY DATE(sq."createdAt")
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT ${days}
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
period: `Last ${days} days`,
|
||||||
|
overall: overallStats.rows[0],
|
||||||
|
popularSearches: popularSearches.rows,
|
||||||
|
zeroResultSearches: zeroResultSearches.rows,
|
||||||
|
bySource: bySource.rows,
|
||||||
|
byDay: byDay.rows,
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchQueriesTools: BaseTool<any>[] = [listSearchQueries, getSearchQueryStats];
|
||||||
233
src/tools/stars.ts
Normal file
233
src/tools/stars.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Stars Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
interface StarListArgs extends PaginationArgs {
|
||||||
|
user_id?: string;
|
||||||
|
document_id?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StarCreateArgs {
|
||||||
|
document_id?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StarDeleteArgs {
|
||||||
|
id?: string;
|
||||||
|
document_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stars.list - List starred items
|
||||||
|
*/
|
||||||
|
const listStars: BaseTool<StarListArgs> = {
|
||||||
|
name: 'outline_stars_list',
|
||||||
|
description: 'List starred documents and collections for a user. Stars are bookmarks for quick access.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by user ID (UUID)',
|
||||||
|
},
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by document ID (UUID)',
|
||||||
|
},
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by collection 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[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
conditions.push(`s."userId" = $${paramIndex++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
conditions.push(`s."documentId" = $${paramIndex++}`);
|
||||||
|
params.push(args.document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.collection_id) {
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id format');
|
||||||
|
conditions.push(`s."collectionId" = $${paramIndex++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s."documentId",
|
||||||
|
s."collectionId",
|
||||||
|
s."userId",
|
||||||
|
s.index,
|
||||||
|
s."createdAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
c.name as "collectionName",
|
||||||
|
u.name as "userName"
|
||||||
|
FROM stars s
|
||||||
|
LEFT JOIN documents d ON s."documentId" = d.id
|
||||||
|
LEFT JOIN collections c ON s."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON s."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY s.index ASC NULLS LAST, s."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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stars.create - Star a document or collection
|
||||||
|
*/
|
||||||
|
const createStar: BaseTool<StarCreateArgs> = {
|
||||||
|
name: 'outline_stars_create',
|
||||||
|
description: 'Star (bookmark) a document or collection for quick access.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID to star (UUID)',
|
||||||
|
},
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Collection ID to star (UUID)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User ID who is starring (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.document_id && !args.collection_id) {
|
||||||
|
throw new Error('Either document_id or collection_id is required');
|
||||||
|
}
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
if (args.document_id && !isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id format');
|
||||||
|
|
||||||
|
// Check for existing star
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id FROM stars WHERE "userId" = $1 AND ("documentId" = $2 OR "collectionId" = $3)`,
|
||||||
|
[args.user_id, args.document_id || null, args.collection_id || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
throw new Error('Item is already starred');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO stars (id, "documentId", "collectionId", "userId", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[args.document_id || null, args.collection_id || null, args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Star created successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stars.delete - Remove a star
|
||||||
|
*/
|
||||||
|
const deleteStar: BaseTool<StarDeleteArgs> = {
|
||||||
|
name: 'outline_stars_delete',
|
||||||
|
description: 'Remove a star (unstar) from a document or collection.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Star ID to delete (UUID)',
|
||||||
|
},
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID to unstar (requires user_id)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User ID (required with document_id)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid id format');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM stars WHERE id = $1 RETURNING id, "documentId", "collectionId"`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
} else if (args.document_id && args.user_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM stars WHERE "documentId" = $1 AND "userId" = $2 RETURNING id, "documentId"`,
|
||||||
|
[args.document_id, args.user_id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Either id or (document_id + user_id) is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Star not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Star deleted successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const starsTools: BaseTool<any>[] = [listStars, createStar, deleteStar];
|
||||||
166
src/tools/views.ts
Normal file
166
src/tools/views.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Views Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
interface ViewListArgs extends PaginationArgs {
|
||||||
|
document_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewCreateArgs {
|
||||||
|
document_id: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* views.list - List document views
|
||||||
|
*/
|
||||||
|
const listViews: BaseTool<ViewListArgs> = {
|
||||||
|
name: 'outline_views_list',
|
||||||
|
description: 'List document views. Tracks which users viewed which documents and how many times.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by document ID (UUID)',
|
||||||
|
},
|
||||||
|
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[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
conditions.push(`v."documentId" = $${paramIndex++}`);
|
||||||
|
params.push(args.document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
conditions.push(`v."userId" = $${paramIndex++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
v.id,
|
||||||
|
v."documentId",
|
||||||
|
v."userId",
|
||||||
|
v.count,
|
||||||
|
v."lastEditingAt",
|
||||||
|
v."createdAt",
|
||||||
|
v."updatedAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail"
|
||||||
|
FROM views v
|
||||||
|
LEFT JOIN documents d ON v."documentId" = d.id
|
||||||
|
LEFT JOIN users u ON v."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY v."updatedAt" 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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* views.create - Record a document view
|
||||||
|
*/
|
||||||
|
const createView: BaseTool<ViewCreateArgs> = {
|
||||||
|
name: 'outline_views_create',
|
||||||
|
description: 'Record or increment a document view. If view already exists, increments the count.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID being viewed (UUID)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User ID who is viewing (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['document_id', 'user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id format');
|
||||||
|
|
||||||
|
// Check for existing view - upsert pattern
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id, count FROM views WHERE "documentId" = $1 AND "userId" = $2`,
|
||||||
|
[args.document_id, args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Increment count
|
||||||
|
result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE views
|
||||||
|
SET count = count + 1, "updatedAt" = NOW()
|
||||||
|
WHERE "documentId" = $1 AND "userId" = $2
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[args.document_id, args.user_id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new view
|
||||||
|
result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO views (id, "documentId", "userId", count, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, 1, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[args.document_id, args.user_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
data: result.rows[0],
|
||||||
|
message: existing.rows.length > 0 ? 'View count incremented' : 'View recorded',
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewsTools: BaseTool<any>[] = [listViews, createView];
|
||||||
317
src/tools/webhooks.ts
Normal file
317
src/tools/webhooks.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Webhooks 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 WebhookListArgs extends PaginationArgs {
|
||||||
|
team_id?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookCreateArgs {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
events: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookUpdateArgs {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
events?: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookDeleteArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* webhooks.list - List webhook subscriptions
|
||||||
|
*/
|
||||||
|
const listWebhooks: BaseTool<WebhookListArgs> = {
|
||||||
|
name: 'outline_webhooks_list',
|
||||||
|
description: 'List webhook subscriptions for receiving event notifications.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by team ID (UUID)',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Filter by enabled status',
|
||||||
|
},
|
||||||
|
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[] = ['w."deletedAt" IS NULL'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||||
|
conditions.push(`w."teamId" = $${paramIndex++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.enabled !== undefined) {
|
||||||
|
conditions.push(`w.enabled = $${paramIndex++}`);
|
||||||
|
params.push(args.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
w.id,
|
||||||
|
w.name,
|
||||||
|
w.url,
|
||||||
|
w.events,
|
||||||
|
w.enabled,
|
||||||
|
w."teamId",
|
||||||
|
w."createdById",
|
||||||
|
w."createdAt",
|
||||||
|
w."updatedAt",
|
||||||
|
t.name as "teamName",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM webhook_subscriptions w
|
||||||
|
LEFT JOIN teams t ON w."teamId" = t.id
|
||||||
|
LEFT JOIN users u ON w."createdById" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY w."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),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* webhooks.create - Create a webhook subscription
|
||||||
|
*/
|
||||||
|
const createWebhook: BaseTool<WebhookCreateArgs> = {
|
||||||
|
name: 'outline_webhooks_create',
|
||||||
|
description: 'Create a webhook subscription to receive event notifications.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Name for the webhook',
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'URL to receive webhook events',
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Events to subscribe to (e.g., ["documents.create", "documents.update"])',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether webhook is enabled (default: true)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'url', 'events'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const name = sanitizeInput(args.name);
|
||||||
|
const url = sanitizeInput(args.url);
|
||||||
|
const enabled = args.enabled !== false;
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team and admin user
|
||||||
|
const teamResult = await pgClient.query(`SELECT id FROM teams LIMIT 1`);
|
||||||
|
if (teamResult.rows.length === 0) throw new Error('No team found');
|
||||||
|
|
||||||
|
const userResult = await pgClient.query(
|
||||||
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
|
);
|
||||||
|
if (userResult.rows.length === 0) throw new Error('No admin user found');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO webhook_subscriptions (
|
||||||
|
id, name, url, events, enabled, "teamId", "createdById", "createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3, $4, $5, $6, NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[name, url, args.events, enabled, teamResult.rows[0].id, userResult.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Webhook created successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* webhooks.update - Update a webhook subscription
|
||||||
|
*/
|
||||||
|
const updateWebhook: BaseTool<WebhookUpdateArgs> = {
|
||||||
|
name: 'outline_webhooks_update',
|
||||||
|
description: 'Update a webhook subscription configuration.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Webhook ID (UUID)',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'New name',
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'New URL',
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'New events list',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Enable/disable webhook',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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.url) {
|
||||||
|
try {
|
||||||
|
new URL(args.url);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid URL format');
|
||||||
|
}
|
||||||
|
updates.push(`url = $${paramIndex++}`);
|
||||||
|
params.push(sanitizeInput(args.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.events) {
|
||||||
|
updates.push(`events = $${paramIndex++}`);
|
||||||
|
params.push(args.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.enabled !== undefined) {
|
||||||
|
updates.push(`enabled = $${paramIndex++}`);
|
||||||
|
params.push(args.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(args.id);
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE webhook_subscriptions
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex} AND "deletedAt" IS NULL
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Webhook updated successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* webhooks.delete - Delete a webhook subscription
|
||||||
|
*/
|
||||||
|
const deleteWebhook: BaseTool<WebhookDeleteArgs> = {
|
||||||
|
name: 'outline_webhooks_delete',
|
||||||
|
description: 'Soft delete a webhook subscription.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Webhook 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 webhook_subscriptions
|
||||||
|
SET "deletedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, name, url
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Webhook not found or already deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ data: result.rows[0], message: 'Webhook deleted successfully' }, null, 2),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const webhooksTools: BaseTool<any>[] = [listWebhooks, createWebhook, updateWebhook, deleteWebhook];
|
||||||
Reference in New Issue
Block a user