diff --git a/README.md b/README.md index b857248..cf3438b 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,68 @@ The server provides the following tools, organized by category: } ``` +### Log Management + +> **Note:** The Logs API requires admin authentication and may not be available in all PocketBase instances or configurations. These tools interact with the PocketBase Logs API as documented at https://pocketbase.io/docs/api-logs/. + +- **list_logs**: List API request logs from PocketBase with filtering, sorting, and pagination. + - *Input Schema*: + ```json + { + "type": "object", + "properties": { + "page": { + "type": "number", + "description": "Page number (defaults to 1).", + "minimum": 1 + }, + "perPage": { + "type": "number", + "description": "Items per page (defaults to 30, max 500).", + "minimum": 1, + "maximum": 500 + }, + "filter": { + "type": "string", + "description": "PocketBase filter string (e.g., \"method='GET'\")." + } + }, + "required": [] + } + ``` + +- **get_log**: Get a single API request log by ID. + - *Input Schema*: + ```json + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the log to fetch." + } + }, + "required": [ + "id" + ] + } + ``` + +- **get_logs_stats**: Get API request logs statistics with optional filtering. + - *Input Schema*: + ```json + { + "type": "object", + "properties": { + "filter": { + "type": "string", + "description": "PocketBase filter string (e.g., \"method='GET'\")." + } + }, + "required": [] + } + ``` + ### Migration Management - **set_migrations_directory**: Set the directory where migration files will be created and read from. @@ -527,7 +589,14 @@ To use this server with Cline, you need to add it to your MCP settings file (`cl "POCKETBASE_ADMIN_TOKEN": "" }, "disabled": false, // Ensure it's enabled - "autoApprove": [] // Default auto-approve settings + "autoApprove": [ + "fetch_record", + "list_collections", + "get_collection_schema", + "list_logs", + "get_log", + "get_logs_stats" + ] // Suggested auto-approve settings } // ... other servers might be listed here ... diff --git a/build/index.js b/build/index.js index 47f0968..7a895f2 100755 --- a/build/index.js +++ b/build/index.js @@ -1,398 +1,8 @@ #!/usr/bin/env node -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; -import PocketBase from 'pocketbase'; -const API_URL = process.env.POCKETBASE_API_URL || 'http://127.0.0.1:8090'; -const ADMIN_TOKEN = process.env.POCKETBASE_ADMIN_TOKEN; -if (!ADMIN_TOKEN) { - throw new Error('POCKETBASE_ADMIN_TOKEN environment variable is required'); -} -class PocketBaseServer { - constructor() { - this.server = new Server({ - name: 'pocketbase-mcp', - version: '0.1.0', - }, { - capabilities: { - tools: {}, - }, - }); - this.pb = new PocketBase(API_URL); - if (ADMIN_TOKEN) { - this.pb.authStore.save(ADMIN_TOKEN, null); - } - this.setupToolHandlers(); - // Error handling - this.server.onerror = (error) => { console.error('[MCP Error]', error); }; - process.on('SIGINT', async () => { - await this.server.close(); - process.exit(0); - }); - } - setupToolHandlers() { - this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'fetch_record', - description: 'Fetch a single record from a PocketBase collection by ID.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - id: { - type: 'string', - description: 'The ID of the record to fetch.', - }, - }, - required: ['collection', 'id'], - }, - }, - { - name: 'list_records', - description: 'List records from a PocketBase collection. Supports pagination using `page` and `perPage` parameters.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - page: { - type: 'number', - description: 'Page number (defaults to 1).', - minimum: 1 - }, - perPage: { - type: 'number', - description: 'Items per page (defaults to 25).', - minimum: 1, - maximum: 100 - }, - filter: { - type: 'string', - description: 'Filter string for the PocketBase query.' - }, - sort: { - type: 'string', - description: 'Sort string for the PocketBase query (e.g., "fieldName,-otherFieldName").' - }, - expand: { - type: 'string', - description: 'Expand string for the PocketBase query (e.g., "relation1,relation2.subRelation").' - } - }, - required: ['collection'], - }, - }, - { - name: 'create_record', - description: 'Create a new record in a PocketBase collection.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - data: { - type: 'object', - description: 'The data for the new record.', - additionalProperties: true - }, - }, - required: ['collection', 'data'], - }, - }, - { - name: 'update_record', - description: 'Update an existing record in a PocketBase collection.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - id: { - type: 'string', - description: 'The ID of the record to update.', - }, - data: { - type: 'object', - description: 'The data to update.', - additionalProperties: true - }, - }, - required: ['collection', 'id', 'data'], - }, - }, - { - name: 'get_collection_schema', - description: 'Get the schema of a PocketBase collection.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - }, - required: ['collection'], - }, - }, - { - name: 'upload_file', - description: 'Upload a file to a PocketBase collection record.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - recordId: { - type: 'string', - description: 'The ID of the record to upload the file to.', - }, - fileField: { - type: 'string', - description: 'The name of the file field in the PocketBase collection.', - }, - fileContent: { - type: 'string', - description: 'The content of the file to upload.', - }, - fileName: { - type: 'string', - description: 'The name of the file.', - } - }, - required: [ - "collection", - "recordId", - "fileField", - "fileContent", - "fileName" - ] - }, - }, - { - name: 'list_collections', - description: 'List all collections in the PocketBase instance.', - inputSchema: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - }, - { - name: 'download_file', - description: 'Download a file from a PocketBase collection record.', - inputSchema: { - type: 'object', - properties: { - collection: { - type: 'string', - description: 'The name of the PocketBase collection.', - }, - recordId: { - type: 'string', - description: 'The ID of the record to download the file from.', - }, - fileField: { - type: 'string', - description: 'The name of the file field in the PocketBase collection.', - }, - downloadPath: { - type: 'string', - description: 'The path where the downloaded file should be saved.', - }, - }, - required: [ - "collection", - "recordId", - "fileField", - "downloadPath" - ] - } - } - ], - })); - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - try { - const { name, arguments: args } = request.params; - switch (name) { - case 'fetch_record': { - if (!args || typeof args !== 'object' || !('collection' in args) || !('id' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection or id"); - } - const { collection, id } = args; - const record = await this.pb.collection(collection).getOne(id); - return { - content: [ - { - type: 'text', - text: JSON.stringify(record, null, 2), - }, - ], - }; - } - case 'list_records': { - if (!args || typeof args !== 'object' || !('collection' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection"); - } - const { collection, page, perPage, filter, sort, expand } = args; - const result = await this.pb.collection(collection).getList(page || 1, perPage || 25, { - filter, - sort, - expand - }); - return { - content: [ - { - type: 'text', - content: result, - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - case 'create_record': { - if (!args || typeof args !== 'object' || !('collection' in args) || !('data' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection or data"); - } - const { collection, data } = args; - const record = await this.pb.collection(collection).create(data); - return { - content: [ - { - type: 'text', - text: JSON.stringify(record, null, 2), - }, - ], - }; - } - case 'update_record': { - if (!args || typeof args !== 'object' || !('collection' in args) || !('id' in args) || !('data' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection, id, or data"); - } - const { collection, id, data } = args; - const record = await this.pb.collection(collection).update(id, data); - return { - content: [ - { - type: 'text', - text: JSON.stringify(record, null, 2), - }, - ], - }; - } - case 'get_collection_schema': { - if (!args || typeof args !== 'object' || !('collection' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection"); - } - const { collection } = args; - const schema = await this.pb.collections.getOne(collection); - return { - content: [ - { - type: 'text', - text: JSON.stringify(schema, null, 2), - }, - ], - }; - } - case 'upload_file': { - if (!args || typeof args !== 'object' || !('collection' in args) || !('recordId' in args) || !('fileField' in args) || !('fileContent' in args) || !('fileName' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection, recordId, fileField, fileContent, or fileName"); - } - const { collection, recordId, fileField, fileContent, fileName } = args; - // Create a Blob from the file content - const blob = new Blob([fileContent]); - // Create a FormData object and append the file - const formData = new FormData(); - formData.append(fileField, blob, fileName); - // Update the record with the file - const record = await this.pb.collection(collection).update(recordId, formData); - return { - content: [ - { - type: 'text', - text: JSON.stringify(record, null, 2), - }, - ], - }; - } - case 'download_file': { - if (!args || typeof args !== 'object' || !('collection' in args) || !('recordId' in args) || !('fileField' in args) || !('downloadPath' in args)) { - throw new McpError(ErrorCode.InvalidParams, "Missing collection, recordId, fileField, or downloadPath"); - } - const { collection, recordId, fileField, downloadPath } = args; - // Fetch the record - const record = await this.pb.collection(collection).getOne(recordId); - // Get the file URL - const fileUrl = this.pb.getFileUrl(record, record[fileField]); - // Return the file URL to the user. They can use this URL to download the file. - return { - content: [ - { - type: 'text', - text: fileUrl - } - ] - }; - // The following code is not possible because we cannot download files within the MCP server - // // Download the file content - // const response = await fetch(fileUrl); - // const fileContent = await response.text(); - // // Save the file using the write_to_file tool - // await write_to_file({ path: downloadPath, content: fileContent }); - // return { - // content: [ - // { - // type: 'text', - // text: `File downloaded to ${downloadPath}`, - // }, - // ], - // }; - } - case 'list_collections': { - const result = await this.pb.collections.getFullList({ sort: '-created' }); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } - default: - throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); - } - } - catch (error) { - console.error(error); - const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; - return { - content: [{ - type: 'text', - text: errorMessage, - }], - isError: true - }; - } - }); - } - async run() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error('PocketBase MCP server running on stdio'); - } -} +import { PocketBaseServer } from './server/pocketbase-server.js'; +// Create and run the server instance const server = new PocketBaseServer(); -server.run().catch(console.error); +server.run().catch(error => { + console.error('Failed to start PocketBase MCP server:', error); + process.exit(1); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index d203fa7..1df25fb 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -8,6 +8,7 @@ import { listRecordTools, handleRecordToolCall } from './record-tools.js'; import { listCollectionTools, handleCollectionToolCall } from './collection-tools.js'; import { listFileTools, handleFileToolCall } from './file-tools.js'; import { listMigrationTools, handleMigrationToolCall } from './migration-tools.js'; // Uncommented +import { listLogTools, handleLogToolCall } from './log-tools.js'; // Import log tools // Combine all tool definitions export function registerTools(): { tools: ToolInfo[] } { // Use ToolInfo[] @@ -16,6 +17,7 @@ export function registerTools(): { tools: ToolInfo[] } { // Use ToolInfo[] ...listCollectionTools(), ...listFileTools(), ...listMigrationTools(), // Uncommented + ...listLogTools(), // Add log tools ]; return { tools }; } @@ -46,6 +48,8 @@ export async function handleToolCall(params: CallToolRequest['params'], pb: Pock return handleFileToolCall(name, toolArgs, pb); } else if (name === 'create_migration' || name === 'create_collection_migration' || name === 'add_field_migration' || name === 'list_migrations') { return handleMigrationToolCall(name, toolArgs, pb); + } else if (name === 'list_logs' || name === 'get_log' || name === 'get_logs_stats') { + return handleLogToolCall(name, toolArgs, pb); } else { throw methodNotFoundError(name); } diff --git a/src/tools/log-tools.ts b/src/tools/log-tools.ts new file mode 100644 index 0000000..c9811bd --- /dev/null +++ b/src/tools/log-tools.ts @@ -0,0 +1,119 @@ +import PocketBase from 'pocketbase'; +import { + ToolResult, ToolInfo, + ListLogsArgs, GetLogArgs, GetLogsStatsArgs +} from '../types/index.js'; +import { invalidParamsError } from '../server/error-handler.js'; + +// Define tool information for registration +const logToolInfo: ToolInfo[] = [ + { + name: 'list_logs', + description: 'List API request logs from PocketBase with filtering, sorting, and pagination.', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number', description: 'Page number (defaults to 1).', minimum: 1 }, + perPage: { type: 'number', description: 'Items per page (defaults to 30, max 500).', minimum: 1, maximum: 500 }, + filter: { type: 'string', description: 'PocketBase filter string (e.g., "method=\'GET\'").' }, + sort: { type: 'string', description: 'PocketBase sort string (e.g., "-created,url").' } + }, + required: [], + }, + }, + { + name: 'get_log', + description: 'Get a single API request log by ID.', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'The ID of the log to fetch.' }, + }, + required: ['id'], + }, + }, + { + name: 'get_logs_stats', + description: 'Get API request logs statistics with optional filtering.', + inputSchema: { + type: 'object', + properties: { + filter: { type: 'string', description: 'PocketBase filter string (e.g., "method=\'GET\'").' }, + }, + required: [], + }, + }, +]; + +export function listLogTools(): ToolInfo[] { + return logToolInfo; +} + +// Handle calls for log-related tools +export async function handleLogToolCall(name: string, args: any, pb: PocketBase): Promise { + switch (name) { + case 'list_logs': + return listLogs(args as ListLogsArgs, pb); + case 'get_log': + return getLog(args as GetLogArgs, pb); + case 'get_logs_stats': + return getLogsStats(args as GetLogsStatsArgs, pb); + default: + // This case should ideally not be reached due to routing in index.ts + throw new Error(`Unknown log tool: ${name}`); + } +} + +// --- Individual Tool Implementations --- + +async function listLogs(args: ListLogsArgs, pb: PocketBase): Promise { + const { page = 1, perPage = 30, filter, sort } = args; + // Make the API request to list logs + const result = await pb.logs.getList( + page, + perPage, + { + filter + }); + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; +} + +async function getLog(args: GetLogArgs, pb: PocketBase): Promise { + if (!args.id) { + throw invalidParamsError("Missing required argument: id"); + } + + // Make the API request to get a single log + const result = await pb.logs.getOne(args.id) + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; +} + +async function getLogsStats(args: GetLogsStatsArgs, pb: PocketBase): Promise { + const { filter } = args; + + try { + // Make the API request to get logs statistics + const result = await pb.logs.getStats({ + filter + }); + + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } catch (error) { + // If there's an error, return a more descriptive error + if (error instanceof Error) { + return { + content: [{ type: 'text', text: `Error fetching log stats: ${error.message}` }], + isError: true + }; + } + throw error; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 7899cb5..74ad61f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,19 @@ // Explicitly export needed types -export type { ToolResult, ToolInfo, FetchRecordArgs, ListRecordsArgs, CreateRecordArgs, UpdateRecordArgs, GetCollectionSchemaArgs, UploadFileArgs, DownloadFileArgs, ListCollectionsArgs } from './tool-types.js'; +export type { + ToolResult, + ToolInfo, + FetchRecordArgs, + ListRecordsArgs, + CreateRecordArgs, + UpdateRecordArgs, + GetCollectionSchemaArgs, + UploadFileArgs, + DownloadFileArgs, + ListCollectionsArgs, + // Log API types + ListLogsArgs, + GetLogArgs, + GetLogsStatsArgs +} from './tool-types.js'; export * from './pocketbase-types.js'; // Keep wildcard export for potentially generated types export * from './migration-types.js'; // Keep wildcard export for now diff --git a/src/types/tool-types.ts b/src/types/tool-types.ts index 9ed6093..8997dfb 100644 --- a/src/types/tool-types.ts +++ b/src/types/tool-types.ts @@ -72,4 +72,20 @@ export interface DownloadFileArgs { export interface ListCollectionsArgs {} // No arguments +// Log API tool argument types +export interface ListLogsArgs { + page?: number; + perPage?: number; + filter?: string; + sort?: string; +} + +export interface GetLogArgs { + id: string; +} + +export interface GetLogsStatsArgs { + filter?: string; +} + // Add types for new migration tools later