feat: Add 22 new tools for complete Outline coverage (v1.1.0)
New modules (22 tools): - Stars (3): list, create, delete - bookmarks - Pins (3): list, create, delete - highlighted docs - Views (2): list, create - view tracking - Reactions (3): list, create, delete - emoji on comments - API Keys (4): list, create, update, delete - Webhooks (4): list, create, update, delete - Backlinks (1): list - read-only view - Search Queries (2): list, stats - analytics Total tools: 86 -> 108 (+22) All 22 new tools validated against Outline v0.78 schema. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
214
src/tools/pins.ts
Normal file
214
src/tools/pins.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Pins Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface PinListArgs extends PaginationArgs {
|
||||
collection_id?: string;
|
||||
team_id?: string;
|
||||
}
|
||||
|
||||
interface PinCreateArgs {
|
||||
document_id: string;
|
||||
collection_id?: string;
|
||||
}
|
||||
|
||||
interface PinDeleteArgs {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* pins.list - List pinned documents
|
||||
*/
|
||||
const listPins: BaseTool<PinListArgs> = {
|
||||
name: 'outline_pins_list',
|
||||
description: 'List pinned documents. Pins highlight important documents at the top of collections or home.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
collection_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by collection ID (UUID)',
|
||||
},
|
||||
team_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by team ID (UUID)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results (default: 25, max: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Results to skip (default: 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id format');
|
||||
conditions.push(`p."collectionId" = $${paramIndex++}`);
|
||||
params.push(args.collection_id);
|
||||
}
|
||||
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||
conditions.push(`p."teamId" = $${paramIndex++}`);
|
||||
params.push(args.team_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
p.id,
|
||||
p."documentId",
|
||||
p."collectionId",
|
||||
p."teamId",
|
||||
p."createdById",
|
||||
p.index,
|
||||
p."createdAt",
|
||||
d.title as "documentTitle",
|
||||
c.name as "collectionName",
|
||||
u.name as "createdByName"
|
||||
FROM pins p
|
||||
LEFT JOIN documents d ON p."documentId" = d.id
|
||||
LEFT JOIN collections c ON p."collectionId" = c.id
|
||||
LEFT JOIN users u ON p."createdById" = u.id
|
||||
${whereClause}
|
||||
ORDER BY p.index ASC NULLS LAST, p."createdAt" DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* pins.create - Pin a document
|
||||
*/
|
||||
const createPin: BaseTool<PinCreateArgs> = {
|
||||
name: 'outline_pins_create',
|
||||
description: 'Pin a document to highlight it at the top of a collection or home.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Document ID to pin (UUID)',
|
||||
},
|
||||
collection_id: {
|
||||
type: 'string',
|
||||
description: 'Collection ID to pin to (UUID, optional - pins to home if not specified)',
|
||||
},
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id format');
|
||||
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id format');
|
||||
|
||||
// Get document to find team
|
||||
const docResult = await pgClient.query(
|
||||
`SELECT id, "teamId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||
[args.document_id]
|
||||
);
|
||||
|
||||
if (docResult.rows.length === 0) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const teamId = docResult.rows[0].teamId;
|
||||
|
||||
// Get admin user for createdById
|
||||
const userResult = await pgClient.query(
|
||||
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new Error('No admin user found');
|
||||
}
|
||||
|
||||
// Check for existing pin
|
||||
const existing = await pgClient.query(
|
||||
`SELECT id FROM pins WHERE "documentId" = $1 AND ("collectionId" = $2 OR ($2 IS NULL AND "collectionId" IS NULL))`,
|
||||
[args.document_id, args.collection_id || null]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
throw new Error('Document is already pinned');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO pins (id, "documentId", "collectionId", "teamId", "createdById", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *
|
||||
`,
|
||||
[args.document_id, args.collection_id || null, teamId, userResult.rows[0].id]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ data: result.rows[0], message: 'Pin created successfully' }, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* pins.delete - Remove a pin
|
||||
*/
|
||||
const deletePin: BaseTool<PinDeleteArgs> = {
|
||||
name: 'outline_pins_delete',
|
||||
description: 'Remove a pin from a document.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Pin ID to delete (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid id format');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`DELETE FROM pins WHERE id = $1 RETURNING id, "documentId", "collectionId"`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Pin not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ data: result.rows[0], message: 'Pin deleted successfully' }, null, 2),
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const pinsTools: BaseTool<any>[] = [listPins, createPin, deletePin];
|
||||
Reference in New Issue
Block a user