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:
2026-01-31 13:25:09 +00:00
commit b05b54033f
30 changed files with 14439 additions and 0 deletions

470
src/tools/shares.ts Normal file
View 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,
];