feat: Initial release MCP Outline PostgreSQL v1.0.0
86 tools across 12 modules for direct PostgreSQL access to Outline Wiki: - Documents (19), Collections (14), Users (9), Groups (8) - Comments (6), Shares (5), Revisions (3), Events (3) - Attachments (5), File Operations (4), OAuth (8), Auth (2) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Outline PostgreSQL
|
||||||
|
DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/DATABASE
|
||||||
|
|
||||||
|
# Componentes
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=outline
|
||||||
|
DB_PASSWORD=your_password
|
||||||
|
DB_NAME=outline
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
tmp/
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
38
CHANGELOG.md
Normal file
38
CHANGELOG.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-01-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release of MCP Outline PostgreSQL
|
||||||
|
- 86 tools across 12 modules for direct PostgreSQL access to Outline Wiki
|
||||||
|
- **Documents (19 tools):** CRUD, search, archive, move, templates, memberships
|
||||||
|
- **Collections (14 tools):** CRUD, user/group memberships, export
|
||||||
|
- **Users (9 tools):** CRUD, suspend, activate, promote, demote
|
||||||
|
- **Groups (8 tools):** CRUD, memberships management
|
||||||
|
- **Comments (6 tools):** CRUD, resolve functionality
|
||||||
|
- **Shares (5 tools):** CRUD, revoke public links
|
||||||
|
- **Revisions (3 tools):** list, info, compare versions
|
||||||
|
- **Events (3 tools):** audit log, statistics
|
||||||
|
- **Attachments (5 tools):** CRUD, storage statistics
|
||||||
|
- **File Operations (4 tools):** import/export job management
|
||||||
|
- **OAuth (8 tools):** OAuth clients and authentications
|
||||||
|
- **Auth (2 tools):** authentication info and config
|
||||||
|
- PostgreSQL client with connection pooling
|
||||||
|
- Rate limiting and security utilities
|
||||||
|
- Full TypeScript implementation with type safety
|
||||||
|
- MCP SDK v1.0.0 compatibility
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Direct SQL access (not Outline API) for better performance
|
||||||
|
- Parameterized queries for SQL injection protection
|
||||||
|
- Soft delete support across all entities
|
||||||
|
- Full-text search using PostgreSQL tsvector
|
||||||
|
- Pagination and sorting on all list operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Developed by Descomplicar® | descomplicar.pt*
|
||||||
130
CLAUDE.md
Normal file
130
CLAUDE.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
MCP server for direct PostgreSQL access to Outline Wiki database. Follows patterns established by `mcp-desk-crm-sql-v3`.
|
||||||
|
|
||||||
|
**Architecture:** Claude Code -> MCP Outline (stdio) -> PostgreSQL (Outline DB)
|
||||||
|
|
||||||
|
**Total Tools:** 86 tools across 12 modules
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build TypeScript to dist/
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run production server
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Development with ts-node
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # MCP entry point
|
||||||
|
├── pg-client.ts # PostgreSQL client wrapper
|
||||||
|
├── config/
|
||||||
|
│ └── database.ts # DB configuration
|
||||||
|
├── types/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── tools.ts # Base tool types
|
||||||
|
│ └── db.ts # Database table types
|
||||||
|
├── tools/
|
||||||
|
│ ├── index.ts # Export all tools
|
||||||
|
│ ├── documents.ts # 19 tools
|
||||||
|
│ ├── collections.ts # 14 tools
|
||||||
|
│ ├── users.ts # 9 tools
|
||||||
|
│ ├── groups.ts # 8 tools
|
||||||
|
│ ├── comments.ts # 6 tools
|
||||||
|
│ ├── shares.ts # 5 tools
|
||||||
|
│ ├── revisions.ts # 3 tools
|
||||||
|
│ ├── events.ts # 3 tools
|
||||||
|
│ ├── attachments.ts # 5 tools
|
||||||
|
│ ├── file-operations.ts # 4 tools
|
||||||
|
│ ├── oauth.ts # 8 tools
|
||||||
|
│ └── auth.ts # 2 tools
|
||||||
|
└── utils/
|
||||||
|
├── logger.ts
|
||||||
|
└── security.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools Summary (86 total)
|
||||||
|
|
||||||
|
| Module | Tools | Description |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| documents | 19 | CRUD, search, archive, move, templates, memberships |
|
||||||
|
| collections | 14 | CRUD, memberships, groups, export |
|
||||||
|
| users | 9 | CRUD, suspend, activate, promote, demote |
|
||||||
|
| groups | 8 | CRUD, memberships |
|
||||||
|
| comments | 6 | CRUD, resolve |
|
||||||
|
| shares | 5 | CRUD, revoke |
|
||||||
|
| revisions | 3 | list, info, compare |
|
||||||
|
| events | 3 | list, info, stats |
|
||||||
|
| attachments | 5 | CRUD, stats |
|
||||||
|
| file-operations | 4 | import/export jobs |
|
||||||
|
| oauth | 8 | OAuth clients, authentications |
|
||||||
|
| auth | 2 | auth info, config |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add to `~/.claude.json` under `mcpServers`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"outline": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/home/ealmeida/mcp-servers/mcp-outline-postgresql/dist/index.js"],
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL": "postgres://outline:password@localhost:5432/outline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Required in `.env`:
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgres://user:password@host:port/outline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Tool Response Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(data, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
| Type | Convention | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| Tool name | snake_case with prefix | `outline_list_documents` |
|
||||||
|
| Function | camelCase | `listDocuments` |
|
||||||
|
| Type | PascalCase | `DocumentRow` |
|
||||||
|
| File | kebab-case | `documents.ts` |
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**PostgreSQL 15** - Direct SQL access (not Outline API)
|
||||||
|
|
||||||
|
Key tables: `documents`, `collections`, `users`, `groups`, `comments`, `revisions`, `shares`, `attachments`, `events`, `stars`, `pins`, `views`, `file_operations`, `oauth_clients`
|
||||||
|
|
||||||
|
Soft deletes: Most entities use `deletedAt` column, not hard deletes.
|
||||||
|
|
||||||
|
See `SPEC-MCP-OUTLINE.md` for complete database schema.
|
||||||
842
SPEC-MCP-OUTLINE.md
Normal file
842
SPEC-MCP-OUTLINE.md
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
# MCP Outline - Especificação Completa
|
||||||
|
|
||||||
|
**Versão:** 1.0.0
|
||||||
|
**Data:** 2026-01-31
|
||||||
|
**Autor:** Descomplicar®
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visão Geral
|
||||||
|
|
||||||
|
### Objectivo
|
||||||
|
|
||||||
|
MCP para acesso directo à base de dados PostgreSQL do Outline, seguindo os padrões estabelecidos pelo `mcp-desk-crm-sql-v3`.
|
||||||
|
|
||||||
|
### Arquitectura
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Claude Code │────▶│ MCP Outline │────▶│ PostgreSQL │
|
||||||
|
│ (Cliente) │◀────│ (Servidor) │◀────│ (Outline DB) │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
│ │
|
||||||
|
│ MCP Protocol │ SQL Directo
|
||||||
|
│ (stdio/SSE) │ (pg client)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decisões Técnicas
|
||||||
|
|
||||||
|
| Aspecto | Decisão | Razão |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| **Acesso** | SQL Directo (não API) | Performance, controlo total |
|
||||||
|
| **BD** | PostgreSQL 15 | Compatível com Outline |
|
||||||
|
| **Transporte** | stdio (dev), SSE (prod) | Padrão MCP |
|
||||||
|
| **Linguagem** | TypeScript | Type safety, padrão Desk |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ambiente de Desenvolvimento
|
||||||
|
|
||||||
|
### Configuração Local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Base de dados
|
||||||
|
Host: localhost
|
||||||
|
Port: 5432
|
||||||
|
User: outline
|
||||||
|
Password: outline_dev_2026
|
||||||
|
Database: outline
|
||||||
|
|
||||||
|
# Connection string
|
||||||
|
postgres://outline:outline_dev_2026@localhost:5432/outline
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrutura do Projecto
|
||||||
|
|
||||||
|
```
|
||||||
|
~/mcp-servers/mcp-outline/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── .env
|
||||||
|
├── .env.example
|
||||||
|
├── CHANGELOG.md
|
||||||
|
├── README.md
|
||||||
|
├── SPEC-MCP-OUTLINE.md ← Este ficheiro
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts ← Entry point MCP
|
||||||
|
│ ├── pg-client.ts ← Cliente PostgreSQL
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── database.ts ← Configuração BD
|
||||||
|
│ ├── types/
|
||||||
|
│ │ ├── index.ts
|
||||||
|
│ │ ├── tools.ts ← Tipos base das tools
|
||||||
|
│ │ └── db.ts ← Tipos das tabelas
|
||||||
|
│ ├── tools/
|
||||||
|
│ │ ├── index.ts ← Exporta todas as tools
|
||||||
|
│ │ ├── documents.ts
|
||||||
|
│ │ ├── collections.ts
|
||||||
|
│ │ ├── users.ts
|
||||||
|
│ │ ├── groups.ts
|
||||||
|
│ │ ├── comments.ts
|
||||||
|
│ │ ├── attachments.ts
|
||||||
|
│ │ ├── shares.ts
|
||||||
|
│ │ ├── revisions.ts
|
||||||
|
│ │ └── events.ts
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── logger.ts
|
||||||
|
│ └── security.ts
|
||||||
|
├── dist/ ← Build output
|
||||||
|
└── tests/
|
||||||
|
└── tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dependências
|
||||||
|
|
||||||
|
### package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "mcp-outline",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"dotenv": "^16.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/pg": "^8.10.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
|
"vitest": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### tsconfig.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tipos Base (Padrão Desk)
|
||||||
|
|
||||||
|
### src/types/tools.ts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
export interface ToolResponse {
|
||||||
|
content: Array<{
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseTool<TArgs = Record<string, unknown>> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: {
|
||||||
|
type: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
handler: (args: TArgs, pgClient: Pool) => Promise<ToolResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Argumentos comuns
|
||||||
|
export interface PaginationArgs {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRangeArgs {
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Outline → Tools MCP
|
||||||
|
|
||||||
|
### 5.1 Documents (17 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `documents.list` | `list_documents` | SELECT | P1 |
|
||||||
|
| `documents.info` | `get_document` | SELECT | P1 |
|
||||||
|
| `documents.create` | `create_document` | INSERT | P1 |
|
||||||
|
| `documents.update` | `update_document` | UPDATE | P1 |
|
||||||
|
| `documents.delete` | `delete_document` | UPDATE (soft) | P1 |
|
||||||
|
| `documents.search` | `search_documents` | SELECT FTS | P1 |
|
||||||
|
| `documents.drafts` | `list_drafts` | SELECT | P2 |
|
||||||
|
| `documents.viewed` | `list_viewed_documents` | SELECT | P2 |
|
||||||
|
| `documents.archive` | `archive_document` | UPDATE | P2 |
|
||||||
|
| `documents.restore` | `restore_document` | UPDATE | P2 |
|
||||||
|
| `documents.move` | `move_document` | UPDATE | P2 |
|
||||||
|
| `documents.unpublish` | `unpublish_document` | UPDATE | P3 |
|
||||||
|
| `documents.templatize` | `templatize_document` | INSERT | P3 |
|
||||||
|
| `documents.export` | `export_document` | SELECT | P2 |
|
||||||
|
| `documents.import` | `import_document` | INSERT | P3 |
|
||||||
|
| `documents.users` | `list_document_users` | SELECT | P2 |
|
||||||
|
| `documents.memberships` | `list_document_memberships` | SELECT | P3 |
|
||||||
|
|
||||||
|
#### Exemplo: list_documents
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const listDocuments: BaseTool = {
|
||||||
|
name: 'list_documents',
|
||||||
|
description: 'Lista documentos com filtros. Suporta paginação e ordenação.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'UUID da collection para filtrar'
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'UUID do autor para filtrar'
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Filtrar apenas templates'
|
||||||
|
},
|
||||||
|
published: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Filtrar apenas publicados (default: true)'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Máximo de resultados (default: 25, max: 100)'
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Offset para paginação'
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['updatedAt', 'createdAt', 'title', 'index'],
|
||||||
|
description: 'Campo para ordenação'
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['ASC', 'DESC'],
|
||||||
|
description: 'Direcção da ordenação'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient) => {
|
||||||
|
const limit = Math.min(args.limit || 25, 100);
|
||||||
|
const offset = args.offset || 0;
|
||||||
|
const sort = args.sort || 'updatedAt';
|
||||||
|
const direction = args.direction || 'DESC';
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.title,
|
||||||
|
d.text,
|
||||||
|
d."collectionId",
|
||||||
|
d."parentDocumentId",
|
||||||
|
d."createdById",
|
||||||
|
d."publishedAt",
|
||||||
|
d."updatedAt",
|
||||||
|
d."archivedAt",
|
||||||
|
d.template,
|
||||||
|
c.name as collection_name,
|
||||||
|
u.name as author_name
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||||
|
LEFT JOIN users u ON d."createdById" = u.id
|
||||||
|
WHERE d."deletedAt" IS NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.collection_id) {
|
||||||
|
sql += ` AND d."collectionId" = $${paramIndex++}`;
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.user_id) {
|
||||||
|
sql += ` AND d."createdById" = $${paramIndex++}`;
|
||||||
|
params.push(args.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.template !== undefined) {
|
||||||
|
sql += ` AND d.template = $${paramIndex++}`;
|
||||||
|
params.push(args.template);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.published !== false) {
|
||||||
|
sql += ` AND d."publishedAt" IS NOT NULL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY d."${sort}" ${direction}`;
|
||||||
|
sql += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pgClient.query(sql, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
documents: result.rows,
|
||||||
|
pagination: { limit, offset, count: result.rows.length }
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Collections (13 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `collections.list` | `list_collections` | SELECT | P1 |
|
||||||
|
| `collections.info` | `get_collection` | SELECT | P1 |
|
||||||
|
| `collections.create` | `create_collection` | INSERT | P1 |
|
||||||
|
| `collections.update` | `update_collection` | UPDATE | P1 |
|
||||||
|
| `collections.delete` | `delete_collection` | UPDATE (soft) | P2 |
|
||||||
|
| `collections.documents` | `list_collection_documents` | SELECT | P1 |
|
||||||
|
| `collections.add_user` | `add_user_to_collection` | INSERT | P2 |
|
||||||
|
| `collections.remove_user` | `remove_user_from_collection` | DELETE | P2 |
|
||||||
|
| `collections.memberships` | `list_collection_memberships` | SELECT | P2 |
|
||||||
|
| `collections.add_group` | `add_group_to_collection` | INSERT | P3 |
|
||||||
|
| `collections.remove_group` | `remove_group_from_collection` | DELETE | P3 |
|
||||||
|
| `collections.group_memberships` | `list_collection_group_memberships` | SELECT | P3 |
|
||||||
|
| `collections.export` | `export_collection` | SELECT | P3 |
|
||||||
|
|
||||||
|
### 5.3 Users (7 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `users.list` | `list_users` | SELECT | P1 |
|
||||||
|
| `users.info` | `get_user` | SELECT | P1 |
|
||||||
|
| `users.create` | `create_user` | INSERT | P2 |
|
||||||
|
| `users.update` | `update_user` | UPDATE | P2 |
|
||||||
|
| `users.delete` | `delete_user` | UPDATE (soft) | P3 |
|
||||||
|
| `users.suspend` | `suspend_user` | UPDATE | P2 |
|
||||||
|
| `users.activate` | `activate_user` | UPDATE | P2 |
|
||||||
|
|
||||||
|
### 5.4 Groups (7 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `groups.list` | `list_groups` | SELECT | P1 |
|
||||||
|
| `groups.info` | `get_group` | SELECT | P1 |
|
||||||
|
| `groups.create` | `create_group` | INSERT | P2 |
|
||||||
|
| `groups.update` | `update_group` | UPDATE | P2 |
|
||||||
|
| `groups.delete` | `delete_group` | DELETE | P3 |
|
||||||
|
| `groups.memberships` | `list_group_members` | SELECT | P2 |
|
||||||
|
| `groups.add_user` | `add_user_to_group` | INSERT | P2 |
|
||||||
|
|
||||||
|
### 5.5 Comments (5 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `comments.list` | `list_comments` | SELECT | P1 |
|
||||||
|
| `comments.info` | `get_comment` | SELECT | P2 |
|
||||||
|
| `comments.create` | `create_comment` | INSERT | P1 |
|
||||||
|
| `comments.update` | `update_comment` | UPDATE | P2 |
|
||||||
|
| `comments.delete` | `delete_comment` | DELETE | P2 |
|
||||||
|
|
||||||
|
### 5.6 Shares (4 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `shares.info` | `get_share` | SELECT | P2 |
|
||||||
|
| `shares.create` | `create_share` | INSERT | P2 |
|
||||||
|
| `shares.update` | `update_share` | UPDATE | P3 |
|
||||||
|
| `shares.delete` | `delete_share` | DELETE | P2 |
|
||||||
|
|
||||||
|
### 5.7 Revisions (2 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `revisions.list` | `list_revisions` | SELECT | P2 |
|
||||||
|
| `revisions.info` | `get_revision` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.8 Events (1 tool)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `events.list` | `list_events` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.9 Attachments (3 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `attachments.create` | `create_attachment` | INSERT | P3 |
|
||||||
|
| `attachments.redirect` | `get_attachment_url` | SELECT | P3 |
|
||||||
|
| `attachments.delete` | `delete_attachment` | DELETE | P3 |
|
||||||
|
|
||||||
|
### 5.10 Auth (2 tools)
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `auth.info` | `get_auth_info` | SELECT | P3 |
|
||||||
|
| `auth.config` | `get_auth_config` | SELECT | P3 |
|
||||||
|
|
||||||
|
### 5.11 Stars (3 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `stars.list` | `list_stars` | SELECT | P2 |
|
||||||
|
| `stars.create` | `star_document` | INSERT | P2 |
|
||||||
|
| `stars.delete` | `unstar_document` | DELETE | P2 |
|
||||||
|
|
||||||
|
### 5.12 Pins (3 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `pins.list` | `list_pins` | SELECT | P2 |
|
||||||
|
| `pins.create` | `pin_document` | INSERT | P2 |
|
||||||
|
| `pins.delete` | `unpin_document` | DELETE | P2 |
|
||||||
|
|
||||||
|
### 5.13 Views (2 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `views.list` | `list_document_views` | SELECT | P2 |
|
||||||
|
| `views.create` | `register_view` | INSERT | P3 |
|
||||||
|
|
||||||
|
### 5.14 Reactions (3 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `reactions.list` | `list_reactions` | SELECT | P3 |
|
||||||
|
| `reactions.create` | `add_reaction` | INSERT | P3 |
|
||||||
|
| `reactions.delete` | `remove_reaction` | DELETE | P3 |
|
||||||
|
|
||||||
|
### 5.15 API Keys (4 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `apiKeys.list` | `list_api_keys` | SELECT | P2 |
|
||||||
|
| `apiKeys.create` | `create_api_key` | INSERT | P2 |
|
||||||
|
| `apiKeys.delete` | `delete_api_key` | DELETE | P2 |
|
||||||
|
| `apiKeys.info` | `get_api_key` | SELECT | P3 |
|
||||||
|
|
||||||
|
### 5.16 Webhooks (4 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `webhooks.list` | `list_webhooks` | SELECT | P3 |
|
||||||
|
| `webhooks.create` | `create_webhook` | INSERT | P3 |
|
||||||
|
| `webhooks.update` | `update_webhook` | UPDATE | P3 |
|
||||||
|
| `webhooks.delete` | `delete_webhook` | DELETE | P3 |
|
||||||
|
|
||||||
|
### 5.17 Backlinks (1 tool) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `backlinks.list` | `list_backlinks` | SELECT | P2 |
|
||||||
|
|
||||||
|
### 5.18 Search Queries (2 tools) - NOVO
|
||||||
|
|
||||||
|
| API Endpoint | Tool MCP | Operação | Prioridade |
|
||||||
|
|--------------|----------|----------|------------|
|
||||||
|
| `searchQueries.list` | `list_search_queries` | SELECT | P3 |
|
||||||
|
| `searchQueries.popular` | `get_popular_searches` | SELECT | P3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Resumo de Tools
|
||||||
|
|
||||||
|
### Por Prioridade
|
||||||
|
|
||||||
|
| Prioridade | Quantidade | Descrição |
|
||||||
|
|------------|------------|-----------|
|
||||||
|
| P1 | 18 | Core: CRUD documentos, collections, users, search |
|
||||||
|
| P2 | 37 | Secundárias: memberships, comments, shares, stars, pins, views, apiKeys |
|
||||||
|
| P3 | 28 | Avançadas: templates, OAuth, attachments, reactions, webhooks |
|
||||||
|
| **Total** | **83** | |
|
||||||
|
|
||||||
|
### Por Módulo
|
||||||
|
|
||||||
|
| Módulo | Tools | Estado |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| Documents | 17 | A implementar |
|
||||||
|
| Collections | 13 | A implementar |
|
||||||
|
| Users | 7 | A implementar |
|
||||||
|
| Groups | 7 | A implementar |
|
||||||
|
| Comments | 5 | A implementar |
|
||||||
|
| Shares | 4 | A implementar |
|
||||||
|
| Revisions | 2 | A implementar |
|
||||||
|
| Events | 1 | A implementar |
|
||||||
|
| Attachments | 3 | A implementar |
|
||||||
|
| Auth | 2 | A implementar |
|
||||||
|
| Stars | 3 | A implementar |
|
||||||
|
| Pins | 3 | A implementar |
|
||||||
|
| Views | 2 | A implementar |
|
||||||
|
| Reactions | 3 | A implementar |
|
||||||
|
| API Keys | 4 | A implementar |
|
||||||
|
| Webhooks | 4 | A implementar |
|
||||||
|
| Backlinks | 1 | A implementar |
|
||||||
|
| Search Queries | 2 | A implementar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Schema PostgreSQL (Outline)
|
||||||
|
|
||||||
|
### Tabelas Reais (41 tabelas - verificado 2026-01-31)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Core Content
|
||||||
|
documents -- Documentos e drafts
|
||||||
|
collections -- Organizações de documentos
|
||||||
|
revisions -- Histórico de versões
|
||||||
|
comments -- Comentários em documentos
|
||||||
|
attachments -- Ficheiros anexados
|
||||||
|
backlinks -- Links entre documentos
|
||||||
|
|
||||||
|
-- Users & Auth
|
||||||
|
users -- Utilizadores
|
||||||
|
teams -- Workspaces
|
||||||
|
groups -- Grupos de utilizadores
|
||||||
|
group_users -- Membros de grupos
|
||||||
|
user_permissions -- Permissões de utilizadores
|
||||||
|
user_authentications -- Autenticações de utilizadores
|
||||||
|
user_passkeys -- Passkeys WebAuthn
|
||||||
|
authentications -- Sessões de autenticação
|
||||||
|
authentication_providers -- Providers OAuth
|
||||||
|
oauth_authentications -- Tokens OAuth
|
||||||
|
oauth_authorization_codes -- Códigos OAuth
|
||||||
|
oauth_clients -- Clientes OAuth registados
|
||||||
|
apiKeys -- API tokens
|
||||||
|
|
||||||
|
-- Permissions
|
||||||
|
collection_users -- Permissões collection-user
|
||||||
|
collection_groups -- Permissões collection-group
|
||||||
|
group_permissions -- Permissões de grupos
|
||||||
|
|
||||||
|
-- Sharing & Social
|
||||||
|
shares -- Links públicos
|
||||||
|
stars -- Favoritos
|
||||||
|
pins -- Documentos fixados
|
||||||
|
views -- Visualizações de documentos
|
||||||
|
reactions -- Reacções (emojis em docs)
|
||||||
|
emojis -- Emojis customizados
|
||||||
|
relationships -- Relações entre entidades
|
||||||
|
|
||||||
|
-- Notifications & Events
|
||||||
|
events -- Audit log
|
||||||
|
notifications -- Alertas
|
||||||
|
subscriptions -- Subscrições de notificações
|
||||||
|
|
||||||
|
-- Import/Export
|
||||||
|
imports -- Imports em curso
|
||||||
|
import_tasks -- Tarefas de import
|
||||||
|
file_operations -- Operações de ficheiros
|
||||||
|
|
||||||
|
-- Integrations
|
||||||
|
integrations -- Integrações externas
|
||||||
|
webhook_subscriptions -- Webhooks registados
|
||||||
|
webhook_deliveries -- Entregas de webhooks
|
||||||
|
|
||||||
|
-- System
|
||||||
|
SequelizeMeta -- Migrações Sequelize
|
||||||
|
team_domains -- Domínios por team
|
||||||
|
search_queries -- Histórico de pesquisas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relações Chave
|
||||||
|
|
||||||
|
```
|
||||||
|
teams (1) ─────────< (N) users
|
||||||
|
teams (1) ─────────< (N) collections
|
||||||
|
teams (1) ─────────< (N) groups
|
||||||
|
teams (1) ─────────< (N) team_domains
|
||||||
|
teams (1) ─────────< (N) integrations
|
||||||
|
|
||||||
|
collections (1) ────< (N) documents
|
||||||
|
collections (1) ────< (N) collection_users
|
||||||
|
collections (1) ────< (N) collection_groups
|
||||||
|
|
||||||
|
documents (1) ──────< (N) comments
|
||||||
|
documents (1) ──────< (N) revisions
|
||||||
|
documents (1) ──────< (N) shares
|
||||||
|
documents (1) ──────< (N) attachments
|
||||||
|
documents (1) ──────< (N) views
|
||||||
|
documents (1) ──────< (N) stars
|
||||||
|
documents (1) ──────< (N) pins
|
||||||
|
documents (1) ──────< (N) reactions
|
||||||
|
documents (1) ──────< (N) backlinks
|
||||||
|
|
||||||
|
users (1) ──────────< (N) documents (createdById)
|
||||||
|
users (1) ──────────< (N) apiKeys
|
||||||
|
users (1) ──────────< (N) user_authentications
|
||||||
|
users (1) ──────────< (N) notifications
|
||||||
|
users (1) ──────────< (N) subscriptions
|
||||||
|
users (N) ─────────<>───── (N) groups (via group_users)
|
||||||
|
|
||||||
|
groups (N) ────────<>───── (N) collections (via collection_groups)
|
||||||
|
|
||||||
|
integrations (1) ───< (N) webhook_subscriptions
|
||||||
|
webhook_subscriptions (1) < (N) webhook_deliveries
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementação
|
||||||
|
|
||||||
|
### Fase 1: Setup (MVP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Criar estrutura
|
||||||
|
mkdir -p ~/mcp-servers/mcp-outline/{src/{tools,types,config,utils},tests}
|
||||||
|
cd ~/mcp-servers/mcp-outline
|
||||||
|
|
||||||
|
# 2. Inicializar
|
||||||
|
npm init -y
|
||||||
|
npm install @modelcontextprotocol/sdk pg dotenv
|
||||||
|
npm install -D typescript @types/node @types/pg tsx vitest
|
||||||
|
|
||||||
|
# 3. Configurar
|
||||||
|
cp .env.example .env
|
||||||
|
# Editar .env com credenciais
|
||||||
|
|
||||||
|
# 4. Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 5. Testar
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase 2: Core Tools (P1)
|
||||||
|
|
||||||
|
Implementar por ordem:
|
||||||
|
1. `list_documents`, `get_document`, `search_documents`
|
||||||
|
2. `list_collections`, `get_collection`
|
||||||
|
3. `list_users`, `get_user`
|
||||||
|
4. `create_document`, `update_document`
|
||||||
|
5. `create_collection`, `update_collection`
|
||||||
|
|
||||||
|
### Fase 3: Secondary Tools (P2)
|
||||||
|
|
||||||
|
1. Comments (CRUD)
|
||||||
|
2. Memberships
|
||||||
|
3. Revisions
|
||||||
|
4. Shares
|
||||||
|
5. Events
|
||||||
|
|
||||||
|
### Fase 4: Advanced Tools (P3)
|
||||||
|
|
||||||
|
1. Templates
|
||||||
|
2. Attachments
|
||||||
|
3. OAuth
|
||||||
|
4. Import/Export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Configuração Claude Code
|
||||||
|
|
||||||
|
### ~/.claude.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"outline": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/home/ealmeida/mcp-servers/mcp-outline/dist/index.js"],
|
||||||
|
"env": {
|
||||||
|
"DATABASE_URL": "postgres://outline:outline_dev_2026@localhost:5432/outline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testes
|
||||||
|
|
||||||
|
### Estrutura
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── setup.ts # Configuração vitest
|
||||||
|
├── tools/
|
||||||
|
│ ├── documents.test.ts
|
||||||
|
│ ├── collections.test.ts
|
||||||
|
│ └── users.test.ts
|
||||||
|
└── integration/
|
||||||
|
└── full-flow.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemplo Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { listDocuments } from '../src/tools/documents';
|
||||||
|
|
||||||
|
describe('Documents Tools', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list_documents returns array', async () => {
|
||||||
|
const result = await listDocuments.handler({}, pool);
|
||||||
|
const data = JSON.parse(result.content[0].text);
|
||||||
|
|
||||||
|
expect(Array.isArray(data.documents)).toBe(true);
|
||||||
|
expect(data.pagination).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Padrões de Código (do Desk)
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// SEMPRE retornar neste formato
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(data, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
const result = await pgClient.query(sql, params);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({ success: true, data: result.rows }, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
}, null, 2)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
| Tipo | Padrão | Exemplo |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Tool name | snake_case | `list_documents` |
|
||||||
|
| Function | camelCase | `listDocuments` |
|
||||||
|
| Type | PascalCase | `DocumentRow` |
|
||||||
|
| File | kebab-case | `documents.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Checklist de Implementação
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
- [ ] Estrutura de pastas criada
|
||||||
|
- [ ] package.json configurado
|
||||||
|
- [ ] tsconfig.json configurado
|
||||||
|
- [ ] .env configurado
|
||||||
|
- [ ] pg-client.ts implementado
|
||||||
|
|
||||||
|
### Tools P1
|
||||||
|
- [ ] list_documents
|
||||||
|
- [ ] get_document
|
||||||
|
- [ ] create_document
|
||||||
|
- [ ] update_document
|
||||||
|
- [ ] delete_document
|
||||||
|
- [ ] search_documents
|
||||||
|
- [ ] list_collections
|
||||||
|
- [ ] get_collection
|
||||||
|
- [ ] create_collection
|
||||||
|
- [ ] update_collection
|
||||||
|
- [ ] list_collection_documents
|
||||||
|
- [ ] list_users
|
||||||
|
- [ ] get_user
|
||||||
|
- [ ] list_groups
|
||||||
|
- [ ] get_group
|
||||||
|
- [ ] list_comments
|
||||||
|
- [ ] create_comment
|
||||||
|
- [ ] list_group_members
|
||||||
|
|
||||||
|
### Tools P2 (novas)
|
||||||
|
- [ ] list_stars
|
||||||
|
- [ ] star_document
|
||||||
|
- [ ] unstar_document
|
||||||
|
- [ ] list_pins
|
||||||
|
- [ ] pin_document
|
||||||
|
- [ ] unpin_document
|
||||||
|
- [ ] list_document_views
|
||||||
|
- [ ] list_backlinks
|
||||||
|
- [ ] list_api_keys
|
||||||
|
- [ ] create_api_key
|
||||||
|
- [ ] delete_api_key
|
||||||
|
|
||||||
|
### Integração
|
||||||
|
- [ ] Build sem erros
|
||||||
|
- [ ] Configurado em ~/.claude.json
|
||||||
|
- [ ] Tools visíveis no Claude Code
|
||||||
|
- [ ] Testes P1 passam
|
||||||
|
|
||||||
|
### Documentação
|
||||||
|
- [ ] README.md
|
||||||
|
- [ ] CHANGELOG.md
|
||||||
|
- [ ] Obsidian docs actualizados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Referências
|
||||||
|
|
||||||
|
- [Outline API Docs](https://www.getoutline.com/developers)
|
||||||
|
- [MCP SDK](https://modelcontextprotocol.io/)
|
||||||
|
- [mcp-desk-crm-sql-v3](~/mcp-servers/mcp-desk-crm-sql-v3/)
|
||||||
|
- [PostgreSQL pg docs](https://node-postgres.com/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Documento gerado: 2026-01-31*
|
||||||
|
*Autor: Descomplicar®*
|
||||||
5001
package-lock.json
generated
Normal file
5001
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp-outline-postgresql",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP Server for Outline Wiki via PostgreSQL direct access",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"keywords": ["mcp", "outline", "postgresql", "wiki"],
|
||||||
|
"author": "Descomplicar",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"@types/jest": "^29.5.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/config/database.ts
Normal file
52
src/config/database.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Database Configuration
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export interface DatabaseConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
ssl?: boolean;
|
||||||
|
connectionString?: string;
|
||||||
|
max?: number; // Max pool size
|
||||||
|
idleTimeoutMillis?: number;
|
||||||
|
connectionTimeoutMillis?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabaseConfig(): DatabaseConfig {
|
||||||
|
// If DATABASE_URL is provided, use it
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return {
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
user: process.env.DB_USER || 'outline',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'outline',
|
||||||
|
ssl: process.env.DB_SSL === 'true',
|
||||||
|
max: parseInt(process.env.DB_POOL_SIZE || '10', 10),
|
||||||
|
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||||
|
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use individual environment variables
|
||||||
|
return {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
user: process.env.DB_USER || 'outline',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || 'outline',
|
||||||
|
ssl: process.env.DB_SSL === 'true',
|
||||||
|
max: parseInt(process.env.DB_POOL_SIZE || '10', 10),
|
||||||
|
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
|
||||||
|
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10)
|
||||||
|
};
|
||||||
|
}
|
||||||
199
src/index.ts
Normal file
199
src/index.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Main Server
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
import {
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ListPromptsRequestSchema
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import { PgClient } from './pg-client.js';
|
||||||
|
import { getDatabaseConfig } from './config/database.js';
|
||||||
|
import { logger } from './utils/logger.js';
|
||||||
|
import { checkRateLimit } from './utils/security.js';
|
||||||
|
import { BaseTool } from './types/tools.js';
|
||||||
|
|
||||||
|
// Import ALL tools
|
||||||
|
import {
|
||||||
|
documentsTools,
|
||||||
|
collectionsTools,
|
||||||
|
usersTools,
|
||||||
|
groupsTools,
|
||||||
|
commentsTools,
|
||||||
|
sharesTools,
|
||||||
|
revisionsTools,
|
||||||
|
eventsTools,
|
||||||
|
attachmentsTools,
|
||||||
|
fileOperationsTools,
|
||||||
|
oauthTools,
|
||||||
|
authTools
|
||||||
|
} from './tools/index.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Combine ALL tools into single array
|
||||||
|
const allTools: BaseTool[] = [
|
||||||
|
// Core functionality
|
||||||
|
...documentsTools,
|
||||||
|
...collectionsTools,
|
||||||
|
...usersTools,
|
||||||
|
...groupsTools,
|
||||||
|
|
||||||
|
// Collaboration
|
||||||
|
...commentsTools,
|
||||||
|
...sharesTools,
|
||||||
|
...revisionsTools,
|
||||||
|
|
||||||
|
// System
|
||||||
|
...eventsTools,
|
||||||
|
...attachmentsTools,
|
||||||
|
...fileOperationsTools,
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
...oauthTools,
|
||||||
|
...authTools
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate all tools have required properties
|
||||||
|
const invalidTools = allTools.filter((tool) => !tool.name || !tool.handler);
|
||||||
|
if (invalidTools.length > 0) {
|
||||||
|
logger.error(`${invalidTools.length} invalid tools found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Get database configuration
|
||||||
|
const config = getDatabaseConfig();
|
||||||
|
|
||||||
|
// Initialize PostgreSQL client
|
||||||
|
const pgClient = new PgClient(config);
|
||||||
|
|
||||||
|
// Test database connection
|
||||||
|
const isConnected = await pgClient.testConnection();
|
||||||
|
if (!isConnected) {
|
||||||
|
throw new Error('Failed to connect to PostgreSQL database');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize MCP server
|
||||||
|
const server = new Server({
|
||||||
|
name: 'mcp-outline',
|
||||||
|
version: '1.0.0'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set capabilities (required for MCP v2.2+)
|
||||||
|
(server as any)._capabilities = {
|
||||||
|
tools: {},
|
||||||
|
resources: {},
|
||||||
|
prompts: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect transport BEFORE registering handlers
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
// Register tools list handler
|
||||||
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
|
tools: allTools.map((tool) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Register resources handler (required even if empty)
|
||||||
|
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||||
|
logger.debug('Resources list requested');
|
||||||
|
return { resources: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register prompts handler (required even if empty)
|
||||||
|
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
||||||
|
logger.debug('Prompts list requested');
|
||||||
|
return { prompts: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register tool call handler
|
||||||
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
|
// Rate limiting (using 'default' as clientId for now)
|
||||||
|
const clientId = process.env.CLIENT_ID || 'default';
|
||||||
|
if (!checkRateLimit('api', clientId)) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Too Many Requests: rate limit exceeded. Try again later.' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the tool handler
|
||||||
|
const tool = allTools.find((t) => t.name === name);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Tool '${name}' not found`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass the pool directly to tool handlers
|
||||||
|
return await tool.handler(args as Record<string, unknown>, pgClient.getPool());
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in tool ${name}:`, {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Error in tool ${name}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log startup (minimal logging for MCP protocol compatibility)
|
||||||
|
if (process.env.LOG_LEVEL !== 'error' && process.env.LOG_LEVEL !== 'none') {
|
||||||
|
logger.info('MCP Server started');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
logger.debug('MCP Outline PostgreSQL Server running', {
|
||||||
|
totalTools: allTools.length,
|
||||||
|
toolsByModule: {
|
||||||
|
documents: documentsTools.length,
|
||||||
|
collections: collectionsTools.length,
|
||||||
|
users: usersTools.length,
|
||||||
|
groups: groupsTools.length,
|
||||||
|
comments: commentsTools.length,
|
||||||
|
shares: sharesTools.length,
|
||||||
|
revisions: revisionsTools.length,
|
||||||
|
events: eventsTools.length,
|
||||||
|
attachments: attachmentsTools.length,
|
||||||
|
fileOperations: fileOperationsTools.length,
|
||||||
|
oauth: oauthTools.length,
|
||||||
|
auth: authTools.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
logger.error('Fatal error', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
158
src/pg-client.ts
Normal file
158
src/pg-client.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - PostgreSQL Client
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool, PoolConfig, QueryResult, QueryResultRow } from 'pg';
|
||||||
|
import { DatabaseConfig } from './config/database.js';
|
||||||
|
import { logger } from './utils/logger.js';
|
||||||
|
|
||||||
|
export class PgClient {
|
||||||
|
private pool: Pool;
|
||||||
|
private isConnected: boolean = false;
|
||||||
|
|
||||||
|
constructor(config: DatabaseConfig) {
|
||||||
|
const poolConfig: PoolConfig = config.connectionString
|
||||||
|
? {
|
||||||
|
connectionString: config.connectionString,
|
||||||
|
max: config.max,
|
||||||
|
idleTimeoutMillis: config.idleTimeoutMillis,
|
||||||
|
connectionTimeoutMillis: config.connectionTimeoutMillis
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
user: config.user,
|
||||||
|
password: config.password,
|
||||||
|
database: config.database,
|
||||||
|
ssl: config.ssl ? { rejectUnauthorized: false } : false,
|
||||||
|
max: config.max,
|
||||||
|
idleTimeoutMillis: config.idleTimeoutMillis,
|
||||||
|
connectionTimeoutMillis: config.connectionTimeoutMillis
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pool = new Pool(poolConfig);
|
||||||
|
|
||||||
|
// Handle pool errors
|
||||||
|
this.pool.on('error', (err) => {
|
||||||
|
logger.error('Unexpected PostgreSQL pool error', { error: err.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying pool for direct access
|
||||||
|
*/
|
||||||
|
getPool(): Pool {
|
||||||
|
return this.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test database connection
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
await client.query('SELECT 1');
|
||||||
|
client.release();
|
||||||
|
this.isConnected = true;
|
||||||
|
logger.info('PostgreSQL connection successful');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('PostgreSQL connection failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
this.isConnected = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query with parameters
|
||||||
|
*/
|
||||||
|
async query<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<T[]> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await this.pool.query<T>(sql, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
logger.debug('Query executed', {
|
||||||
|
sql: sql.substring(0, 100),
|
||||||
|
duration,
|
||||||
|
rowCount: result.rowCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
logger.error('Query failed', {
|
||||||
|
sql: sql.substring(0, 100),
|
||||||
|
duration,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and return the full result
|
||||||
|
*/
|
||||||
|
async queryRaw<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {
|
||||||
|
return this.pool.query<T>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and return a single row
|
||||||
|
*/
|
||||||
|
async queryOne<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<T | null> {
|
||||||
|
const rows = await this.query<T>(sql, params);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute multiple queries in a transaction
|
||||||
|
*/
|
||||||
|
async transaction<T>(callback: (client: any) => Promise<T>): Promise<T> {
|
||||||
|
const client = await this.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await callback(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the pool
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
this.isConnected = false;
|
||||||
|
logger.info('PostgreSQL pool closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if connected
|
||||||
|
*/
|
||||||
|
isPoolConnected(): boolean {
|
||||||
|
return this.isConnected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton factory
|
||||||
|
let instance: PgClient | null = null;
|
||||||
|
|
||||||
|
export function createPgClient(config: DatabaseConfig): PgClient {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new PgClient(config);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPgClient(): PgClient | null {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
490
src/tools/attachments.ts
Normal file
490
src/tools/attachments.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Attachments Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, CreateAttachmentArgs, GetAttachmentArgs, PaginationArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
interface AttachmentListArgs extends PaginationArgs {
|
||||||
|
document_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
team_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* attachments.list - List attachments with optional filters
|
||||||
|
*/
|
||||||
|
const listAttachments: BaseTool<AttachmentListArgs> = {
|
||||||
|
name: 'outline_attachments_list',
|
||||||
|
description: 'List file attachments with optional filtering by document, user, or team. Supports pagination.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by document ID (UUID)',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by user ID who uploaded (UUID)',
|
||||||
|
},
|
||||||
|
team_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by team ID (UUID)',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results (default: 25, max: 100)',
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of 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.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
conditions.push(`a."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(`a."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(`a."teamId" = $${paramIndex++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.key,
|
||||||
|
a.url,
|
||||||
|
a."contentType",
|
||||||
|
a.size,
|
||||||
|
a.acl,
|
||||||
|
a."documentId",
|
||||||
|
a."userId",
|
||||||
|
a."teamId",
|
||||||
|
a."createdAt",
|
||||||
|
a."updatedAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
u.name as "uploadedByName",
|
||||||
|
u.email as "uploadedByEmail"
|
||||||
|
FROM attachments a
|
||||||
|
LEFT JOIN documents d ON a."documentId" = d.id
|
||||||
|
LEFT JOIN users u ON a."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a."createdAt" DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: result.rows.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* attachments.info - Get detailed information about a specific attachment
|
||||||
|
*/
|
||||||
|
const getAttachment: BaseTool<GetAttachmentArgs> = {
|
||||||
|
name: 'outline_attachments_info',
|
||||||
|
description: 'Get detailed information about a specific attachment by ID.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Attachment ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid attachment ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.key,
|
||||||
|
a.url,
|
||||||
|
a."contentType",
|
||||||
|
a.size,
|
||||||
|
a.acl,
|
||||||
|
a."documentId",
|
||||||
|
a."userId",
|
||||||
|
a."teamId",
|
||||||
|
a."createdAt",
|
||||||
|
a."updatedAt",
|
||||||
|
a."deletedAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
d."collectionId",
|
||||||
|
u.name as "uploadedByName",
|
||||||
|
u.email as "uploadedByEmail",
|
||||||
|
t.name as "teamName"
|
||||||
|
FROM attachments a
|
||||||
|
LEFT JOIN documents d ON a."documentId" = d.id
|
||||||
|
LEFT JOIN users u ON a."userId" = u.id
|
||||||
|
LEFT JOIN teams t ON a."teamId" = t.id
|
||||||
|
WHERE a.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Attachment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* attachments.create - Create a new attachment record
|
||||||
|
*/
|
||||||
|
const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
||||||
|
name: 'outline_attachments_create',
|
||||||
|
description: 'Create a new attachment record. Note: This creates the database record only, actual file upload is handled separately.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Attachment filename/key',
|
||||||
|
},
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID to attach to (UUID, optional)',
|
||||||
|
},
|
||||||
|
content_type: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'MIME type (e.g., "image/png", "application/pdf")',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'File size in bytes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'content_type', 'size'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (args.document_id && !isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document exists if provided
|
||||||
|
if (args.document_id) {
|
||||||
|
const docCheck = await pgClient.query(
|
||||||
|
'SELECT id, "teamId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL',
|
||||||
|
[args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docCheck.rows.length === 0) {
|
||||||
|
throw new Error('Document not found or deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first admin user and team
|
||||||
|
const userQuery = await pgClient.query(
|
||||||
|
'SELECT u.id, u."teamId" FROM users u WHERE u."isAdmin" = true AND u."deletedAt" IS NULL LIMIT 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userQuery.rows.length === 0) {
|
||||||
|
throw new Error('No valid user found to create attachment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = userQuery.rows[0].id;
|
||||||
|
const teamId = userQuery.rows[0].teamId;
|
||||||
|
|
||||||
|
// Generate URL and key (in real implementation, this would be S3/storage URL)
|
||||||
|
const key = `attachments/${Date.now()}-${args.name}`;
|
||||||
|
const url = `/api/attachments.redirect?id=PLACEHOLDER`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO attachments (
|
||||||
|
key,
|
||||||
|
url,
|
||||||
|
"contentType",
|
||||||
|
size,
|
||||||
|
acl,
|
||||||
|
"documentId",
|
||||||
|
"userId",
|
||||||
|
"teamId",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [
|
||||||
|
key,
|
||||||
|
url,
|
||||||
|
args.content_type,
|
||||||
|
args.size,
|
||||||
|
'private', // Default ACL
|
||||||
|
args.document_id || null,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* attachments.delete - Delete an attachment (soft delete)
|
||||||
|
*/
|
||||||
|
const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
||||||
|
name: 'outline_attachments_delete',
|
||||||
|
description: 'Soft delete an attachment. The attachment record is marked as deleted but not removed from the database.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Attachment ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid attachment ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE attachments
|
||||||
|
SET
|
||||||
|
"deletedAt" = NOW(),
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, key, "documentId"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Attachment not found or already deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Attachment deleted successfully',
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* attachments.stats - Get attachment statistics
|
||||||
|
*/
|
||||||
|
const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> = {
|
||||||
|
name: 'outline_attachments_stats',
|
||||||
|
description: 'Get statistics about attachments including total count, size, and breakdown by content type.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
team_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter statistics by team ID (UUID)',
|
||||||
|
},
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter statistics by document ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const conditions: string[] = ['a."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(`a."teamId" = $${paramIndex++}`);
|
||||||
|
params.push(args.team_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
conditions.push(`a."documentId" = $${paramIndex++}`);
|
||||||
|
params.push(args.document_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
|
||||||
|
// Overall statistics
|
||||||
|
const overallStatsQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as "totalAttachments",
|
||||||
|
SUM(size) as "totalSize",
|
||||||
|
AVG(size) as "averageSize",
|
||||||
|
COUNT(DISTINCT "documentId") as "documentsWithAttachments",
|
||||||
|
COUNT(DISTINCT "userId") as "uniqueUploaders"
|
||||||
|
FROM attachments a
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// By content type
|
||||||
|
const byContentTypeQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
a."contentType",
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(size) as "totalSize"
|
||||||
|
FROM attachments a
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY a."contentType"
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top uploaders
|
||||||
|
const topUploadersQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
a."userId",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail",
|
||||||
|
COUNT(*) as "attachmentCount",
|
||||||
|
SUM(a.size) as "totalSize"
|
||||||
|
FROM attachments a
|
||||||
|
LEFT JOIN users u ON a."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY a."userId", u.name, u.email
|
||||||
|
ORDER BY "attachmentCount" DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recent uploads
|
||||||
|
const recentUploadsQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
a.id,
|
||||||
|
a.key,
|
||||||
|
a."contentType",
|
||||||
|
a.size,
|
||||||
|
a."createdAt",
|
||||||
|
u.name as "uploadedByName",
|
||||||
|
d.title as "documentTitle"
|
||||||
|
FROM attachments a
|
||||||
|
LEFT JOIN users u ON a."userId" = u.id
|
||||||
|
LEFT JOIN documents d ON a."documentId" = d.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a."createdAt" DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
overall: overallStatsQuery.rows[0],
|
||||||
|
byContentType: byContentTypeQuery.rows,
|
||||||
|
topUploaders: topUploadersQuery.rows,
|
||||||
|
recentUploads: recentUploadsQuery.rows,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all attachment tools
|
||||||
|
export const attachmentsTools: BaseTool<any>[] = [
|
||||||
|
listAttachments,
|
||||||
|
getAttachment,
|
||||||
|
createAttachment,
|
||||||
|
deleteAttachment,
|
||||||
|
getAttachmentStats,
|
||||||
|
];
|
||||||
159
src/tools/auth.ts
Normal file
159
src/tools/auth.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Authentication Tools
|
||||||
|
* Provides authentication information and configuration
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||||
|
|
||||||
|
interface AuthenticationProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
teamId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auth.info - Get current authentication details
|
||||||
|
*/
|
||||||
|
const getAuthInfo: BaseTool<Record<string, never>> = {
|
||||||
|
name: 'outline_auth_info',
|
||||||
|
description: 'Get information about the current authentication context. Returns team and user information based on the database connection.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
// Get general authentication statistics
|
||||||
|
const statsQuery = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
(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 "isSuspended" = true) as suspended_users,
|
||||||
|
(SELECT COUNT(*) FROM teams) as total_teams,
|
||||||
|
(SELECT COUNT(*) FROM oauth_clients) as oauth_clients,
|
||||||
|
(SELECT COUNT(*) FROM oauth_authentications) as oauth_authentications
|
||||||
|
`);
|
||||||
|
|
||||||
|
const stats = statsQuery.rows[0];
|
||||||
|
|
||||||
|
// Get recent authentication activity
|
||||||
|
const recentActivity = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.name,
|
||||||
|
u.email,
|
||||||
|
u."lastActiveAt",
|
||||||
|
u."lastSignedInAt",
|
||||||
|
u."isAdmin",
|
||||||
|
u."isSuspended"
|
||||||
|
FROM users u
|
||||||
|
WHERE u."deletedAt" IS NULL
|
||||||
|
ORDER BY u."lastActiveAt" DESC NULLS LAST
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
statistics: {
|
||||||
|
totalUsers: stats.total_users,
|
||||||
|
adminUsers: stats.admin_users,
|
||||||
|
suspendedUsers: stats.suspended_users,
|
||||||
|
totalTeams: stats.total_teams,
|
||||||
|
oauthClients: stats.oauth_clients,
|
||||||
|
oauthAuthentications: stats.oauth_authentications,
|
||||||
|
},
|
||||||
|
recentActivity: recentActivity.rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auth.config - Get authentication provider configuration
|
||||||
|
*/
|
||||||
|
const getAuthConfig: BaseTool<Record<string, never>> = {
|
||||||
|
name: 'outline_auth_config',
|
||||||
|
description: 'Get authentication provider configuration. Returns enabled authentication methods and their settings.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
// Get authentication providers
|
||||||
|
const providersQuery = await pgClient.query<AuthenticationProvider>(`
|
||||||
|
SELECT
|
||||||
|
ap.id,
|
||||||
|
ap.name,
|
||||||
|
ap.enabled,
|
||||||
|
ap."teamId",
|
||||||
|
ap."createdAt",
|
||||||
|
ap."updatedAt",
|
||||||
|
t.name as "teamName"
|
||||||
|
FROM authentication_providers ap
|
||||||
|
LEFT JOIN teams t ON ap."teamId" = t.id
|
||||||
|
ORDER BY ap.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get team authentication settings
|
||||||
|
const teamsQuery = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
subdomain,
|
||||||
|
domain,
|
||||||
|
"guestSignin",
|
||||||
|
"inviteRequired",
|
||||||
|
"defaultUserRole",
|
||||||
|
"createdAt"
|
||||||
|
FROM teams
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get OAuth client statistics per team
|
||||||
|
const oauthStatsQuery = await pgClient.query(`
|
||||||
|
SELECT
|
||||||
|
t.id as "teamId",
|
||||||
|
t.name as "teamName",
|
||||||
|
COUNT(oc.id) as "clientCount"
|
||||||
|
FROM teams t
|
||||||
|
LEFT JOIN oauth_clients oc ON oc."teamId" = t.id
|
||||||
|
GROUP BY t.id, t.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
providers: providersQuery.rows,
|
||||||
|
teams: teamsQuery.rows,
|
||||||
|
oauthStatistics: oauthStatsQuery.rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all authentication tools
|
||||||
|
export const authTools: BaseTool<any>[] = [getAuthInfo, getAuthConfig];
|
||||||
1334
src/tools/collections.ts
Normal file
1334
src/tools/collections.ts
Normal file
File diff suppressed because it is too large
Load Diff
480
src/tools/comments.ts
Normal file
480
src/tools/comments.ts
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Comments Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, CommentArgs, GetCommentArgs, CreateCommentArgs, UpdateCommentArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* comments.list - List comments with optional filters
|
||||||
|
*/
|
||||||
|
const listComments: BaseTool<CommentArgs> = {
|
||||||
|
name: 'outline_comments_list',
|
||||||
|
description: 'List comments with optional filtering by document or collection. Supports pagination.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
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 number of results (default: 25, max: 100)',
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of 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(`c."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(`d."collectionId" = $${paramIndex++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.data,
|
||||||
|
c."documentId",
|
||||||
|
c."parentCommentId",
|
||||||
|
c."createdById",
|
||||||
|
c."resolvedById",
|
||||||
|
c."resolvedAt",
|
||||||
|
c."createdAt",
|
||||||
|
c."updatedAt",
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail",
|
||||||
|
ru.name as "resolvedByName",
|
||||||
|
d.title as "documentTitle"
|
||||||
|
FROM comments c
|
||||||
|
LEFT JOIN users u ON c."createdById" = u.id
|
||||||
|
LEFT JOIN users ru ON c."resolvedById" = ru.id
|
||||||
|
LEFT JOIN documents d ON c."documentId" = d.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c."createdAt" DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: result.rows.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* comments.info - Get detailed information about a specific comment
|
||||||
|
*/
|
||||||
|
const getComment: BaseTool<GetCommentArgs> = {
|
||||||
|
name: 'outline_comments_info',
|
||||||
|
description: 'Get detailed information about a specific comment by ID.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comment ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid comment ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.data,
|
||||||
|
c."documentId",
|
||||||
|
c."parentCommentId",
|
||||||
|
c."createdById",
|
||||||
|
c."resolvedById",
|
||||||
|
c."resolvedAt",
|
||||||
|
c."createdAt",
|
||||||
|
c."updatedAt",
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail",
|
||||||
|
ru.name as "resolvedByName",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
d."collectionId"
|
||||||
|
FROM comments c
|
||||||
|
LEFT JOIN users u ON c."createdById" = u.id
|
||||||
|
LEFT JOIN users ru ON c."resolvedById" = ru.id
|
||||||
|
LEFT JOIN documents d ON c."documentId" = d.id
|
||||||
|
WHERE c.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get replies if this is a parent comment
|
||||||
|
const repliesQuery = `
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.data,
|
||||||
|
c."createdById",
|
||||||
|
c."createdAt",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM comments c
|
||||||
|
LEFT JOIN users u ON c."createdById" = u.id
|
||||||
|
WHERE c."parentCommentId" = $1
|
||||||
|
ORDER BY c."createdAt" ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const replies = await pgClient.query(repliesQuery, [args.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...result.rows[0],
|
||||||
|
replies: replies.rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* comments.create - Create a new comment
|
||||||
|
*/
|
||||||
|
const createComment: BaseTool<CreateCommentArgs> = {
|
||||||
|
name: 'outline_comments_create',
|
||||||
|
description: 'Create a new comment on a document. Can be a top-level comment or a reply to another comment.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID (UUID)',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Comment data (JSON object with content)',
|
||||||
|
},
|
||||||
|
parent_comment_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Parent comment ID for replies (UUID, optional)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['document_id', 'data'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.parent_comment_id && !isValidUUID(args.parent_comment_id)) {
|
||||||
|
throw new Error('Invalid parent_comment_id format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document exists
|
||||||
|
const docCheck = await pgClient.query(
|
||||||
|
'SELECT id FROM documents WHERE id = $1 AND "deletedAt" IS NULL',
|
||||||
|
[args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docCheck.rows.length === 0) {
|
||||||
|
throw new Error('Document not found or deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parent comment exists if provided
|
||||||
|
if (args.parent_comment_id) {
|
||||||
|
const parentCheck = await pgClient.query(
|
||||||
|
'SELECT id FROM comments WHERE id = $1 AND "documentId" = $2',
|
||||||
|
[args.parent_comment_id, args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parentCheck.rows.length === 0) {
|
||||||
|
throw new Error('Parent comment not found or not on the same document');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: In real implementation, createdById should come from authentication context
|
||||||
|
// For now, we'll get the first admin user
|
||||||
|
const userQuery = await pgClient.query(
|
||||||
|
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userQuery.rows.length === 0) {
|
||||||
|
throw new Error('No valid user found to create comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdById = userQuery.rows[0].id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO comments (
|
||||||
|
"documentId",
|
||||||
|
"data",
|
||||||
|
"parentCommentId",
|
||||||
|
"createdById",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
) VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [
|
||||||
|
args.document_id,
|
||||||
|
JSON.stringify(args.data),
|
||||||
|
args.parent_comment_id || null,
|
||||||
|
createdById,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* comments.update - Update an existing comment
|
||||||
|
*/
|
||||||
|
const updateComment: BaseTool<UpdateCommentArgs> = {
|
||||||
|
name: 'outline_comments_update',
|
||||||
|
description: 'Update the content of an existing comment.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comment ID (UUID)',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Updated comment data (JSON object)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id', 'data'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid comment ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE comments
|
||||||
|
SET
|
||||||
|
"data" = $1,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [JSON.stringify(args.data), args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* comments.delete - Delete a comment
|
||||||
|
*/
|
||||||
|
const deleteComment: BaseTool<GetCommentArgs> = {
|
||||||
|
name: 'outline_comments_delete',
|
||||||
|
description: 'Delete a comment. This will also delete all replies to this comment.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comment ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid comment ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete replies first
|
||||||
|
await pgClient.query('DELETE FROM comments WHERE "parentCommentId" = $1', [args.id]);
|
||||||
|
|
||||||
|
// Delete the comment
|
||||||
|
const result = await pgClient.query('DELETE FROM comments WHERE id = $1 RETURNING id', [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Comment deleted successfully',
|
||||||
|
id: result.rows[0].id,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* comments.resolve - Mark a comment as resolved
|
||||||
|
*/
|
||||||
|
const resolveComment: BaseTool<GetCommentArgs> = {
|
||||||
|
name: 'outline_comments_resolve',
|
||||||
|
description: 'Mark a comment as resolved. Can also be used to unresolve a comment by setting resolved to false.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Comment ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid comment ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first admin user as resolver
|
||||||
|
const userQuery = await pgClient.query(
|
||||||
|
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userQuery.rows.length === 0) {
|
||||||
|
throw new Error('No valid user found to resolve comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedById = userQuery.rows[0].id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE comments
|
||||||
|
SET
|
||||||
|
"resolvedById" = $1,
|
||||||
|
"resolvedAt" = NOW(),
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [resolvedById, args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all comment tools
|
||||||
|
export const commentsTools: BaseTool<any>[] = [
|
||||||
|
listComments,
|
||||||
|
getComment,
|
||||||
|
createComment,
|
||||||
|
updateComment,
|
||||||
|
deleteComment,
|
||||||
|
resolveComment,
|
||||||
|
];
|
||||||
1342
src/tools/documents.ts
Normal file
1342
src/tools/documents.ts
Normal file
File diff suppressed because it is too large
Load Diff
370
src/tools/events.ts
Normal file
370
src/tools/events.ts
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Events Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, EventArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* events.list - List events with optional filters
|
||||||
|
*/
|
||||||
|
const listEvents: BaseTool<EventArgs> = {
|
||||||
|
name: 'outline_events_list',
|
||||||
|
description: 'List system events with optional filtering by name, actor, document, collection, or date range. Useful for audit logs and activity tracking.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by event name (e.g., "documents.create", "users.signin")',
|
||||||
|
},
|
||||||
|
actor_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by actor user ID (UUID)',
|
||||||
|
},
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by document ID (UUID)',
|
||||||
|
},
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by collection ID (UUID)',
|
||||||
|
},
|
||||||
|
date_from: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter events from this date (ISO 8601 format)',
|
||||||
|
},
|
||||||
|
date_to: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter events until this date (ISO 8601 format)',
|
||||||
|
},
|
||||||
|
audit_log: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Filter only audit log worthy events (default: false)',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results (default: 25, max: 100)',
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of 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.name) {
|
||||||
|
conditions.push(`e.name = $${paramIndex++}`);
|
||||||
|
params.push(args.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.actor_id) {
|
||||||
|
if (!isValidUUID(args.actor_id)) {
|
||||||
|
throw new Error('Invalid actor_id format');
|
||||||
|
}
|
||||||
|
conditions.push(`e."actorId" = $${paramIndex++}`);
|
||||||
|
params.push(args.actor_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
conditions.push(`e."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(`e."collectionId" = $${paramIndex++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.date_from) {
|
||||||
|
conditions.push(`e."createdAt" >= $${paramIndex++}`);
|
||||||
|
params.push(args.date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.date_to) {
|
||||||
|
conditions.push(`e."createdAt" <= $${paramIndex++}`);
|
||||||
|
params.push(args.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log filter - common audit events
|
||||||
|
if (args.audit_log) {
|
||||||
|
conditions.push(`e.name IN (
|
||||||
|
'users.create', 'users.update', 'users.delete', 'users.signin',
|
||||||
|
'documents.create', 'documents.update', 'documents.delete', 'documents.publish',
|
||||||
|
'collections.create', 'collections.update', 'collections.delete',
|
||||||
|
'groups.create', 'groups.update', 'groups.delete',
|
||||||
|
'shares.create', 'shares.revoke'
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.name,
|
||||||
|
e."modelId",
|
||||||
|
e."actorId",
|
||||||
|
e."userId",
|
||||||
|
e."collectionId",
|
||||||
|
e."documentId",
|
||||||
|
e."teamId",
|
||||||
|
e.ip,
|
||||||
|
e.data,
|
||||||
|
e."createdAt",
|
||||||
|
actor.name as "actorName",
|
||||||
|
actor.email as "actorEmail",
|
||||||
|
u.name as "userName",
|
||||||
|
c.name as "collectionName",
|
||||||
|
d.title as "documentTitle"
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN users actor ON e."actorId" = actor.id
|
||||||
|
LEFT JOIN users u ON e."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON e."collectionId" = c.id
|
||||||
|
LEFT JOIN documents d ON e."documentId" = d.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY e."createdAt" DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: result.rows.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* events.info - Get detailed information about a specific event
|
||||||
|
*/
|
||||||
|
const getEvent: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_events_info',
|
||||||
|
description: 'Get detailed information about a specific event by ID, including all associated metadata.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Event ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid event ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.name,
|
||||||
|
e."modelId",
|
||||||
|
e."actorId",
|
||||||
|
e."userId",
|
||||||
|
e."collectionId",
|
||||||
|
e."documentId",
|
||||||
|
e."teamId",
|
||||||
|
e.ip,
|
||||||
|
e.data,
|
||||||
|
e."createdAt",
|
||||||
|
actor.name as "actorName",
|
||||||
|
actor.email as "actorEmail",
|
||||||
|
actor."isAdmin" as "actorIsAdmin",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail",
|
||||||
|
c.name as "collectionName",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
t.name as "teamName"
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN users actor ON e."actorId" = actor.id
|
||||||
|
LEFT JOIN users u ON e."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON e."collectionId" = c.id
|
||||||
|
LEFT JOIN documents d ON e."documentId" = d.id
|
||||||
|
LEFT JOIN teams t ON e."teamId" = t.id
|
||||||
|
WHERE e.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* events.stats - Get event statistics and summaries
|
||||||
|
*/
|
||||||
|
const getEventStats: BaseTool<EventArgs> = {
|
||||||
|
name: 'outline_events_stats',
|
||||||
|
description: 'Get statistical analysis of events. Provides event counts by type, top actors, and activity trends.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
date_from: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Statistics from this date (ISO 8601 format)',
|
||||||
|
},
|
||||||
|
date_to: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Statistics until this date (ISO 8601 format)',
|
||||||
|
},
|
||||||
|
collection_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter statistics by collection ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.date_from) {
|
||||||
|
conditions.push(`e."createdAt" >= $${paramIndex++}`);
|
||||||
|
params.push(args.date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.date_to) {
|
||||||
|
conditions.push(`e."createdAt" <= $${paramIndex++}`);
|
||||||
|
params.push(args.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.collection_id) {
|
||||||
|
if (!isValidUUID(args.collection_id)) {
|
||||||
|
throw new Error('Invalid collection_id format');
|
||||||
|
}
|
||||||
|
conditions.push(`e."collectionId" = $${paramIndex++}`);
|
||||||
|
params.push(args.collection_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Event counts by type
|
||||||
|
const eventsByTypeQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
e.name,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM events e
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY e.name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top actors
|
||||||
|
const topActorsQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
e."actorId",
|
||||||
|
u.name as "actorName",
|
||||||
|
u.email as "actorEmail",
|
||||||
|
COUNT(*) as "eventCount"
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN users u ON e."actorId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY e."actorId", u.name, u.email
|
||||||
|
ORDER BY "eventCount" DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Activity by day (last 30 days or filtered range)
|
||||||
|
const activityByDayQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
DATE(e."createdAt") as date,
|
||||||
|
COUNT(*) as "eventCount"
|
||||||
|
FROM events e
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY DATE(e."createdAt")
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 30`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Total statistics
|
||||||
|
const totalsQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as "totalEvents",
|
||||||
|
COUNT(DISTINCT e."actorId") as "uniqueActors",
|
||||||
|
COUNT(DISTINCT e."documentId") as "affectedDocuments",
|
||||||
|
COUNT(DISTINCT e."collectionId") as "affectedCollections"
|
||||||
|
FROM events e
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
totals: totalsQuery.rows[0],
|
||||||
|
eventsByType: eventsByTypeQuery.rows,
|
||||||
|
topActors: topActorsQuery.rows,
|
||||||
|
activityByDay: activityByDayQuery.rows,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all event tools
|
||||||
|
export const eventsTools: BaseTool<any>[] = [
|
||||||
|
listEvents,
|
||||||
|
getEvent,
|
||||||
|
getEventStats,
|
||||||
|
];
|
||||||
303
src/tools/file-operations.ts
Normal file
303
src/tools/file-operations.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - File Operations Tools
|
||||||
|
* Handles background file operations (import/export tracking)
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import {
|
||||||
|
BaseTool,
|
||||||
|
ToolResponse,
|
||||||
|
FileOperationArgs,
|
||||||
|
GetFileOperationArgs,
|
||||||
|
} from '../types/tools.js';
|
||||||
|
import { FileOperation } from '../types/db.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* file_operations.list - List background file operations
|
||||||
|
*/
|
||||||
|
const listFileOperations: BaseTool<FileOperationArgs> = {
|
||||||
|
name: 'outline_file_operations_list',
|
||||||
|
description: 'List background file operations (imports/exports) with optional filtering by type. Returns operation status, progress, and download URLs for completed exports.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['import', 'export'],
|
||||||
|
description: 'Filter by operation type',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results to return',
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { type, limit = 25, offset = 0 } = args;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
fo.id,
|
||||||
|
fo.type,
|
||||||
|
fo.state,
|
||||||
|
fo.format,
|
||||||
|
fo.size,
|
||||||
|
fo.key,
|
||||||
|
fo.url,
|
||||||
|
fo.error,
|
||||||
|
fo."collectionId",
|
||||||
|
fo."userId",
|
||||||
|
fo."teamId",
|
||||||
|
fo."createdAt",
|
||||||
|
fo."updatedAt",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail",
|
||||||
|
c.name as "collectionName"
|
||||||
|
FROM file_operations fo
|
||||||
|
LEFT JOIN users u ON fo."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON fo."collectionId" = c.id
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
query += ` AND fo.type = $${paramIndex}`;
|
||||||
|
params.push(type);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY fo."createdAt" DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
const operations = result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
state: row.state,
|
||||||
|
format: row.format,
|
||||||
|
size: row.size,
|
||||||
|
url: row.url,
|
||||||
|
error: row.error,
|
||||||
|
collectionId: row.collectionId,
|
||||||
|
collectionName: row.collectionName,
|
||||||
|
userId: row.userId,
|
||||||
|
userName: row.userName,
|
||||||
|
userEmail: row.userEmail,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: operations,
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
total: result.rowCount || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* file_operations.info - Get file operation details
|
||||||
|
*/
|
||||||
|
const getFileOperation: BaseTool<GetFileOperationArgs> = {
|
||||||
|
name: 'outline_file_operations_info',
|
||||||
|
description: 'Get detailed information about a specific file operation by ID. Use this to track job status and get download URLs for completed exports.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File operation UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query<FileOperation>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
fo.*,
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail",
|
||||||
|
c.name as "collectionName"
|
||||||
|
FROM file_operations fo
|
||||||
|
LEFT JOIN users u ON fo."userId" = u.id
|
||||||
|
LEFT JOIN collections c ON fo."collectionId" = c.id
|
||||||
|
WHERE fo.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`File operation not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = result.rows[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: operation,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* file_operations.redirect - Get download URL for completed file operation
|
||||||
|
*/
|
||||||
|
const redirectFileOperation: BaseTool<GetFileOperationArgs> = {
|
||||||
|
name: 'outline_file_operations_redirect',
|
||||||
|
description: 'Get the download URL for a completed file operation. Returns the URL field from the operation if state is "complete".',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File operation UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query<FileOperation>(
|
||||||
|
`
|
||||||
|
SELECT id, state, url, type, format
|
||||||
|
FROM file_operations
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`File operation not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const operation = result.rows[0];
|
||||||
|
|
||||||
|
if (operation.state !== 'complete') {
|
||||||
|
throw new Error(
|
||||||
|
`File operation not complete. Current state: ${operation.state}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!operation.url) {
|
||||||
|
throw new Error('Download URL not available for this operation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
id: operation.id,
|
||||||
|
url: operation.url,
|
||||||
|
type: operation.type,
|
||||||
|
format: operation.format,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* file_operations.delete - Remove file operation record
|
||||||
|
*/
|
||||||
|
const deleteFileOperation: BaseTool<GetFileOperationArgs> = {
|
||||||
|
name: 'outline_file_operations_delete',
|
||||||
|
description: 'Delete a file operation record from the database. This removes the tracking record but does not delete the actual file if it was uploaded to storage.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File operation UUID to delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
DELETE FROM file_operations
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, type, state
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`File operation not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
deleted: result.rows[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all file operation tools
|
||||||
|
export const fileOperationsTools: BaseTool<any>[] = [
|
||||||
|
listFileOperations,
|
||||||
|
getFileOperation,
|
||||||
|
redirectFileOperation,
|
||||||
|
deleteFileOperation,
|
||||||
|
];
|
||||||
564
src/tools/groups.ts
Normal file
564
src/tools/groups.ts
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Groups Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, GroupArgs, GetGroupArgs, CreateGroupArgs, UpdateGroupArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.list - List all groups
|
||||||
|
*/
|
||||||
|
const listGroups: BaseTool<GroupArgs> = {
|
||||||
|
name: 'outline_list_groups',
|
||||||
|
description: 'List all groups with optional search query. Supports pagination and returns group details including member counts.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Search query to filter groups by name',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results to return (max 100)',
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip for pagination',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const query = args.query ? sanitizeInput(args.query) : undefined;
|
||||||
|
|
||||||
|
let whereConditions = ['g."deletedAt" IS NULL'];
|
||||||
|
const queryParams: any[] = [limit, offset];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
whereConditions.push(`LOWER(g.name) LIKE LOWER($${paramIndex})`);
|
||||||
|
queryParams.push(`%${query}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.name,
|
||||||
|
g."teamId",
|
||||||
|
g."createdById",
|
||||||
|
g."createdAt",
|
||||||
|
g."updatedAt",
|
||||||
|
t.name as "teamName",
|
||||||
|
u.name as "createdByName",
|
||||||
|
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount",
|
||||||
|
(SELECT COUNT(*) FROM groups WHERE ${whereConditions.join(' AND ')}) as total
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN teams t ON g."teamId" = t.id
|
||||||
|
LEFT JOIN users u ON g."createdById" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY g."createdAt" DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
groups: result.rows,
|
||||||
|
total: result.rows.length > 0 ? parseInt(result.rows[0].total) : 0,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.info - Get group details by ID
|
||||||
|
*/
|
||||||
|
const getGroup: BaseTool<GetGroupArgs> = {
|
||||||
|
name: 'outline_get_group',
|
||||||
|
description: 'Get detailed information about a specific group by its ID. Returns group details and member statistics.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
g.name,
|
||||||
|
g."teamId",
|
||||||
|
g."createdById",
|
||||||
|
g."createdAt",
|
||||||
|
g."updatedAt",
|
||||||
|
t.name as "teamName",
|
||||||
|
u.name as "createdByName",
|
||||||
|
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount"
|
||||||
|
FROM groups g
|
||||||
|
LEFT JOIN teams t ON g."teamId" = t.id
|
||||||
|
LEFT JOIN users u ON g."createdById" = u.id
|
||||||
|
WHERE g.id = $1 AND g."deletedAt" IS NULL
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Group not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.create - Create new group
|
||||||
|
*/
|
||||||
|
const createGroup: BaseTool<CreateGroupArgs> = {
|
||||||
|
name: 'outline_create_group',
|
||||||
|
description: 'Create a new group with the specified name. Groups can be used to organize users and manage permissions.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Name of the group',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const name = sanitizeInput(args.name);
|
||||||
|
|
||||||
|
// Get team ID (assuming first team, adjust as needed)
|
||||||
|
const teamResult = await pgClient.query(`SELECT id FROM teams LIMIT 1`);
|
||||||
|
if (teamResult.rows.length === 0) {
|
||||||
|
throw new Error('No team found');
|
||||||
|
}
|
||||||
|
const teamId = teamResult.rows[0].id;
|
||||||
|
|
||||||
|
// Get first admin user as creator (adjust as needed)
|
||||||
|
const userResult = await pgClient.query(
|
||||||
|
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1`
|
||||||
|
);
|
||||||
|
if (userResult.rows.length === 0) {
|
||||||
|
throw new Error('No admin user found');
|
||||||
|
}
|
||||||
|
const createdById = userResult.rows[0].id;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO groups (
|
||||||
|
id, name, "teamId", "createdById",
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3,
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[name, teamId, createdById]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'Group created successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.update - Update group details
|
||||||
|
*/
|
||||||
|
const updateGroup: BaseTool<UpdateGroupArgs> = {
|
||||||
|
name: 'outline_update_group',
|
||||||
|
description: 'Update the name of an existing group.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group UUID',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'New name for the group',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id', 'name'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = sanitizeInput(args.name);
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE groups
|
||||||
|
SET name = $1, "updatedAt" = NOW()
|
||||||
|
WHERE id = $2 AND "deletedAt" IS NULL
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[name, args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Group not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'Group updated successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.delete - Soft delete group
|
||||||
|
*/
|
||||||
|
const deleteGroup: BaseTool<GetGroupArgs> = {
|
||||||
|
name: 'outline_delete_group',
|
||||||
|
description: 'Soft delete a group. This marks the group as deleted but preserves the data for audit purposes.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group UUID to delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE groups
|
||||||
|
SET "deletedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, name
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Group not found or already deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'Group deleted successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.memberships - List group members
|
||||||
|
*/
|
||||||
|
const listGroupMembers: BaseTool<GetGroupArgs> = {
|
||||||
|
name: 'outline_list_group_members',
|
||||||
|
description: 'List all members of a specific group. Returns user details for each group member.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
gu.id as "membershipId",
|
||||||
|
gu."userId",
|
||||||
|
gu."groupId",
|
||||||
|
gu."createdById",
|
||||||
|
gu."createdAt",
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail",
|
||||||
|
u."isAdmin" as "userIsAdmin",
|
||||||
|
creator.name as "addedByName"
|
||||||
|
FROM group_users gu
|
||||||
|
JOIN users u ON gu."userId" = u.id
|
||||||
|
LEFT JOIN users creator ON gu."createdById" = creator.id
|
||||||
|
WHERE gu."groupId" = $1 AND gu."deletedAt" IS NULL AND u."deletedAt" IS NULL
|
||||||
|
ORDER BY gu."createdAt" DESC
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
members: result.rows,
|
||||||
|
total: result.rows.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.add_user - Add user to group
|
||||||
|
*/
|
||||||
|
const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
|
||||||
|
name: 'outline_add_user_to_group',
|
||||||
|
description: 'Add a user to a group. Creates a group membership relationship.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group UUID',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to add to the group',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id', 'user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
if (!isValidUUID(args.user_id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if group exists
|
||||||
|
const groupCheck = await pgClient.query(
|
||||||
|
`SELECT id FROM groups WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
if (groupCheck.rows.length === 0) {
|
||||||
|
throw new Error('Group not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const userCheck = await pgClient.query(
|
||||||
|
`SELECT id FROM users WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||||
|
[args.user_id]
|
||||||
|
);
|
||||||
|
if (userCheck.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already in group
|
||||||
|
const existingMembership = await pgClient.query(
|
||||||
|
`SELECT id FROM group_users WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL`,
|
||||||
|
[args.id, args.user_id]
|
||||||
|
);
|
||||||
|
if (existingMembership.rows.length > 0) {
|
||||||
|
throw new Error('User is already a member of this group');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first admin user as creator (adjust as needed)
|
||||||
|
const creatorResult = await pgClient.query(
|
||||||
|
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1`
|
||||||
|
);
|
||||||
|
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO group_users (
|
||||||
|
id, "userId", "groupId", "createdById",
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3,
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[args.user_id, args.id, createdById]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User added to group successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* groups.remove_user - Remove user from group
|
||||||
|
*/
|
||||||
|
const removeUserFromGroup: BaseTool<{ id: string; user_id: string }> = {
|
||||||
|
name: 'outline_remove_user_from_group',
|
||||||
|
description: 'Remove a user from a group. Soft deletes the group membership.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Group UUID',
|
||||||
|
},
|
||||||
|
user_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to remove from the group',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id', 'user_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
if (!isValidUUID(args.user_id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE group_users
|
||||||
|
SET "deletedAt" = NOW()
|
||||||
|
WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, "userId", "groupId"
|
||||||
|
`,
|
||||||
|
[args.id, args.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User is not a member of this group');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User removed from group successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all group tools
|
||||||
|
export const groupsTools: BaseTool<any>[] = [
|
||||||
|
listGroups,
|
||||||
|
getGroup,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
deleteGroup,
|
||||||
|
listGroupMembers,
|
||||||
|
addUserToGroup,
|
||||||
|
removeUserFromGroup,
|
||||||
|
];
|
||||||
41
src/tools/index.ts
Normal file
41
src/tools/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Tools Index
|
||||||
|
* Central export for all MCP tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Document Tools - Core document management (to be implemented)
|
||||||
|
export { documentsTools } from './documents.js';
|
||||||
|
|
||||||
|
// Collection Tools - Collection management (to be implemented)
|
||||||
|
export { collectionsTools } from './collections.js';
|
||||||
|
|
||||||
|
// User Tools - User management (to be implemented)
|
||||||
|
export { usersTools } from './users.js';
|
||||||
|
|
||||||
|
// Group Tools - Group and team management (to be implemented)
|
||||||
|
export { groupsTools } from './groups.js';
|
||||||
|
|
||||||
|
// Comment Tools - Comment management (to be implemented)
|
||||||
|
export { commentsTools } from './comments.js';
|
||||||
|
|
||||||
|
// Share Tools - Document sharing and public links (to be implemented)
|
||||||
|
export { sharesTools } from './shares.js';
|
||||||
|
|
||||||
|
// Revision Tools - Document version history (to be implemented)
|
||||||
|
export { revisionsTools } from './revisions.js';
|
||||||
|
|
||||||
|
// Event Tools - Audit log and activity tracking (to be implemented)
|
||||||
|
export { eventsTools } from './events.js';
|
||||||
|
|
||||||
|
// Attachment Tools - File attachments (to be implemented)
|
||||||
|
export { attachmentsTools } from './attachments.js';
|
||||||
|
|
||||||
|
// File Operation Tools - Import/Export operations
|
||||||
|
export { fileOperationsTools } from './file-operations.js';
|
||||||
|
|
||||||
|
// OAuth Tools - OAuth client management
|
||||||
|
export { oauthTools } from './oauth.js';
|
||||||
|
|
||||||
|
// Auth Tools - Authentication and authorization
|
||||||
|
export { authTools } from './auth.js';
|
||||||
546
src/tools/oauth.ts
Normal file
546
src/tools/oauth.ts
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - OAuth Tools
|
||||||
|
* Manages OAuth applications and user authentications
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import {
|
||||||
|
BaseTool,
|
||||||
|
ToolResponse,
|
||||||
|
OAuthClientArgs,
|
||||||
|
GetOAuthClientArgs,
|
||||||
|
CreateOAuthClientArgs,
|
||||||
|
UpdateOAuthClientArgs,
|
||||||
|
PaginationArgs,
|
||||||
|
} from '../types/tools.js';
|
||||||
|
|
||||||
|
interface OAuthClient {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
secret: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
description?: string;
|
||||||
|
teamId: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthAuthentication {
|
||||||
|
id: string;
|
||||||
|
providerId: string;
|
||||||
|
userId: string;
|
||||||
|
teamId: string;
|
||||||
|
scopes: string[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_clients.list - List OAuth applications
|
||||||
|
*/
|
||||||
|
const listOAuthClients: BaseTool<OAuthClientArgs> = {
|
||||||
|
name: 'outline_oauth_clients_list',
|
||||||
|
description: 'List all registered OAuth applications/clients. Returns client details including name, redirect URIs, and creation info.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results to return',
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit = 25, offset = 0 } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
oc.*,
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail"
|
||||||
|
FROM oauth_clients oc
|
||||||
|
LEFT JOIN users u ON oc."createdById" = u.id
|
||||||
|
ORDER BY oc."createdAt" DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clients = result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
redirectUris: row.redirectUris,
|
||||||
|
description: row.description,
|
||||||
|
teamId: row.teamId,
|
||||||
|
createdById: row.createdById,
|
||||||
|
createdByName: row.createdByName,
|
||||||
|
createdByEmail: row.createdByEmail,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
// Secret omitted for security
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: clients,
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
total: result.rowCount || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_clients.info - Get OAuth client details
|
||||||
|
*/
|
||||||
|
const getOAuthClient: BaseTool<GetOAuthClientArgs> = {
|
||||||
|
name: 'outline_oauth_clients_info',
|
||||||
|
description: 'Get detailed information about a specific OAuth client/application by ID.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OAuth client UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query<OAuthClient>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
oc.*,
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail"
|
||||||
|
FROM oauth_clients oc
|
||||||
|
LEFT JOIN users u ON oc."createdById" = u.id
|
||||||
|
WHERE oc.id = $1
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`OAuth client not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = result.rows[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: client,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_clients.create - Register new OAuth application
|
||||||
|
*/
|
||||||
|
const createOAuthClient: BaseTool<CreateOAuthClientArgs> = {
|
||||||
|
name: 'outline_oauth_clients_create',
|
||||||
|
description: 'Register a new OAuth application/client. Generates client credentials for OAuth flow integration.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Application name',
|
||||||
|
},
|
||||||
|
redirect_uris: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'List of allowed redirect URIs for OAuth flow',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional application description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'redirect_uris'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { name, redirect_uris, description } = args;
|
||||||
|
|
||||||
|
// Generate random client secret (in production, use crypto.randomBytes)
|
||||||
|
const secret = `sk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO oauth_clients (name, secret, "redirectUris", description)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[name, secret, JSON.stringify(redirect_uris), description]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'OAuth client created successfully. Store the secret securely - it will not be shown again.',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_clients.update - Modify OAuth application settings
|
||||||
|
*/
|
||||||
|
const updateOAuthClient: BaseTool<UpdateOAuthClientArgs> = {
|
||||||
|
name: 'outline_oauth_clients_update',
|
||||||
|
description: 'Update OAuth client/application settings such as name, redirect URIs, or description.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OAuth client UUID',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'New application name',
|
||||||
|
},
|
||||||
|
redirect_uris: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Updated list of allowed redirect URIs',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Updated description',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id, name, redirect_uris, description } = args;
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (name !== undefined) {
|
||||||
|
updates.push(`name = $${paramIndex}`);
|
||||||
|
values.push(name);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirect_uris !== undefined) {
|
||||||
|
updates.push(`"redirectUris" = $${paramIndex}`);
|
||||||
|
values.push(JSON.stringify(redirect_uris));
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description !== undefined) {
|
||||||
|
updates.push(`description = $${paramIndex}`);
|
||||||
|
values.push(description);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new Error('No fields to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`"updatedAt" = NOW()`);
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE oauth_clients
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`OAuth client not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_clients.rotate_secret - Generate new client secret
|
||||||
|
*/
|
||||||
|
const rotateOAuthClientSecret: BaseTool<GetOAuthClientArgs> = {
|
||||||
|
name: 'outline_oauth_clients_rotate_secret',
|
||||||
|
description: 'Generate a new client secret for an OAuth application. The old secret will be invalidated.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OAuth client UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const newSecret = `sk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE oauth_clients
|
||||||
|
SET secret = $1, "updatedAt" = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, name, secret, "updatedAt"
|
||||||
|
`,
|
||||||
|
[newSecret, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`OAuth client not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'Secret rotated successfully. Update your application configuration with the new secret.',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_clients.delete - Remove OAuth application
|
||||||
|
*/
|
||||||
|
const deleteOAuthClient: BaseTool<GetOAuthClientArgs> = {
|
||||||
|
name: 'outline_oauth_clients_delete',
|
||||||
|
description: 'Delete an OAuth client/application. This will revoke all active authentications using this client.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OAuth client UUID to delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
DELETE FROM oauth_clients
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, name
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`OAuth client not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
deleted: result.rows[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_authentications.list - List user OAuth authentications
|
||||||
|
*/
|
||||||
|
const listOAuthAuthentications: BaseTool<PaginationArgs> = {
|
||||||
|
name: 'outline_oauth_authentications_list',
|
||||||
|
description: 'List all OAuth authentications (user authorizations). Shows which users have granted access to which providers.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results to return',
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit = 25, offset = 0 } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
oa.*,
|
||||||
|
u.name as "userName",
|
||||||
|
u.email as "userEmail"
|
||||||
|
FROM oauth_authentications oa
|
||||||
|
LEFT JOIN users u ON oa."userId" = u.id
|
||||||
|
ORDER BY oa."createdAt" DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
total: result.rowCount || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oauth_authentications.delete - Revoke OAuth authentication
|
||||||
|
*/
|
||||||
|
const deleteOAuthAuthentication: BaseTool<{ id: string }> = {
|
||||||
|
name: 'outline_oauth_authentications_delete',
|
||||||
|
description: 'Revoke an OAuth authentication. This disconnects the user from the OAuth provider.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'OAuth authentication UUID to revoke',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { id } = args;
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
DELETE FROM oauth_authentications
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, "providerId", "userId"
|
||||||
|
`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error(`OAuth authentication not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
revoked: result.rows[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all OAuth tools
|
||||||
|
export const oauthTools: BaseTool<any>[] = [
|
||||||
|
listOAuthClients,
|
||||||
|
getOAuthClient,
|
||||||
|
createOAuthClient,
|
||||||
|
updateOAuthClient,
|
||||||
|
rotateOAuthClientSecret,
|
||||||
|
deleteOAuthClient,
|
||||||
|
listOAuthAuthentications,
|
||||||
|
deleteOAuthAuthentication,
|
||||||
|
];
|
||||||
335
src/tools/revisions.ts
Normal file
335
src/tools/revisions.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Revisions Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, RevisionArgs, GetRevisionArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revisions.list - List document revisions
|
||||||
|
*/
|
||||||
|
const listRevisions: BaseTool<RevisionArgs> = {
|
||||||
|
name: 'outline_revisions_list',
|
||||||
|
description: 'List all revisions for a specific document. Revisions are ordered from newest to oldest.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID (UUID)',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results (default: 25, max: 100)',
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip (default: 0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['document_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
|
||||||
|
// Verify document exists
|
||||||
|
const docCheck = await pgClient.query(
|
||||||
|
'SELECT id, title FROM documents WHERE id = $1',
|
||||||
|
[args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docCheck.rows.length === 0) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.version,
|
||||||
|
r."editorVersion",
|
||||||
|
r.title,
|
||||||
|
r.emoji,
|
||||||
|
r."documentId",
|
||||||
|
r."userId",
|
||||||
|
r."createdAt",
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail",
|
||||||
|
LENGTH(r.text) as "textLength"
|
||||||
|
FROM revisions r
|
||||||
|
LEFT JOIN users u ON r."userId" = u.id
|
||||||
|
WHERE r."documentId" = $1
|
||||||
|
ORDER BY r.version DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [args.document_id, limit, offset]);
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = await pgClient.query(
|
||||||
|
'SELECT COUNT(*) as total FROM revisions WHERE "documentId" = $1',
|
||||||
|
[args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows,
|
||||||
|
document: docCheck.rows[0],
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: parseInt(countQuery.rows[0].total),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revisions.info - Get detailed information about a specific revision
|
||||||
|
*/
|
||||||
|
const getRevision: BaseTool<GetRevisionArgs> = {
|
||||||
|
name: 'outline_revisions_info',
|
||||||
|
description: 'Get detailed information about a specific revision, including full text content.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Revision ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid revision ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.version,
|
||||||
|
r."editorVersion",
|
||||||
|
r.title,
|
||||||
|
r.text,
|
||||||
|
r.emoji,
|
||||||
|
r."documentId",
|
||||||
|
r."userId",
|
||||||
|
r."createdAt",
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail",
|
||||||
|
d.title as "currentDocumentTitle"
|
||||||
|
FROM revisions r
|
||||||
|
LEFT JOIN users u ON r."userId" = u.id
|
||||||
|
LEFT JOIN documents d ON r."documentId" = d.id
|
||||||
|
WHERE r.id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Revision not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get revision statistics
|
||||||
|
const statsQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as "totalRevisions",
|
||||||
|
MIN(version) as "firstVersion",
|
||||||
|
MAX(version) as "latestVersion"
|
||||||
|
FROM revisions
|
||||||
|
WHERE "documentId" = $1`,
|
||||||
|
[result.rows[0].documentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
statistics: statsQuery.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revisions.compare - Compare two revisions or a revision with current document
|
||||||
|
*/
|
||||||
|
const compareRevisions: BaseTool<{ id: string; compare_to?: string }> = {
|
||||||
|
name: 'outline_revisions_compare',
|
||||||
|
description: 'Compare two document revisions or compare a revision with the current document version. Returns both versions for comparison.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'First revision ID (UUID)',
|
||||||
|
},
|
||||||
|
compare_to: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Second revision ID to compare with (UUID). If not provided, compares with current document.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid revision ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.compare_to && !isValidUUID(args.compare_to)) {
|
||||||
|
throw new Error('Invalid compare_to revision ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first revision
|
||||||
|
const revision1Query = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
r.id,
|
||||||
|
r.version,
|
||||||
|
r.title,
|
||||||
|
r.text,
|
||||||
|
r.emoji,
|
||||||
|
r."documentId",
|
||||||
|
r."createdAt",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM revisions r
|
||||||
|
LEFT JOIN users u ON r."userId" = u.id
|
||||||
|
WHERE r.id = $1`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (revision1Query.rows.length === 0) {
|
||||||
|
throw new Error('Revision not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision1 = revision1Query.rows[0];
|
||||||
|
let revision2;
|
||||||
|
|
||||||
|
if (args.compare_to) {
|
||||||
|
// Compare with another revision
|
||||||
|
const revision2Query = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
r.id,
|
||||||
|
r.version,
|
||||||
|
r.title,
|
||||||
|
r.text,
|
||||||
|
r.emoji,
|
||||||
|
r."documentId",
|
||||||
|
r."createdAt",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM revisions r
|
||||||
|
LEFT JOIN users u ON r."userId" = u.id
|
||||||
|
WHERE r.id = $1`,
|
||||||
|
[args.compare_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (revision2Query.rows.length === 0) {
|
||||||
|
throw new Error('Comparison revision not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
revision2 = revision2Query.rows[0];
|
||||||
|
|
||||||
|
// Verify both revisions are from the same document
|
||||||
|
if (revision1.documentId !== revision2.documentId) {
|
||||||
|
throw new Error('Revisions are from different documents');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compare with current document
|
||||||
|
const currentDocQuery = await pgClient.query(
|
||||||
|
`SELECT
|
||||||
|
d.id,
|
||||||
|
d.title,
|
||||||
|
d.text,
|
||||||
|
d.emoji,
|
||||||
|
d."updatedAt" as "createdAt",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM documents d
|
||||||
|
LEFT JOIN users u ON d."updatedById" = u.id
|
||||||
|
WHERE d.id = $1`,
|
||||||
|
[revision1.documentId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentDocQuery.rows.length === 0) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
revision2 = {
|
||||||
|
...currentDocQuery.rows[0],
|
||||||
|
version: 'current',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate basic diff statistics
|
||||||
|
const textLengthDiff = revision2.text.length - revision1.text.length;
|
||||||
|
const titleChanged = revision1.title !== revision2.title;
|
||||||
|
const emojiChanged = revision1.emoji !== revision2.emoji;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
revision1: {
|
||||||
|
id: revision1.id,
|
||||||
|
version: revision1.version,
|
||||||
|
title: revision1.title,
|
||||||
|
text: revision1.text,
|
||||||
|
emoji: revision1.emoji,
|
||||||
|
createdAt: revision1.createdAt,
|
||||||
|
createdByName: revision1.createdByName,
|
||||||
|
},
|
||||||
|
revision2: {
|
||||||
|
id: revision2.id,
|
||||||
|
version: revision2.version,
|
||||||
|
title: revision2.title,
|
||||||
|
text: revision2.text,
|
||||||
|
emoji: revision2.emoji,
|
||||||
|
createdAt: revision2.createdAt,
|
||||||
|
createdByName: revision2.createdByName,
|
||||||
|
},
|
||||||
|
comparison: {
|
||||||
|
titleChanged,
|
||||||
|
emojiChanged,
|
||||||
|
textLengthDiff,
|
||||||
|
textLengthDiffPercent: ((textLengthDiff / revision1.text.length) * 100).toFixed(2) + '%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all revision tools
|
||||||
|
export const revisionsTools: BaseTool<any>[] = [
|
||||||
|
listRevisions,
|
||||||
|
getRevision,
|
||||||
|
compareRevisions,
|
||||||
|
];
|
||||||
470
src/tools/shares.ts
Normal file
470
src/tools/shares.ts
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Shares Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, ShareArgs, GetShareArgs, CreateShareArgs, UpdateShareArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID, isValidUrlId } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shares.list - List document shares with optional filters
|
||||||
|
*/
|
||||||
|
const listShares: BaseTool<ShareArgs> = {
|
||||||
|
name: 'outline_shares_list',
|
||||||
|
description: 'List document shares with optional filtering. Supports pagination.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Filter by document ID (UUID)',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results (default: 25, max: 100)',
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip (default: 0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const conditions: string[] = ['s."revokedAt" IS NULL'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s."urlId",
|
||||||
|
s."documentId",
|
||||||
|
s."userId",
|
||||||
|
s."teamId",
|
||||||
|
s."includeChildDocuments",
|
||||||
|
s.published,
|
||||||
|
s.domain,
|
||||||
|
s."lastAccessedAt",
|
||||||
|
s.views,
|
||||||
|
s."createdAt",
|
||||||
|
s."updatedAt",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail"
|
||||||
|
FROM shares s
|
||||||
|
LEFT JOIN documents d ON s."documentId" = d.id
|
||||||
|
LEFT JOIN users u ON s."userId" = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY s."createdAt" DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: result.rows.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shares.info - Get detailed information about a specific share
|
||||||
|
*/
|
||||||
|
const getShare: BaseTool<GetShareArgs> = {
|
||||||
|
name: 'outline_shares_info',
|
||||||
|
description: 'Get detailed information about a specific share by ID or document ID.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Share ID (UUID)',
|
||||||
|
},
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID to find share for (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
let query: string;
|
||||||
|
let params: string[];
|
||||||
|
|
||||||
|
if (args.id) {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid share ID format');
|
||||||
|
}
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s."urlId",
|
||||||
|
s."documentId",
|
||||||
|
s."userId",
|
||||||
|
s."teamId",
|
||||||
|
s."includeChildDocuments",
|
||||||
|
s.published,
|
||||||
|
s.domain,
|
||||||
|
s."lastAccessedAt",
|
||||||
|
s.views,
|
||||||
|
s."createdAt",
|
||||||
|
s."updatedAt",
|
||||||
|
s."revokedAt",
|
||||||
|
s."revokedById",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
d.text as "documentText",
|
||||||
|
u.name as "createdByName",
|
||||||
|
u.email as "createdByEmail",
|
||||||
|
ru.name as "revokedByName"
|
||||||
|
FROM shares s
|
||||||
|
LEFT JOIN documents d ON s."documentId" = d.id
|
||||||
|
LEFT JOIN users u ON s."userId" = u.id
|
||||||
|
LEFT JOIN users ru ON s."revokedById" = ru.id
|
||||||
|
WHERE s.id = $1
|
||||||
|
`;
|
||||||
|
params = [args.id];
|
||||||
|
} else if (args.document_id) {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s."urlId",
|
||||||
|
s."documentId",
|
||||||
|
s."userId",
|
||||||
|
s."teamId",
|
||||||
|
s."includeChildDocuments",
|
||||||
|
s.published,
|
||||||
|
s.domain,
|
||||||
|
s."lastAccessedAt",
|
||||||
|
s.views,
|
||||||
|
s."createdAt",
|
||||||
|
s."updatedAt",
|
||||||
|
s."revokedAt",
|
||||||
|
s."revokedById",
|
||||||
|
d.title as "documentTitle",
|
||||||
|
u.name as "createdByName"
|
||||||
|
FROM shares s
|
||||||
|
LEFT JOIN documents d ON s."documentId" = d.id
|
||||||
|
LEFT JOIN users u ON s."userId" = u.id
|
||||||
|
WHERE s."documentId" = $1 AND s."revokedAt" IS NULL
|
||||||
|
ORDER BY s."createdAt" DESC
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
params = [args.document_id];
|
||||||
|
} else {
|
||||||
|
throw new Error('Either id or document_id must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Share not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shares.create - Create a new document share
|
||||||
|
*/
|
||||||
|
const createShare: BaseTool<CreateShareArgs> = {
|
||||||
|
name: 'outline_shares_create',
|
||||||
|
description: 'Create a new share link for a document. Returns the share details including the URL ID.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
document_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Document ID to share (UUID)',
|
||||||
|
},
|
||||||
|
published: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether the share is published (default: true)',
|
||||||
|
},
|
||||||
|
include_child_documents: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include child documents in the share (default: false)',
|
||||||
|
},
|
||||||
|
url_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Custom URL ID (optional, will be auto-generated if not provided)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['document_id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.document_id)) {
|
||||||
|
throw new Error('Invalid document_id format');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.url_id && !isValidUrlId(args.url_id)) {
|
||||||
|
throw new Error('Invalid url_id format. Use only alphanumeric characters, hyphens, and underscores.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify document exists
|
||||||
|
const docCheck = await pgClient.query(
|
||||||
|
'SELECT id, "teamId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL',
|
||||||
|
[args.document_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (docCheck.rows.length === 0) {
|
||||||
|
throw new Error('Document not found or deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamId = docCheck.rows[0].teamId;
|
||||||
|
|
||||||
|
// Get first admin user as creator
|
||||||
|
const userQuery = await pgClient.query(
|
||||||
|
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userQuery.rows.length === 0) {
|
||||||
|
throw new Error('No valid user found to create share');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = userQuery.rows[0].id;
|
||||||
|
|
||||||
|
// Generate urlId if not provided
|
||||||
|
const urlId = args.url_id || `share-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO shares (
|
||||||
|
"urlId",
|
||||||
|
"documentId",
|
||||||
|
"userId",
|
||||||
|
"teamId",
|
||||||
|
"includeChildDocuments",
|
||||||
|
published,
|
||||||
|
views,
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, 0, NOW(), NOW())
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [
|
||||||
|
urlId,
|
||||||
|
args.document_id,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
args.include_child_documents || false,
|
||||||
|
args.published !== false, // Default to true
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shares.update - Update an existing share
|
||||||
|
*/
|
||||||
|
const updateShare: BaseTool<UpdateShareArgs> = {
|
||||||
|
name: 'outline_shares_update',
|
||||||
|
description: 'Update an existing share. Can change published status or include child documents setting.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Share ID (UUID)',
|
||||||
|
},
|
||||||
|
published: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Whether the share is published',
|
||||||
|
},
|
||||||
|
include_child_documents: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Include child documents in the share',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid share ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.published !== undefined) {
|
||||||
|
updates.push(`published = $${paramIndex++}`);
|
||||||
|
params.push(args.published);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.include_child_documents !== undefined) {
|
||||||
|
updates.push(`"includeChildDocuments" = $${paramIndex++}`);
|
||||||
|
params.push(args.include_child_documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new Error('No updates provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`"updatedAt" = NOW()`);
|
||||||
|
params.push(args.id);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE shares
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex} AND "revokedAt" IS NULL
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, params);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Share not found or already revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* shares.revoke - Revoke a share link
|
||||||
|
*/
|
||||||
|
const revokeShare: BaseTool<GetShareArgs> = {
|
||||||
|
name: 'outline_shares_revoke',
|
||||||
|
description: 'Revoke a share link, making it no longer accessible.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Share ID (UUID)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id!)) {
|
||||||
|
throw new Error('Invalid share ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first admin user as revoker
|
||||||
|
const userQuery = await pgClient.query(
|
||||||
|
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userQuery.rows.length === 0) {
|
||||||
|
throw new Error('No valid user found to revoke share');
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokedById = userQuery.rows[0].id;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE shares
|
||||||
|
SET
|
||||||
|
"revokedAt" = NOW(),
|
||||||
|
"revokedById" = $1,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE id = $2 AND "revokedAt" IS NULL
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pgClient.query(query, [revokedById, args.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('Share not found or already revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Share revoked successfully',
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all share tools
|
||||||
|
export const sharesTools: BaseTool<any>[] = [
|
||||||
|
listShares,
|
||||||
|
getShare,
|
||||||
|
createShare,
|
||||||
|
updateShare,
|
||||||
|
revokeShare,
|
||||||
|
];
|
||||||
660
src/tools/users.ts
Normal file
660
src/tools/users.ts
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Users Tools
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { BaseTool, ToolResponse, UserArgs, GetUserArgs, CreateUserArgs, UpdateUserArgs } from '../types/tools.js';
|
||||||
|
import { validatePagination, isValidUUID, isValidEmail, sanitizeInput } from '../utils/security.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.list - List users with filtering
|
||||||
|
*/
|
||||||
|
const listUsers: BaseTool<UserArgs> = {
|
||||||
|
name: 'outline_list_users',
|
||||||
|
description: 'List users with optional filtering by query string, role, or status. Supports pagination and returns user profiles including roles and suspension status.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Search query to filter users by name or email',
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['all', 'admins', 'members', 'suspended', 'invited'],
|
||||||
|
description: 'Filter users by role or status',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of results to return (max 100)',
|
||||||
|
default: 25,
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of results to skip for pagination',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||||
|
const query = args.query ? sanitizeInput(args.query) : undefined;
|
||||||
|
const filter = args.filter || 'all';
|
||||||
|
|
||||||
|
let whereConditions = ['u."deletedAt" IS NULL'];
|
||||||
|
const queryParams: any[] = [limit, offset];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
// Add search query filter
|
||||||
|
if (query) {
|
||||||
|
whereConditions.push(`(LOWER(u.name) LIKE LOWER($${paramIndex}) OR LOWER(u.email) LIKE LOWER($${paramIndex}))`);
|
||||||
|
queryParams.push(`%${query}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add role/status filters
|
||||||
|
switch (filter) {
|
||||||
|
case 'admins':
|
||||||
|
whereConditions.push('u."isAdmin" = true');
|
||||||
|
break;
|
||||||
|
case 'members':
|
||||||
|
whereConditions.push('u."isAdmin" = false AND u."isViewer" = false');
|
||||||
|
break;
|
||||||
|
case 'suspended':
|
||||||
|
whereConditions.push('u."isSuspended" = true');
|
||||||
|
break;
|
||||||
|
case 'invited':
|
||||||
|
whereConditions.push('u."lastSignedInAt" IS NULL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u."avatarUrl",
|
||||||
|
u.language,
|
||||||
|
u.preferences,
|
||||||
|
u."notificationSettings",
|
||||||
|
u.timezone,
|
||||||
|
u."isAdmin",
|
||||||
|
u."isViewer",
|
||||||
|
u."isSuspended",
|
||||||
|
u."lastActiveAt",
|
||||||
|
u."lastSignedInAt",
|
||||||
|
u."suspendedAt",
|
||||||
|
u."suspendedById",
|
||||||
|
u."teamId",
|
||||||
|
u."createdAt",
|
||||||
|
u."updatedAt",
|
||||||
|
t.name as "teamName",
|
||||||
|
(SELECT COUNT(*) FROM users WHERE ${whereConditions.join(' AND ')}) as total
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN teams t ON u."teamId" = t.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY u."createdAt" DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
users: result.rows,
|
||||||
|
total: result.rows.length > 0 ? parseInt(result.rows[0].total) : 0,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.info - Get user details by ID
|
||||||
|
*/
|
||||||
|
const getUser: BaseTool<GetUserArgs> = {
|
||||||
|
name: 'outline_get_user',
|
||||||
|
description: 'Get detailed information about a specific user by their ID. Returns full user profile including preferences, permissions, and activity.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.username,
|
||||||
|
u.name,
|
||||||
|
u."avatarUrl",
|
||||||
|
u.language,
|
||||||
|
u.preferences,
|
||||||
|
u."notificationSettings",
|
||||||
|
u.timezone,
|
||||||
|
u."isAdmin",
|
||||||
|
u."isViewer",
|
||||||
|
u."isSuspended",
|
||||||
|
u."lastActiveAt",
|
||||||
|
u."lastSignedInAt",
|
||||||
|
u."suspendedAt",
|
||||||
|
u."suspendedById",
|
||||||
|
u."teamId",
|
||||||
|
u."createdAt",
|
||||||
|
u."updatedAt",
|
||||||
|
t.name as "teamName",
|
||||||
|
suspender.name as "suspendedByName",
|
||||||
|
(SELECT COUNT(*) FROM documents WHERE "createdById" = u.id AND "deletedAt" IS NULL) as "documentCount",
|
||||||
|
(SELECT COUNT(*) FROM collections WHERE "createdById" = u.id AND "deletedAt" IS NULL) as "collectionCount"
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN teams t ON u."teamId" = t.id
|
||||||
|
LEFT JOIN users suspender ON u."suspendedById" = suspender.id
|
||||||
|
WHERE u.id = $1 AND u."deletedAt" IS NULL
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.create - Create new user
|
||||||
|
*/
|
||||||
|
const createUser: BaseTool<CreateUserArgs> = {
|
||||||
|
name: 'outline_create_user',
|
||||||
|
description: 'Create a new user with specified name, email, and optional role. User will be added to the team associated with the database.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Full name of the user',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Email address (must be unique)',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['admin', 'member', 'viewer'],
|
||||||
|
description: 'User role (default: member)',
|
||||||
|
default: 'member',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'email'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
const name = sanitizeInput(args.name);
|
||||||
|
const email = sanitizeInput(args.email);
|
||||||
|
const role = args.role || 'member';
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
throw new Error('Invalid email format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await pgClient.query(
|
||||||
|
`SELECT id FROM users WHERE email = $1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.rows.length > 0) {
|
||||||
|
throw new Error('User with this email already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team ID (assuming first team, adjust as needed)
|
||||||
|
const teamResult = await pgClient.query(`SELECT id FROM teams LIMIT 1`);
|
||||||
|
if (teamResult.rows.length === 0) {
|
||||||
|
throw new Error('No team found');
|
||||||
|
}
|
||||||
|
const teamId = teamResult.rows[0].id;
|
||||||
|
|
||||||
|
const isAdmin = role === 'admin';
|
||||||
|
const isViewer = role === 'viewer';
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
INSERT INTO users (
|
||||||
|
id, email, name, "teamId", "isAdmin", "isViewer",
|
||||||
|
"createdAt", "updatedAt"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3, $4, $5,
|
||||||
|
NOW(), NOW()
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
[email, name, teamId, isAdmin, isViewer]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User created successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.update - Update user details
|
||||||
|
*/
|
||||||
|
const updateUser: BaseTool<UpdateUserArgs> = {
|
||||||
|
name: 'outline_update_user',
|
||||||
|
description: 'Update user profile information such as name, avatar, or language preferences.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Updated full name',
|
||||||
|
},
|
||||||
|
avatar_url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'URL to avatar image',
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Language code (e.g., en_US, pt_PT)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (args.name !== undefined) {
|
||||||
|
updates.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(sanitizeInput(args.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.avatar_url !== undefined) {
|
||||||
|
updates.push(`"avatarUrl" = $${paramIndex++}`);
|
||||||
|
values.push(sanitizeInput(args.avatar_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.language !== undefined) {
|
||||||
|
updates.push(`language = $${paramIndex++}`);
|
||||||
|
values.push(sanitizeInput(args.language));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new Error('No updates provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`"updatedAt" = NOW()`);
|
||||||
|
values.push(args.id);
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex} AND "deletedAt" IS NULL
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User updated successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.delete - Soft delete user
|
||||||
|
*/
|
||||||
|
const deleteUser: BaseTool<GetUserArgs> = {
|
||||||
|
name: 'outline_delete_user',
|
||||||
|
description: 'Soft delete a user. This marks the user as deleted but preserves their data for audit purposes.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET "deletedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, email, name
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found or already deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User deleted successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.suspend - Suspend user account
|
||||||
|
*/
|
||||||
|
const suspendUser: BaseTool<GetUserArgs> = {
|
||||||
|
name: 'outline_suspend_user',
|
||||||
|
description: 'Suspend a user account. Suspended users cannot access the system but their data is preserved.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to suspend',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET "isSuspended" = true, "suspendedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, email, name, "isSuspended", "suspendedAt"
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User suspended successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.activate - Activate suspended user
|
||||||
|
*/
|
||||||
|
const activateUser: BaseTool<GetUserArgs> = {
|
||||||
|
name: 'outline_activate_user',
|
||||||
|
description: 'Reactivate a suspended user account, restoring their access to the system.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to activate',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET "isSuspended" = false, "suspendedAt" = NULL, "suspendedById" = NULL
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, email, name, "isSuspended"
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User activated successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.promote - Promote user to admin
|
||||||
|
*/
|
||||||
|
const promoteUser: BaseTool<GetUserArgs> = {
|
||||||
|
name: 'outline_promote_user',
|
||||||
|
description: 'Promote a user to admin role, granting them full administrative permissions.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to promote',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET "isAdmin" = true, "isViewer" = false, "updatedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, email, name, "isAdmin", "isViewer"
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User promoted to admin successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* users.demote - Demote admin to member
|
||||||
|
*/
|
||||||
|
const demoteUser: BaseTool<GetUserArgs> = {
|
||||||
|
name: 'outline_demote_user',
|
||||||
|
description: 'Demote an admin user to regular member role, removing administrative permissions.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'User UUID to demote',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
|
},
|
||||||
|
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||||
|
if (!isValidUUID(args.id)) {
|
||||||
|
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pgClient.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET "isAdmin" = false, "updatedAt" = NOW()
|
||||||
|
WHERE id = $1 AND "deletedAt" IS NULL
|
||||||
|
RETURNING id, email, name, "isAdmin", "isViewer"
|
||||||
|
`,
|
||||||
|
[args.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
data: result.rows[0],
|
||||||
|
message: 'User demoted to member successfully',
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export all user tools
|
||||||
|
export const usersTools: BaseTool<any>[] = [
|
||||||
|
listUsers,
|
||||||
|
getUser,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
suspendUser,
|
||||||
|
activateUser,
|
||||||
|
promoteUser,
|
||||||
|
demoteUser,
|
||||||
|
];
|
||||||
324
src/types/db.ts
Normal file
324
src/types/db.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Database Types
|
||||||
|
* Based on Outline PostgreSQL schema
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Base row type for all database queries
|
||||||
|
export interface DatabaseRow {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document entity
|
||||||
|
export interface Document extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
urlId: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
emoji?: string;
|
||||||
|
collectionId: string;
|
||||||
|
parentDocumentId?: string;
|
||||||
|
createdById: string;
|
||||||
|
lastModifiedById?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
archivedAt?: Date;
|
||||||
|
template: boolean;
|
||||||
|
templateId?: string;
|
||||||
|
fullWidth: boolean;
|
||||||
|
insightsEnabled: boolean;
|
||||||
|
sourceMetadata?: object;
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection entity
|
||||||
|
export interface Collection extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
urlId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
index?: string;
|
||||||
|
permission?: string;
|
||||||
|
maintainerApprovalRequired: boolean;
|
||||||
|
documentStructure?: object;
|
||||||
|
sharing: boolean;
|
||||||
|
sort?: object;
|
||||||
|
teamId: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
archivedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User entity
|
||||||
|
export interface User extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
language?: string;
|
||||||
|
preferences?: object;
|
||||||
|
notificationSettings?: object;
|
||||||
|
timezone?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isViewer: boolean;
|
||||||
|
isSuspended: boolean;
|
||||||
|
lastActiveAt?: Date;
|
||||||
|
lastActiveIp?: string;
|
||||||
|
lastSignedInAt?: Date;
|
||||||
|
lastSignedInIp?: string;
|
||||||
|
lastSigninEmailSentAt?: Date;
|
||||||
|
suspendedAt?: Date;
|
||||||
|
suspendedById?: string;
|
||||||
|
teamId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team entity
|
||||||
|
export interface Team extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subdomain?: string;
|
||||||
|
domain?: string;
|
||||||
|
defaultCollectionId?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
sharing: boolean;
|
||||||
|
inviteRequired: boolean;
|
||||||
|
memberCollectionCreate: boolean;
|
||||||
|
guestSignin: boolean;
|
||||||
|
documentEmbeds: boolean;
|
||||||
|
collaborativeEditing: boolean;
|
||||||
|
defaultUserRole: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group entity
|
||||||
|
export interface Group extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
teamId: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupUser entity
|
||||||
|
export interface GroupUser extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
groupId: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment entity
|
||||||
|
export interface Comment extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
data: object;
|
||||||
|
documentId: string;
|
||||||
|
parentCommentId?: string;
|
||||||
|
createdById: string;
|
||||||
|
resolvedById?: string;
|
||||||
|
resolvedAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share entity
|
||||||
|
export interface Share extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
urlId: string;
|
||||||
|
documentId: string;
|
||||||
|
userId: string;
|
||||||
|
teamId: string;
|
||||||
|
includeChildDocuments: boolean;
|
||||||
|
published: boolean;
|
||||||
|
domain?: string;
|
||||||
|
lastAccessedAt?: Date;
|
||||||
|
views: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
revokedAt?: Date;
|
||||||
|
revokedById?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision entity
|
||||||
|
export interface Revision extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
editorVersion?: string;
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
emoji?: string;
|
||||||
|
documentId: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event entity
|
||||||
|
export interface Event extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
modelId?: string;
|
||||||
|
actorId?: string;
|
||||||
|
userId?: string;
|
||||||
|
collectionId?: string;
|
||||||
|
documentId?: string;
|
||||||
|
teamId: string;
|
||||||
|
ip?: string;
|
||||||
|
data?: object;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment entity
|
||||||
|
export interface Attachment extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
acl: string;
|
||||||
|
documentId?: string;
|
||||||
|
userId: string;
|
||||||
|
teamId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionUser entity (permissions)
|
||||||
|
export interface CollectionUser extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
collectionId: string;
|
||||||
|
userId: string;
|
||||||
|
permission: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionGroup entity (group permissions)
|
||||||
|
export interface CollectionGroup extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
collectionId: string;
|
||||||
|
groupId: string;
|
||||||
|
permission: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Star entity
|
||||||
|
export interface Star extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
index?: string;
|
||||||
|
documentId?: string;
|
||||||
|
collectionId?: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin entity
|
||||||
|
export interface Pin extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
index?: string;
|
||||||
|
documentId: string;
|
||||||
|
collectionId?: string;
|
||||||
|
teamId: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View entity
|
||||||
|
export interface View extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
userId: string;
|
||||||
|
count: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApiKey entity
|
||||||
|
export interface ApiKey extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
secret: string;
|
||||||
|
hash: string;
|
||||||
|
lastActiveAt?: Date;
|
||||||
|
expiresAt?: Date;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileOperation entity
|
||||||
|
export interface FileOperation extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
state: string;
|
||||||
|
format?: string;
|
||||||
|
size?: number;
|
||||||
|
key?: string;
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
collectionId?: string;
|
||||||
|
userId: string;
|
||||||
|
teamId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebhookSubscription entity
|
||||||
|
export interface WebhookSubscription extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
events: string[];
|
||||||
|
secret?: string;
|
||||||
|
teamId: string;
|
||||||
|
createdById: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchQuery entity
|
||||||
|
export interface SearchQuery extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
results: number;
|
||||||
|
source: string;
|
||||||
|
userId: string;
|
||||||
|
teamId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration entity
|
||||||
|
export interface Integration extends DatabaseRow {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
service: string;
|
||||||
|
events: string[];
|
||||||
|
settings?: object;
|
||||||
|
authentication?: object;
|
||||||
|
collectionId?: string;
|
||||||
|
teamId: string;
|
||||||
|
userId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
7
src/types/index.ts
Normal file
7
src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Types Index
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './tools.js';
|
||||||
|
export * from './db.js';
|
||||||
292
src/types/tools.ts
Normal file
292
src/types/tools.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Tool Types
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
// Base types for all tools
|
||||||
|
export interface ToolResponse {
|
||||||
|
content: Array<{
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
[key: string]: unknown; // Index signature for MCP SDK compatibility
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseTool<TArgs = Record<string, unknown>> {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: {
|
||||||
|
type: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
handler: (args: TArgs, pgClient: Pool) => Promise<ToolResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common argument types
|
||||||
|
export interface PaginationArgs {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRangeArgs {
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortArgs {
|
||||||
|
sort?: string;
|
||||||
|
direction?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document specific types
|
||||||
|
export interface DocumentArgs extends PaginationArgs, SortArgs {
|
||||||
|
collection_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
template?: boolean;
|
||||||
|
published?: boolean;
|
||||||
|
archived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetDocumentArgs {
|
||||||
|
id?: string;
|
||||||
|
share_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDocumentArgs {
|
||||||
|
title: string;
|
||||||
|
text?: string;
|
||||||
|
collection_id: string;
|
||||||
|
parent_document_id?: string;
|
||||||
|
template?: boolean;
|
||||||
|
publish?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDocumentArgs {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
done?: boolean;
|
||||||
|
append?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchDocumentsArgs extends PaginationArgs {
|
||||||
|
query: string;
|
||||||
|
collection_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
include_archived?: boolean;
|
||||||
|
include_drafts?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveDocumentArgs {
|
||||||
|
id: string;
|
||||||
|
collection_id?: string;
|
||||||
|
parent_document_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection specific types
|
||||||
|
export interface CollectionArgs extends PaginationArgs {
|
||||||
|
include_deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCollectionArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCollectionArgs {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
permission?: 'read' | 'read_write';
|
||||||
|
sharing?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
sort?: { field: string; direction: 'asc' | 'desc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCollectionArgs {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
permission?: 'read' | 'read_write';
|
||||||
|
sharing?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
sort?: { field: string; direction: 'asc' | 'desc' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// User specific types
|
||||||
|
export interface UserArgs extends PaginationArgs {
|
||||||
|
query?: string;
|
||||||
|
filter?: 'all' | 'admins' | 'members' | 'suspended' | 'invited';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetUserArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserArgs {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role?: 'admin' | 'member' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserArgs {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group specific types
|
||||||
|
export interface GroupArgs extends PaginationArgs {
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetGroupArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGroupArgs {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGroupArgs {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment specific types
|
||||||
|
export interface CommentArgs extends PaginationArgs {
|
||||||
|
document_id?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCommentArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentArgs {
|
||||||
|
document_id: string;
|
||||||
|
data: object;
|
||||||
|
parent_comment_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCommentArgs {
|
||||||
|
id: string;
|
||||||
|
data: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share specific types
|
||||||
|
export interface ShareArgs extends PaginationArgs {
|
||||||
|
document_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetShareArgs {
|
||||||
|
id?: string;
|
||||||
|
document_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateShareArgs {
|
||||||
|
document_id: string;
|
||||||
|
published?: boolean;
|
||||||
|
include_child_documents?: boolean;
|
||||||
|
url_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateShareArgs {
|
||||||
|
id: string;
|
||||||
|
published?: boolean;
|
||||||
|
include_child_documents?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision specific types
|
||||||
|
export interface RevisionArgs extends PaginationArgs {
|
||||||
|
document_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetRevisionArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event specific types
|
||||||
|
export interface EventArgs extends PaginationArgs, DateRangeArgs {
|
||||||
|
name?: string;
|
||||||
|
actor_id?: string;
|
||||||
|
document_id?: string;
|
||||||
|
collection_id?: string;
|
||||||
|
audit_log?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment specific types
|
||||||
|
export interface CreateAttachmentArgs {
|
||||||
|
name: string;
|
||||||
|
document_id?: string;
|
||||||
|
content_type: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAttachmentArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Operation specific types
|
||||||
|
export interface FileOperationArgs extends PaginationArgs {
|
||||||
|
type?: 'import' | 'export';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetFileOperationArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth Client specific types
|
||||||
|
export interface OAuthClientArgs extends PaginationArgs {}
|
||||||
|
|
||||||
|
export interface GetOAuthClientArgs {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOAuthClientArgs {
|
||||||
|
name: string;
|
||||||
|
redirect_uris: string[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOAuthClientArgs {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
redirect_uris?: string[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership specific types
|
||||||
|
export interface MembershipArgs extends PaginationArgs {
|
||||||
|
query?: string;
|
||||||
|
permission?: 'read' | 'read_write' | 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMemberArgs {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
permission?: 'read' | 'read_write' | 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveMemberArgs {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddGroupMemberArgs {
|
||||||
|
id: string;
|
||||||
|
group_id: string;
|
||||||
|
permission?: 'read' | 'read_write' | 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveGroupMemberArgs {
|
||||||
|
id: string;
|
||||||
|
group_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper type to ensure type safety
|
||||||
|
export type ToolHandler<TArgs> = (args: TArgs, pgClient: Pool) => Promise<ToolResponse>;
|
||||||
7
src/utils/index.ts
Normal file
7
src/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Utils Index
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './logger.js';
|
||||||
|
export * from './security.js';
|
||||||
90
src/utils/logger.ts
Normal file
90
src/utils/logger.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Logger
|
||||||
|
* Optimized for MCP - reduce logs to not exhaust Claude context
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
info: 2,
|
||||||
|
debug: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private level: LogLevel;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.level = (process.env.LOG_LEVEL as LogLevel) || 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldLog(level: LogLevel): boolean {
|
||||||
|
return LOG_LEVELS[level] <= LOG_LEVELS[this.level];
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatLog(level: LogLevel, message: string, data?: Record<string, unknown>): string {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
...(data && { data })
|
||||||
|
};
|
||||||
|
return JSON.stringify(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private write(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
||||||
|
if (!this.shouldLog(level)) return;
|
||||||
|
|
||||||
|
const formatted = this.formatLog(level, message, data);
|
||||||
|
|
||||||
|
// For MCP, send logs to stderr
|
||||||
|
if (process.env.MCP_MODE !== 'false') {
|
||||||
|
process.stderr.write(formatted + '\n');
|
||||||
|
} else {
|
||||||
|
console.log(formatted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, data?: Record<string, unknown>): void {
|
||||||
|
this.write('error', message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, data?: Record<string, unknown>): void {
|
||||||
|
this.write('warn', message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, data?: Record<string, unknown>): void {
|
||||||
|
this.write('info', message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, data?: Record<string, unknown>): void {
|
||||||
|
this.write('debug', message, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger();
|
||||||
|
|
||||||
|
// Log queries for auditing (if enabled) - OPTIMIZED
|
||||||
|
export function logQuery(
|
||||||
|
sql: string,
|
||||||
|
_params?: any[],
|
||||||
|
duration?: number,
|
||||||
|
_clientId?: string
|
||||||
|
): void {
|
||||||
|
// DISABLED by default to save Claude context
|
||||||
|
if (process.env.ENABLE_AUDIT_LOG === 'true' && process.env.NODE_ENV !== 'production') {
|
||||||
|
logger.debug('SQL', {
|
||||||
|
sql: sql.substring(0, 50),
|
||||||
|
duration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/utils/security.ts
Normal file
115
src/utils/security.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* MCP Outline PostgreSQL - Security Utilities
|
||||||
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Rate limiting store
|
||||||
|
const rateLimitStore: Map<string, { count: number; resetAt: number }> = new Map();
|
||||||
|
|
||||||
|
// Rate limit configuration
|
||||||
|
const RATE_LIMIT_WINDOW = 60000; // 1 minute
|
||||||
|
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '100', 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request should be rate limited
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(type: string, clientId: string): boolean {
|
||||||
|
const key = `${type}:${clientId}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateLimitStore.get(key);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
rateLimitStore.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= RATE_LIMIT_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize SQL input to prevent injection
|
||||||
|
* Note: Always use parameterized queries, this is a secondary safety measure
|
||||||
|
*/
|
||||||
|
export function sanitizeInput(input: string): string {
|
||||||
|
if (typeof input !== 'string') return input;
|
||||||
|
|
||||||
|
// Remove null bytes
|
||||||
|
let sanitized = input.replace(/\0/g, '');
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
sanitized = sanitized.trim();
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate UUID format
|
||||||
|
*/
|
||||||
|
export function isValidUUID(uuid: string): boolean {
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate URL ID format (Outline uses URL-safe IDs)
|
||||||
|
*/
|
||||||
|
export function isValidUrlId(urlId: string): boolean {
|
||||||
|
const urlIdRegex = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
return urlIdRegex.test(urlId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*/
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities for safe display
|
||||||
|
*/
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, (char) => map[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate pagination parameters
|
||||||
|
*/
|
||||||
|
export function validatePagination(limit?: number, offset?: number): { limit: number; offset: number } {
|
||||||
|
const maxLimit = 100;
|
||||||
|
const defaultLimit = 25;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit: Math.min(Math.max(1, limit || defaultLimit), maxLimit),
|
||||||
|
offset: Math.max(0, offset || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate sort direction
|
||||||
|
*/
|
||||||
|
export function validateSortDirection(direction?: string): 'ASC' | 'DESC' {
|
||||||
|
const upper = (direction || 'DESC').toUpperCase();
|
||||||
|
return upper === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate sort field against allowed fields
|
||||||
|
*/
|
||||||
|
export function validateSortField(field: string | undefined, allowedFields: string[], defaultField: string): string {
|
||||||
|
if (!field) return defaultField;
|
||||||
|
return allowedFields.includes(field) ? field : defaultField;
|
||||||
|
}
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user