Harvest Natural Language Time Entry MCP Server
by adrian-dotco
- src
#!/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 axios from 'axios';
import * as chrono from 'chrono-node';
// Time report response types
interface TimeReportResult {
client_id?: number;
client_name?: string;
project_id?: number;
project_name?: string;
task_id?: number;
task_name?: string;
user_id?: number;
user_name?: string;
weekly_capacity?: number;
avatar_url?: string;
is_contractor?: boolean;
total_hours: number;
billable_hours: number;
currency: string;
billable_amount: number;
}
interface TimeReportResponse {
results: TimeReportResult[];
per_page: number;
total_pages: number;
total_entries: number;
next_page: number | null;
previous_page: number | null;
page: number;
}
const HARVEST_ACCESS_TOKEN = process.env.HARVEST_ACCESS_TOKEN;
const HARVEST_ACCOUNT_ID = process.env.HARVEST_ACCOUNT_ID;
const STANDARD_WORK_DAY_HOURS = parseFloat(process.env.STANDARD_WORK_DAY_HOURS || '7.5');
const TIMEZONE = process.env.TIMEZONE || 'Australia/Perth';
if (!HARVEST_ACCESS_TOKEN || !HARVEST_ACCOUNT_ID) {
throw new Error('HARVEST_ACCESS_TOKEN and HARVEST_ACCOUNT_ID environment variables are required');
}
// Special patterns for leave requests
const LEAVE_PATTERNS = {
sick: {
triggers: ['sick', 'ill', 'unwell'],
project: '[LV] Leave',
task: "Person (Sick/Carer's) Leave",
},
annual: {
triggers: ['annual leave', 'vacation', 'holiday', 'time off'],
project: '[LV] Leave',
task: 'Annual Leave',
}
};
class HarvestServer {
private server: Server;
private axiosInstance;
constructor() {
this.server = new Server(
{
name: 'harvest-server',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.axiosInstance = axios.create({
baseURL: 'https://api.harvestapp.com/v2',
headers: {
'Authorization': `Bearer ${HARVEST_ACCESS_TOKEN}`,
'Harvest-Account-Id': HARVEST_ACCOUNT_ID,
'User-Agent': 'Harvest MCP Server (cline@example.com)',
},
});
this.setupToolHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private isLeaveRequest(text: string): { isLeave: boolean; type?: keyof typeof LEAVE_PATTERNS } {
const lowercaseText = text.toLowerCase();
for (const [type, pattern] of Object.entries(LEAVE_PATTERNS)) {
if (pattern.triggers.some(trigger => lowercaseText.includes(trigger))) {
return { isLeave: true, type: type as keyof typeof LEAVE_PATTERNS };
}
}
return { isLeave: false };
}
private parseDateRange(text: string): { from: string; to: string } {
const lowercaseText = text.toLowerCase();
const now = new Date(new Date().toLocaleString('en-US', { timeZone: TIMEZONE }));
// Handle common time ranges
if (lowercaseText.includes('last month')) {
const from = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const to = new Date(now.getFullYear(), now.getMonth(), 0);
return {
from: from.toISOString().split('T')[0],
to: to.toISOString().split('T')[0]
};
}
if (lowercaseText.includes('this month')) {
const from = new Date(now.getFullYear(), now.getMonth(), 1);
const to = now;
return {
from: from.toISOString().split('T')[0],
to: to.toISOString().split('T')[0]
};
}
if (lowercaseText.includes('this week')) {
const from = new Date(now);
from.setDate(now.getDate() - now.getDay());
return {
from: from.toISOString().split('T')[0],
to: now.toISOString().split('T')[0]
};
}
if (lowercaseText.includes('last week')) {
const from = new Date(now);
from.setDate(now.getDate() - now.getDay() - 7);
const to = new Date(from);
to.setDate(from.getDate() + 6);
return {
from: from.toISOString().split('T')[0],
to: to.toISOString().split('T')[0]
};
}
// Default to parsing with chrono
const dates = chrono.parse(text);
if (dates.length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'Could not parse date range from input');
}
return {
from: dates[0].start.date().toISOString().split('T')[0],
to: (dates[0].end?.date() || dates[0].start.date()).toISOString().split('T')[0]
};
}
private async parseTimeEntry(text: string) {
const lowercaseText = text.toLowerCase();
const now = new Date(new Date().toLocaleString('en-US', { timeZone: TIMEZONE }));
// Check if this is a leave request
const leaveCheck = this.isLeaveRequest(text);
if (leaveCheck.isLeave && leaveCheck.type) {
// For leave requests, use the full work day
return {
spent_date: now.toISOString().split('T')[0],
hours: STANDARD_WORK_DAY_HOURS,
isLeave: true,
leaveType: leaveCheck.type
};
}
// For regular time entries
let date: Date;
if (lowercaseText.includes('today')) {
date = now;
} else {
const parsed = chrono.parseDate(text);
if (!parsed) {
throw new McpError(ErrorCode.InvalidParams, 'Could not parse date from input');
}
date = parsed;
}
// Extract hours/minutes
const durationMatch = text.match(/(\d+)\s*(hour|hr|h|minute|min|m)s?/i);
if (!durationMatch) {
throw new McpError(ErrorCode.InvalidParams, 'Could not parse duration from input');
}
const amount = parseInt(durationMatch[1]);
const unit = durationMatch[2].toLowerCase();
const hours = unit.startsWith('h') ? amount : amount / 60;
return {
spent_date: date.toISOString().split('T')[0],
hours,
isLeave: false
};
}
private async findProject(text: string, isLeave: boolean = false, leaveType?: keyof typeof LEAVE_PATTERNS): Promise<number> {
const response = await this.axiosInstance.get('/projects');
const projects = response.data.projects;
if (isLeave && leaveType) {
// For leave requests, look for the specific leave project
const leaveProject = projects.find((p: { name: string; id: number }) =>
p.name === LEAVE_PATTERNS[leaveType].project
);
if (leaveProject) {
return leaveProject.id;
}
}
// For regular entries or if leave project not found
const projectMatch = projects.find((p: { name: string; id: number }) =>
text.toLowerCase().includes(p.name.toLowerCase())
);
if (!projectMatch) {
throw new McpError(ErrorCode.InvalidParams, 'Could not find matching project');
}
return projectMatch.id;
}
private async findTask(projectId: number, text: string, isLeave: boolean = false, leaveType?: keyof typeof LEAVE_PATTERNS): Promise<number> {
const response = await this.axiosInstance.get(`/projects/${projectId}/task_assignments`);
const tasks = response.data.task_assignments;
if (isLeave && leaveType) {
// For leave requests, look for the specific leave task
const leaveTask = tasks.find((t: { task: { name: string; id: number } }) =>
t.task.name === LEAVE_PATTERNS[leaveType].task
);
if (leaveTask) {
return leaveTask.task.id;
}
}
// For regular entries or if leave task not found
const taskMatch = tasks.find((t: { task: { name: string; id: number } }) =>
text.toLowerCase().includes(t.task.name.toLowerCase())
);
if (!taskMatch) {
// Default to first task if no match found
return tasks[0].task.id;
}
return taskMatch.task.id;
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'log_time',
description: 'Log time entry using natural language',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Natural language time entry (e.g. "2 hours on Project X doing development work yesterday")',
},
},
required: ['text'],
},
},
{
name: 'list_projects',
description: 'List available Harvest projects',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'list_tasks',
description: 'List available tasks for a project',
inputSchema: {
type: 'object',
properties: {
project_id: {
type: 'number',
description: 'Project ID',
},
},
required: ['project_id'],
},
},
{
name: 'list_entries',
description: 'List recent time entries',
inputSchema: {
type: 'object',
properties: {
from: {
type: 'string',
description: 'Start date (YYYY-MM-DD)',
},
to: {
type: 'string',
description: 'End date (YYYY-MM-DD)',
},
},
},
},
{
name: 'get_time_report',
description: 'Get time reports using natural language',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Natural language query (e.g., "Show time report for last month", "Get time summary for Project X")',
},
},
required: ['text'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case 'log_time': {
const { text } = request.params.arguments as { text: string };
try {
// Parse time entry details
const { spent_date, hours, isLeave, leaveType } = await this.parseTimeEntry(text);
// Find matching project
const project_id = await this.findProject(text, isLeave, leaveType);
// Find matching task
const task_id = await this.findTask(project_id, text, isLeave, leaveType);
// Create time entry
const response = await this.axiosInstance.post('/time_entries', {
project_id,
task_id,
spent_date,
hours,
notes: text,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
`Harvest API error: ${error.response?.data?.message ?? error.message}`
);
}
throw error;
}
}
case 'list_projects': {
const response = await this.axiosInstance.get('/projects');
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data.projects.map((p: { id: number; name: string; code: string; is_active: boolean }) => ({
id: p.id,
name: p.name,
code: p.code,
is_active: p.is_active,
})), null, 2),
},
],
};
}
case 'list_tasks': {
const { project_id } = request.params.arguments as { project_id: number };
const response = await this.axiosInstance.get(`/projects/${project_id}/task_assignments`);
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data.task_assignments.map((t: { task: { id: number; name: string } }) => ({
id: t.task.id,
name: t.task.name,
})), null, 2),
},
],
};
}
case 'list_entries': {
const { from, to } = request.params.arguments as { from?: string; to?: string };
const params: Record<string, string> = {};
if (from) params.from = from;
if (to) params.to = to;
const response = await this.axiosInstance.get('/time_entries', { params });
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data.time_entries.map((e: { id: number; spent_date: string; hours: number; notes: string; project: { name: string }; task: { name: string } }) => ({
id: e.id,
spent_date: e.spent_date,
hours: e.hours,
notes: e.notes,
project: e.project.name,
task: e.task.name,
})), null, 2),
},
],
};
}
case 'get_time_report': {
const { text } = request.params.arguments as { text: string };
try {
const { from, to } = this.parseDateRange(text);
const lowercaseText = text.toLowerCase();
let endpoint = '/reports/time/projects'; // default to project report
if (lowercaseText.includes('by client') || lowercaseText.includes('for client')) {
endpoint = '/reports/time/clients';
} else if (lowercaseText.includes('by task') || lowercaseText.includes('tasks')) {
endpoint = '/reports/time/tasks';
} else if (lowercaseText.includes('by team') || lowercaseText.includes('by user')) {
endpoint = '/reports/time/team';
}
const response = await this.axiosInstance.get<TimeReportResponse>(endpoint, {
params: { from, to }
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data, null, 2),
},
],
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
`Harvest API error: ${error.response?.data?.message ?? error.message}`
);
}
throw error;
}
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Harvest MCP server running on stdio');
}
}
const server = new HarvestServer();
server.run().catch(console.error);