ActivityWatch MCP Server

import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { activitywatch_list_buckets_tool } from "./bucketList.js"; import { activitywatch_run_query_tool } from "./query.js"; import { activitywatch_get_events_tool } from "./rawEvents.js"; import { activitywatch_query_examples_tool } from "./queryExamples.js"; import { activitywatch_get_settings_tool } from "./getSettings.js"; // Helper function to handle type-safe tool responses const makeSafeToolResponse = (handler: Function) => async (...args: any[]) => { try { const result = await handler(...args); if (!result) { return { content: [{ type: "text", text: "Error: Tool handler returned no result" }], isError: true }; } return result; } catch (error) { console.error("Tool execution error:", error); return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } }; // Create server instance const server = new Server({ name: "activitywatch-server", version: "1.1.0" }, { capabilities: { tools: {} } }); // Register tools list handler server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: activitywatch_list_buckets_tool.name, description: activitywatch_list_buckets_tool.description, inputSchema: activitywatch_list_buckets_tool.inputSchema }, { name: activitywatch_query_examples_tool.name, description: activitywatch_query_examples_tool.description, inputSchema: activitywatch_query_examples_tool.inputSchema }, { name: activitywatch_run_query_tool.name, description: activitywatch_run_query_tool.description, inputSchema: activitywatch_run_query_tool.inputSchema }, { name: activitywatch_get_events_tool.name, description: activitywatch_get_events_tool.description, inputSchema: activitywatch_get_events_tool.inputSchema }, { name: activitywatch_get_settings_tool.name, description: activitywatch_get_settings_tool.description, inputSchema: activitywatch_get_settings_tool.inputSchema } ] }; }); // Register tool call handler server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { // Default empty object if arguments is undefined let args = request.params.arguments || {}; if (request.params.name === activitywatch_list_buckets_tool.name) { // Cast to the expected type for the bucket list tool return makeSafeToolResponse(activitywatch_list_buckets_tool.handler)({ type: typeof args.type === 'string' ? args.type : undefined, includeData: Boolean(args.includeData) }); } else if (request.params.name === activitywatch_query_examples_tool.name) { return makeSafeToolResponse(activitywatch_query_examples_tool.handler)(); } else if (request.params.name === activitywatch_run_query_tool.name) { // For the query tool, we need to validate and normalize the args // First, log the raw arguments to debug format issues console.error(`\nRAW ARGS FROM MCP CLIENT:`); console.error(JSON.stringify(request.params.arguments, null, 2)); console.error(`\nTYPE: ${typeof request.params.arguments}`); console.error(`\nARRAY? ${Array.isArray(request.params.arguments)}`); // Make a mutable copy of the arguments let queryArgs = {...(request.params.arguments || {})}; // Try to see if this is JSON string that needs parsing if (typeof request.params.arguments === 'string') { try { const parsedArgs = JSON.parse(request.params.arguments); console.error(`Parsed string arguments into object:`); console.error(JSON.stringify(parsedArgs, null, 2)); queryArgs = parsedArgs; } catch (e) { console.error(`Failed to parse arguments string: ${e}`); } } // More diagnostic info if (queryArgs.query) { console.error(`Query type: ${typeof queryArgs.query}`); console.error(`Query array? ${Array.isArray(queryArgs.query)}`); console.error(`Query value: ${JSON.stringify(queryArgs.query, null, 2)}`); if (Array.isArray(queryArgs.query) && queryArgs.query.length > 0) { console.error(`First item type: ${typeof queryArgs.query[0]}`); console.error(`First item array? ${Array.isArray(queryArgs.query[0])}`); } } // Validate timeperiods if (!queryArgs.timeperiods) { return makeSafeToolResponse(() => ({ content: [{ type: "text", text: "Error: Missing required parameter 'timeperiods' (must be an array of date ranges)" }], isError: true }))(); } if (!Array.isArray(queryArgs.timeperiods)) { // Try to normalize a single string to an array if (typeof queryArgs.timeperiods === 'string') { queryArgs.timeperiods = [queryArgs.timeperiods]; console.error(`Normalized timeperiods from string to array: ${JSON.stringify(queryArgs.timeperiods)}`); } else { return makeSafeToolResponse(() => ({ content: [{ type: "text", text: "Error: 'timeperiods' must be an array of date ranges in format: ['2024-10-28/2024-10-29']" }], isError: true }))(); } } // Validate query if (!queryArgs.query) { return makeSafeToolResponse(() => ({ content: [{ type: "text", text: "Error: Missing required parameter 'query'" }], isError: true }))(); } // Handle different query formats if (!Array.isArray(queryArgs.query)) { // If it's a string, wrap it in an array if (typeof queryArgs.query === 'string') { queryArgs.query = [queryArgs.query]; console.error(`Normalized query from string to array: ${JSON.stringify(queryArgs.query)}`); } else { return makeSafeToolResponse(() => formatValidationError())(); } } // Check for double-wrapped array format (an issue with some MCP clients) if (Array.isArray(queryArgs.query) && queryArgs.query.length === 1 && Array.isArray(queryArgs.query[0])) { // Extract the inner array const innerArray = queryArgs.query[0]; console.error(`Detected double-wrapped query array from MCP client. Unwrapping...`); console.error(`Original: ${JSON.stringify(queryArgs.query)}`); if (Array.isArray(innerArray) && innerArray.length >= 1) { // If the inner array is itself an array, take its first element if (Array.isArray(innerArray[0])) { console.error(`Triple-nested array detected! Unwrapping multiple levels...`); queryArgs.query = innerArray[0] as unknown as string[]; } else { queryArgs.query = innerArray as unknown as string[]; } console.error(`Unwrapped: ${JSON.stringify(queryArgs.query)}`); } } // Special case: Check if we received an array of query lines that need to be combined if (Array.isArray(queryArgs.query) && queryArgs.query.length > 1) { // Check if they look like separate query statements const areQueryStatements = queryArgs.query.some(q => typeof q === 'string' && (q.includes('=') || q.trim().endsWith(';')) ); if (areQueryStatements) { // Join them into a single query string const combinedQuery = queryArgs.query.join(' '); queryArgs.query = [combinedQuery]; console.error(`Combined multiple query statements into a single string: ${combinedQuery}`); } } // Log the processed query console.error(`Processed query for execution: ${JSON.stringify({timeperiods: queryArgs.timeperiods, query: queryArgs.query})}`); return makeSafeToolResponse(activitywatch_run_query_tool.handler)({ timeperiods: queryArgs.timeperiods as string[], query: queryArgs.query as string[], name: typeof queryArgs.name === 'string' ? queryArgs.name : undefined }); // Helper function to return a nicely formatted validation error function formatValidationError() { return { content: [{ type: "text", text: `Error: Invalid query format. The correct format for the 'run-query' tool is: { "timeperiods": ["2024-10-28/2024-10-29"], "query": ["events = query_bucket('bucket-id'); another_statement; RETURN = result;"] } NOTE THE FORMAT: 1. 'timeperiods' is an array with date ranges formatted as start/end with a slash 2. 'query' is an array with a SINGLE STRING containing all statements 3. All query statements must be in the same string, separated by semicolons COMMON ERRORS: - Splitting query statements into separate array items (WRONG) - Not using semicolons between statements - Not wrapping query in an array - Double-wrapping the query in nested arrays (some MCP clients may do this) DEBUGGING TIP: If you're working with an MCP client that consistently produces errors, try examining the exact format of the query parameter it sends. The server tries to automatically detect and handle various formats, but may need additional configuration. ` }], isError: true }; } } else if (request.params.name === activitywatch_get_events_tool.name) { // For the raw events tool if (!args.bucketId || typeof args.bucketId !== 'string') { return makeSafeToolResponse(() => ({ content: [{ type: "text", text: "Error: Missing required parameter 'bucketId' (must be a string)" }], isError: true }))(); } return makeSafeToolResponse(activitywatch_get_events_tool.handler)({ bucketId: args.bucketId, limit: typeof args.limit === 'number' ? args.limit : undefined, start: typeof args.start === 'string' ? args.start : undefined, end: typeof args.end === 'string' ? args.end : undefined }); } else if (request.params.name === activitywatch_get_settings_tool.name) { // For the settings tool return makeSafeToolResponse(activitywatch_get_settings_tool.handler)({ key: typeof args.key === 'string' ? args.key : undefined }); } // Always return a properly formatted and type-safe response return makeSafeToolResponse(() => ({ content: [{ type: "text", text: `Error: Tool not found: ${request.params.name}` }], isError: true }))(); }); async function main() { // Output application banner console.error("ActivityWatch MCP Server"); console.error("======================="); console.error("Version: 1.1.0"); console.error("API Endpoint: http://localhost:5600/api/0"); console.error("Tools: activitywatch_list_buckets, activitywatch_query_examples, activitywatch_run_query, activitywatch_get_events, activitywatch_get_settings"); console.error("======================="); console.error("For help with query format, use the 'activitywatch_query_examples' tool first"); console.error("'activitywatch_run_query' Example format:"); console.error(`{ "timeperiods": ["2024-10-28/2024-10-29"], "query": ["events = query_bucket('aw-watcher-window_UNI-qUxy6XHnLkk'); RETURN = events;"] }`); console.error("IMPORTANT: The query array must contain a single string with ALL statements joined with semicolons;"); console.error("the statements should NOT be split into separate array elements."); console.error("NOTE: Some MCP clients may wrap the query in an additional array. The server attempts to detect"); console.error("and handle this automatically but may produce confusing error messages if detection fails."); const transport = new StdioServerTransport(); await server.connect(transport); console.error("ActivityWatch MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });