Building a Harvest Time Tracking Integration with Model Context Protocol
tech
Mikko Harju

Building a Harvest Time Tracking Integration with Model Context Protocol

Efficiently tracking time and managing projects is essential for businesses of all sizes and having a good sense of the data is ever more important. Harvest is a popular time tracking tool used by thousands of companies, but what if you could interact with your time tracking data through natural language conversations?

Let's explore how to build a Model Context Protocol (MCP) server that connects Claude (or any other MCP-compatible large language model) with the Harvest API. This integration allows users to query time entries, manage projects, and track time through simple conversational prompts.

What is Model Context Protocol?

The Model Context Protocol is an open standard that enables seamless communication between large language models (LLMs) like Claude and external data sources or tools. MCP provides a standardized way for LLMs to access external context, execute functions, and return results to users.

By using MCP, we can give Claude the ability to interact with our Harvest time tracking data through natural language. Users can simply ask questions like "How many hours did our team log last week?" or "Create a time entry for my current project," and Claude can handle these requests by calling the appropriate Harvest API endpoints.

Setting Up the Harvest MCP Server

Let's build an MCP server that integrates with the Harvest API. We'll use Python with the official MCP SDK to create our server.

Prerequisites

Before we begin, you'll need:

1. A Harvest account with API access
2. Python 3.10 or higher
3. Claude for Desktop (or another MCP-compatible client)

Step 1: Install Required Packages

uv init
uv add mcp httpx

Step 2: Create the Server Code

Here's the core of our Harvest MCP server:

import os
import json
import httpx
from mcp.server.fastmcp import FastMCP

Initialize FastMCP server

mcp = FastMCP("harvest-api")

Get environment variables for Harvest API

HARVEST_ACCOUNT_ID = os.environ.get("HARVEST_ACCOUNT_ID")
HARVEST_API_KEY = os.environ.get("HARVEST_API_KEY")

Helper function to make Harvest API requests

async def harvest_request(path, params=None, method="GET"):
   if not HARVEST_ACCOUNT_ID or not HARVEST_API_KEY:
       raise ValueError("Missing Harvest API credentials. Set HARVEST_ACCOUNT_ID and HARVEST_API_KEY environment variables.")
   
   headers = {
       "Harvest-Account-Id": HARVEST_ACCOUNT_ID,
       "Authorization": f"Bearer {HARVEST_API_KEY}",
       "User-Agent": "Harvest MCP Server",
       "Content-Type": "application/json"
   }
   
   url = f"https://api.harvestapp.com/v2/{path}"
   
   async with httpx.AsyncClient() as client:
       if method == "GET":
           response = await client.get(url, headers=headers, params=params)
       else:
           response = await client.request(method, url, headers=headers, json=params)
       
       if response.status_code != 200:
           raise Exception(f"Harvest API Error: {response.status_code} {response.text}")
       
       return response.json()

This code sets up our server and creates a helper function for making authenticated requests to the Harvest API.

Step 3: Define MCP Tools

Next, we'll define tools that can be invoked by Claude. Each tool maps to a specific Harvest API endpoint:

@mcp.tool()
async def list_users(is_active: bool = None, page: int = None, per_page: int = None):
   """List all users in your Harvest account.
   Args:
       is_active: Pass true to only return active users and false to return inactive users
       page: The page number for pagination
       per_page: The number of records to return per page (1-2000)
   """
   params = {}
   if is_active is not None:
       params["is_active"] = "true" if is_active else "false"
   else:
       params["is_active"] = "true"
   if page is not None:
       params["page"] = str(page)
   if per_page is not None:
       params["per_page"] = str(per_page)
   else:
       params["per_page"] = 100
   
   response = await harvest_request("users", params)
   return json.dumps(response, indent=2)
@mcp.tool()
async def list_time_entries(user_id: int = None, from_date: str = None, to_date: str = None, is_running: bool = None):
   """List time entries with optional filtering.
   Args:
       user_id: Filter by user ID
       from_date: Only return time entries with a spent_date on or after the given date (YYYY-MM-DD)
       to_date: Only return time entries with a spent_date on or before the given date (YYYY-MM-DD)
       is_running: Pass true to only return running time entries and false to return non-running time entries
   """
   params = {}
   if user_id is not None:
       params["user_id"] = str(user_id)
   if from_date is not None:
       params["from"] = from_date
   if to_date is not None:
       params["to"] = to_date
   if is_running is not None:
       params["is_running"] = "true" if is_running else "false"
   
   response = await harvest_request("time_entries", params)
   return json.dumps(response, indent=2)

We've created two essential tools:
- list_users - Gets all users in the Harvest account
- list_time_entries - Gets time entries with flexible filtering options

Step 4: Add Time Entry Management Tools

Let's add tools for creating and managing time entries:

@mcp.tool()
async def create_time_entry(project_id: int, task_id: int, spent_date: str, hours: float, notes: str = None):
   """Create a new time entry.
   
   Args:
       project_id: The ID of the project to associate with the time entry
       task_id: The ID of the task to associate with the time entry
       spent_date: The date when the time was spent (YYYY-MM-DD)
       hours: The number of hours spent
       notes: Optional notes about the time entry
   """
   params = {
       "project_id": project_id,
       "task_id": task_id,
       "spent_date": spent_date,
       "hours": hours
   }
   
   if notes:
       params["notes"] = notes
   
   response = await harvest_request("time_entries", params, method="POST")
   return json.dumps(response, indent=2)
@mcp.tool()
async def stop_timer(time_entry_id: int):
   """Stop a running timer.
   
   Args:
       time_entry_id: The ID of the running time entry to stop
   """
   response = await harvest_request(f"time_entries/{time_entry_id}/stop", method="PATCH")
   return json.dumps(response, indent=2)

Step 5: Run the Server

Finally, we'll add the code to run our server:

if __name__ == "__main__":
   # Initialize and run the server
   mcp.run(transport='stdio')

Connecting to Claude for Desktop

To connect our server to Claude for Desktop, we need to update the `claude_desktop_config.json` file:

{
 "mcpServers": {
   "harvest": {
     "command": "uv",
     "args": ["run", "--directory", "/path/to", "harvest_server.py"],
     "env": {
       "HARVEST_ACCOUNT_ID": "your_account_id",
       "HARVEST_API_KEY": "your_access_token"
     }
   }
 }
}

Make sure to replace `/path/to/harvest_server.py` with the actual path to your server script and add your Harvest API credentials.

After you've configured Claude Desktop, you can start asking questions like:

"How many hours has Mikko Harju clocked this week?"
"Describe our company, our staff and their roles"

Of course, this does not end here. You can also try out summarizing tasks that people have done in a given project this week, what projects did certain individuals contribute to and other interesting tidbits you can imagine.

And extending that either by improving the server we implemented above or with aid from other tools, your imagination is the only limiting factor.

Happy time tracking!

Mikko Harju

With deep expertise in software development and emerging technologies, Mikko shares practical insights and concrete examples from real-world projects. Passionate about scalable tech, AI and emerging trends.

About the author

Mikko Harju

Latest Blog Posts

Read all Posts