Compare commits
5 Commits
42fc0c6d6d
...
83b70f557e
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b70f557e | |||
| fa0e052620 | |||
| 9213970d44 | |||
| 7116722d73 | |||
| 6f5d17516b |
61
CHANGELOG.md
61
CHANGELOG.md
@@ -2,6 +2,67 @@
|
|||||||
|
|
||||||
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.2.0] - 2026-01-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Teams (5 tools):** get, update, stats, domains, settings - Team/workspace management
|
||||||
|
- **Integrations (6 tools):** list, get, create, update, delete, sync - External integrations (Slack, embeds)
|
||||||
|
- **Notifications (4 tools):** list, mark read, mark all read, settings - User notification management
|
||||||
|
- **Subscriptions (4 tools):** list, subscribe, unsubscribe, settings - Document subscription management
|
||||||
|
- **Templates (5 tools):** list, get, create from, convert to/from - Document template management
|
||||||
|
- **Imports (4 tools):** list, status, create, cancel - Import job management
|
||||||
|
- **Emojis (3 tools):** list, create, delete - Custom emoji management
|
||||||
|
- **User Permissions (3 tools):** list, grant, revoke - Document/collection permission management
|
||||||
|
- **Bulk Operations (6 tools):** archive, delete, move, restore documents; add/remove users from collection
|
||||||
|
- **Advanced Search (6 tools):** advanced search, facets, recent, user activity, orphaned, duplicates
|
||||||
|
- **Analytics (6 tools):** overview, user activity, content insights, collection stats, growth metrics, search analytics
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Total tools increased from 108 to 160
|
||||||
|
- Updated module exports and index files
|
||||||
|
- Improved database schema compatibility
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Users:** Adapted to Outline schema - use `role` enum instead of `isAdmin`/`isViewer`/`isSuspended` booleans
|
||||||
|
- **Users:** Removed non-existent `username` column
|
||||||
|
- **Groups:** Fixed `group_users` table queries - no `deletedAt` column, composite PK
|
||||||
|
- **Groups:** Fixed ambiguous column references in subqueries
|
||||||
|
- **Attachments:** Removed non-existent `url` and `deletedAt` columns
|
||||||
|
- **Attachments:** Changed delete to hard delete (no soft delete support)
|
||||||
|
- **Auth:** Use `suspendedAt IS NOT NULL` for suspended count, return `role` instead of `isAdmin`
|
||||||
|
- **Comments:** Use `role='admin'` for admin user queries
|
||||||
|
- **Documents:** Use `suspendedAt IS NULL` for active user checks
|
||||||
|
- **Events:** Return `actorRole` instead of `actorIsAdmin`
|
||||||
|
- **Shares:** Use `role='admin'` for admin user queries
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Users suspend/activate now use `suspendedAt` column instead of boolean
|
||||||
|
- Groups member count uses correct join without deletedAt filter
|
||||||
|
- All modules validated against Outline v0.78 PostgreSQL schema
|
||||||
|
|
||||||
## [1.0.0] - 2026-01-31
|
## [1.0.0] - 2026-01-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
66
CLAUDE.md
66
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:** 160 tools across 31 modules
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -40,24 +40,43 @@ src/
|
|||||||
│ └── db.ts # Database table types
|
│ └── db.ts # Database table types
|
||||||
├── tools/
|
├── tools/
|
||||||
│ ├── index.ts # Export all tools
|
│ ├── index.ts # Export all tools
|
||||||
│ ├── documents.ts # 19 tools
|
│ ├── documents.ts # 19 tools - Core document management
|
||||||
│ ├── collections.ts # 14 tools
|
│ ├── collections.ts # 14 tools - Collection management
|
||||||
│ ├── users.ts # 9 tools
|
│ ├── users.ts # 9 tools - User management
|
||||||
│ ├── groups.ts # 8 tools
|
│ ├── groups.ts # 8 tools - Group management
|
||||||
│ ├── comments.ts # 6 tools
|
│ ├── comments.ts # 6 tools - Comment system
|
||||||
│ ├── shares.ts # 5 tools
|
│ ├── shares.ts # 5 tools - Document sharing
|
||||||
│ ├── revisions.ts # 3 tools
|
│ ├── revisions.ts # 3 tools - Version history
|
||||||
│ ├── events.ts # 3 tools
|
│ ├── events.ts # 3 tools - Audit log
|
||||||
│ ├── attachments.ts # 5 tools
|
│ ├── attachments.ts # 5 tools - File attachments
|
||||||
│ ├── file-operations.ts # 4 tools
|
│ ├── file-operations.ts # 4 tools - Import/export jobs
|
||||||
│ ├── oauth.ts # 8 tools
|
│ ├── oauth.ts # 8 tools - OAuth management
|
||||||
│ └── auth.ts # 2 tools
|
│ ├── auth.ts # 2 tools - Authentication
|
||||||
|
│ ├── stars.ts # 3 tools - Bookmarks
|
||||||
|
│ ├── pins.ts # 3 tools - Pinned documents
|
||||||
|
│ ├── views.ts # 2 tools - View tracking
|
||||||
|
│ ├── reactions.ts # 3 tools - Emoji reactions
|
||||||
|
│ ├── api-keys.ts # 4 tools - API keys
|
||||||
|
│ ├── webhooks.ts # 4 tools - Webhooks
|
||||||
|
│ ├── backlinks.ts # 1 tool - Link references
|
||||||
|
│ ├── search-queries.ts # 2 tools - Search analytics
|
||||||
|
│ ├── teams.ts # 5 tools - Team/workspace
|
||||||
|
│ ├── integrations.ts # 6 tools - External integrations
|
||||||
|
│ ├── notifications.ts # 4 tools - Notifications
|
||||||
|
│ ├── subscriptions.ts # 4 tools - Subscriptions
|
||||||
|
│ ├── templates.ts # 5 tools - Templates
|
||||||
|
│ ├── imports-tools.ts # 4 tools - Import jobs
|
||||||
|
│ ├── emojis.ts # 3 tools - Custom emojis
|
||||||
|
│ ├── user-permissions.ts # 3 tools - Permissions
|
||||||
|
│ ├── bulk-operations.ts # 6 tools - Batch operations
|
||||||
|
│ ├── advanced-search.ts # 6 tools - Advanced search
|
||||||
|
│ └── analytics.ts # 6 tools - Analytics
|
||||||
└── utils/
|
└── utils/
|
||||||
├── logger.ts
|
├── logger.ts
|
||||||
└── security.ts
|
└── security.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools Summary (86 total)
|
## Tools Summary (160 total)
|
||||||
|
|
||||||
| Module | Tools | Description |
|
| Module | Tools | Description |
|
||||||
|--------|-------|-------------|
|
|--------|-------|-------------|
|
||||||
@@ -73,6 +92,25 @@ 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) |
|
||||||
|
| teams | 5 | get, update, stats, domains, settings |
|
||||||
|
| integrations | 6 | list, get, create, update, delete, sync |
|
||||||
|
| notifications | 4 | list, mark read, mark all read, settings |
|
||||||
|
| subscriptions | 4 | list, subscribe, unsubscribe, settings |
|
||||||
|
| templates | 5 | list, get, create from, convert to/from |
|
||||||
|
| imports | 4 | list, status, create, cancel |
|
||||||
|
| emojis | 3 | list, create, delete |
|
||||||
|
| user-permissions | 3 | list, grant, revoke |
|
||||||
|
| bulk-operations | 6 | archive, delete, move, restore, add/remove users |
|
||||||
|
| advanced-search | 6 | advanced search, facets, recent, user activity, orphaned, duplicates |
|
||||||
|
| analytics | 6 | overview, user activity, content insights, collection stats, growth, search |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
96
CONTINUE.md
96
CONTINUE.md
@@ -2,56 +2,69 @@
|
|||||||
|
|
||||||
## Estado Actual
|
## Estado Actual
|
||||||
|
|
||||||
**MCP Outline PostgreSQL v1.0.0** - DESENVOLVIMENTO CONCLUÍDO
|
**MCP Outline PostgreSQL v1.2.0** - DESENVOLVIMENTO COMPLETO
|
||||||
|
|
||||||
- 86 tools implementadas em 12 módulos
|
- 160 tools implementadas em 31 módulos
|
||||||
- Build passa sem erros
|
- Build passa sem erros
|
||||||
- Repositório: https://git.descomplicar.pt/ealmeida/mcp-outline-postgresql
|
- Repositório: https://git.descomplicar.pt/ealmeida/mcp-outline-postgresql
|
||||||
- Configurado em `~/.claude.json` como `outline-postgresql`
|
- Configurado em `~/.claude.json` como `outline-postgresql`
|
||||||
|
|
||||||
## O Que Foi Feito
|
## Módulos Implementados (31 total, 160 tools)
|
||||||
|
|
||||||
1. Estrutura completa do MCP seguindo padrões desk-crm-sql-v3
|
### Core (50 tools)
|
||||||
2. 12 módulos de tools:
|
- documents (19) - CRUD, search, archive, move, templates, memberships
|
||||||
- documents (19), collections (14), users (9), groups (8)
|
- collections (14) - CRUD, memberships, groups, export
|
||||||
- comments (6), shares (5), revisions (3), events (3)
|
- users (9) - CRUD, suspend, activate, promote, demote
|
||||||
- attachments (5), file-operations (4), oauth (8), auth (2)
|
- groups (8) - CRUD, memberships
|
||||||
3. PostgreSQL client com connection pooling
|
|
||||||
4. Tipos TypeScript completos
|
|
||||||
5. Utilitários de segurança e logging
|
|
||||||
6. CHANGELOG, CLAUDE.md, SPEC actualizados
|
|
||||||
7. Git repo criado e pushed
|
|
||||||
|
|
||||||
## Próximos Passos (Para Testar)
|
### Collaboration (14 tools)
|
||||||
|
- comments (6) - CRUD, resolve
|
||||||
|
- shares (5) - CRUD, revoke
|
||||||
|
- revisions (3) - list, info, compare
|
||||||
|
|
||||||
```bash
|
### System (12 tools)
|
||||||
# 1. Verificar se PostgreSQL do Outline está acessível
|
- events (3) - audit log, statistics
|
||||||
docker exec -it outline-postgres psql -U outline -d outline -c "SELECT 1"
|
- attachments (5) - CRUD, stats
|
||||||
|
- file-operations (4) - import/export jobs
|
||||||
|
|
||||||
# 2. Reiniciar Claude Code para carregar o MCP
|
### Authentication (10 tools)
|
||||||
|
- oauth (8) - OAuth clients, authentications
|
||||||
|
- auth (2) - auth info, config
|
||||||
|
|
||||||
# 3. Testar uma tool simples
|
### User Engagement (14 tools)
|
||||||
# (no Claude Code) usar outline_list_documents ou outline_list_collections
|
- stars (3) - bookmarks
|
||||||
```
|
- pins (3) - pinned documents
|
||||||
|
- views (2) - view tracking
|
||||||
|
- reactions (3) - emoji reactions
|
||||||
|
- emojis (3) - custom emojis
|
||||||
|
|
||||||
## Prompt Para Continuar
|
### API & Integration (14 tools)
|
||||||
|
- api-keys (4) - programmatic access
|
||||||
|
- webhooks (4) - event subscriptions
|
||||||
|
- integrations (6) - external integrations (Slack, embeds)
|
||||||
|
|
||||||
```
|
### Notifications (8 tools)
|
||||||
Continuo o trabalho no MCP Outline PostgreSQL.
|
- notifications (4) - user notifications
|
||||||
|
- subscriptions (4) - document subscriptions
|
||||||
|
|
||||||
Path: /home/ealmeida/mcp-servers/mcp-outline-postgresql
|
### Templates & Imports (9 tools)
|
||||||
|
- templates (5) - document templates
|
||||||
|
- imports (4) - import job management
|
||||||
|
|
||||||
Estado: v1.0.0 completo com 86 tools. Preciso testar a ligação ao PostgreSQL
|
### Permissions (3 tools)
|
||||||
e validar que as tools funcionam correctamente.
|
- user-permissions (3) - grant/revoke permissions
|
||||||
|
|
||||||
Tarefas pendentes:
|
### Bulk Operations (6 tools)
|
||||||
1. Testar conexão ao PostgreSQL do Outline (Docker)
|
- bulk-operations (6) - batch archive, delete, move, restore, user management
|
||||||
2. Validar tools principais: list_documents, list_collections, search_documents
|
|
||||||
3. Corrigir eventuais erros de schema (nomes de colunas PostgreSQL)
|
|
||||||
4. Adicionar mais tools se necessário (stars, pins, views, etc.)
|
|
||||||
|
|
||||||
O MCP está configurado em ~/.claude.json como "outline-postgresql".
|
### Analytics & Search (15 tools)
|
||||||
```
|
- backlinks (1) - document link references
|
||||||
|
- search-queries (2) - search analytics
|
||||||
|
- advanced-search (6) - faceted search, recent, orphaned, duplicates
|
||||||
|
- analytics (6) - overview, user activity, content insights, growth metrics
|
||||||
|
|
||||||
|
### Teams (5 tools)
|
||||||
|
- teams (5) - team/workspace management
|
||||||
|
|
||||||
## Configuração Actual
|
## Configuração Actual
|
||||||
|
|
||||||
@@ -66,13 +79,26 @@ O MCP está configurado em ~/.claude.json como "outline-postgresql".
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Prompt Para Continuar
|
||||||
|
|
||||||
|
```
|
||||||
|
Continuo o trabalho no MCP Outline PostgreSQL.
|
||||||
|
|
||||||
|
Path: /home/ealmeida/mcp-servers/mcp-outline-postgresql
|
||||||
|
|
||||||
|
Estado: v1.2.0 completo com 160 tools em 31 módulos.
|
||||||
|
|
||||||
|
O MCP está configurado em ~/.claude.json como "outline-postgresql".
|
||||||
|
```
|
||||||
|
|
||||||
## Ficheiros Chave
|
## Ficheiros Chave
|
||||||
|
|
||||||
- `src/index.ts` - Entry point MCP
|
- `src/index.ts` - Entry point MCP
|
||||||
- `src/tools/*.ts` - 12 módulos de tools
|
- `src/tools/*.ts` - 31 módulos de tools
|
||||||
- `src/pg-client.ts` - Cliente PostgreSQL
|
- `src/pg-client.ts` - Cliente PostgreSQL
|
||||||
- `.env` - Configuração BD local
|
- `.env` - Configuração BD local
|
||||||
- `SPEC-MCP-OUTLINE.md` - Especificação completa
|
- `SPEC-MCP-OUTLINE.md` - Especificação completa
|
||||||
|
- `CHANGELOG.md` - Histórico de alterações
|
||||||
|
|
||||||
---
|
---
|
||||||
*Última actualização: 2026-01-31*
|
*Última actualização: 2026-01-31*
|
||||||
|
|||||||
@@ -472,6 +472,132 @@ 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 |
|
||||||
|
|
||||||
|
### 5.31 Templates (5 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `templates.list` | `list_templates` | SELECT | P1 |
|
||||||
|
| `templates.info` | `get_template` | SELECT | P1 |
|
||||||
|
| `templates.create` | `create_from_template` | INSERT | P1 |
|
||||||
|
| `templates.convert` | `convert_to_template` | UPDATE | P2 |
|
||||||
|
| `templates.unconvert` | `convert_from_template` | UPDATE | P2 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Resumo de Tools
|
## 6. Resumo de Tools
|
||||||
@@ -480,25 +606,27 @@ export const listDocuments: BaseTool = {
|
|||||||
|
|
||||||
| Prioridade | Quantidade | Descrição |
|
| Prioridade | Quantidade | Descrição |
|
||||||
|------------|------------|-----------|
|
|------------|------------|-----------|
|
||||||
| P1 | 18 | Core: CRUD documentos, collections, users, search |
|
| P1 | 32 | Core: CRUD, search, templates, integrations, notifications |
|
||||||
| P2 | 37 | Secundárias: memberships, comments, shares, stars, pins, views, apiKeys |
|
| P2 | 85 | Secundárias: bulk ops, analytics, search avançado, embeds |
|
||||||
| P3 | 28 | Avançadas: templates, OAuth, attachments, reactions, webhooks |
|
| P3 | 27 | Avançadas: OAuth, sync externo, import URL |
|
||||||
| **Total** | **83** | |
|
| **Total** | **144** | |
|
||||||
|
|
||||||
### Por Módulo
|
### Por Módulo
|
||||||
|
|
||||||
| Módulo | Tools | Estado |
|
| Módulo | Tools | Estado |
|
||||||
|--------|-------|--------|
|
|--------|-------|--------|
|
||||||
| Documents | 17 | A implementar |
|
| Documents | 17 | ✅ Implementado |
|
||||||
| Collections | 13 | A implementar |
|
| Collections | 13 | ✅ Implementado |
|
||||||
| Users | 7 | A implementar |
|
| Users | 7 | ✅ Implementado |
|
||||||
| Groups | 7 | A implementar |
|
| Groups | 7 | ✅ Implementado |
|
||||||
| Comments | 5 | A implementar |
|
| Comments | 5 | ✅ Implementado |
|
||||||
| Shares | 4 | A implementar |
|
| Shares | 4 | ✅ Implementado |
|
||||||
| Revisions | 2 | A implementar |
|
| Revisions | 2 | ✅ Implementado |
|
||||||
| Events | 1 | A implementar |
|
| Events | 1 | ✅ Implementado |
|
||||||
| Attachments | 3 | A implementar |
|
| Attachments | 3 | ✅ Implementado |
|
||||||
| Auth | 2 | A implementar |
|
| Auth | 2 | ✅ Implementado |
|
||||||
|
| OAuth | 8 | ✅ Implementado |
|
||||||
|
| File Operations | 4 | ✅ Implementado |
|
||||||
| Stars | 3 | A implementar |
|
| Stars | 3 | A implementar |
|
||||||
| Pins | 3 | A implementar |
|
| Pins | 3 | A implementar |
|
||||||
| Views | 2 | A implementar |
|
| Views | 2 | A implementar |
|
||||||
@@ -507,6 +635,19 @@ export const listDocuments: BaseTool = {
|
|||||||
| Webhooks | 4 | A implementar |
|
| Webhooks | 4 | A implementar |
|
||||||
| Backlinks | 1 | A implementar |
|
| Backlinks | 1 | A implementar |
|
||||||
| Search Queries | 2 | A implementar |
|
| Search Queries | 2 | A implementar |
|
||||||
|
| Teams | 5 | A implementar |
|
||||||
|
| Integrations | 6 | A implementar (CRÍTICO) |
|
||||||
|
| Notifications | 4 | A implementar |
|
||||||
|
| Subscriptions | 4 | A implementar |
|
||||||
|
| Imports | 4 | A implementar |
|
||||||
|
| Emojis | 3 | A implementar |
|
||||||
|
| User Permissions | 3 | A implementar |
|
||||||
|
| Bulk Operations | 6 | A implementar |
|
||||||
|
| Export/Import | 4 | A implementar |
|
||||||
|
| Advanced Search | 6 | A implementar |
|
||||||
|
| Analytics | 6 | A implementar |
|
||||||
|
| External Sync | 5 | A implementar |
|
||||||
|
| Templates | 5 | A implementar |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
80
src/index.ts
80
src/index.ts
@@ -33,7 +33,27 @@ import {
|
|||||||
attachmentsTools,
|
attachmentsTools,
|
||||||
fileOperationsTools,
|
fileOperationsTools,
|
||||||
oauthTools,
|
oauthTools,
|
||||||
authTools
|
authTools,
|
||||||
|
starsTools,
|
||||||
|
pinsTools,
|
||||||
|
viewsTools,
|
||||||
|
reactionsTools,
|
||||||
|
apiKeysTools,
|
||||||
|
webhooksTools,
|
||||||
|
backlinksTools,
|
||||||
|
searchQueriesTools,
|
||||||
|
// New modules
|
||||||
|
teamsTools,
|
||||||
|
integrationsTools,
|
||||||
|
notificationsTools,
|
||||||
|
subscriptionsTools,
|
||||||
|
templatesTools,
|
||||||
|
importsTools,
|
||||||
|
emojisTools,
|
||||||
|
userPermissionsTools,
|
||||||
|
bulkOperationsTools,
|
||||||
|
advancedSearchTools,
|
||||||
|
analyticsTools
|
||||||
} from './tools/index.js';
|
} from './tools/index.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -58,7 +78,42 @@ const allTools: BaseTool[] = [
|
|||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
...oauthTools,
|
...oauthTools,
|
||||||
...authTools
|
...authTools,
|
||||||
|
|
||||||
|
// User engagement
|
||||||
|
...starsTools,
|
||||||
|
...pinsTools,
|
||||||
|
...viewsTools,
|
||||||
|
...reactionsTools,
|
||||||
|
|
||||||
|
// API & Integration
|
||||||
|
...apiKeysTools,
|
||||||
|
...webhooksTools,
|
||||||
|
...integrationsTools,
|
||||||
|
|
||||||
|
// Analytics & Search
|
||||||
|
...backlinksTools,
|
||||||
|
...searchQueriesTools,
|
||||||
|
...advancedSearchTools,
|
||||||
|
...analyticsTools,
|
||||||
|
|
||||||
|
// Teams & Workspace
|
||||||
|
...teamsTools,
|
||||||
|
|
||||||
|
// Notifications & Subscriptions
|
||||||
|
...notificationsTools,
|
||||||
|
...subscriptionsTools,
|
||||||
|
|
||||||
|
// Templates & Imports
|
||||||
|
...templatesTools,
|
||||||
|
...importsTools,
|
||||||
|
|
||||||
|
// Custom content
|
||||||
|
...emojisTools,
|
||||||
|
|
||||||
|
// Permissions & Bulk operations
|
||||||
|
...userPermissionsTools,
|
||||||
|
...bulkOperationsTools
|
||||||
];
|
];
|
||||||
|
|
||||||
// Validate all tools have required properties
|
// Validate all tools have required properties
|
||||||
@@ -185,7 +240,26 @@ 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,
|
||||||
|
teams: teamsTools.length,
|
||||||
|
integrations: integrationsTools.length,
|
||||||
|
notifications: notificationsTools.length,
|
||||||
|
subscriptions: subscriptionsTools.length,
|
||||||
|
templates: templatesTools.length,
|
||||||
|
imports: importsTools.length,
|
||||||
|
emojis: emojisTools.length,
|
||||||
|
userPermissions: userPermissionsTools.length,
|
||||||
|
bulkOperations: bulkOperationsTools.length,
|
||||||
|
advancedSearch: advancedSearchTools.length,
|
||||||
|
analytics: analyticsTools.length
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
425
src/tools/advanced-search.ts
Normal file
425
src/tools/advanced-search.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Advanced Search Tools
|
||||||
|
* Full-text search, filters, faceted search
|
||||||
|
* @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 AdvancedSearchArgs extends PaginationArgs {
|
||||||
|
query: string;
|
||||||
|
collection_ids?: string[];
|
||||||
|
user_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
|
template?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search.documents_advanced - Advanced document search
|
||||||
|
*/
|
||||||
|
const advancedSearchDocuments: BaseTool<AdvancedSearchArgs> = {
|
||||||
|
name: 'outline_search_documents_advanced',
|
||||||
|
description: 'Advanced full-text search with filters for documents.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Search query (full-text search)' },
|
||||||
|
collection_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by collection IDs (UUIDs)' },
|
||||||
|
user_id: { type: 'string', description: 'Filter by author user ID (UUID)' },
|
||||||
|
date_from: { type: 'string', description: 'Filter from date (ISO format)' },
|
||||||
|
date_to: { type: 'string', description: 'Filter to date (ISO format)' },
|
||||||
|
include_archived: { type: 'boolean', description: 'Include archived documents (default: false)' },
|
||||||
|
template: { type: 'boolean', description: 'Filter templates only or exclude templates' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = ['d."deletedAt" IS NULL'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
// Full-text search
|
||||||
|
const searchQuery = sanitizeInput(args.query);
|
||||||
|
conditions.push(`(d.title ILIKE $${idx} OR d.text ILIKE $${idx})`);
|
||||||
|
params.push(`%${searchQuery}%`);
|
||||||
|
idx++;
|
||||||
|
|
||||||
|
if (args.collection_ids && args.collection_ids.length > 0) {
|
||||||
|
for (const id of args.collection_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid collection ID: ${id}`);
|
||||||
|
}
|
||||||
|
conditions.push(`d."collectionId" = ANY($${idx++})`);
|
||||||
|
params.push(args.collection_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
conditions.push(`d."createdById" = $${idx++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.date_from) {
|
||||||
|
conditions.push(`d."createdAt" >= $${idx++}`);
|
||||||
|
params.push(args.date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.date_to) {
|
||||||
|
conditions.push(`d."createdAt" <= $${idx++}`);
|
||||||
|
params.push(args.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.include_archived) {
|
||||||
|
conditions.push(`d."archivedAt" IS NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.template !== undefined) {
|
||||||
|
conditions.push(`d.template = $${idx++}`);
|
||||||
|
params.push(args.template);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, d.template,
|
||||||
|
d."collectionId", d."createdById",
|
||||||
|
d."createdAt", d."updatedAt", d."publishedAt", d."archivedAt",
|
||||||
|
c.name as "collectionName",
|
||||||
|
u.name as "createdByName",
|
||||||
|
SUBSTRING(d.text, 1, 200) as "textPreview"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON d."createdById" = u.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY d."updatedAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
data: result.rows,
|
||||||
|
query: args.query,
|
||||||
|
pagination: { limit, offset, total: result.rows.length }
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search.facets - Get search facets/filters
|
||||||
|
*/
|
||||||
|
const getSearchFacets: BaseTool<{ query?: string }> = {
|
||||||
|
name: 'outline_get_search_facets',
|
||||||
|
description: 'Get available search facets (collections, users, date ranges) for filtering.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Optional query to narrow facets' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const baseCondition = args.query
|
||||||
|
? `WHERE (d.title ILIKE $1 OR d.text ILIKE $1) AND d."deletedAt" IS NULL`
|
||||||
|
: `WHERE d."deletedAt" IS NULL`;
|
||||||
|
const queryParam = args.query ? [`%${sanitizeInput(args.query)}%`] : [];
|
||||||
|
|
||||||
|
// Collections facet
|
||||||
|
const collections = await pgClient.query(`
|
||||||
|
SELECT c.id, c.name, COUNT(d.id) as "documentCount"
|
||||||
|
FROM collections c
|
||||||
|
LEFT JOIN documents d ON d."collectionId" = c.id AND d."deletedAt" IS NULL
|
||||||
|
WHERE c."deletedAt" IS NULL
|
||||||
|
GROUP BY c.id, c.name
|
||||||
|
ORDER BY "documentCount" DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Authors facet
|
||||||
|
const authors = await pgClient.query(`
|
||||||
|
SELECT u.id, u.name, COUNT(d.id) as "documentCount"
|
||||||
|
FROM users u
|
||||||
|
JOIN documents d ON d."createdById" = u.id
|
||||||
|
${baseCondition}
|
||||||
|
GROUP BY u.id, u.name
|
||||||
|
ORDER BY "documentCount" DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, queryParam);
|
||||||
|
|
||||||
|
// Date range stats
|
||||||
|
const dateStats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
MIN(d."createdAt") as "oldestDocument",
|
||||||
|
MAX(d."createdAt") as "newestDocument",
|
||||||
|
COUNT(*) as "totalDocuments"
|
||||||
|
FROM documents d
|
||||||
|
${baseCondition}
|
||||||
|
`, queryParam);
|
||||||
|
|
||||||
|
// Templates count
|
||||||
|
const templates = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE template = true) as "templateCount",
|
||||||
|
COUNT(*) FILTER (WHERE template = false) as "documentCount"
|
||||||
|
FROM documents d
|
||||||
|
${baseCondition}
|
||||||
|
`, queryParam);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
collections: collections.rows,
|
||||||
|
authors: authors.rows,
|
||||||
|
dateRange: dateStats.rows[0],
|
||||||
|
documentTypes: templates.rows[0],
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search.recent - Get recently updated documents
|
||||||
|
*/
|
||||||
|
const searchRecent: BaseTool<PaginationArgs & { collection_id?: string; days?: number }> = {
|
||||||
|
name: 'outline_search_recent',
|
||||||
|
description: 'Get recently updated documents.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||||
|
days: { type: 'number', description: 'Number of days to look back (default: 7)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const days = args.days || 7;
|
||||||
|
const conditions: string[] = [
|
||||||
|
'd."deletedAt" IS NULL',
|
||||||
|
'd."archivedAt" IS NULL',
|
||||||
|
`d."updatedAt" >= NOW() - INTERVAL '${days} days'`
|
||||||
|
];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.collection_id) {
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
conditions.push(`d."collectionId" = $${idx++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, d."collectionId",
|
||||||
|
d."updatedAt", d."createdAt",
|
||||||
|
c.name as "collectionName",
|
||||||
|
u.name as "lastModifiedByName"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON d."lastModifiedById" = u.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY d."updatedAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
data: result.rows,
|
||||||
|
days,
|
||||||
|
pagination: { limit, offset, total: result.rows.length }
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search.by_user_activity - Search by user activity
|
||||||
|
*/
|
||||||
|
const searchByUserActivity: BaseTool<PaginationArgs & { user_id: string; activity_type?: string }> = {
|
||||||
|
name: 'outline_search_by_user_activity',
|
||||||
|
description: 'Find documents a user has interacted with (created, edited, viewed, starred).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
activity_type: { type: 'string', description: 'Activity type: created, edited, viewed, starred (default: all)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
required: ['user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
|
||||||
|
const results: any = {};
|
||||||
|
|
||||||
|
if (!args.activity_type || args.activity_type === 'created') {
|
||||||
|
const created = await pgClient.query(`
|
||||||
|
SELECT d.id, d.title, d."createdAt" as "activityAt", 'created' as "activityType"
|
||||||
|
FROM documents d
|
||||||
|
WHERE d."createdById" = $1 AND d."deletedAt" IS NULL
|
||||||
|
ORDER BY d."createdAt" DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`, [args.user_id, limit, offset]);
|
||||||
|
results.created = created.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.activity_type || args.activity_type === 'edited') {
|
||||||
|
const edited = await pgClient.query(`
|
||||||
|
SELECT d.id, d.title, d."updatedAt" as "activityAt", 'edited' as "activityType"
|
||||||
|
FROM documents d
|
||||||
|
WHERE d."lastModifiedById" = $1 AND d."deletedAt" IS NULL
|
||||||
|
ORDER BY d."updatedAt" DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`, [args.user_id, limit, offset]);
|
||||||
|
results.edited = edited.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.activity_type || args.activity_type === 'viewed') {
|
||||||
|
const viewed = await pgClient.query(`
|
||||||
|
SELECT d.id, d.title, v."updatedAt" as "activityAt", 'viewed' as "activityType"
|
||||||
|
FROM views v
|
||||||
|
JOIN documents d ON v."documentId" = d.id
|
||||||
|
WHERE v."userId" = $1 AND d."deletedAt" IS NULL
|
||||||
|
ORDER BY v."updatedAt" DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`, [args.user_id, limit, offset]);
|
||||||
|
results.viewed = viewed.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.activity_type || args.activity_type === 'starred') {
|
||||||
|
const starred = await pgClient.query(`
|
||||||
|
SELECT d.id, d.title, s."createdAt" as "activityAt", 'starred' as "activityType"
|
||||||
|
FROM stars s
|
||||||
|
JOIN documents d ON s."documentId" = d.id
|
||||||
|
WHERE s."userId" = $1 AND d."deletedAt" IS NULL
|
||||||
|
ORDER BY s."createdAt" DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`, [args.user_id, limit, offset]);
|
||||||
|
results.starred = starred.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: results, userId: args.user_id }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search.orphaned_documents - Find orphaned documents
|
||||||
|
*/
|
||||||
|
const searchOrphanedDocuments: BaseTool<PaginationArgs> = {
|
||||||
|
name: 'outline_search_orphaned_documents',
|
||||||
|
description: 'Find documents without a collection or with deleted parent.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
|
||||||
|
// Documents without collection or with deleted collection
|
||||||
|
const orphaned = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d."collectionId", d."parentDocumentId",
|
||||||
|
d."createdAt", d."updatedAt",
|
||||||
|
CASE
|
||||||
|
WHEN d."collectionId" IS NULL THEN 'no_collection'
|
||||||
|
WHEN c."deletedAt" IS NOT NULL THEN 'deleted_collection'
|
||||||
|
WHEN d."parentDocumentId" IS NOT NULL AND pd."deletedAt" IS NOT NULL THEN 'deleted_parent'
|
||||||
|
ELSE 'orphaned'
|
||||||
|
END as "orphanReason"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
LEFT JOIN documents pd ON d."parentDocumentId" = pd.id
|
||||||
|
WHERE d."deletedAt" IS NULL
|
||||||
|
AND (
|
||||||
|
d."collectionId" IS NULL
|
||||||
|
OR c."deletedAt" IS NOT NULL
|
||||||
|
OR (d."parentDocumentId" IS NOT NULL AND pd."deletedAt" IS NOT NULL)
|
||||||
|
)
|
||||||
|
ORDER BY d."updatedAt" DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, [limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
data: orphaned.rows,
|
||||||
|
pagination: { limit, offset, total: orphaned.rows.length }
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* search.duplicates - Find potential duplicate documents
|
||||||
|
*/
|
||||||
|
const searchDuplicates: BaseTool<PaginationArgs & { similarity_threshold?: number }> = {
|
||||||
|
name: 'outline_search_duplicates',
|
||||||
|
description: 'Find documents with similar or identical titles.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
similarity_threshold: { type: 'number', description: 'Minimum similarity (0-1, default: 0.8)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
|
||||||
|
// Find exact title duplicates
|
||||||
|
const exactDuplicates = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d1.id as "document1Id", d1.title as "document1Title",
|
||||||
|
d2.id as "document2Id", d2.title as "document2Title",
|
||||||
|
c1.name as "collection1Name", c2.name as "collection2Name"
|
||||||
|
FROM documents d1
|
||||||
|
JOIN documents d2 ON LOWER(d1.title) = LOWER(d2.title) AND d1.id < d2.id
|
||||||
|
LEFT JOIN collections c1 ON d1."collectionId" = c1.id
|
||||||
|
LEFT JOIN collections c2 ON d2."collectionId" = c2.id
|
||||||
|
WHERE d1."deletedAt" IS NULL AND d2."deletedAt" IS NULL
|
||||||
|
AND d1.template = false AND d2.template = false
|
||||||
|
ORDER BY d1.title
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, [limit, offset]);
|
||||||
|
|
||||||
|
// Find documents with similar titles (starts with same prefix)
|
||||||
|
const similarTitles = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d1.id as "document1Id", d1.title as "document1Title",
|
||||||
|
d2.id as "document2Id", d2.title as "document2Title"
|
||||||
|
FROM documents d1
|
||||||
|
JOIN documents d2 ON
|
||||||
|
LEFT(LOWER(d1.title), 20) = LEFT(LOWER(d2.title), 20)
|
||||||
|
AND d1.id < d2.id
|
||||||
|
AND d1.title != d2.title
|
||||||
|
WHERE d1."deletedAt" IS NULL AND d2."deletedAt" IS NULL
|
||||||
|
AND d1.template = false AND d2.template = false
|
||||||
|
AND LENGTH(d1.title) > 10
|
||||||
|
ORDER BY d1.title
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, [limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
exactDuplicates: exactDuplicates.rows,
|
||||||
|
similarTitles: similarTitles.rows,
|
||||||
|
pagination: { limit, offset }
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const advancedSearchTools: BaseTool<any>[] = [
|
||||||
|
advancedSearchDocuments, getSearchFacets, searchRecent,
|
||||||
|
searchByUserActivity, searchOrphanedDocuments, searchDuplicates
|
||||||
|
];
|
||||||
452
src/tools/analytics.ts
Normal file
452
src/tools/analytics.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Analytics Tools
|
||||||
|
* Usage statistics, reports, insights
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||||
|
import { isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
interface DateRangeArgs {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analytics.overview - Get overall workspace analytics
|
||||||
|
*/
|
||||||
|
const getAnalyticsOverview: BaseTool<DateRangeArgs> = {
|
||||||
|
name: 'outline_analytics_overview',
|
||||||
|
description: 'Get overall workspace analytics including document counts, user activity, etc.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
date_from: { type: 'string', description: 'Start date (ISO format)' },
|
||||||
|
date_to: { type: 'string', description: 'End date (ISO format)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const dateCondition = args.date_from && args.date_to
|
||||||
|
? `AND "createdAt" BETWEEN '${args.date_from}' AND '${args.date_to}'`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Document stats
|
||||||
|
const docStats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as "totalDocuments",
|
||||||
|
COUNT(*) FILTER (WHERE template = true) as "templates",
|
||||||
|
COUNT(*) FILTER (WHERE "archivedAt" IS NOT NULL) as "archived",
|
||||||
|
COUNT(*) FILTER (WHERE "publishedAt" IS NOT NULL) as "published",
|
||||||
|
COUNT(*) FILTER (WHERE "deletedAt" IS NOT NULL) as "deleted"
|
||||||
|
FROM documents
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Collection stats
|
||||||
|
const collStats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as "totalCollections",
|
||||||
|
COUNT(*) FILTER (WHERE "deletedAt" IS NULL) as "active"
|
||||||
|
FROM collections
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User stats
|
||||||
|
const userStats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as "totalUsers",
|
||||||
|
COUNT(*) FILTER (WHERE "suspendedAt" IS NULL AND "deletedAt" IS NULL) as "active",
|
||||||
|
COUNT(*) FILTER (WHERE role = 'admin') as "admins"
|
||||||
|
FROM users
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Recent activity
|
||||||
|
const recentActivity = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '24 hours') as "documentsLast24h",
|
||||||
|
COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '7 days') as "documentsLast7d",
|
||||||
|
COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '30 days') as "documentsLast30d"
|
||||||
|
FROM documents
|
||||||
|
WHERE "deletedAt" IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// View stats
|
||||||
|
const viewStats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as "totalViews",
|
||||||
|
COUNT(DISTINCT "userId") as "uniqueViewers",
|
||||||
|
COUNT(DISTINCT "documentId") as "viewedDocuments"
|
||||||
|
FROM views
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
documents: docStats.rows[0],
|
||||||
|
collections: collStats.rows[0],
|
||||||
|
users: userStats.rows[0],
|
||||||
|
recentActivity: recentActivity.rows[0],
|
||||||
|
views: viewStats.rows[0],
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analytics.user_activity - Get user activity analytics
|
||||||
|
*/
|
||||||
|
const getUserActivityAnalytics: BaseTool<{ user_id?: string; days?: number }> = {
|
||||||
|
name: 'outline_analytics_user_activity',
|
||||||
|
description: 'Get detailed user activity analytics.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'Specific user ID (UUID), or all users if omitted' },
|
||||||
|
days: { type: 'number', description: 'Number of days to analyze (default: 30)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const days = args.days || 30;
|
||||||
|
const userCondition = args.user_id ? `AND u.id = '${args.user_id}'` : '';
|
||||||
|
|
||||||
|
if (args.user_id && !isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
|
||||||
|
// Most active users
|
||||||
|
const activeUsers = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
u.id, u.name, u.email,
|
||||||
|
COUNT(DISTINCT d.id) FILTER (WHERE d."createdAt" >= NOW() - INTERVAL '${days} days') as "documentsCreated",
|
||||||
|
COUNT(DISTINCT d2.id) FILTER (WHERE d2."updatedAt" >= NOW() - INTERVAL '${days} days') as "documentsEdited",
|
||||||
|
COUNT(DISTINCT v."documentId") FILTER (WHERE v."createdAt" >= NOW() - INTERVAL '${days} days') as "documentsViewed",
|
||||||
|
COUNT(DISTINCT c.id) FILTER (WHERE c."createdAt" >= NOW() - INTERVAL '${days} days') as "commentsAdded"
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN documents d ON d."createdById" = u.id
|
||||||
|
LEFT JOIN documents d2 ON d2."lastModifiedById" = u.id
|
||||||
|
LEFT JOIN views v ON v."userId" = u.id
|
||||||
|
LEFT JOIN comments c ON c."createdById" = u.id
|
||||||
|
WHERE u."deletedAt" IS NULL ${userCondition}
|
||||||
|
GROUP BY u.id, u.name, u.email
|
||||||
|
ORDER BY "documentsCreated" DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Activity by day of week
|
||||||
|
const activityByDay = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(DOW FROM d."createdAt") as "dayOfWeek",
|
||||||
|
COUNT(*) as "documentsCreated"
|
||||||
|
FROM documents d
|
||||||
|
WHERE d."createdAt" >= NOW() - INTERVAL '${days} days'
|
||||||
|
AND d."deletedAt" IS NULL
|
||||||
|
GROUP BY EXTRACT(DOW FROM d."createdAt")
|
||||||
|
ORDER BY "dayOfWeek"
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Activity by hour
|
||||||
|
const activityByHour = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM d."createdAt") as "hour",
|
||||||
|
COUNT(*) as "documentsCreated"
|
||||||
|
FROM documents d
|
||||||
|
WHERE d."createdAt" >= NOW() - INTERVAL '${days} days'
|
||||||
|
AND d."deletedAt" IS NULL
|
||||||
|
GROUP BY EXTRACT(HOUR FROM d."createdAt")
|
||||||
|
ORDER BY "hour"
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
activeUsers: activeUsers.rows,
|
||||||
|
activityByDayOfWeek: activityByDay.rows,
|
||||||
|
activityByHour: activityByHour.rows,
|
||||||
|
periodDays: days,
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analytics.content_insights - Get content insights
|
||||||
|
*/
|
||||||
|
const getContentInsights: BaseTool<{ collection_id?: string }> = {
|
||||||
|
name: 'outline_analytics_content_insights',
|
||||||
|
description: 'Get insights about content: popular documents, stale content, etc.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const collectionCondition = args.collection_id
|
||||||
|
? `AND d."collectionId" = '${args.collection_id}'`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
// Most viewed documents
|
||||||
|
const mostViewed = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||||
|
COUNT(v.id) as "viewCount",
|
||||||
|
COUNT(DISTINCT v."userId") as "uniqueViewers"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN views v ON v."documentId" = d.id
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
WHERE d."deletedAt" IS NULL ${collectionCondition}
|
||||||
|
GROUP BY d.id, d.title, d.emoji, c.name
|
||||||
|
ORDER BY "viewCount" DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Most starred documents
|
||||||
|
const mostStarred = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||||
|
COUNT(s.id) as "starCount"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN stars s ON s."documentId" = d.id
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
WHERE d."deletedAt" IS NULL ${collectionCondition}
|
||||||
|
GROUP BY d.id, d.title, d.emoji, c.name
|
||||||
|
HAVING COUNT(s.id) > 0
|
||||||
|
ORDER BY "starCount" DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Stale documents (not updated in 90 days)
|
||||||
|
const staleDocuments = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||||
|
d."updatedAt",
|
||||||
|
EXTRACT(DAY FROM NOW() - d."updatedAt") as "daysSinceUpdate"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
WHERE d."deletedAt" IS NULL
|
||||||
|
AND d."archivedAt" IS NULL
|
||||||
|
AND d.template = false
|
||||||
|
AND d."updatedAt" < NOW() - INTERVAL '90 days'
|
||||||
|
${collectionCondition}
|
||||||
|
ORDER BY d."updatedAt" ASC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Documents without views
|
||||||
|
const neverViewed = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||||
|
d."createdAt"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN views v ON v."documentId" = d.id
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
WHERE d."deletedAt" IS NULL
|
||||||
|
AND d.template = false
|
||||||
|
AND v.id IS NULL
|
||||||
|
${collectionCondition}
|
||||||
|
ORDER BY d."createdAt" DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
mostViewed: mostViewed.rows,
|
||||||
|
mostStarred: mostStarred.rows,
|
||||||
|
staleDocuments: staleDocuments.rows,
|
||||||
|
neverViewed: neverViewed.rows,
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analytics.collection_stats - Get collection statistics
|
||||||
|
*/
|
||||||
|
const getCollectionStats: BaseTool<{ collection_id?: string }> = {
|
||||||
|
name: 'outline_analytics_collection_stats',
|
||||||
|
description: 'Get detailed statistics for collections.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collection_id: { type: 'string', description: 'Specific collection ID (UUID), or all collections if omitted' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const collectionCondition = args.collection_id
|
||||||
|
? `AND c.id = '${args.collection_id}'`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
const stats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
c.id, c.name, c.icon, c.color,
|
||||||
|
COUNT(DISTINCT d.id) as "documentCount",
|
||||||
|
COUNT(DISTINCT d.id) FILTER (WHERE d.template = true) as "templateCount",
|
||||||
|
COUNT(DISTINCT d.id) FILTER (WHERE d."archivedAt" IS NOT NULL) as "archivedCount",
|
||||||
|
COUNT(DISTINCT cu."userId") as "memberCount",
|
||||||
|
COUNT(DISTINCT cg."groupId") as "groupCount",
|
||||||
|
MAX(d."updatedAt") as "lastDocumentUpdate",
|
||||||
|
AVG(LENGTH(d.text)) as "avgDocumentLength"
|
||||||
|
FROM collections c
|
||||||
|
LEFT JOIN documents d ON d."collectionId" = c.id AND d."deletedAt" IS NULL
|
||||||
|
LEFT JOIN collection_users cu ON cu."collectionId" = c.id
|
||||||
|
LEFT JOIN collection_group_memberships cg ON cg."collectionId" = c.id
|
||||||
|
WHERE c."deletedAt" IS NULL ${collectionCondition}
|
||||||
|
GROUP BY c.id, c.name, c.icon, c.color
|
||||||
|
ORDER BY "documentCount" DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: stats.rows }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analytics.growth_metrics - Get growth metrics over time
|
||||||
|
*/
|
||||||
|
const getGrowthMetrics: BaseTool<{ period?: string }> = {
|
||||||
|
name: 'outline_analytics_growth_metrics',
|
||||||
|
description: 'Get growth metrics: documents, users, activity over time.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
period: { type: 'string', description: 'Period: week, month, quarter, year (default: month)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const period = args.period || 'month';
|
||||||
|
const intervals: Record<string, string> = {
|
||||||
|
week: '7 days',
|
||||||
|
month: '30 days',
|
||||||
|
quarter: '90 days',
|
||||||
|
year: '365 days',
|
||||||
|
};
|
||||||
|
const interval = intervals[period] || '30 days';
|
||||||
|
|
||||||
|
// Document growth by day
|
||||||
|
const documentGrowth = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
DATE(d."createdAt") as date,
|
||||||
|
COUNT(*) as "newDocuments",
|
||||||
|
SUM(COUNT(*)) OVER (ORDER BY DATE(d."createdAt")) as "cumulativeDocuments"
|
||||||
|
FROM documents d
|
||||||
|
WHERE d."createdAt" >= NOW() - INTERVAL '${interval}'
|
||||||
|
AND d."deletedAt" IS NULL
|
||||||
|
GROUP BY DATE(d."createdAt")
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User growth
|
||||||
|
const userGrowth = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
DATE(u."createdAt") as date,
|
||||||
|
COUNT(*) as "newUsers",
|
||||||
|
SUM(COUNT(*)) OVER (ORDER BY DATE(u."createdAt")) as "cumulativeUsers"
|
||||||
|
FROM users u
|
||||||
|
WHERE u."createdAt" >= NOW() - INTERVAL '${interval}'
|
||||||
|
AND u."deletedAt" IS NULL
|
||||||
|
GROUP BY DATE(u."createdAt")
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Collection growth
|
||||||
|
const collectionGrowth = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
DATE(c."createdAt") as date,
|
||||||
|
COUNT(*) as "newCollections"
|
||||||
|
FROM collections c
|
||||||
|
WHERE c."createdAt" >= NOW() - INTERVAL '${interval}'
|
||||||
|
AND c."deletedAt" IS NULL
|
||||||
|
GROUP BY DATE(c."createdAt")
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Period comparison
|
||||||
|
const comparison = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "createdAt" >= NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "currentPeriodDocs",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "createdAt" >= NOW() - INTERVAL '${interval}' * 2 AND "createdAt" < NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "previousPeriodDocs",
|
||||||
|
(SELECT COUNT(*) FROM users WHERE "createdAt" >= NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "currentPeriodUsers",
|
||||||
|
(SELECT COUNT(*) FROM users WHERE "createdAt" >= NOW() - INTERVAL '${interval}' * 2 AND "createdAt" < NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "previousPeriodUsers"
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
documentGrowth: documentGrowth.rows,
|
||||||
|
userGrowth: userGrowth.rows,
|
||||||
|
collectionGrowth: collectionGrowth.rows,
|
||||||
|
periodComparison: comparison.rows[0],
|
||||||
|
period,
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* analytics.search_analytics - Get search analytics
|
||||||
|
*/
|
||||||
|
const getSearchAnalytics: BaseTool<{ days?: number }> = {
|
||||||
|
name: 'outline_analytics_search',
|
||||||
|
description: 'Get search analytics: popular queries, search patterns.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
days: { type: 'number', description: 'Number of days to analyze (default: 30)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const days = args.days || 30;
|
||||||
|
|
||||||
|
// Popular search queries
|
||||||
|
const popularQueries = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
query,
|
||||||
|
COUNT(*) as "searchCount",
|
||||||
|
COUNT(DISTINCT "userId") as "uniqueSearchers"
|
||||||
|
FROM search_queries
|
||||||
|
WHERE "createdAt" >= NOW() - INTERVAL '${days} days'
|
||||||
|
GROUP BY query
|
||||||
|
ORDER BY "searchCount" DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Search volume by day
|
||||||
|
const searchVolume = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
DATE("createdAt") as date,
|
||||||
|
COUNT(*) as "searches",
|
||||||
|
COUNT(DISTINCT "userId") as "uniqueSearchers"
|
||||||
|
FROM search_queries
|
||||||
|
WHERE "createdAt" >= NOW() - INTERVAL '${days} days'
|
||||||
|
GROUP BY DATE("createdAt")
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Zero result queries (if results column exists)
|
||||||
|
const zeroResults = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
query,
|
||||||
|
COUNT(*) as "searchCount"
|
||||||
|
FROM search_queries
|
||||||
|
WHERE "createdAt" >= NOW() - INTERVAL '${days} days'
|
||||||
|
AND results = 0
|
||||||
|
GROUP BY query
|
||||||
|
ORDER BY "searchCount" DESC
|
||||||
|
LIMIT 10
|
||||||
|
`).catch(() => ({ rows: [] })); // Handle if results column doesn't exist
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
popularQueries: popularQueries.rows,
|
||||||
|
searchVolume: searchVolume.rows,
|
||||||
|
zeroResultQueries: zeroResults.rows,
|
||||||
|
periodDays: days,
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const analyticsTools: BaseTool<any>[] = [
|
||||||
|
getAnalyticsOverview, getUserActivityAnalytics, getContentInsights,
|
||||||
|
getCollectionStats, getGrowthMetrics, getSearchAnalytics
|
||||||
|
];
|
||||||
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];
|
||||||
@@ -46,7 +46,7 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
|
|||||||
},
|
},
|
||||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
const conditions: string[] = ['a."deletedAt" IS NULL'];
|
const conditions: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
@@ -74,13 +74,12 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
|
|||||||
params.push(args.team_id);
|
params.push(args.team_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
a.id,
|
a.id,
|
||||||
a.key,
|
a.key,
|
||||||
a.url,
|
|
||||||
a."contentType",
|
a."contentType",
|
||||||
a.size,
|
a.size,
|
||||||
a.acl,
|
a.acl,
|
||||||
@@ -89,6 +88,8 @@ const listAttachments: BaseTool<AttachmentListArgs> = {
|
|||||||
a."teamId",
|
a."teamId",
|
||||||
a."createdAt",
|
a."createdAt",
|
||||||
a."updatedAt",
|
a."updatedAt",
|
||||||
|
a."lastAccessedAt",
|
||||||
|
a."expiresAt",
|
||||||
d.title as "documentTitle",
|
d.title as "documentTitle",
|
||||||
u.name as "uploadedByName",
|
u.name as "uploadedByName",
|
||||||
u.email as "uploadedByEmail"
|
u.email as "uploadedByEmail"
|
||||||
@@ -151,7 +152,6 @@ const getAttachment: BaseTool<GetAttachmentArgs> = {
|
|||||||
SELECT
|
SELECT
|
||||||
a.id,
|
a.id,
|
||||||
a.key,
|
a.key,
|
||||||
a.url,
|
|
||||||
a."contentType",
|
a."contentType",
|
||||||
a.size,
|
a.size,
|
||||||
a.acl,
|
a.acl,
|
||||||
@@ -160,7 +160,8 @@ const getAttachment: BaseTool<GetAttachmentArgs> = {
|
|||||||
a."teamId",
|
a."teamId",
|
||||||
a."createdAt",
|
a."createdAt",
|
||||||
a."updatedAt",
|
a."updatedAt",
|
||||||
a."deletedAt",
|
a."lastAccessedAt",
|
||||||
|
a."expiresAt",
|
||||||
d.title as "documentTitle",
|
d.title as "documentTitle",
|
||||||
d."collectionId",
|
d."collectionId",
|
||||||
u.name as "uploadedByName",
|
u.name as "uploadedByName",
|
||||||
@@ -243,7 +244,7 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
|||||||
|
|
||||||
// Get first admin user and team
|
// Get first admin user and team
|
||||||
const userQuery = await pgClient.query(
|
const userQuery = await pgClient.query(
|
||||||
'SELECT u.id, u."teamId" FROM users u WHERE u."isAdmin" = true AND u."deletedAt" IS NULL LIMIT 1'
|
"SELECT u.id, u.\"teamId\" FROM users u WHERE u.role = 'admin' AND u.\"deletedAt\" IS NULL LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userQuery.rows.length === 0) {
|
if (userQuery.rows.length === 0) {
|
||||||
@@ -253,14 +254,13 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
|||||||
const userId = userQuery.rows[0].id;
|
const userId = userQuery.rows[0].id;
|
||||||
const teamId = userQuery.rows[0].teamId;
|
const teamId = userQuery.rows[0].teamId;
|
||||||
|
|
||||||
// Generate URL and key (in real implementation, this would be S3/storage URL)
|
// Generate key (path in storage)
|
||||||
const key = `attachments/${Date.now()}-${args.name}`;
|
const key = `attachments/${Date.now()}-${args.name}`;
|
||||||
const url = `/api/attachments.redirect?id=PLACEHOLDER`;
|
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO attachments (
|
INSERT INTO attachments (
|
||||||
|
id,
|
||||||
key,
|
key,
|
||||||
url,
|
|
||||||
"contentType",
|
"contentType",
|
||||||
size,
|
size,
|
||||||
acl,
|
acl,
|
||||||
@@ -269,13 +269,12 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
|||||||
"teamId",
|
"teamId",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt"
|
"updatedAt"
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pgClient.query(query, [
|
const result = await pgClient.query(query, [
|
||||||
key,
|
key,
|
||||||
url,
|
|
||||||
args.content_type,
|
args.content_type,
|
||||||
args.size,
|
args.size,
|
||||||
'private', // Default ACL
|
'private', // Default ACL
|
||||||
@@ -306,7 +305,7 @@ const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
|||||||
*/
|
*/
|
||||||
const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
||||||
name: 'outline_attachments_delete',
|
name: 'outline_attachments_delete',
|
||||||
description: 'Soft delete an attachment. The attachment record is marked as deleted but not removed from the database.',
|
description: 'Delete an attachment permanently.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -323,18 +322,15 @@ const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE attachments
|
DELETE FROM attachments
|
||||||
SET
|
WHERE id = $1
|
||||||
"deletedAt" = NOW(),
|
|
||||||
"updatedAt" = NOW()
|
|
||||||
WHERE id = $1 AND "deletedAt" IS NULL
|
|
||||||
RETURNING id, key, "documentId"
|
RETURNING id, key, "documentId"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pgClient.query(query, [args.id]);
|
const result = await pgClient.query(query, [args.id]);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
throw new Error('Attachment not found or already deleted');
|
throw new Error('Attachment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -376,7 +372,7 @@ const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> =
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
const conditions: string[] = ['a."deletedAt" IS NULL'];
|
const conditions: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
@@ -396,7 +392,7 @@ const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> =
|
|||||||
params.push(args.document_id);
|
params.push(args.document_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
// Overall statistics
|
// Overall statistics
|
||||||
const overallStatsQuery = await pgClient.query(
|
const overallStatsQuery = await pgClient.query(
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const getAuthInfo: BaseTool<Record<string, never>> = {
|
|||||||
const statsQuery = await pgClient.query(`
|
const statsQuery = await pgClient.query(`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL) as total_users,
|
(SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL) as total_users,
|
||||||
(SELECT COUNT(*) FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL) as admin_users,
|
(SELECT COUNT(*) FROM users WHERE role = 'admin' AND "deletedAt" IS NULL) as admin_users,
|
||||||
(SELECT COUNT(*) FROM users WHERE "isSuspended" = true) as suspended_users,
|
(SELECT COUNT(*) FROM users WHERE "suspendedAt" IS NOT NULL) as suspended_users,
|
||||||
(SELECT COUNT(*) FROM teams) as total_teams,
|
(SELECT COUNT(*) FROM teams) as total_teams,
|
||||||
(SELECT COUNT(*) FROM oauth_clients) as oauth_clients,
|
(SELECT COUNT(*) FROM oauth_clients) as oauth_clients,
|
||||||
(SELECT COUNT(*) FROM oauth_authentications) as oauth_authentications
|
(SELECT COUNT(*) FROM oauth_authentications) as oauth_authentications
|
||||||
@@ -48,8 +48,8 @@ const getAuthInfo: BaseTool<Record<string, never>> = {
|
|||||||
u.email,
|
u.email,
|
||||||
u."lastActiveAt",
|
u."lastActiveAt",
|
||||||
u."lastSignedInAt",
|
u."lastSignedInAt",
|
||||||
u."isAdmin",
|
u.role,
|
||||||
u."isSuspended"
|
u."suspendedAt"
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u."deletedAt" IS NULL
|
WHERE u."deletedAt" IS NULL
|
||||||
ORDER BY u."lastActiveAt" DESC NULLS LAST
|
ORDER BY u."lastActiveAt" DESC NULLS LAST
|
||||||
|
|||||||
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];
|
||||||
287
src/tools/bulk-operations.ts
Normal file
287
src/tools/bulk-operations.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Bulk Operations Tools
|
||||||
|
* Batch operations on documents, collections, etc.
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||||
|
import { isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bulk.archive_documents - Archive multiple documents
|
||||||
|
*/
|
||||||
|
const bulkArchiveDocuments: BaseTool<{ document_ids: string[] }> = {
|
||||||
|
name: 'outline_bulk_archive_documents',
|
||||||
|
description: 'Archive multiple documents at once.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||||
|
},
|
||||||
|
required: ['document_ids'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||||
|
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||||
|
|
||||||
|
// Validate all IDs
|
||||||
|
for (const id of args.document_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE documents
|
||||||
|
SET "archivedAt" = NOW(), "updatedAt" = NOW()
|
||||||
|
WHERE id = ANY($1) AND "archivedAt" IS NULL AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, title
|
||||||
|
`, [args.document_ids]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
archived: result.rows,
|
||||||
|
archivedCount: result.rows.length,
|
||||||
|
requestedCount: args.document_ids.length,
|
||||||
|
message: `${result.rows.length} documents archived`
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bulk.delete_documents - Soft delete multiple documents
|
||||||
|
*/
|
||||||
|
const bulkDeleteDocuments: BaseTool<{ document_ids: string[] }> = {
|
||||||
|
name: 'outline_bulk_delete_documents',
|
||||||
|
description: 'Soft delete multiple documents at once.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||||
|
},
|
||||||
|
required: ['document_ids'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||||
|
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||||
|
|
||||||
|
for (const id of args.document_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedById = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||||
|
const userId = deletedById.rows.length > 0 ? deletedById.rows[0].id : null;
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE documents
|
||||||
|
SET "deletedAt" = NOW(), "deletedById" = $2, "updatedAt" = NOW()
|
||||||
|
WHERE id = ANY($1) AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, title
|
||||||
|
`, [args.document_ids, userId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
deleted: result.rows,
|
||||||
|
deletedCount: result.rows.length,
|
||||||
|
requestedCount: args.document_ids.length,
|
||||||
|
message: `${result.rows.length} documents deleted`
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bulk.move_documents - Move multiple documents to collection
|
||||||
|
*/
|
||||||
|
const bulkMoveDocuments: BaseTool<{ document_ids: string[]; collection_id: string; parent_document_id?: string }> = {
|
||||||
|
name: 'outline_bulk_move_documents',
|
||||||
|
description: 'Move multiple documents to a collection or under a parent document.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||||
|
collection_id: { type: 'string', description: 'Target collection ID (UUID)' },
|
||||||
|
parent_document_id: { type: 'string', description: 'Optional parent document ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['document_ids', 'collection_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||||
|
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
|
||||||
|
|
||||||
|
for (const id of args.document_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify collection exists
|
||||||
|
const collectionCheck = await pgClient.query(
|
||||||
|
`SELECT id FROM collections WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||||
|
[args.collection_id]
|
||||||
|
);
|
||||||
|
if (collectionCheck.rows.length === 0) throw new Error('Collection not found');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE documents
|
||||||
|
SET "collectionId" = $2, "parentDocumentId" = $3, "updatedAt" = NOW()
|
||||||
|
WHERE id = ANY($1) AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, title, "collectionId"
|
||||||
|
`, [args.document_ids, args.collection_id, args.parent_document_id || null]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
moved: result.rows,
|
||||||
|
movedCount: result.rows.length,
|
||||||
|
targetCollectionId: args.collection_id,
|
||||||
|
message: `${result.rows.length} documents moved`
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bulk.restore_documents - Restore multiple deleted documents
|
||||||
|
*/
|
||||||
|
const bulkRestoreDocuments: BaseTool<{ document_ids: string[] }> = {
|
||||||
|
name: 'outline_bulk_restore_documents',
|
||||||
|
description: 'Restore multiple soft-deleted documents.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||||
|
},
|
||||||
|
required: ['document_ids'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||||
|
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||||
|
|
||||||
|
for (const id of args.document_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE documents
|
||||||
|
SET "deletedAt" = NULL, "deletedById" = NULL, "updatedAt" = NOW()
|
||||||
|
WHERE id = ANY($1) AND "deletedAt" IS NOT NULL
|
||||||
|
RETURNING id, title
|
||||||
|
`, [args.document_ids]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
restored: result.rows,
|
||||||
|
restoredCount: result.rows.length,
|
||||||
|
requestedCount: args.document_ids.length,
|
||||||
|
message: `${result.rows.length} documents restored`
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bulk.add_users_to_collection - Add multiple users to collection
|
||||||
|
*/
|
||||||
|
const bulkAddUsersToCollection: BaseTool<{ user_ids: string[]; collection_id: string; permission?: string }> = {
|
||||||
|
name: 'outline_bulk_add_users_to_collection',
|
||||||
|
description: 'Add multiple users to a collection with specified permission.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' },
|
||||||
|
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
|
||||||
|
permission: { type: 'string', description: 'Permission level: read_write, read, admin (default: read_write)' },
|
||||||
|
},
|
||||||
|
required: ['user_ids', 'collection_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required');
|
||||||
|
if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation');
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
for (const id of args.user_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = args.permission || 'read_write';
|
||||||
|
const creatorResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||||
|
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_ids[0];
|
||||||
|
|
||||||
|
const added: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (const userId of args.user_ids) {
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`,
|
||||||
|
[userId, args.collection_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
skipped.push(userId);
|
||||||
|
} else {
|
||||||
|
await pgClient.query(`
|
||||||
|
INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt")
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
`, [userId, args.collection_id, permission, createdById]);
|
||||||
|
added.push(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
addedUserIds: added,
|
||||||
|
skippedUserIds: skipped,
|
||||||
|
addedCount: added.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
permission,
|
||||||
|
message: `${added.length} users added, ${skipped.length} already existed`
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bulk.remove_users_from_collection - Remove multiple users from collection
|
||||||
|
*/
|
||||||
|
const bulkRemoveUsersFromCollection: BaseTool<{ user_ids: string[]; collection_id: string }> = {
|
||||||
|
name: 'outline_bulk_remove_users_from_collection',
|
||||||
|
description: 'Remove multiple users from a collection.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' },
|
||||||
|
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['user_ids', 'collection_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required');
|
||||||
|
if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation');
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
for (const id of args.user_ids) {
|
||||||
|
if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
DELETE FROM collection_users
|
||||||
|
WHERE "userId" = ANY($1) AND "collectionId" = $2
|
||||||
|
RETURNING "userId"
|
||||||
|
`, [args.user_ids, args.collection_id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
removedUserIds: result.rows.map(r => r.userId),
|
||||||
|
removedCount: result.rows.length,
|
||||||
|
requestedCount: args.user_ids.length,
|
||||||
|
message: `${result.rows.length} users removed from collection`
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkOperationsTools: BaseTool<any>[] = [
|
||||||
|
bulkArchiveDocuments, bulkDeleteDocuments, bulkMoveDocuments,
|
||||||
|
bulkRestoreDocuments, bulkAddUsersToCollection, bulkRemoveUsersFromCollection
|
||||||
|
];
|
||||||
@@ -252,7 +252,7 @@ const createComment: BaseTool<CreateCommentArgs> = {
|
|||||||
// Note: In real implementation, createdById should come from authentication context
|
// Note: In real implementation, createdById should come from authentication context
|
||||||
// For now, we'll get the first admin user
|
// For now, we'll get the first admin user
|
||||||
const userQuery = await pgClient.query(
|
const userQuery = await pgClient.query(
|
||||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userQuery.rows.length === 0) {
|
if (userQuery.rows.length === 0) {
|
||||||
@@ -427,7 +427,7 @@ const resolveComment: BaseTool<GetCommentArgs> = {
|
|||||||
|
|
||||||
// Get first admin user as resolver
|
// Get first admin user as resolver
|
||||||
const userQuery = await pgClient.query(
|
const userQuery = await pgClient.query(
|
||||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userQuery.rows.length === 0) {
|
if (userQuery.rows.length === 0) {
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
|
|||||||
|
|
||||||
// Obter primeiro utilizador activo como createdById (necessário)
|
// Obter primeiro utilizador activo como createdById (necessário)
|
||||||
const userResult = await pgClient.query(
|
const userResult = await pgClient.query(
|
||||||
`SELECT id FROM users WHERE "deletedAt" IS NULL AND "isSuspended" = false LIMIT 1`
|
`SELECT id FROM users WHERE "deletedAt" IS NULL AND "suspendedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userResult.rows.length === 0) {
|
if (userResult.rows.length === 0) {
|
||||||
|
|||||||
136
src/tools/emojis.ts
Normal file
136
src/tools/emojis.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Emojis Tools
|
||||||
|
* Custom emoji management
|
||||||
|
* @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 EmojiListArgs extends PaginationArgs {
|
||||||
|
team_id?: string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* emojis.list - List custom emojis
|
||||||
|
*/
|
||||||
|
const listEmojis: BaseTool<EmojiListArgs> = {
|
||||||
|
name: 'outline_list_emojis',
|
||||||
|
description: 'List custom emojis available in the workspace.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: { type: 'string', description: 'Filter by team ID (UUID)' },
|
||||||
|
search: { type: 'string', description: 'Search emojis by name' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id');
|
||||||
|
conditions.push(`e."teamId" = $${idx++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
if (args.search) {
|
||||||
|
conditions.push(`e.name ILIKE $${idx++}`);
|
||||||
|
params.push(`%${sanitizeInput(args.search)}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
e.id, e.name, e.url, e."teamId", e."createdById",
|
||||||
|
e."createdAt", e."updatedAt",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM emojis e
|
||||||
|
LEFT JOIN users u ON e."createdById" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY e.name ASC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* emojis.create - Create custom emoji
|
||||||
|
*/
|
||||||
|
const createEmoji: BaseTool<{ name: string; url: string }> = {
|
||||||
|
name: 'outline_create_emoji',
|
||||||
|
description: 'Create a new custom emoji.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: 'Emoji name (without colons)' },
|
||||||
|
url: { type: 'string', description: 'URL to emoji image' },
|
||||||
|
},
|
||||||
|
required: ['name', 'url'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const teamResult = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL 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');
|
||||||
|
|
||||||
|
// Check if emoji name already exists
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id FROM emojis WHERE name = $1 AND "teamId" = $2`,
|
||||||
|
[sanitizeInput(args.name), teamResult.rows[0].id]
|
||||||
|
);
|
||||||
|
if (existing.rows.length > 0) throw new Error('Emoji with this name already exists');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
INSERT INTO emojis (id, name, url, "teamId", "createdById", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [sanitizeInput(args.name), args.url, teamResult.rows[0].id, userResult.rows[0].id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Emoji created' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* emojis.delete - Delete custom emoji
|
||||||
|
*/
|
||||||
|
const deleteEmoji: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_delete_emoji',
|
||||||
|
description: 'Delete a custom emoji.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Emoji ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid emoji ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`DELETE FROM emojis WHERE id = $1 RETURNING id, name`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Emoji not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Emoji deleted' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emojisTools: BaseTool<any>[] = [listEmojis, createEmoji, deleteEmoji];
|
||||||
@@ -202,7 +202,7 @@ const getEvent: BaseTool<{ id: string }> = {
|
|||||||
e."createdAt",
|
e."createdAt",
|
||||||
actor.name as "actorName",
|
actor.name as "actorName",
|
||||||
actor.email as "actorEmail",
|
actor.email as "actorEmail",
|
||||||
actor."isAdmin" as "actorIsAdmin",
|
actor.role as "actorRole",
|
||||||
u.name as "userName",
|
u.name as "userName",
|
||||||
u.email as "userEmail",
|
u.email as "userEmail",
|
||||||
c.name as "collectionName",
|
c.name as "collectionName",
|
||||||
|
|||||||
@@ -53,14 +53,15 @@ const listGroups: BaseTool<GroupArgs> = {
|
|||||||
SELECT
|
SELECT
|
||||||
g.id,
|
g.id,
|
||||||
g.name,
|
g.name,
|
||||||
|
g.description,
|
||||||
g."teamId",
|
g."teamId",
|
||||||
g."createdById",
|
g."createdById",
|
||||||
g."createdAt",
|
g."createdAt",
|
||||||
g."updatedAt",
|
g."updatedAt",
|
||||||
t.name as "teamName",
|
t.name as "teamName",
|
||||||
u.name as "createdByName",
|
u.name as "createdByName",
|
||||||
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount",
|
(SELECT COUNT(*) FROM group_users gu WHERE gu."groupId" = g.id) as "memberCount",
|
||||||
(SELECT COUNT(*) FROM groups WHERE ${whereConditions.join(' AND ')}) as total
|
(SELECT COUNT(*) FROM groups g2 WHERE g2."deletedAt" IS NULL) as total
|
||||||
FROM groups g
|
FROM groups g
|
||||||
LEFT JOIN teams t ON g."teamId" = t.id
|
LEFT JOIN teams t ON g."teamId" = t.id
|
||||||
LEFT JOIN users u ON g."createdById" = u.id
|
LEFT JOIN users u ON g."createdById" = u.id
|
||||||
@@ -119,13 +120,14 @@ const getGroup: BaseTool<GetGroupArgs> = {
|
|||||||
SELECT
|
SELECT
|
||||||
g.id,
|
g.id,
|
||||||
g.name,
|
g.name,
|
||||||
|
g.description,
|
||||||
g."teamId",
|
g."teamId",
|
||||||
g."createdById",
|
g."createdById",
|
||||||
g."createdAt",
|
g."createdAt",
|
||||||
g."updatedAt",
|
g."updatedAt",
|
||||||
t.name as "teamName",
|
t.name as "teamName",
|
||||||
u.name as "createdByName",
|
u.name as "createdByName",
|
||||||
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount"
|
(SELECT COUNT(*) FROM group_users gu WHERE gu."groupId" = g.id) as "memberCount"
|
||||||
FROM groups g
|
FROM groups g
|
||||||
LEFT JOIN teams t ON g."teamId" = t.id
|
LEFT JOIN teams t ON g."teamId" = t.id
|
||||||
LEFT JOIN users u ON g."createdById" = u.id
|
LEFT JOIN users u ON g."createdById" = u.id
|
||||||
@@ -183,7 +185,7 @@ const createGroup: BaseTool<CreateGroupArgs> = {
|
|||||||
|
|
||||||
// Get first admin user as creator (adjust as needed)
|
// Get first admin user as creator (adjust as needed)
|
||||||
const userResult = await pgClient.query(
|
const userResult = await pgClient.query(
|
||||||
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1`
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
if (userResult.rows.length === 0) {
|
if (userResult.rows.length === 0) {
|
||||||
throw new Error('No admin user found');
|
throw new Error('No admin user found');
|
||||||
@@ -359,19 +361,19 @@ const listGroupMembers: BaseTool<GetGroupArgs> = {
|
|||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
gu.id as "membershipId",
|
|
||||||
gu."userId",
|
gu."userId",
|
||||||
gu."groupId",
|
gu."groupId",
|
||||||
gu."createdById",
|
gu."createdById",
|
||||||
gu."createdAt",
|
gu."createdAt",
|
||||||
|
gu.permission,
|
||||||
u.name as "userName",
|
u.name as "userName",
|
||||||
u.email as "userEmail",
|
u.email as "userEmail",
|
||||||
u."isAdmin" as "userIsAdmin",
|
u.role as "userRole",
|
||||||
creator.name as "addedByName"
|
creator.name as "addedByName"
|
||||||
FROM group_users gu
|
FROM group_users gu
|
||||||
JOIN users u ON gu."userId" = u.id
|
JOIN users u ON gu."userId" = u.id
|
||||||
LEFT JOIN users creator ON gu."createdById" = creator.id
|
LEFT JOIN users creator ON gu."createdById" = creator.id
|
||||||
WHERE gu."groupId" = $1 AND gu."deletedAt" IS NULL AND u."deletedAt" IS NULL
|
WHERE gu."groupId" = $1 AND u."deletedAt" IS NULL
|
||||||
ORDER BY gu."createdAt" DESC
|
ORDER BY gu."createdAt" DESC
|
||||||
`,
|
`,
|
||||||
[args.id]
|
[args.id]
|
||||||
@@ -445,7 +447,7 @@ const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
|
|||||||
|
|
||||||
// Check if user is already in group
|
// Check if user is already in group
|
||||||
const existingMembership = await pgClient.query(
|
const existingMembership = await pgClient.query(
|
||||||
`SELECT id FROM group_users WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL`,
|
`SELECT "userId" FROM group_users WHERE "groupId" = $1 AND "userId" = $2`,
|
||||||
[args.id, args.user_id]
|
[args.id, args.user_id]
|
||||||
);
|
);
|
||||||
if (existingMembership.rows.length > 0) {
|
if (existingMembership.rows.length > 0) {
|
||||||
@@ -454,18 +456,18 @@ const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
|
|||||||
|
|
||||||
// Get first admin user as creator (adjust as needed)
|
// Get first admin user as creator (adjust as needed)
|
||||||
const creatorResult = await pgClient.query(
|
const creatorResult = await pgClient.query(
|
||||||
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1`
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
|
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
|
||||||
|
|
||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
INSERT INTO group_users (
|
INSERT INTO group_users (
|
||||||
id, "userId", "groupId", "createdById",
|
"userId", "groupId", "createdById",
|
||||||
"createdAt", "updatedAt"
|
"createdAt", "updatedAt"
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
gen_random_uuid(), $1, $2, $3,
|
$1, $2, $3,
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
@@ -521,10 +523,9 @@ const removeUserFromGroup: BaseTool<{ id: string; user_id: string }> = {
|
|||||||
|
|
||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
UPDATE group_users
|
DELETE FROM group_users
|
||||||
SET "deletedAt" = NOW()
|
WHERE "groupId" = $1 AND "userId" = $2
|
||||||
WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL
|
RETURNING "userId", "groupId"
|
||||||
RETURNING id, "userId", "groupId"
|
|
||||||
`,
|
`,
|
||||||
[args.id, args.user_id]
|
[args.id, args.user_id]
|
||||||
);
|
);
|
||||||
|
|||||||
179
src/tools/imports-tools.ts
Normal file
179
src/tools/imports-tools.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Imports 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 ImportListArgs extends PaginationArgs {
|
||||||
|
team_id?: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* imports.list - List imports
|
||||||
|
*/
|
||||||
|
const listImports: BaseTool<ImportListArgs> = {
|
||||||
|
name: 'outline_list_imports',
|
||||||
|
description: 'List document import jobs.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: { type: 'string', description: 'Filter by team ID (UUID)' },
|
||||||
|
state: { type: 'string', description: 'Filter by state (pending, processing, completed, failed)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id');
|
||||||
|
conditions.push(`i."teamId" = $${idx++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
if (args.state) {
|
||||||
|
conditions.push(`i.state = $${idx++}`);
|
||||||
|
params.push(args.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
i.id, i.state, i.type, i."documentCount", i."fileCount",
|
||||||
|
i."teamId", i."createdById", i."integrationId",
|
||||||
|
i."createdAt", i."updatedAt",
|
||||||
|
u.name as "createdByName",
|
||||||
|
t.name as "teamName"
|
||||||
|
FROM imports i
|
||||||
|
LEFT JOIN users u ON i."createdById" = u.id
|
||||||
|
LEFT JOIN teams t ON i."teamId" = t.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY i."createdAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* imports.status - Get import status
|
||||||
|
*/
|
||||||
|
const getImportStatus: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_get_import_status',
|
||||||
|
description: 'Get detailed status of an import job.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Import ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid import ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT i.*, u.name as "createdByName"
|
||||||
|
FROM imports i
|
||||||
|
LEFT JOIN users u ON i."createdById" = u.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Import not found');
|
||||||
|
|
||||||
|
// Get import tasks
|
||||||
|
const tasks = await pgClient.query(`
|
||||||
|
SELECT id, state, "documentId", "createdAt"
|
||||||
|
FROM import_tasks
|
||||||
|
WHERE "importId" = $1
|
||||||
|
ORDER BY "createdAt" DESC
|
||||||
|
LIMIT 50
|
||||||
|
`, [args.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
import: result.rows[0],
|
||||||
|
tasks: tasks.rows,
|
||||||
|
taskCount: tasks.rows.length,
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* imports.create - Create import job
|
||||||
|
*/
|
||||||
|
const createImport: BaseTool<{ type: string; collection_id?: string }> = {
|
||||||
|
name: 'outline_create_import',
|
||||||
|
description: 'Create a new import job. Note: This creates the job record, actual file upload handled separately.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: { type: 'string', description: 'Import type (e.g., "notion", "confluence", "markdown")' },
|
||||||
|
collection_id: { type: 'string', description: 'Target collection ID (UUID, optional)' },
|
||||||
|
},
|
||||||
|
required: ['type'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
const team = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`);
|
||||||
|
if (team.rows.length === 0) throw new Error('No team found');
|
||||||
|
|
||||||
|
const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||||
|
if (user.rows.length === 0) throw new Error('No admin user found');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
INSERT INTO imports (id, type, state, "teamId", "createdById", "documentCount", "fileCount", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, 'pending', $2, $3, 0, 0, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [args.type, team.rows[0].id, user.rows[0].id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Import job created' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* imports.cancel - Cancel import job
|
||||||
|
*/
|
||||||
|
const cancelImport: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_cancel_import',
|
||||||
|
description: 'Cancel a pending or processing import job.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Import ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid import ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE imports
|
||||||
|
SET state = 'cancelled', "updatedAt" = NOW()
|
||||||
|
WHERE id = $1 AND state IN ('pending', 'processing')
|
||||||
|
RETURNING id, state, type
|
||||||
|
`, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Import not found or cannot be cancelled');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Import cancelled' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importsTools: BaseTool<any>[] = [listImports, getImportStatus, createImport, cancelImport];
|
||||||
@@ -39,3 +39,60 @@ 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';
|
||||||
|
|
||||||
|
// Teams Tools - Team/workspace management
|
||||||
|
export { teamsTools } from './teams.js';
|
||||||
|
|
||||||
|
// Integrations Tools - External integrations (Slack, embeds, etc.)
|
||||||
|
export { integrationsTools } from './integrations.js';
|
||||||
|
|
||||||
|
// Notifications Tools - User notifications
|
||||||
|
export { notificationsTools } from './notifications.js';
|
||||||
|
|
||||||
|
// Subscriptions Tools - Document subscriptions
|
||||||
|
export { subscriptionsTools } from './subscriptions.js';
|
||||||
|
|
||||||
|
// Templates Tools - Document templates
|
||||||
|
export { templatesTools } from './templates.js';
|
||||||
|
|
||||||
|
// Imports Tools - Import job management
|
||||||
|
export { importsTools } from './imports-tools.js';
|
||||||
|
|
||||||
|
// Emojis Tools - Custom emoji management
|
||||||
|
export { emojisTools } from './emojis.js';
|
||||||
|
|
||||||
|
// User Permissions Tools - Permission management
|
||||||
|
export { userPermissionsTools } from './user-permissions.js';
|
||||||
|
|
||||||
|
// Bulk Operations Tools - Batch operations
|
||||||
|
export { bulkOperationsTools } from './bulk-operations.js';
|
||||||
|
|
||||||
|
// Advanced Search Tools - Full-text search and facets
|
||||||
|
export { advancedSearchTools } from './advanced-search.js';
|
||||||
|
|
||||||
|
// Analytics Tools - Usage statistics and insights
|
||||||
|
export { analyticsTools } from './analytics.js';
|
||||||
|
|||||||
275
src/tools/integrations.ts
Normal file
275
src/tools/integrations.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Integrations Tools
|
||||||
|
* CRÍTICO para embeds e integrações externas
|
||||||
|
* @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 IntegrationListArgs extends PaginationArgs {
|
||||||
|
team_id?: string;
|
||||||
|
service?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntegrationCreateArgs {
|
||||||
|
service: string;
|
||||||
|
type?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
events?: string[];
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntegrationUpdateArgs {
|
||||||
|
id: string;
|
||||||
|
events?: string[];
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* integrations.list - List integrations
|
||||||
|
*/
|
||||||
|
const listIntegrations: BaseTool<IntegrationListArgs> = {
|
||||||
|
name: 'outline_list_integrations',
|
||||||
|
description: 'List configured integrations (Slack, embed sources, etc.).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: { type: 'string', description: 'Filter by team ID (UUID)' },
|
||||||
|
service: { type: 'string', description: 'Filter by service (e.g., "slack", "github")' },
|
||||||
|
type: { type: 'string', description: 'Filter by type (e.g., "embed", "linkedAccount")' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = ['i."deletedAt" IS NULL'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id');
|
||||||
|
conditions.push(`i."teamId" = $${idx++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
if (args.service) {
|
||||||
|
conditions.push(`i.service = $${idx++}`);
|
||||||
|
params.push(sanitizeInput(args.service));
|
||||||
|
}
|
||||||
|
if (args.type) {
|
||||||
|
conditions.push(`i.type = $${idx++}`);
|
||||||
|
params.push(sanitizeInput(args.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
i.id, i.service, i.type, i.events, i.settings,
|
||||||
|
i."teamId", i."userId", i."collectionId", i."authenticationId",
|
||||||
|
i."createdAt", i."updatedAt",
|
||||||
|
t.name as "teamName",
|
||||||
|
u.name as "userName",
|
||||||
|
c.name as "collectionName"
|
||||||
|
FROM integrations i
|
||||||
|
LEFT JOIN teams t ON i."teamId" = t.id
|
||||||
|
LEFT JOIN users u ON i."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON i."collectionId" = c.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY i."createdAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* integrations.info - Get integration details
|
||||||
|
*/
|
||||||
|
const getIntegration: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_get_integration',
|
||||||
|
description: 'Get detailed information about a specific integration.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
i.*, t.name as "teamName", u.name as "userName", c.name as "collectionName"
|
||||||
|
FROM integrations i
|
||||||
|
LEFT JOIN teams t ON i."teamId" = t.id
|
||||||
|
LEFT JOIN users u ON i."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON i."collectionId" = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* integrations.create - Create integration
|
||||||
|
*/
|
||||||
|
const createIntegration: BaseTool<IntegrationCreateArgs> = {
|
||||||
|
name: 'outline_create_integration',
|
||||||
|
description: 'Create a new integration (embed source, webhook, etc.).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
service: { type: 'string', description: 'Service name (e.g., "slack", "github", "figma")' },
|
||||||
|
type: { type: 'string', description: 'Integration type (e.g., "embed", "linkedAccount")' },
|
||||||
|
collection_id: { type: 'string', description: 'Link to collection (UUID, optional)' },
|
||||||
|
events: { type: 'array', items: { type: 'string' }, description: 'Events to listen for' },
|
||||||
|
settings: { type: 'object', description: 'Integration settings' },
|
||||||
|
},
|
||||||
|
required: ['service'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
const teamResult = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL 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`);
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
INSERT INTO integrations (id, service, type, "teamId", "userId", "collectionId", events, settings, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
sanitizeInput(args.service),
|
||||||
|
args.type || 'embed',
|
||||||
|
teamResult.rows[0].id,
|
||||||
|
userResult.rows.length > 0 ? userResult.rows[0].id : null,
|
||||||
|
args.collection_id || null,
|
||||||
|
args.events || [],
|
||||||
|
args.settings ? JSON.stringify(args.settings) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration created' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* integrations.update - Update integration
|
||||||
|
*/
|
||||||
|
const updateIntegration: BaseTool<IntegrationUpdateArgs> = {
|
||||||
|
name: 'outline_update_integration',
|
||||||
|
description: 'Update an integration settings or events.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||||
|
events: { type: 'array', items: { type: 'string' }, description: 'Events to listen for' },
|
||||||
|
settings: { type: 'object', description: 'Settings to merge' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||||
|
|
||||||
|
const updates: string[] = ['"updatedAt" = NOW()'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.events) {
|
||||||
|
updates.push(`events = $${idx++}`);
|
||||||
|
params.push(args.events);
|
||||||
|
}
|
||||||
|
if (args.settings) {
|
||||||
|
updates.push(`settings = COALESCE(settings, '{}'::jsonb) || $${idx++}::jsonb`);
|
||||||
|
params.push(JSON.stringify(args.settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(args.id);
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE integrations SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration updated' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* integrations.delete - Delete integration
|
||||||
|
*/
|
||||||
|
const deleteIntegration: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_delete_integration',
|
||||||
|
description: 'Soft delete an integration.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE integrations SET "deletedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, service, type`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration deleted' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* integrations.sync - Trigger integration sync
|
||||||
|
*/
|
||||||
|
const syncIntegration: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_sync_integration',
|
||||||
|
description: 'Trigger a sync for an integration. Updates lastSyncedAt timestamp.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE integrations SET "updatedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, service, type, "updatedAt"`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Sync triggered', lastSyncedAt: result.rows[0].updatedAt }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const integrationsTools: BaseTool<any>[] = [
|
||||||
|
listIntegrations, getIntegration, createIntegration, updateIntegration, deleteIntegration, syncIntegration
|
||||||
|
];
|
||||||
173
src/tools/notifications.ts
Normal file
173
src/tools/notifications.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Notifications 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 NotificationListArgs extends PaginationArgs {
|
||||||
|
user_id?: string;
|
||||||
|
event?: string;
|
||||||
|
unread_only?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* notifications.list - List notifications
|
||||||
|
*/
|
||||||
|
const listNotifications: BaseTool<NotificationListArgs> = {
|
||||||
|
name: 'outline_list_notifications',
|
||||||
|
description: 'List notifications for a user. Can filter by event type and read status.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'Filter by user ID (UUID)' },
|
||||||
|
event: { type: 'string', description: 'Filter by event type (e.g., "documents.update")' },
|
||||||
|
unread_only: { type: 'boolean', description: 'Only show unread notifications (default: false)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = ['n."archivedAt" IS NULL'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
conditions.push(`n."userId" = $${idx++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
if (args.event) {
|
||||||
|
conditions.push(`n.event = $${idx++}`);
|
||||||
|
params.push(args.event);
|
||||||
|
}
|
||||||
|
if (args.unread_only) {
|
||||||
|
conditions.push(`n."viewedAt" IS NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
n.id, n.event, n.data, n."viewedAt", n."emailedAt", n."createdAt",
|
||||||
|
n."userId", n."actorId", n."documentId", n."collectionId", n."commentId",
|
||||||
|
actor.name as "actorName",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
c.name as "collectionName"
|
||||||
|
FROM notifications n
|
||||||
|
LEFT JOIN users actor ON n."actorId" = actor.id
|
||||||
|
LEFT JOIN documents d ON n."documentId" = d.id
|
||||||
|
LEFT JOIN collections c ON n."collectionId" = c.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY n."createdAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* notifications.read - Mark notification as read
|
||||||
|
*/
|
||||||
|
const markNotificationRead: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_mark_notification_read',
|
||||||
|
description: 'Mark a notification as read (sets viewedAt).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Notification ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid notification ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE notifications SET "viewedAt" = NOW() WHERE id = $1 AND "viewedAt" IS NULL RETURNING id, event, "viewedAt"`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Notification not found or already read');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Marked as read' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* notifications.readAll - Mark all notifications as read
|
||||||
|
*/
|
||||||
|
const markAllNotificationsRead: BaseTool<{ user_id: string }> = {
|
||||||
|
name: 'outline_mark_all_notifications_read',
|
||||||
|
description: 'Mark all notifications as read for a user.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE notifications SET "viewedAt" = NOW() WHERE "userId" = $1 AND "viewedAt" IS NULL RETURNING id`,
|
||||||
|
[args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ markedCount: result.rows.length, message: 'All notifications marked as read' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* notifications.settings - Get notification settings for user
|
||||||
|
*/
|
||||||
|
const getNotificationSettings: BaseTool<{ user_id: string }> = {
|
||||||
|
name: 'outline_get_notification_settings',
|
||||||
|
description: 'Get notification settings/preferences for a user.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`SELECT id, name, email, "notificationSettings" FROM users WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||||
|
[args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('User not found');
|
||||||
|
|
||||||
|
// Also get notification stats
|
||||||
|
const stats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE "viewedAt" IS NULL) as unread
|
||||||
|
FROM notifications
|
||||||
|
WHERE "userId" = $1 AND "archivedAt" IS NULL
|
||||||
|
`, [args.user_id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
user: { id: result.rows[0].id, name: result.rows[0].name },
|
||||||
|
settings: result.rows[0].notificationSettings,
|
||||||
|
stats: stats.rows[0],
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notificationsTools: BaseTool<any>[] = [
|
||||||
|
listNotifications, markNotificationRead, markAllNotificationsRead, getNotificationSettings
|
||||||
|
];
|
||||||
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];
|
||||||
@@ -260,7 +260,7 @@ const createShare: BaseTool<CreateShareArgs> = {
|
|||||||
|
|
||||||
// Get first admin user as creator
|
// Get first admin user as creator
|
||||||
const userQuery = await pgClient.query(
|
const userQuery = await pgClient.query(
|
||||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userQuery.rows.length === 0) {
|
if (userQuery.rows.length === 0) {
|
||||||
@@ -416,7 +416,7 @@ const revokeShare: BaseTool<GetShareArgs> = {
|
|||||||
|
|
||||||
// Get first admin user as revoker
|
// Get first admin user as revoker
|
||||||
const userQuery = await pgClient.query(
|
const userQuery = await pgClient.query(
|
||||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userQuery.rows.length === 0) {
|
if (userQuery.rows.length === 0) {
|
||||||
|
|||||||
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];
|
||||||
192
src/tools/subscriptions.ts
Normal file
192
src/tools/subscriptions.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Subscriptions 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 SubscriptionListArgs extends PaginationArgs {
|
||||||
|
user_id?: string;
|
||||||
|
document_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* subscriptions.list - List subscriptions
|
||||||
|
*/
|
||||||
|
const listSubscriptions: BaseTool<SubscriptionListArgs> = {
|
||||||
|
name: 'outline_list_subscriptions',
|
||||||
|
description: 'List document subscriptions. Subscriptions determine who gets notified of document changes.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'Filter by user ID (UUID)' },
|
||||||
|
document_id: { type: 'string', description: 'Filter by document ID (UUID)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
conditions.push(`s."userId" = $${idx++}`);
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
conditions.push(`s."documentId" = $${idx++}`);
|
||||||
|
params.push(args.document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
s.id, s."userId", s."documentId", s.event, s."createdAt",
|
||||||
|
u.name as "userName", u.email as "userEmail",
|
||||||
|
d.title as "documentTitle"
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN users u ON s."userId" = u.id
|
||||||
|
LEFT JOIN documents d ON s."documentId" = d.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY s."createdAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* subscriptions.create - Subscribe to document
|
||||||
|
*/
|
||||||
|
const subscribeToDocument: BaseTool<{ document_id: string; user_id: string; event?: string }> = {
|
||||||
|
name: 'outline_subscribe_to_document',
|
||||||
|
description: 'Subscribe a user to document notifications.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: { type: 'string', description: 'Document ID (UUID)' },
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
event: { type: 'string', description: 'Event type to subscribe to (default: all)' },
|
||||||
|
},
|
||||||
|
required: ['document_id', 'user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
|
||||||
|
// Check if already subscribed
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id FROM subscriptions WHERE "documentId" = $1 AND "userId" = $2`,
|
||||||
|
[args.document_id, args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
throw new Error('User already subscribed to this document');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
INSERT INTO subscriptions (id, "documentId", "userId", event, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [args.document_id, args.user_id, args.event || 'documents.update']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Subscribed successfully' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* subscriptions.delete - Unsubscribe from document
|
||||||
|
*/
|
||||||
|
const unsubscribeFromDocument: BaseTool<{ id?: string; document_id?: string; user_id?: string }> = {
|
||||||
|
name: 'outline_unsubscribe_from_document',
|
||||||
|
description: 'Unsubscribe from document notifications.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Subscription ID (UUID)' },
|
||||||
|
document_id: { type: 'string', description: 'Document ID (requires user_id)' },
|
||||||
|
user_id: { type: 'string', description: 'User ID (requires document_id)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid id');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM subscriptions WHERE id = $1 RETURNING id, "documentId", "userId"`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
} else if (args.document_id && args.user_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM subscriptions WHERE "documentId" = $1 AND "userId" = $2 RETURNING id, "documentId", "userId"`,
|
||||||
|
[args.document_id, args.user_id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Either id or (document_id + user_id) required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Subscription not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Unsubscribed successfully' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* subscriptions.settings - Get subscription settings
|
||||||
|
*/
|
||||||
|
const getSubscriptionSettings: BaseTool<{ user_id: string }> = {
|
||||||
|
name: 'outline_get_subscription_settings',
|
||||||
|
description: 'Get subscription summary and settings for a user.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
|
||||||
|
const subscriptions = await pgClient.query(`
|
||||||
|
SELECT s.id, s."documentId", s.event, d.title as "documentTitle"
|
||||||
|
FROM subscriptions s
|
||||||
|
LEFT JOIN documents d ON s."documentId" = d.id
|
||||||
|
WHERE s."userId" = $1
|
||||||
|
ORDER BY s."createdAt" DESC
|
||||||
|
`, [args.user_id]);
|
||||||
|
|
||||||
|
const userSettings = await pgClient.query(
|
||||||
|
`SELECT "notificationSettings" FROM users WHERE id = $1`,
|
||||||
|
[args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({
|
||||||
|
subscriptions: subscriptions.rows,
|
||||||
|
totalSubscriptions: subscriptions.rows.length,
|
||||||
|
userSettings: userSettings.rows[0]?.notificationSettings || {},
|
||||||
|
}, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscriptionsTools: BaseTool<any>[] = [
|
||||||
|
listSubscriptions, subscribeToDocument, unsubscribeFromDocument, getSubscriptionSettings
|
||||||
|
];
|
||||||
222
src/tools/teams.ts
Normal file
222
src/tools/teams.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Teams Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||||
|
import { isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* teams.info - Get team details
|
||||||
|
*/
|
||||||
|
const getTeam: BaseTool<{ id?: string }> = {
|
||||||
|
name: 'outline_get_team',
|
||||||
|
description: 'Get detailed information about a team (workspace). If no ID provided, returns the first/default team.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Team ID (UUID, optional)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
t.id, t.name, t.subdomain, t.domain, t."avatarUrl",
|
||||||
|
t.sharing, t."documentEmbeds", t."guestSignin", t."inviteRequired",
|
||||||
|
t."collaborativeEditing", t."defaultUserRole", t."memberCollectionCreate",
|
||||||
|
t."memberTeamCreate", t."passkeysEnabled", t.description, t.preferences,
|
||||||
|
t."lastActiveAt", t."suspendedAt", t."createdAt", t."updatedAt",
|
||||||
|
(SELECT COUNT(*) FROM users WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "userCount",
|
||||||
|
(SELECT COUNT(*) FROM collections WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "collectionCount",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "documentCount"
|
||||||
|
FROM teams t
|
||||||
|
WHERE t."deletedAt" IS NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
if (args.id) {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||||
|
query += ` AND t.id = $1`;
|
||||||
|
params.push(args.id);
|
||||||
|
}
|
||||||
|
query += ` LIMIT 1`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
if (result.rows.length === 0) throw new Error('Team not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* teams.update - Update team settings
|
||||||
|
*/
|
||||||
|
const updateTeam: BaseTool<{
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
sharing?: boolean;
|
||||||
|
document_embeds?: boolean;
|
||||||
|
guest_signin?: boolean;
|
||||||
|
invite_required?: boolean;
|
||||||
|
default_user_role?: string;
|
||||||
|
}> = {
|
||||||
|
name: 'outline_update_team',
|
||||||
|
description: 'Update team settings and preferences.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Team ID (UUID)' },
|
||||||
|
name: { type: 'string', description: 'Team name' },
|
||||||
|
sharing: { type: 'boolean', description: 'Allow document sharing' },
|
||||||
|
document_embeds: { type: 'boolean', description: 'Allow document embeds' },
|
||||||
|
guest_signin: { type: 'boolean', description: 'Allow guest signin' },
|
||||||
|
invite_required: { type: 'boolean', description: 'Require invite to join' },
|
||||||
|
default_user_role: { type: 'string', enum: ['admin', 'member', 'viewer'], description: 'Default role for new users' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||||
|
|
||||||
|
const updates: string[] = ['"updatedAt" = NOW()'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.name) { updates.push(`name = $${idx++}`); params.push(sanitizeInput(args.name)); }
|
||||||
|
if (args.sharing !== undefined) { updates.push(`sharing = $${idx++}`); params.push(args.sharing); }
|
||||||
|
if (args.document_embeds !== undefined) { updates.push(`"documentEmbeds" = $${idx++}`); params.push(args.document_embeds); }
|
||||||
|
if (args.guest_signin !== undefined) { updates.push(`"guestSignin" = $${idx++}`); params.push(args.guest_signin); }
|
||||||
|
if (args.invite_required !== undefined) { updates.push(`"inviteRequired" = $${idx++}`); params.push(args.invite_required); }
|
||||||
|
if (args.default_user_role) { updates.push(`"defaultUserRole" = $${idx++}`); params.push(args.default_user_role); }
|
||||||
|
|
||||||
|
params.push(args.id);
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE teams SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Team not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Team updated' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* teams.stats - Get team statistics
|
||||||
|
*/
|
||||||
|
const getTeamStats: BaseTool<{ id?: string }> = {
|
||||||
|
name: 'outline_get_team_stats',
|
||||||
|
description: 'Get comprehensive statistics for a team including users, documents, collections, and activity.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Team ID (UUID, optional - uses default team)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let teamCondition = '';
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||||
|
teamCondition = `AND "teamId" = $1`;
|
||||||
|
params.push(args.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL ${teamCondition}) as "totalUsers",
|
||||||
|
(SELECT COUNT(*) FROM users WHERE role = 'admin' AND "deletedAt" IS NULL ${teamCondition}) as "adminUsers",
|
||||||
|
(SELECT COUNT(*) FROM users WHERE "suspendedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition}) as "suspendedUsers",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "totalDocuments",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE template = true AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "templateDocuments",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "publishedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "publishedDocuments",
|
||||||
|
(SELECT COUNT(*) FROM collections WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'c."teamId"')}) as "totalCollections",
|
||||||
|
(SELECT COUNT(*) FROM groups WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'g."teamId"')}) as "totalGroups",
|
||||||
|
(SELECT COUNT(*) FROM shares ${args.id ? 'WHERE "teamId" = $1' : ''}) as "totalShares",
|
||||||
|
(SELECT COUNT(*) FROM integrations WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'i."teamId"')}) as "totalIntegrations"
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: stats.rows[0] }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* teams.domains - List team domains
|
||||||
|
*/
|
||||||
|
const listTeamDomains: BaseTool<{ team_id?: string }> = {
|
||||||
|
name: 'outline_list_team_domains',
|
||||||
|
description: 'List allowed domains for a team. Domains control who can sign up.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: { type: 'string', description: 'Team ID (UUID, optional)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
td.id, td.name, td."teamId", td."createdById", td."createdAt",
|
||||||
|
u.name as "createdByName", t.name as "teamName"
|
||||||
|
FROM team_domains td
|
||||||
|
LEFT JOIN users u ON td."createdById" = u.id
|
||||||
|
LEFT JOIN teams t ON td."teamId" = t.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
if (args.team_id) {
|
||||||
|
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||||
|
query += ` WHERE td."teamId" = $1`;
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
query += ` ORDER BY td."createdAt" DESC`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* teams.updateSettings - Update team preferences
|
||||||
|
*/
|
||||||
|
const updateTeamSettings: BaseTool<{ id: string; preferences: Record<string, any> }> = {
|
||||||
|
name: 'outline_update_team_settings',
|
||||||
|
description: 'Update team preferences (JSON settings object).',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Team ID (UUID)' },
|
||||||
|
preferences: { type: 'object', description: 'Preferences object to merge' },
|
||||||
|
},
|
||||||
|
required: ['id', 'preferences'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`UPDATE teams SET preferences = COALESCE(preferences, '{}'::jsonb) || $1::jsonb, "updatedAt" = NOW()
|
||||||
|
WHERE id = $2 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, name, preferences`,
|
||||||
|
[JSON.stringify(args.preferences), args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Team not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Settings updated' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const teamsTools: BaseTool<any>[] = [getTeam, updateTeam, getTeamStats, listTeamDomains, updateTeamSettings];
|
||||||
223
src/tools/templates.ts
Normal file
223
src/tools/templates.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Templates Tools
|
||||||
|
* Templates are documents with template=true
|
||||||
|
* @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 TemplateListArgs extends PaginationArgs {
|
||||||
|
collection_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* templates.list - List templates
|
||||||
|
*/
|
||||||
|
const listTemplates: BaseTool<TemplateListArgs> = {
|
||||||
|
name: 'outline_list_templates',
|
||||||
|
description: 'List document templates. Templates are reusable document structures.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||||
|
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = ['d.template = true', 'd."deletedAt" IS NULL'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.collection_id) {
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
conditions.push(`d."collectionId" = $${idx++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.id, d.title, d.emoji, d."collectionId", d."createdById",
|
||||||
|
d."createdAt", d."updatedAt",
|
||||||
|
c.name as "collectionName",
|
||||||
|
u.name as "createdByName",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON d."createdById" = u.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY d."updatedAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* templates.info - Get template details
|
||||||
|
*/
|
||||||
|
const getTemplate: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_get_template',
|
||||||
|
description: 'Get detailed information about a template including its content.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', description: 'Template ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) throw new Error('Invalid template ID');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
d.*, c.name as "collectionName", u.name as "createdByName",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON d."createdById" = u.id
|
||||||
|
WHERE d.id = $1 AND d.template = true AND d."deletedAt" IS NULL
|
||||||
|
`, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Template not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* templates.create - Create document from template
|
||||||
|
*/
|
||||||
|
const createFromTemplate: BaseTool<{ template_id: string; title: string; collection_id?: string; parent_document_id?: string }> = {
|
||||||
|
name: 'outline_create_from_template',
|
||||||
|
description: 'Create a new document from a template.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
template_id: { type: 'string', description: 'Template ID (UUID)' },
|
||||||
|
title: { type: 'string', description: 'Title for the new document' },
|
||||||
|
collection_id: { type: 'string', description: 'Collection ID (UUID, optional - uses template collection)' },
|
||||||
|
parent_document_id: { type: 'string', description: 'Parent document ID (UUID, optional)' },
|
||||||
|
},
|
||||||
|
required: ['template_id', 'title'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
|
||||||
|
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
|
||||||
|
|
||||||
|
// Get template
|
||||||
|
const template = await pgClient.query(
|
||||||
|
`SELECT * FROM documents WHERE id = $1 AND template = true AND "deletedAt" IS NULL`,
|
||||||
|
[args.template_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (template.rows.length === 0) throw new Error('Template not found');
|
||||||
|
|
||||||
|
const t = template.rows[0];
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||||
|
const userId = user.rows.length > 0 ? user.rows[0].id : t.createdById;
|
||||||
|
|
||||||
|
// Create document from template
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
INSERT INTO documents (
|
||||||
|
id, title, text, emoji, "collectionId", "teamId", "parentDocumentId",
|
||||||
|
"templateId", "createdById", "lastModifiedById", template,
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $8, false, NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
sanitizeInput(args.title),
|
||||||
|
t.text,
|
||||||
|
t.emoji,
|
||||||
|
args.collection_id || t.collectionId,
|
||||||
|
t.teamId,
|
||||||
|
args.parent_document_id || null,
|
||||||
|
args.template_id,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Document created from template' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* templates.convert - Convert document to template
|
||||||
|
*/
|
||||||
|
const convertToTemplate: BaseTool<{ document_id: string }> = {
|
||||||
|
name: 'outline_convert_to_template',
|
||||||
|
description: 'Convert an existing document to a template.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: { type: 'string', description: 'Document ID to convert (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['document_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE documents
|
||||||
|
SET template = true, "updatedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL AND template = false
|
||||||
|
RETURNING id, title, template
|
||||||
|
`, [args.document_id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Document not found or already a template');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to template' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* templates.unconvert - Convert template back to document
|
||||||
|
*/
|
||||||
|
const convertFromTemplate: BaseTool<{ template_id: string }> = {
|
||||||
|
name: 'outline_convert_from_template',
|
||||||
|
description: 'Convert a template back to a regular document.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
template_id: { type: 'string', description: 'Template ID to convert (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['template_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
|
||||||
|
|
||||||
|
const result = await pgClient.query(`
|
||||||
|
UPDATE documents
|
||||||
|
SET template = false, "updatedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL AND template = true
|
||||||
|
RETURNING id, title, template
|
||||||
|
`, [args.template_id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Template not found or already a document');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to document' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templatesTools: BaseTool<any>[] = [
|
||||||
|
listTemplates, getTemplate, createFromTemplate, convertToTemplate, convertFromTemplate
|
||||||
|
];
|
||||||
243
src/tools/user-permissions.ts
Normal file
243
src/tools/user-permissions.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - User Permissions Tools
|
||||||
|
* Document/Collection level permission management
|
||||||
|
* @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 PermissionListArgs extends PaginationArgs {
|
||||||
|
user_id?: string;
|
||||||
|
document_id?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* user_permissions.list - List user permissions
|
||||||
|
*/
|
||||||
|
const listUserPermissions: BaseTool<PermissionListArgs> = {
|
||||||
|
name: 'outline_list_user_permissions',
|
||||||
|
description: 'List user permissions on documents and collections.',
|
||||||
|
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: 'Max results (default: 25)' },
|
||||||
|
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
|
||||||
|
const results: any = { documentPermissions: [], collectionPermissions: [] };
|
||||||
|
|
||||||
|
// Document permissions (user_permissions table)
|
||||||
|
if (!args.collection_id) {
|
||||||
|
const docConditions: string[] = [];
|
||||||
|
const docParams: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
docConditions.push(`up."userId" = $${idx++}`);
|
||||||
|
docParams.push(args.user_id);
|
||||||
|
}
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
docConditions.push(`up."documentId" = $${idx++}`);
|
||||||
|
docParams.push(args.document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docWhere = docConditions.length > 0 ? `WHERE ${docConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const docResult = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
up.id, up."userId", up."documentId", up.permission,
|
||||||
|
up."createdById", up."createdAt", up."updatedAt",
|
||||||
|
u.name as "userName", u.email as "userEmail",
|
||||||
|
d.title as "documentTitle"
|
||||||
|
FROM user_permissions up
|
||||||
|
LEFT JOIN users u ON up."userId" = u.id
|
||||||
|
LEFT JOIN documents d ON up."documentId" = d.id
|
||||||
|
${docWhere}
|
||||||
|
ORDER BY up."createdAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...docParams, limit, offset]);
|
||||||
|
|
||||||
|
results.documentPermissions = docResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection permissions (collection_users table)
|
||||||
|
if (!args.document_id) {
|
||||||
|
const colConditions: string[] = [];
|
||||||
|
const colParams: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
colConditions.push(`cu."userId" = $${idx++}`);
|
||||||
|
colParams.push(args.user_id);
|
||||||
|
}
|
||||||
|
if (args.collection_id) {
|
||||||
|
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||||
|
colConditions.push(`cu."collectionId" = $${idx++}`);
|
||||||
|
colParams.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colWhere = colConditions.length > 0 ? `WHERE ${colConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const colResult = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
cu."userId", cu."collectionId", cu.permission,
|
||||||
|
cu."createdById", cu."createdAt", cu."updatedAt",
|
||||||
|
u.name as "userName", u.email as "userEmail",
|
||||||
|
c.name as "collectionName"
|
||||||
|
FROM collection_users cu
|
||||||
|
LEFT JOIN users u ON cu."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON cu."collectionId" = c.id
|
||||||
|
${colWhere}
|
||||||
|
ORDER BY cu."createdAt" DESC
|
||||||
|
LIMIT $${idx++} OFFSET $${idx}
|
||||||
|
`, [...colParams, limit, offset]);
|
||||||
|
|
||||||
|
results.collectionPermissions = colResult.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: results, pagination: { limit, offset } }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* user_permissions.grant - Grant permission to user
|
||||||
|
*/
|
||||||
|
const grantUserPermission: BaseTool<{ user_id: string; document_id?: string; collection_id?: string; permission: string }> = {
|
||||||
|
name: 'outline_grant_user_permission',
|
||||||
|
description: 'Grant permission to a user on a document or collection.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
document_id: { type: 'string', description: 'Document ID (UUID) - provide either this or collection_id' },
|
||||||
|
collection_id: { type: 'string', description: 'Collection ID (UUID) - provide either this or document_id' },
|
||||||
|
permission: { type: 'string', description: 'Permission level: read_write, read, or admin (for collections)' },
|
||||||
|
},
|
||||||
|
required: ['user_id', 'permission'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
if (!args.document_id && !args.collection_id) throw new Error('Either document_id or collection_id required');
|
||||||
|
if (args.document_id && args.collection_id) throw new Error('Provide only one of document_id or collection_id');
|
||||||
|
|
||||||
|
const creatorResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||||
|
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
|
||||||
|
// Check if permission already exists
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT id FROM user_permissions WHERE "userId" = $1 AND "documentId" = $2`,
|
||||||
|
[args.user_id, args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Update existing
|
||||||
|
result = await pgClient.query(`
|
||||||
|
UPDATE user_permissions
|
||||||
|
SET permission = $1, "updatedAt" = NOW()
|
||||||
|
WHERE "userId" = $2 AND "documentId" = $3
|
||||||
|
RETURNING *
|
||||||
|
`, [args.permission, args.user_id, args.document_id]);
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
result = await pgClient.query(`
|
||||||
|
INSERT INTO user_permissions (id, "userId", "documentId", permission, "createdById", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [args.user_id, args.document_id, args.permission, createdById]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isValidUUID(args.collection_id!)) throw new Error('Invalid collection_id');
|
||||||
|
|
||||||
|
// Check if permission already exists
|
||||||
|
const existing = await pgClient.query(
|
||||||
|
`SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`,
|
||||||
|
[args.user_id, args.collection_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
// Update existing
|
||||||
|
result = await pgClient.query(`
|
||||||
|
UPDATE collection_users
|
||||||
|
SET permission = $1, "updatedAt" = NOW()
|
||||||
|
WHERE "userId" = $2 AND "collectionId" = $3
|
||||||
|
RETURNING *
|
||||||
|
`, [args.permission, args.user_id, args.collection_id]);
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
result = await pgClient.query(`
|
||||||
|
INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt")
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`, [args.user_id, args.collection_id, args.permission, createdById]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Permission granted' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* user_permissions.revoke - Revoke permission from user
|
||||||
|
*/
|
||||||
|
const revokeUserPermission: BaseTool<{ user_id: string; document_id?: string; collection_id?: string }> = {
|
||||||
|
name: 'outline_revoke_user_permission',
|
||||||
|
description: 'Revoke permission from a user on a document or collection.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||||
|
document_id: { type: 'string', description: 'Document ID (UUID)' },
|
||||||
|
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
|
||||||
|
},
|
||||||
|
required: ['user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||||
|
if (!args.document_id && !args.collection_id) throw new Error('Either document_id or collection_id required');
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM user_permissions WHERE "userId" = $1 AND "documentId" = $2 RETURNING *`,
|
||||||
|
[args.user_id, args.document_id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!isValidUUID(args.collection_id!)) throw new Error('Invalid collection_id');
|
||||||
|
result = await pgClient.query(
|
||||||
|
`DELETE FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2 RETURNING *`,
|
||||||
|
[args.user_id, args.collection_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rows.length === 0) throw new Error('Permission not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Permission revoked' }, null, 2) }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userPermissionsTools: BaseTool<any>[] = [listUserPermissions, grantUserPermission, revokeUserPermission];
|
||||||
@@ -56,13 +56,13 @@ const listUsers: BaseTool<UserArgs> = {
|
|||||||
// Add role/status filters
|
// Add role/status filters
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'admins':
|
case 'admins':
|
||||||
whereConditions.push('u."isAdmin" = true');
|
whereConditions.push("u.role = 'admin'");
|
||||||
break;
|
break;
|
||||||
case 'members':
|
case 'members':
|
||||||
whereConditions.push('u."isAdmin" = false AND u."isViewer" = false');
|
whereConditions.push("u.role = 'member'");
|
||||||
break;
|
break;
|
||||||
case 'suspended':
|
case 'suspended':
|
||||||
whereConditions.push('u."isSuspended" = true');
|
whereConditions.push('u."suspendedAt" IS NOT NULL');
|
||||||
break;
|
break;
|
||||||
case 'invited':
|
case 'invited':
|
||||||
whereConditions.push('u."lastSignedInAt" IS NULL');
|
whereConditions.push('u."lastSignedInAt" IS NULL');
|
||||||
@@ -76,16 +76,13 @@ const listUsers: BaseTool<UserArgs> = {
|
|||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.email,
|
u.email,
|
||||||
u.username,
|
|
||||||
u.name,
|
u.name,
|
||||||
u."avatarUrl",
|
u."avatarUrl",
|
||||||
u.language,
|
u.language,
|
||||||
u.preferences,
|
u.preferences,
|
||||||
u."notificationSettings",
|
u."notificationSettings",
|
||||||
u.timezone,
|
u.timezone,
|
||||||
u."isAdmin",
|
u.role,
|
||||||
u."isViewer",
|
|
||||||
u."isSuspended",
|
|
||||||
u."lastActiveAt",
|
u."lastActiveAt",
|
||||||
u."lastSignedInAt",
|
u."lastSignedInAt",
|
||||||
u."suspendedAt",
|
u."suspendedAt",
|
||||||
@@ -94,7 +91,7 @@ const listUsers: BaseTool<UserArgs> = {
|
|||||||
u."createdAt",
|
u."createdAt",
|
||||||
u."updatedAt",
|
u."updatedAt",
|
||||||
t.name as "teamName",
|
t.name as "teamName",
|
||||||
(SELECT COUNT(*) FROM users WHERE ${whereConditions.join(' AND ')}) as total
|
(SELECT COUNT(*) FROM users u2 WHERE u2."deletedAt" IS NULL) as total
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN teams t ON u."teamId" = t.id
|
LEFT JOIN teams t ON u."teamId" = t.id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
@@ -152,16 +149,13 @@ const getUser: BaseTool<GetUserArgs> = {
|
|||||||
SELECT
|
SELECT
|
||||||
u.id,
|
u.id,
|
||||||
u.email,
|
u.email,
|
||||||
u.username,
|
|
||||||
u.name,
|
u.name,
|
||||||
u."avatarUrl",
|
u."avatarUrl",
|
||||||
u.language,
|
u.language,
|
||||||
u.preferences,
|
u.preferences,
|
||||||
u."notificationSettings",
|
u."notificationSettings",
|
||||||
u.timezone,
|
u.timezone,
|
||||||
u."isAdmin",
|
u.role,
|
||||||
u."isViewer",
|
|
||||||
u."isSuspended",
|
|
||||||
u."lastActiveAt",
|
u."lastActiveAt",
|
||||||
u."lastSignedInAt",
|
u."lastSignedInAt",
|
||||||
u."suspendedAt",
|
u."suspendedAt",
|
||||||
@@ -254,22 +248,19 @@ const createUser: BaseTool<CreateUserArgs> = {
|
|||||||
}
|
}
|
||||||
const teamId = teamResult.rows[0].id;
|
const teamId = teamResult.rows[0].id;
|
||||||
|
|
||||||
const isAdmin = role === 'admin';
|
|
||||||
const isViewer = role === 'viewer';
|
|
||||||
|
|
||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
id, email, name, "teamId", "isAdmin", "isViewer",
|
id, email, name, "teamId", role,
|
||||||
"createdAt", "updatedAt"
|
"createdAt", "updatedAt"
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
gen_random_uuid(), $1, $2, $3, $4, $5,
|
gen_random_uuid(), $1, $2, $3, $4,
|
||||||
NOW(), NOW()
|
NOW(), NOW()
|
||||||
)
|
)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`,
|
`,
|
||||||
[email, name, teamId, isAdmin, isViewer]
|
[email, name, teamId, role]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -458,9 +449,9 @@ const suspendUser: BaseTool<GetUserArgs> = {
|
|||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET "isSuspended" = true, "suspendedAt" = NOW()
|
SET "suspendedAt" = NOW()
|
||||||
WHERE id = $1 AND "deletedAt" IS NULL
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
RETURNING id, email, name, "isSuspended", "suspendedAt"
|
RETURNING id, email, name, "suspendedAt"
|
||||||
`,
|
`,
|
||||||
[args.id]
|
[args.id]
|
||||||
);
|
);
|
||||||
@@ -511,9 +502,9 @@ const activateUser: BaseTool<GetUserArgs> = {
|
|||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET "isSuspended" = false, "suspendedAt" = NULL, "suspendedById" = NULL
|
SET "suspendedAt" = NULL, "suspendedById" = NULL
|
||||||
WHERE id = $1 AND "deletedAt" IS NULL
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
RETURNING id, email, name, "isSuspended"
|
RETURNING id, email, name, "suspendedAt"
|
||||||
`,
|
`,
|
||||||
[args.id]
|
[args.id]
|
||||||
);
|
);
|
||||||
@@ -564,9 +555,9 @@ const promoteUser: BaseTool<GetUserArgs> = {
|
|||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET "isAdmin" = true, "isViewer" = false, "updatedAt" = NOW()
|
SET role = 'admin', "updatedAt" = NOW()
|
||||||
WHERE id = $1 AND "deletedAt" IS NULL
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
RETURNING id, email, name, "isAdmin", "isViewer"
|
RETURNING id, email, name, role
|
||||||
`,
|
`,
|
||||||
[args.id]
|
[args.id]
|
||||||
);
|
);
|
||||||
@@ -617,9 +608,9 @@ const demoteUser: BaseTool<GetUserArgs> = {
|
|||||||
const result = await pgClient.query(
|
const result = await pgClient.query(
|
||||||
`
|
`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET "isAdmin" = false, "updatedAt" = NOW()
|
SET role = 'member', "updatedAt" = NOW()
|
||||||
WHERE id = $1 AND "deletedAt" IS NULL
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
RETURNING id, email, name, "isAdmin", "isViewer"
|
RETURNING id, email, name, role
|
||||||
`,
|
`,
|
||||||
[args.id]
|
[args.id]
|
||||||
);
|
);
|
||||||
|
|||||||
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