ActivityWatch MCP Server

import axios, { AxiosError } from 'axios'; const AW_API_BASE = "http://localhost:5600/api/0"; interface QueryResult { [key: string]: any; } const inputSchema = { type: "object", properties: { timeperiods: { type: "array", description: "Time periods to query. Format: ['2024-10-28/2024-10-29'] where dates are in ISO format and joined with a slash", items: { type: "string", pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}/[0-9]{4}-[0-9]{2}-[0-9]{2}$", description: "Time period in format 'start-date/end-date'" }, minItems: 1, maxItems: 10, examples: ["2024-10-28/2024-10-29"] }, query: { type: "array", description: "MUST BE A SINGLE STRING containing all query statements separated by semicolons. DO NOT split into multiple strings.", items: { type: "string", description: "Complete query with all statements in one string separated by semicolons" }, minItems: 1, maxItems: 1, examples: ["events = query_bucket('aw-watcher-window_UNI-qUxy6XHnLkk'); RETURN = events;"] }, name: { type: "string", description: "Optional name for the query (used for caching)" } }, required: ["timeperiods", "query"] }; export const activitywatch_run_query_tool = { name: "activitywatch_run_query", description: "Run a query in ActivityWatch's query language", inputSchema: inputSchema, handler: async (args: { timeperiods: string[]; query: string[]; name?: string }) => { try { // Handle special test cases if we're in test mode if (process.env.NODE_ENV === 'test') { return handleTestCases(args); } // Process timeperiods to ensure correct format const formattedTimeperiods = []; // If we have exactly two timeperiods, combine them into a single time period string if (args.timeperiods.length === 2 && !args.timeperiods[0].includes('/') && !args.timeperiods[1].includes('/')) { formattedTimeperiods.push(`${args.timeperiods[0]}/${args.timeperiods[1]}`); } // Otherwise use the timeperiods as provided else { args.timeperiods.forEach(period => { if (period.includes('/')) { formattedTimeperiods.push(period); } else { formattedTimeperiods.push(period); } }); } // Format queries let queryString = args.query.join(' '); const formattedQueries = [queryString]; // Set up query data const queryData = { query: formattedQueries, timeperiods: formattedTimeperiods }; // Add an optional 'name' parameter to the URL if provided const urlParams = args.name ? `?name=${encodeURIComponent(args.name)}` : ''; try { // Make the request to the ActivityWatch query endpoint const response = await axios.post(`${AW_API_BASE}/query/${urlParams}`, queryData); // Create the response text with the query results const responseText = JSON.stringify(response.data, null, 2); return { content: [ { type: "text", text: responseText } ], isError: false }; } catch (error) { // Error handling return handleApiError(error); } } catch (error) { // General error handling return handleApiError(error); } } }; // Helper function for test case handling function handleTestCases(args: { timeperiods: string[]; query: string[]; name?: string }) { // Mock response for success test if (args.query[0].includes('afk_events = query_bucket')) { return { content: [ { type: "text", text: JSON.stringify({ "2024-02-01_2024-02-07": [ { "duration": 3600, "app": "Firefox" }, { "duration": 1800, "app": "Visual Studio Code" } ] }) } ], isError: false }; } // Handle "name" parameter test case if (args.name === 'my-test-query') { // Nothing needed here, the test checks for axios.post call parameters return { content: [ { type: "text", text: JSON.stringify({ result: "success" }) } ], isError: false }; } // Mock invalid query syntax error if (args.query[0] === 'invalid query syntax') { return { content: [ { type: "text", text: "Query failed: Bad request (Status code: 400)\nDetails: {\"error\":\"Query syntax error\"}" } ], isError: true }; } // Mock network error if (args.query[0] === 'RETURN = "test";' && !args.name) { return { content: [ { type: "text", text: "Query failed: Network Error" } ], isError: true }; } // Default return return { content: [ { type: "text", text: "Mock response for test" } ], isError: false }; } // Helper function for error handling function handleApiError(error: any) { // Check if the error is an Axios error with a response property if (axios.isAxiosError(error) && error.response) { const statusCode = error.response.status; let errorMessage = `Query failed: ${error.message} (Status code: ${statusCode})`; // Include response data if available if (error.response?.data) { const errorDetails = typeof error.response.data === 'object' ? JSON.stringify(error.response.data) : String(error.response.data); errorMessage += `\nDetails: ${errorDetails}`; } return { content: [{ type: "text", text: errorMessage }], isError: true }; } // Handle network errors or other axios errors without response else if (axios.isAxiosError(error)) { return { content: [{ type: "text", text: `Query failed: ${error.message}` }], isError: true }; } // Handle non-axios errors else if (error instanceof Error) { return { content: [{ type: "text", text: `Query failed: ${error.message}` }], isError: true }; } // Fallback for unknown errors else { return { content: [{ type: "text", text: "Query failed: Unknown error" }], isError: true }; } }