Fixed 3 schema compatibility bugs found during Round 3 write testing: - create_document: Added id, urlId, teamId, isWelcome, fullWidth, insightsEnabled - create_collection: Added id, maintainerApprovalRequired - shares_create: Added id, allowIndexing, showLastUpdated All write operations now include required NOT NULL columns. Bumped version to 1.3.6. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
475 lines
12 KiB
TypeScript
475 lines
12 KiB
TypeScript
/**
|
|
* MCP Outline PostgreSQL - Shares Tools
|
|
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { randomBytes } from 'crypto';
|
|
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 role = 'admin' 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 (using crypto for better uniqueness)
|
|
const urlId = args.url_id || `share-${Date.now()}-${randomBytes(6).toString('base64url')}`;
|
|
|
|
const query = `
|
|
INSERT INTO shares (
|
|
id,
|
|
"urlId",
|
|
"documentId",
|
|
"userId",
|
|
"teamId",
|
|
"includeChildDocuments",
|
|
published,
|
|
views,
|
|
"allowIndexing",
|
|
"showLastUpdated",
|
|
"createdAt",
|
|
"updatedAt"
|
|
) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, 0, false, false, 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 role = 'admin' 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,
|
|
];
|