@mnicole-dev/harvest-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +222 -0
  2. package/dist/index.js +941 -0
  3. package/package.json +34 -0
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # @mnicole-dev/harvest-mcp-server
2
+
3
+ A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for the [Harvest](https://www.getharvest.com) time tracking API (v2). Manage time entries, projects, tasks, clients, users, and timers from any MCP-compatible client.
4
+
5
+ ## Features
6
+
7
+ **37 tools** covering the full Harvest API v2:
8
+
9
+ ### Time Entries (8 tools)
10
+ | Tool | Description |
11
+ |------|-------------|
12
+ | `list-time-entries` | List entries with filters (date range, project, user, task, billed, running) |
13
+ | `get-time-entry` | Get a single time entry by ID |
14
+ | `create-time-entry` | Log hours for a project/task on a date (duration or start/end time) |
15
+ | `update-time-entry` | Update hours, notes, date, project, or task |
16
+ | `delete-time-entry` | Delete a time entry |
17
+ | `start-timer` | Start a running timer for a project/task |
18
+ | `stop-timer` | Stop a running timer |
19
+ | `restart-timer` | Restart a stopped timer |
20
+
21
+ ### Projects (4 tools)
22
+ | Tool | Description |
23
+ |------|-------------|
24
+ | `list-projects` | List all projects (filter by client, active status) |
25
+ | `get-project` | Get project details (budget, rates, dates, notes) |
26
+ | `create-project` | Create a project with billing, budget, and fee settings |
27
+ | `update-project` | Update project settings |
28
+ | `delete-project` | Delete a project |
29
+
30
+ ### Tasks (4 tools)
31
+ | Tool | Description |
32
+ |------|-------------|
33
+ | `list-tasks` | List all tasks in the account |
34
+ | `create-task` | Create a new task |
35
+ | `update-task` | Update a task |
36
+ | `delete-task` | Delete a task |
37
+
38
+ ### Task Assignments (4 tools)
39
+ | Tool | Description |
40
+ |------|-------------|
41
+ | `list-project-task-assignments` | List which tasks are assigned to a project |
42
+ | `create-task-assignment` | Assign a task to a project |
43
+ | `update-task-assignment` | Update task assignment (billable, rate, budget) |
44
+ | `delete-task-assignment` | Remove a task from a project |
45
+
46
+ ### User Assignments (4 tools)
47
+ | Tool | Description |
48
+ |------|-------------|
49
+ | `list-project-user-assignments` | List which users are assigned to a project |
50
+ | `create-user-assignment` | Assign a user to a project |
51
+ | `update-user-assignment` | Update assignment (PM role, rate, budget) |
52
+ | `delete-user-assignment` | Remove a user from a project |
53
+
54
+ ### Users (5 tools)
55
+ | Tool | Description |
56
+ |------|-------------|
57
+ | `get-me` | Get the authenticated user's profile |
58
+ | `list-users` | List all users in the account |
59
+ | `get-user` | Get a user's full profile |
60
+ | `create-user` | Create a new user |
61
+ | `update-user` | Update a user (or archive with isActive: false) |
62
+ | `delete-user` | Delete a user (only if no time entries/expenses) |
63
+
64
+ ### Clients (4 tools)
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `list-clients` | List all clients |
68
+ | `get-client` | Get a client's details |
69
+ | `create-client` | Create a new client |
70
+ | `update-client` | Update a client |
71
+ | `delete-client` | Delete a client |
72
+
73
+ ### Company (1 tool)
74
+ | Tool | Description |
75
+ |------|-------------|
76
+ | `get-company` | Get company settings (plan, capacity, format, currency) |
77
+
78
+ ## Requirements
79
+
80
+ - Node.js 18+
81
+ - A Harvest account with API access
82
+ - A [personal access token](https://id.getharvest.com/developers) and your account ID
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ npm install -g @mnicole-dev/harvest-mcp-server
88
+ ```
89
+
90
+ Or run directly with `npx`:
91
+
92
+ ```bash
93
+ npx @mnicole-dev/harvest-mcp-server
94
+ ```
95
+
96
+ ## Configuration
97
+
98
+ Set these environment variables:
99
+
100
+ ```bash
101
+ export HARVEST_ACCESS_TOKEN=your-token-here
102
+ export HARVEST_ACCOUNT_ID=your-account-id
103
+ ```
104
+
105
+ ### Claude Code
106
+
107
+ Add to your `~/.claude.json`:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "harvest": {
113
+ "type": "stdio",
114
+ "command": "npx",
115
+ "args": ["-y", "@mnicole-dev/harvest-mcp-server"],
116
+ "env": {
117
+ "HARVEST_ACCESS_TOKEN": "your-token-here",
118
+ "HARVEST_ACCOUNT_ID": "your-account-id"
119
+ }
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ### Claude Desktop
126
+
127
+ Add to your config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "harvest": {
133
+ "command": "npx",
134
+ "args": ["-y", "@mnicole-dev/harvest-mcp-server"],
135
+ "env": {
136
+ "HARVEST_ACCESS_TOKEN": "your-token-here",
137
+ "HARVEST_ACCOUNT_ID": "your-account-id"
138
+ }
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ ### Cursor / Windsurf / other MCP clients
145
+
146
+ Use the stdio transport with `npx -y @mnicole-dev/harvest-mcp-server` as the command and pass both environment variables.
147
+
148
+ ## Examples
149
+
150
+ ### Log time
151
+
152
+ ```
153
+ > Log 4 hours on project 12345, task 67890 for today with notes "API development"
154
+ ```
155
+
156
+ Calls `create-time-entry` with:
157
+ ```json
158
+ {
159
+ "projectId": 12345,
160
+ "taskId": 67890,
161
+ "spentDate": "2026-03-17",
162
+ "hours": 4,
163
+ "notes": "API development"
164
+ }
165
+ ```
166
+
167
+ ### Start a timer
168
+
169
+ ```
170
+ > Start a timer for project 12345, task 67890
171
+ ```
172
+
173
+ Calls `start-timer` — creates a running time entry. Use `stop-timer` to stop it.
174
+
175
+ ### View this week's entries
176
+
177
+ ```
178
+ > Show me all time entries from Monday to Friday this week
179
+ ```
180
+
181
+ Calls `list-time-entries` with `from` and `to` date parameters.
182
+
183
+ ### Create a project
184
+
185
+ ```
186
+ > Create a new billable project "Website Redesign" for client 123, billed by tasks, budget 100 hours
187
+ ```
188
+
189
+ Calls `create-project` with:
190
+ ```json
191
+ {
192
+ "clientId": 123,
193
+ "name": "Website Redesign",
194
+ "isBillable": true,
195
+ "billBy": "Tasks",
196
+ "budgetBy": "project",
197
+ "budget": 100
198
+ }
199
+ ```
200
+
201
+ ## How it works
202
+
203
+ 1. The MCP client sends a tool call to the server via stdio
204
+ 2. The server authenticates with the Harvest API v2 using your personal access token
205
+ 3. The request is forwarded to `https://api.harvestapp.com/api/v2/`
206
+ 4. The response is formatted as human-readable text and returned
207
+
208
+ All requests include the required `Authorization`, `Harvest-Account-Id`, and `User-Agent` headers.
209
+
210
+ ## Development
211
+
212
+ ```bash
213
+ git clone https://github.com/mnicole-dev/harvest-mcp-server.git
214
+ cd harvest-mcp-server
215
+ pnpm install
216
+ pnpm dev # Run with tsx (requires HARVEST_ACCESS_TOKEN + HARVEST_ACCOUNT_ID)
217
+ pnpm build # Build to dist/
218
+ ```
219
+
220
+ ## License
221
+
222
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,941 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ var API_BASE = "https://api.harvestapp.com/api/v2";
8
+ function getCredentials() {
9
+ const token = process.env["HARVEST_ACCESS_TOKEN"];
10
+ const accountId = process.env["HARVEST_ACCOUNT_ID"];
11
+ if (!token) throw new Error("HARVEST_ACCESS_TOKEN environment variable is required");
12
+ if (!accountId) throw new Error("HARVEST_ACCOUNT_ID environment variable is required");
13
+ return { token, accountId };
14
+ }
15
+ async function harvestFetch(path, options) {
16
+ const { token, accountId } = getCredentials();
17
+ let url = `${API_BASE}${path}`;
18
+ if (options?.params) {
19
+ const qs = new URLSearchParams(options.params).toString();
20
+ if (qs) url += `?${qs}`;
21
+ }
22
+ const { params: _, ...fetchOptions } = options ?? {};
23
+ return fetch(url, {
24
+ ...fetchOptions,
25
+ headers: {
26
+ Authorization: `Bearer ${token}`,
27
+ "Harvest-Account-Id": accountId,
28
+ "User-Agent": "harvest-mcp-server",
29
+ "Content-Type": "application/json",
30
+ ...fetchOptions?.headers
31
+ }
32
+ });
33
+ }
34
+ async function assertOk(resp, action) {
35
+ if (resp.ok) {
36
+ if (resp.status === 200 || resp.status === 201) return resp.json();
37
+ return {};
38
+ }
39
+ const body = await resp.text();
40
+ throw new Error(`${action} failed (${resp.status}): ${body}`);
41
+ }
42
+ function textResult(text) {
43
+ return { content: [{ type: "text", text }] };
44
+ }
45
+ function today() {
46
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
47
+ }
48
+ var server = new McpServer({
49
+ name: "harvest-mcp-server",
50
+ version: "1.0.0"
51
+ });
52
+ server.tool(
53
+ "list-time-entries",
54
+ "List time entries with optional filters (date range, project, user, task). Returns the most recent entries.",
55
+ {
56
+ from: z.string().optional().describe("Start date (YYYY-MM-DD). Defaults to today."),
57
+ to: z.string().optional().describe("End date (YYYY-MM-DD). Defaults to today."),
58
+ projectId: z.number().optional().describe("Filter by project ID"),
59
+ taskId: z.number().optional().describe("Filter by task ID"),
60
+ userId: z.number().optional().describe("Filter by user ID"),
61
+ isBilled: z.boolean().optional().describe("Filter by invoiced status"),
62
+ isRunning: z.boolean().optional().describe("Filter by running timer status"),
63
+ perPage: z.number().min(1).max(2e3).default(100).describe("Results per page")
64
+ },
65
+ async (params) => {
66
+ const qp = {};
67
+ qp["from"] = params.from ?? today();
68
+ qp["to"] = params.to ?? today();
69
+ if (params.projectId) qp["project_id"] = String(params.projectId);
70
+ if (params.taskId) qp["task_id"] = String(params.taskId);
71
+ if (params.userId) qp["user_id"] = String(params.userId);
72
+ if (params.isBilled !== void 0) qp["is_billed"] = String(params.isBilled);
73
+ if (params.isRunning !== void 0) qp["is_running"] = String(params.isRunning);
74
+ qp["per_page"] = String(params.perPage);
75
+ const resp = await harvestFetch("/time_entries", { params: qp });
76
+ const data = await assertOk(resp, "List time entries");
77
+ if (data.time_entries.length === 0) return textResult("No time entries found.");
78
+ const lines = data.time_entries.map((e) => {
79
+ const running = e.is_running ? " [RUNNING]" : "";
80
+ const notes = e.notes ? ` \u2014 ${e.notes}` : "";
81
+ return `- **${e.spent_date}** | ${e.hours}h | ${e.project.name} > ${e.task.name}${notes}${running} (id: ${e.id})`;
82
+ });
83
+ lines.push(`
84
+ _Total: ${data.total_entries} entries_`);
85
+ return textResult(lines.join("\n"));
86
+ }
87
+ );
88
+ server.tool(
89
+ "get-time-entry",
90
+ "Retrieve a single time entry by ID.",
91
+ {
92
+ timeEntryId: z.number().describe("Time entry ID")
93
+ },
94
+ async ({ timeEntryId }) => {
95
+ const resp = await harvestFetch(`/time_entries/${timeEntryId}`);
96
+ const e = await assertOk(resp, "Get time entry");
97
+ const running = e.is_running ? " [RUNNING]" : "";
98
+ const notes = e.notes ? `
99
+ Notes: ${e.notes}` : "";
100
+ const times = e.started_time && e.ended_time ? `
101
+ Time: ${e.started_time} \u2192 ${e.ended_time}` : "";
102
+ return textResult(
103
+ `**${e.project.name} > ${e.task.name}**
104
+ Date: ${e.spent_date}
105
+ Hours: ${e.hours}h${running}${times}${notes}
106
+ User: ${e.user.name}
107
+ ID: ${e.id}`
108
+ );
109
+ }
110
+ );
111
+ server.tool(
112
+ "create-time-entry",
113
+ "Create a new time entry (log hours for a project/task on a date).",
114
+ {
115
+ projectId: z.number().describe("Project ID"),
116
+ taskId: z.number().describe("Task ID"),
117
+ spentDate: z.string().default("today").describe('Date (YYYY-MM-DD or "today")'),
118
+ hours: z.number().optional().describe("Hours to log. Omit to start a running timer."),
119
+ notes: z.string().optional().describe("Notes/description"),
120
+ startedTime: z.string().optional().describe('Start time (e.g. "8:00am"). Use instead of hours for time-based tracking.'),
121
+ endedTime: z.string().optional().describe('End time (e.g. "5:00pm"). Omit to start a running timer.'),
122
+ userId: z.number().optional().describe("User ID (defaults to authenticated user)")
123
+ },
124
+ async (params) => {
125
+ const body = {
126
+ project_id: params.projectId,
127
+ task_id: params.taskId,
128
+ spent_date: params.spentDate === "today" ? today() : params.spentDate
129
+ };
130
+ if (params.hours !== void 0) body["hours"] = params.hours;
131
+ if (params.notes) body["notes"] = params.notes;
132
+ if (params.startedTime) body["started_time"] = params.startedTime;
133
+ if (params.endedTime) body["ended_time"] = params.endedTime;
134
+ if (params.userId) body["user_id"] = params.userId;
135
+ const resp = await harvestFetch("/time_entries", {
136
+ method: "POST",
137
+ body: JSON.stringify(body)
138
+ });
139
+ const e = await assertOk(resp, "Create time entry");
140
+ const running = e.is_running ? " (timer running)" : "";
141
+ return textResult(
142
+ `Time entry created!
143
+ - **${e.project.name} > ${e.task.name}**
144
+ - Date: ${e.spent_date}
145
+ - Hours: ${e.hours}h${running}
146
+ - ID: ${e.id}`
147
+ );
148
+ }
149
+ );
150
+ server.tool(
151
+ "update-time-entry",
152
+ "Update an existing time entry (change hours, notes, date, project, or task).",
153
+ {
154
+ timeEntryId: z.number().describe("Time entry ID"),
155
+ projectId: z.number().optional().describe("New project ID"),
156
+ taskId: z.number().optional().describe("New task ID"),
157
+ spentDate: z.string().optional().describe("New date (YYYY-MM-DD)"),
158
+ hours: z.number().optional().describe("New hours"),
159
+ notes: z.string().optional().describe("New notes"),
160
+ startedTime: z.string().optional().describe("New start time"),
161
+ endedTime: z.string().optional().describe("New end time")
162
+ },
163
+ async (params) => {
164
+ const body = {};
165
+ if (params.projectId) body["project_id"] = params.projectId;
166
+ if (params.taskId) body["task_id"] = params.taskId;
167
+ if (params.spentDate) body["spent_date"] = params.spentDate;
168
+ if (params.hours !== void 0) body["hours"] = params.hours;
169
+ if (params.notes !== void 0) body["notes"] = params.notes;
170
+ if (params.startedTime) body["started_time"] = params.startedTime;
171
+ if (params.endedTime) body["ended_time"] = params.endedTime;
172
+ const resp = await harvestFetch(`/time_entries/${params.timeEntryId}`, {
173
+ method: "PATCH",
174
+ body: JSON.stringify(body)
175
+ });
176
+ const e = await assertOk(resp, "Update time entry");
177
+ return textResult(
178
+ `Time entry updated!
179
+ - **${e.project.name} > ${e.task.name}**
180
+ - Date: ${e.spent_date}
181
+ - Hours: ${e.hours}h
182
+ - ID: ${e.id}`
183
+ );
184
+ }
185
+ );
186
+ server.tool(
187
+ "delete-time-entry",
188
+ "Delete a time entry by ID.",
189
+ {
190
+ timeEntryId: z.number().describe("Time entry ID")
191
+ },
192
+ async ({ timeEntryId }) => {
193
+ const resp = await harvestFetch(`/time_entries/${timeEntryId}`, {
194
+ method: "DELETE"
195
+ });
196
+ if (!resp.ok) {
197
+ const body = await resp.text();
198
+ throw new Error(`Delete failed (${resp.status}): ${body}`);
199
+ }
200
+ return textResult(`Time entry ${timeEntryId} deleted.`);
201
+ }
202
+ );
203
+ server.tool(
204
+ "start-timer",
205
+ "Start a timer for a project/task. Creates a new running time entry.",
206
+ {
207
+ projectId: z.number().describe("Project ID"),
208
+ taskId: z.number().describe("Task ID"),
209
+ notes: z.string().optional().describe("Notes/description")
210
+ },
211
+ async (params) => {
212
+ const body = {
213
+ project_id: params.projectId,
214
+ task_id: params.taskId,
215
+ spent_date: today()
216
+ };
217
+ if (params.notes) body["notes"] = params.notes;
218
+ const resp = await harvestFetch("/time_entries", {
219
+ method: "POST",
220
+ body: JSON.stringify(body)
221
+ });
222
+ const e = await assertOk(resp, "Start timer");
223
+ return textResult(
224
+ `Timer started!
225
+ - **${e.project.name} > ${e.task.name}**
226
+ - ID: ${e.id}`
227
+ );
228
+ }
229
+ );
230
+ server.tool(
231
+ "stop-timer",
232
+ "Stop a running time entry timer.",
233
+ {
234
+ timeEntryId: z.number().describe("Time entry ID of the running timer")
235
+ },
236
+ async ({ timeEntryId }) => {
237
+ const resp = await harvestFetch(`/time_entries/${timeEntryId}/stop`, {
238
+ method: "PATCH"
239
+ });
240
+ const e = await assertOk(resp, "Stop timer");
241
+ return textResult(
242
+ `Timer stopped!
243
+ - **${e.project.name} > ${e.task.name}**
244
+ - Hours: ${e.hours}h
245
+ - ID: ${e.id}`
246
+ );
247
+ }
248
+ );
249
+ server.tool(
250
+ "restart-timer",
251
+ "Restart a previously stopped time entry timer.",
252
+ {
253
+ timeEntryId: z.number().describe("Time entry ID to restart")
254
+ },
255
+ async ({ timeEntryId }) => {
256
+ const resp = await harvestFetch(`/time_entries/${timeEntryId}/restart`, {
257
+ method: "PATCH"
258
+ });
259
+ const e = await assertOk(resp, "Restart timer");
260
+ return textResult(
261
+ `Timer restarted!
262
+ - **${e.project.name} > ${e.task.name}**
263
+ - ID: ${e.id}`
264
+ );
265
+ }
266
+ );
267
+ server.tool(
268
+ "list-projects",
269
+ "List all projects, optionally filtered by client or active status.",
270
+ {
271
+ isActive: z.boolean().optional().describe("Filter by active status"),
272
+ clientId: z.number().optional().describe("Filter by client ID"),
273
+ perPage: z.number().min(1).max(2e3).default(100).describe("Results per page")
274
+ },
275
+ async (params) => {
276
+ const qp = {};
277
+ if (params.isActive !== void 0) qp["is_active"] = String(params.isActive);
278
+ if (params.clientId) qp["client_id"] = String(params.clientId);
279
+ qp["per_page"] = String(params.perPage);
280
+ const resp = await harvestFetch("/projects", { params: qp });
281
+ const data = await assertOk(resp, "List projects");
282
+ if (data.projects.length === 0) return textResult("No projects found.");
283
+ const lines = data.projects.map((p) => {
284
+ const budget = p.budget ? ` | Budget: ${p.budget}h` : "";
285
+ const code = p.code ? ` (${p.code})` : "";
286
+ const active = p.is_active ? "" : " [INACTIVE]";
287
+ return `- **${p.name}**${code} \u2014 ${p.client.name}${budget}${active} (id: ${p.id})`;
288
+ });
289
+ lines.push(`
290
+ _Total: ${data.total_entries} projects_`);
291
+ return textResult(lines.join("\n"));
292
+ }
293
+ );
294
+ server.tool(
295
+ "get-project",
296
+ "Retrieve a single project by ID with full details.",
297
+ {
298
+ projectId: z.number().describe("Project ID")
299
+ },
300
+ async ({ projectId }) => {
301
+ const resp = await harvestFetch(`/projects/${projectId}`);
302
+ const p = await assertOk(resp, "Get project");
303
+ const notes = p.notes ? `
304
+ Notes: ${p.notes}` : "";
305
+ const dates = p.starts_on || p.ends_on ? `
306
+ Dates: ${p.starts_on ?? "?"} \u2192 ${p.ends_on ?? "?"}` : "";
307
+ return textResult(
308
+ `**${p.name}** (${p.code || "no code"})
309
+ Client: ${p.client.name}
310
+ Active: ${p.is_active} | Billable: ${p.is_billable}
311
+ Budget: ${p.budget ?? "none"} (${p.budget_by})
312
+ Hourly rate: ${p.hourly_rate ?? "none"}${dates}${notes}
313
+ ID: ${p.id}`
314
+ );
315
+ }
316
+ );
317
+ server.tool(
318
+ "list-tasks",
319
+ "List all tasks in the Harvest account.",
320
+ {
321
+ isActive: z.boolean().optional().describe("Filter by active status"),
322
+ perPage: z.number().min(1).max(2e3).default(100).describe("Results per page")
323
+ },
324
+ async (params) => {
325
+ const qp = {};
326
+ if (params.isActive !== void 0) qp["is_active"] = String(params.isActive);
327
+ qp["per_page"] = String(params.perPage);
328
+ const resp = await harvestFetch("/tasks", { params: qp });
329
+ const data = await assertOk(resp, "List tasks");
330
+ if (data.tasks.length === 0) return textResult("No tasks found.");
331
+ const lines = data.tasks.map((t) => {
332
+ const active = t.is_active ? "" : " [INACTIVE]";
333
+ const billable = t.billable_by_default ? "billable" : "non-billable";
334
+ return `- **${t.name}** \u2014 ${billable}, ${t.default_hourly_rate}/h${active} (id: ${t.id})`;
335
+ });
336
+ lines.push(`
337
+ _Total: ${data.total_entries} tasks_`);
338
+ return textResult(lines.join("\n"));
339
+ }
340
+ );
341
+ server.tool(
342
+ "list-project-task-assignments",
343
+ "List task assignments for a project (which tasks can be logged against a project).",
344
+ {
345
+ projectId: z.number().describe("Project ID"),
346
+ isActive: z.boolean().optional().describe("Filter by active status")
347
+ },
348
+ async (params) => {
349
+ const qp = {};
350
+ if (params.isActive !== void 0) qp["is_active"] = String(params.isActive);
351
+ const resp = await harvestFetch(
352
+ `/projects/${params.projectId}/task_assignments`,
353
+ { params: qp }
354
+ );
355
+ const data = await assertOk(resp, "List task assignments");
356
+ if (data.task_assignments.length === 0) return textResult("No task assignments found.");
357
+ const lines = data.task_assignments.map((ta) => {
358
+ const billable = ta.billable ? "billable" : "non-billable";
359
+ const rate = ta.hourly_rate ? `, ${ta.hourly_rate}/h` : "";
360
+ const budget = ta.budget ? `, budget: ${ta.budget}h` : "";
361
+ return `- **${ta.task.name}** \u2014 ${billable}${rate}${budget} (task_id: ${ta.task.id})`;
362
+ });
363
+ lines.push(`
364
+ _Total: ${data.total_entries} assignments_`);
365
+ return textResult(lines.join("\n"));
366
+ }
367
+ );
368
+ server.tool(
369
+ "get-me",
370
+ "Get the currently authenticated user profile.",
371
+ {},
372
+ async () => {
373
+ const resp = await harvestFetch("/users/me");
374
+ const u = await assertOk(resp, "Get me");
375
+ const capacity = (u.weekly_capacity / 3600).toFixed(0);
376
+ return textResult(
377
+ `**${u.first_name} ${u.last_name}**
378
+ Email: ${u.email}
379
+ Timezone: ${u.timezone}
380
+ Weekly capacity: ${capacity}h
381
+ Roles: ${u.roles.join(", ") || "none"}
382
+ ID: ${u.id}`
383
+ );
384
+ }
385
+ );
386
+ server.tool(
387
+ "list-users",
388
+ "List all users in the Harvest account.",
389
+ {
390
+ isActive: z.boolean().optional().describe("Filter by active status"),
391
+ perPage: z.number().min(1).max(2e3).default(100).describe("Results per page")
392
+ },
393
+ async (params) => {
394
+ const qp = {};
395
+ if (params.isActive !== void 0) qp["is_active"] = String(params.isActive);
396
+ qp["per_page"] = String(params.perPage);
397
+ const resp = await harvestFetch("/users", { params: qp });
398
+ const data = await assertOk(resp, "List users");
399
+ if (data.users.length === 0) return textResult("No users found.");
400
+ const lines = data.users.map((u) => {
401
+ const active = u.is_active ? "" : " [INACTIVE]";
402
+ return `- **${u.first_name} ${u.last_name}** \u2014 ${u.email}${active} (id: ${u.id})`;
403
+ });
404
+ lines.push(`
405
+ _Total: ${data.total_entries} users_`);
406
+ return textResult(lines.join("\n"));
407
+ }
408
+ );
409
+ server.tool(
410
+ "list-clients",
411
+ "List all clients in the Harvest account.",
412
+ {
413
+ isActive: z.boolean().optional().describe("Filter by active status"),
414
+ perPage: z.number().min(1).max(2e3).default(100).describe("Results per page")
415
+ },
416
+ async (params) => {
417
+ const qp = {};
418
+ if (params.isActive !== void 0) qp["is_active"] = String(params.isActive);
419
+ qp["per_page"] = String(params.perPage);
420
+ const resp = await harvestFetch("/clients", { params: qp });
421
+ const data = await assertOk(resp, "List clients");
422
+ if (data.clients.length === 0) return textResult("No clients found.");
423
+ const lines = data.clients.map((c) => {
424
+ const active = c.is_active ? "" : " [INACTIVE]";
425
+ return `- **${c.name}** \u2014 ${c.currency}${active} (id: ${c.id})`;
426
+ });
427
+ lines.push(`
428
+ _Total: ${data.total_entries} clients_`);
429
+ return textResult(lines.join("\n"));
430
+ }
431
+ );
432
+ server.tool(
433
+ "create-project",
434
+ "Create a new project.",
435
+ {
436
+ clientId: z.number().describe("Client ID"),
437
+ name: z.string().describe("Project name"),
438
+ isBillable: z.boolean().describe("Whether the project is billable"),
439
+ billBy: z.enum(["Project", "Tasks", "People", "none"]).describe("Billing method"),
440
+ budgetBy: z.enum(["project", "project_cost", "task", "task_fees", "person", "none"]).describe("Budget method"),
441
+ code: z.string().optional().describe("Project code"),
442
+ isActive: z.boolean().optional().describe("Active status (default: true)"),
443
+ isFixedFee: z.boolean().optional().describe("Fixed fee project"),
444
+ hourlyRate: z.number().optional().describe("Hourly rate"),
445
+ budget: z.number().optional().describe("Budget in hours"),
446
+ budgetIsMonthly: z.boolean().optional().describe("Monthly budget (default: false)"),
447
+ costBudget: z.number().optional().describe("Monetary budget"),
448
+ fee: z.number().optional().describe("Fixed fee amount"),
449
+ notes: z.string().optional().describe("Project notes"),
450
+ startsOn: z.string().optional().describe("Start date (YYYY-MM-DD)"),
451
+ endsOn: z.string().optional().describe("End date (YYYY-MM-DD)"),
452
+ notifyWhenOverBudget: z.boolean().optional().describe("Notify when over budget"),
453
+ overBudgetNotificationPercentage: z.number().optional().describe("Budget notification threshold %"),
454
+ showBudgetToAll: z.boolean().optional().describe("Show budget to all users")
455
+ },
456
+ async (params) => {
457
+ const body = {
458
+ client_id: params.clientId,
459
+ name: params.name,
460
+ is_billable: params.isBillable,
461
+ bill_by: params.billBy,
462
+ budget_by: params.budgetBy
463
+ };
464
+ if (params.code) body["code"] = params.code;
465
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
466
+ if (params.isFixedFee !== void 0) body["is_fixed_fee"] = params.isFixedFee;
467
+ if (params.hourlyRate !== void 0) body["hourly_rate"] = params.hourlyRate;
468
+ if (params.budget !== void 0) body["budget"] = params.budget;
469
+ if (params.budgetIsMonthly !== void 0) body["budget_is_monthly"] = params.budgetIsMonthly;
470
+ if (params.costBudget !== void 0) body["cost_budget"] = params.costBudget;
471
+ if (params.fee !== void 0) body["fee"] = params.fee;
472
+ if (params.notes) body["notes"] = params.notes;
473
+ if (params.startsOn) body["starts_on"] = params.startsOn;
474
+ if (params.endsOn) body["ends_on"] = params.endsOn;
475
+ if (params.notifyWhenOverBudget !== void 0)
476
+ body["notify_when_over_budget"] = params.notifyWhenOverBudget;
477
+ if (params.overBudgetNotificationPercentage !== void 0)
478
+ body["over_budget_notification_percentage"] = params.overBudgetNotificationPercentage;
479
+ if (params.showBudgetToAll !== void 0) body["show_budget_to_all"] = params.showBudgetToAll;
480
+ const resp = await harvestFetch("/projects", { method: "POST", body: JSON.stringify(body) });
481
+ const p = await assertOk(resp, "Create project");
482
+ return textResult(`Project created!
483
+ - **${p.name}** \u2014 ${p.client.name}
484
+ - ID: ${p.id}`);
485
+ }
486
+ );
487
+ server.tool(
488
+ "update-project",
489
+ "Update an existing project.",
490
+ {
491
+ projectId: z.number().describe("Project ID"),
492
+ name: z.string().optional().describe("New name"),
493
+ clientId: z.number().optional().describe("New client ID"),
494
+ isBillable: z.boolean().optional().describe("Billable status"),
495
+ billBy: z.enum(["Project", "Tasks", "People", "none"]).optional().describe("Billing method"),
496
+ budgetBy: z.enum(["project", "project_cost", "task", "task_fees", "person", "none"]).optional().describe("Budget method"),
497
+ code: z.string().optional().describe("Project code"),
498
+ isActive: z.boolean().optional().describe("Active status"),
499
+ hourlyRate: z.number().optional().describe("Hourly rate"),
500
+ budget: z.number().optional().describe("Budget in hours"),
501
+ fee: z.number().optional().describe("Fixed fee amount"),
502
+ notes: z.string().optional().describe("Project notes"),
503
+ startsOn: z.string().optional().describe("Start date (YYYY-MM-DD)"),
504
+ endsOn: z.string().optional().describe("End date (YYYY-MM-DD)")
505
+ },
506
+ async (params) => {
507
+ const body = {};
508
+ if (params.name) body["name"] = params.name;
509
+ if (params.clientId) body["client_id"] = params.clientId;
510
+ if (params.isBillable !== void 0) body["is_billable"] = params.isBillable;
511
+ if (params.billBy) body["bill_by"] = params.billBy;
512
+ if (params.budgetBy) body["budget_by"] = params.budgetBy;
513
+ if (params.code) body["code"] = params.code;
514
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
515
+ if (params.hourlyRate !== void 0) body["hourly_rate"] = params.hourlyRate;
516
+ if (params.budget !== void 0) body["budget"] = params.budget;
517
+ if (params.fee !== void 0) body["fee"] = params.fee;
518
+ if (params.notes !== void 0) body["notes"] = params.notes;
519
+ if (params.startsOn) body["starts_on"] = params.startsOn;
520
+ if (params.endsOn) body["ends_on"] = params.endsOn;
521
+ const resp = await harvestFetch(`/projects/${params.projectId}`, { method: "PATCH", body: JSON.stringify(body) });
522
+ const p = await assertOk(resp, "Update project");
523
+ return textResult(`Project updated: **${p.name}** (id: ${p.id})`);
524
+ }
525
+ );
526
+ server.tool(
527
+ "delete-project",
528
+ "Delete a project by ID.",
529
+ { projectId: z.number().describe("Project ID") },
530
+ async ({ projectId }) => {
531
+ const resp = await harvestFetch(`/projects/${projectId}`, { method: "DELETE" });
532
+ if (!resp.ok) {
533
+ const b = await resp.text();
534
+ throw new Error(`Delete failed (${resp.status}): ${b}`);
535
+ }
536
+ return textResult(`Project ${projectId} deleted.`);
537
+ }
538
+ );
539
+ server.tool(
540
+ "create-task-assignment",
541
+ "Assign a task to a project.",
542
+ {
543
+ projectId: z.number().describe("Project ID"),
544
+ taskId: z.number().describe("Task ID to assign"),
545
+ isActive: z.boolean().optional().describe("Active status"),
546
+ billable: z.boolean().optional().describe("Billable"),
547
+ hourlyRate: z.number().optional().describe("Hourly rate"),
548
+ budget: z.number().optional().describe("Budget in hours")
549
+ },
550
+ async (params) => {
551
+ const body = { task_id: params.taskId };
552
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
553
+ if (params.billable !== void 0) body["billable"] = params.billable;
554
+ if (params.hourlyRate !== void 0) body["hourly_rate"] = params.hourlyRate;
555
+ if (params.budget !== void 0) body["budget"] = params.budget;
556
+ const resp = await harvestFetch(`/projects/${params.projectId}/task_assignments`, { method: "POST", body: JSON.stringify(body) });
557
+ const ta = await assertOk(resp, "Create task assignment");
558
+ return textResult(`Task assigned: **${ta.task.name}** (assignment id: ${ta.id})`);
559
+ }
560
+ );
561
+ server.tool(
562
+ "update-task-assignment",
563
+ "Update a task assignment on a project.",
564
+ {
565
+ projectId: z.number().describe("Project ID"),
566
+ taskAssignmentId: z.number().describe("Task assignment ID"),
567
+ isActive: z.boolean().optional().describe("Active status"),
568
+ billable: z.boolean().optional().describe("Billable"),
569
+ hourlyRate: z.number().optional().describe("Hourly rate"),
570
+ budget: z.number().optional().describe("Budget in hours")
571
+ },
572
+ async (params) => {
573
+ const body = {};
574
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
575
+ if (params.billable !== void 0) body["billable"] = params.billable;
576
+ if (params.hourlyRate !== void 0) body["hourly_rate"] = params.hourlyRate;
577
+ if (params.budget !== void 0) body["budget"] = params.budget;
578
+ const resp = await harvestFetch(`/projects/${params.projectId}/task_assignments/${params.taskAssignmentId}`, { method: "PATCH", body: JSON.stringify(body) });
579
+ const ta = await assertOk(resp, "Update task assignment");
580
+ return textResult(`Task assignment updated: **${ta.task.name}** (id: ${ta.id})`);
581
+ }
582
+ );
583
+ server.tool(
584
+ "delete-task-assignment",
585
+ "Remove a task assignment from a project.",
586
+ {
587
+ projectId: z.number().describe("Project ID"),
588
+ taskAssignmentId: z.number().describe("Task assignment ID")
589
+ },
590
+ async ({ projectId, taskAssignmentId }) => {
591
+ const resp = await harvestFetch(`/projects/${projectId}/task_assignments/${taskAssignmentId}`, { method: "DELETE" });
592
+ if (!resp.ok) {
593
+ const b = await resp.text();
594
+ throw new Error(`Delete failed (${resp.status}): ${b}`);
595
+ }
596
+ return textResult(`Task assignment ${taskAssignmentId} deleted.`);
597
+ }
598
+ );
599
+ server.tool(
600
+ "list-project-user-assignments",
601
+ "List user assignments for a project (which users are assigned).",
602
+ {
603
+ projectId: z.number().describe("Project ID"),
604
+ isActive: z.boolean().optional().describe("Filter by active status")
605
+ },
606
+ async (params) => {
607
+ const qp = {};
608
+ if (params.isActive !== void 0) qp["is_active"] = String(params.isActive);
609
+ const resp = await harvestFetch(`/projects/${params.projectId}/user_assignments`, { params: qp });
610
+ const data = await assertOk(resp, "List user assignments");
611
+ if (data.user_assignments.length === 0) return textResult("No user assignments found.");
612
+ const lines = data.user_assignments.map((ua) => {
613
+ const pm = ua.is_project_manager ? " [PM]" : "";
614
+ const rate = ua.hourly_rate ? `, ${ua.hourly_rate}/h` : "";
615
+ return `- **${ua.user.name}**${pm}${rate} (user_id: ${ua.user.id}, assignment_id: ${ua.id})`;
616
+ });
617
+ lines.push(`
618
+ _Total: ${data.total_entries} assignments_`);
619
+ return textResult(lines.join("\n"));
620
+ }
621
+ );
622
+ server.tool(
623
+ "create-user-assignment",
624
+ "Assign a user to a project.",
625
+ {
626
+ projectId: z.number().describe("Project ID"),
627
+ userId: z.number().describe("User ID to assign"),
628
+ isActive: z.boolean().optional().describe("Active status"),
629
+ isProjectManager: z.boolean().optional().describe("Project manager role"),
630
+ useDefaultRates: z.boolean().optional().describe("Use default rates"),
631
+ hourlyRate: z.number().optional().describe("Hourly rate"),
632
+ budget: z.number().optional().describe("Budget in hours")
633
+ },
634
+ async (params) => {
635
+ const body = { user_id: params.userId };
636
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
637
+ if (params.isProjectManager !== void 0) body["is_project_manager"] = params.isProjectManager;
638
+ if (params.useDefaultRates !== void 0) body["use_default_rates"] = params.useDefaultRates;
639
+ if (params.hourlyRate !== void 0) body["hourly_rate"] = params.hourlyRate;
640
+ if (params.budget !== void 0) body["budget"] = params.budget;
641
+ const resp = await harvestFetch(`/projects/${params.projectId}/user_assignments`, { method: "POST", body: JSON.stringify(body) });
642
+ const ua = await assertOk(resp, "Create user assignment");
643
+ return textResult(`User assigned: **${ua.user.name}** (assignment id: ${ua.id})`);
644
+ }
645
+ );
646
+ server.tool(
647
+ "update-user-assignment",
648
+ "Update a user assignment on a project.",
649
+ {
650
+ projectId: z.number().describe("Project ID"),
651
+ userAssignmentId: z.number().describe("User assignment ID"),
652
+ isActive: z.boolean().optional().describe("Active status"),
653
+ isProjectManager: z.boolean().optional().describe("Project manager role"),
654
+ useDefaultRates: z.boolean().optional().describe("Use default rates"),
655
+ hourlyRate: z.number().optional().describe("Hourly rate"),
656
+ budget: z.number().optional().describe("Budget in hours")
657
+ },
658
+ async (params) => {
659
+ const body = {};
660
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
661
+ if (params.isProjectManager !== void 0) body["is_project_manager"] = params.isProjectManager;
662
+ if (params.useDefaultRates !== void 0) body["use_default_rates"] = params.useDefaultRates;
663
+ if (params.hourlyRate !== void 0) body["hourly_rate"] = params.hourlyRate;
664
+ if (params.budget !== void 0) body["budget"] = params.budget;
665
+ const resp = await harvestFetch(`/projects/${params.projectId}/user_assignments/${params.userAssignmentId}`, { method: "PATCH", body: JSON.stringify(body) });
666
+ const ua = await assertOk(resp, "Update user assignment");
667
+ return textResult(`User assignment updated: **${ua.user.name}** (id: ${ua.id})`);
668
+ }
669
+ );
670
+ server.tool(
671
+ "delete-user-assignment",
672
+ "Remove a user assignment from a project.",
673
+ {
674
+ projectId: z.number().describe("Project ID"),
675
+ userAssignmentId: z.number().describe("User assignment ID")
676
+ },
677
+ async ({ projectId, userAssignmentId }) => {
678
+ const resp = await harvestFetch(`/projects/${projectId}/user_assignments/${userAssignmentId}`, { method: "DELETE" });
679
+ if (!resp.ok) {
680
+ const b = await resp.text();
681
+ throw new Error(`Delete failed (${resp.status}): ${b}`);
682
+ }
683
+ return textResult(`User assignment ${userAssignmentId} deleted.`);
684
+ }
685
+ );
686
+ server.tool(
687
+ "create-task",
688
+ "Create a new task in the Harvest account.",
689
+ {
690
+ name: z.string().describe("Task name"),
691
+ billableByDefault: z.boolean().optional().describe("Billable by default (default: true)"),
692
+ defaultHourlyRate: z.number().optional().describe("Default hourly rate"),
693
+ isDefault: z.boolean().optional().describe("Auto-add to new projects"),
694
+ isActive: z.boolean().optional().describe("Active status")
695
+ },
696
+ async (params) => {
697
+ const body = { name: params.name };
698
+ if (params.billableByDefault !== void 0) body["billable_by_default"] = params.billableByDefault;
699
+ if (params.defaultHourlyRate !== void 0) body["default_hourly_rate"] = params.defaultHourlyRate;
700
+ if (params.isDefault !== void 0) body["is_default"] = params.isDefault;
701
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
702
+ const resp = await harvestFetch("/tasks", { method: "POST", body: JSON.stringify(body) });
703
+ const t = await assertOk(resp, "Create task");
704
+ return textResult(`Task created: **${t.name}** (id: ${t.id})`);
705
+ }
706
+ );
707
+ server.tool(
708
+ "update-task",
709
+ "Update an existing task.",
710
+ {
711
+ taskId: z.number().describe("Task ID"),
712
+ name: z.string().optional().describe("New name"),
713
+ billableByDefault: z.boolean().optional().describe("Billable by default"),
714
+ defaultHourlyRate: z.number().optional().describe("Default hourly rate"),
715
+ isDefault: z.boolean().optional().describe("Auto-add to new projects"),
716
+ isActive: z.boolean().optional().describe("Active status")
717
+ },
718
+ async (params) => {
719
+ const body = {};
720
+ if (params.name) body["name"] = params.name;
721
+ if (params.billableByDefault !== void 0) body["billable_by_default"] = params.billableByDefault;
722
+ if (params.defaultHourlyRate !== void 0) body["default_hourly_rate"] = params.defaultHourlyRate;
723
+ if (params.isDefault !== void 0) body["is_default"] = params.isDefault;
724
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
725
+ const resp = await harvestFetch(`/tasks/${params.taskId}`, { method: "PATCH", body: JSON.stringify(body) });
726
+ const t = await assertOk(resp, "Update task");
727
+ return textResult(`Task updated: **${t.name}** (id: ${t.id})`);
728
+ }
729
+ );
730
+ server.tool(
731
+ "delete-task",
732
+ "Delete a task by ID.",
733
+ { taskId: z.number().describe("Task ID") },
734
+ async ({ taskId }) => {
735
+ const resp = await harvestFetch(`/tasks/${taskId}`, { method: "DELETE" });
736
+ if (!resp.ok) {
737
+ const b = await resp.text();
738
+ throw new Error(`Delete failed (${resp.status}): ${b}`);
739
+ }
740
+ return textResult(`Task ${taskId} deleted.`);
741
+ }
742
+ );
743
+ server.tool(
744
+ "get-client",
745
+ "Retrieve a single client by ID.",
746
+ { clientId: z.number().describe("Client ID") },
747
+ async ({ clientId }) => {
748
+ const resp = await harvestFetch(`/clients/${clientId}`);
749
+ const c = await assertOk(resp, "Get client");
750
+ const address = c.address ? `
751
+ Address: ${c.address}` : "";
752
+ return textResult(`**${c.name}**
753
+ Currency: ${c.currency}
754
+ Active: ${c.is_active}${address}
755
+ ID: ${c.id}`);
756
+ }
757
+ );
758
+ server.tool(
759
+ "create-client",
760
+ "Create a new client.",
761
+ {
762
+ name: z.string().describe("Client name"),
763
+ isActive: z.boolean().optional().describe("Active status"),
764
+ address: z.string().optional().describe("Client address"),
765
+ currency: z.string().optional().describe("Currency code (e.g. EUR, USD)")
766
+ },
767
+ async (params) => {
768
+ const body = { name: params.name };
769
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
770
+ if (params.address) body["address"] = params.address;
771
+ if (params.currency) body["currency"] = params.currency;
772
+ const resp = await harvestFetch("/clients", { method: "POST", body: JSON.stringify(body) });
773
+ const c = await assertOk(resp, "Create client");
774
+ return textResult(`Client created: **${c.name}** (id: ${c.id})`);
775
+ }
776
+ );
777
+ server.tool(
778
+ "update-client",
779
+ "Update an existing client.",
780
+ {
781
+ clientId: z.number().describe("Client ID"),
782
+ name: z.string().optional().describe("New name"),
783
+ isActive: z.boolean().optional().describe("Active status"),
784
+ address: z.string().optional().describe("Address"),
785
+ currency: z.string().optional().describe("Currency code")
786
+ },
787
+ async (params) => {
788
+ const body = {};
789
+ if (params.name) body["name"] = params.name;
790
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
791
+ if (params.address !== void 0) body["address"] = params.address;
792
+ if (params.currency) body["currency"] = params.currency;
793
+ const resp = await harvestFetch(`/clients/${params.clientId}`, { method: "PATCH", body: JSON.stringify(body) });
794
+ const c = await assertOk(resp, "Update client");
795
+ return textResult(`Client updated: **${c.name}** (id: ${c.id})`);
796
+ }
797
+ );
798
+ server.tool(
799
+ "delete-client",
800
+ "Delete a client by ID.",
801
+ { clientId: z.number().describe("Client ID") },
802
+ async ({ clientId }) => {
803
+ const resp = await harvestFetch(`/clients/${clientId}`, { method: "DELETE" });
804
+ if (!resp.ok) {
805
+ const b = await resp.text();
806
+ throw new Error(`Delete failed (${resp.status}): ${b}`);
807
+ }
808
+ return textResult(`Client ${clientId} deleted.`);
809
+ }
810
+ );
811
+ server.tool(
812
+ "get-user",
813
+ "Retrieve a single user by ID.",
814
+ { userId: z.number().describe("User ID") },
815
+ async ({ userId }) => {
816
+ const resp = await harvestFetch(`/users/${userId}`);
817
+ const u = await assertOk(resp, "Get user");
818
+ const capacity = (u.weekly_capacity / 3600).toFixed(0);
819
+ return textResult(
820
+ `**${u.first_name} ${u.last_name}**
821
+ Email: ${u.email}
822
+ Timezone: ${u.timezone}
823
+ Capacity: ${capacity}h/week
824
+ Contractor: ${u.is_contractor}
825
+ Roles: ${u.roles.join(", ") || "none"}
826
+ Access: ${u.access_roles.join(", ") || "member"}
827
+ Active: ${u.is_active}
828
+ ID: ${u.id}`
829
+ );
830
+ }
831
+ );
832
+ server.tool(
833
+ "create-user",
834
+ "Create a new user in the Harvest account.",
835
+ {
836
+ firstName: z.string().describe("First name"),
837
+ lastName: z.string().describe("Last name"),
838
+ email: z.string().describe("Email address"),
839
+ timezone: z.string().optional().describe("Timezone"),
840
+ isContractor: z.boolean().optional().describe("Contractor status"),
841
+ isActive: z.boolean().optional().describe("Active status"),
842
+ weeklyCapacity: z.number().optional().describe("Weekly capacity in seconds"),
843
+ defaultHourlyRate: z.number().optional().describe("Default hourly rate"),
844
+ costRate: z.number().optional().describe("Cost rate"),
845
+ roles: z.array(z.string()).optional().describe("Roles"),
846
+ accessRoles: z.array(z.string()).optional().describe("Access roles (administrator, manager, member)"),
847
+ hasAccessToAllFutureProjects: z.boolean().optional().describe("Access to all future projects")
848
+ },
849
+ async (params) => {
850
+ const body = {
851
+ first_name: params.firstName,
852
+ last_name: params.lastName,
853
+ email: params.email
854
+ };
855
+ if (params.timezone) body["timezone"] = params.timezone;
856
+ if (params.isContractor !== void 0) body["is_contractor"] = params.isContractor;
857
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
858
+ if (params.weeklyCapacity !== void 0) body["weekly_capacity"] = params.weeklyCapacity;
859
+ if (params.defaultHourlyRate !== void 0) body["default_hourly_rate"] = params.defaultHourlyRate;
860
+ if (params.costRate !== void 0) body["cost_rate"] = params.costRate;
861
+ if (params.roles) body["roles"] = params.roles;
862
+ if (params.accessRoles) body["access_roles"] = params.accessRoles;
863
+ if (params.hasAccessToAllFutureProjects !== void 0)
864
+ body["has_access_to_all_future_projects"] = params.hasAccessToAllFutureProjects;
865
+ const resp = await harvestFetch("/users", { method: "POST", body: JSON.stringify(body) });
866
+ const u = await assertOk(resp, "Create user");
867
+ return textResult(`User created: **${u.first_name} ${u.last_name}** (id: ${u.id})`);
868
+ }
869
+ );
870
+ server.tool(
871
+ "update-user",
872
+ "Update an existing user.",
873
+ {
874
+ userId: z.number().describe("User ID"),
875
+ firstName: z.string().optional().describe("First name"),
876
+ lastName: z.string().optional().describe("Last name"),
877
+ email: z.string().optional().describe("Email address"),
878
+ timezone: z.string().optional().describe("Timezone"),
879
+ isContractor: z.boolean().optional().describe("Contractor status"),
880
+ isActive: z.boolean().optional().describe("Active status (false to archive)"),
881
+ weeklyCapacity: z.number().optional().describe("Weekly capacity in seconds"),
882
+ roles: z.array(z.string()).optional().describe("Roles"),
883
+ accessRoles: z.array(z.string()).optional().describe("Access roles")
884
+ },
885
+ async (params) => {
886
+ const body = {};
887
+ if (params.firstName) body["first_name"] = params.firstName;
888
+ if (params.lastName) body["last_name"] = params.lastName;
889
+ if (params.email) body["email"] = params.email;
890
+ if (params.timezone) body["timezone"] = params.timezone;
891
+ if (params.isContractor !== void 0) body["is_contractor"] = params.isContractor;
892
+ if (params.isActive !== void 0) body["is_active"] = params.isActive;
893
+ if (params.weeklyCapacity !== void 0) body["weekly_capacity"] = params.weeklyCapacity;
894
+ if (params.roles) body["roles"] = params.roles;
895
+ if (params.accessRoles) body["access_roles"] = params.accessRoles;
896
+ const resp = await harvestFetch(`/users/${params.userId}`, { method: "PATCH", body: JSON.stringify(body) });
897
+ const u = await assertOk(resp, "Update user");
898
+ return textResult(`User updated: **${u.first_name} ${u.last_name}** (id: ${u.id})`);
899
+ }
900
+ );
901
+ server.tool(
902
+ "delete-user",
903
+ "Delete a user by ID (only if no time entries or expenses).",
904
+ { userId: z.number().describe("User ID") },
905
+ async ({ userId }) => {
906
+ const resp = await harvestFetch(`/users/${userId}`, { method: "DELETE" });
907
+ if (!resp.ok) {
908
+ const b = await resp.text();
909
+ throw new Error(`Delete failed (${resp.status}): ${b}`);
910
+ }
911
+ return textResult(`User ${userId} deleted.`);
912
+ }
913
+ );
914
+ server.tool(
915
+ "get-company",
916
+ "Get company information and settings.",
917
+ {},
918
+ async () => {
919
+ const resp = await harvestFetch("/company");
920
+ const c = await assertOk(resp, "Get company");
921
+ const capacity = (c.weekly_capacity / 3600).toFixed(0);
922
+ return textResult(
923
+ `**${c.name}**
924
+ Domain: ${c.full_domain}
925
+ Plan: ${c.plan_type}
926
+ Week starts: ${c.week_start_day}
927
+ Time format: ${c.time_format} (${c.clock})
928
+ Currency: ${c.currency_code_display}
929
+ Weekly capacity: ${capacity}h`
930
+ );
931
+ }
932
+ );
933
+ async function main() {
934
+ const transport = new StdioServerTransport();
935
+ await server.connect(transport);
936
+ }
937
+ main().catch((err) => {
938
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}
939
+ `);
940
+ process.exit(1);
941
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@mnicole-dev/harvest-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for the Harvest time tracking API (v2)",
5
+ "type": "module",
6
+ "bin": {
7
+ "harvest-mcp-server": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup src/index.ts --format esm --out-dir dist --clean",
15
+ "dev": "tsx src/index.ts"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "harvest",
20
+ "time-tracking",
21
+ "timesheet",
22
+ "model-context-protocol"
23
+ ],
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "@modelcontextprotocol/sdk": "^1.12.1",
27
+ "zod": "^4.3.6"
28
+ },
29
+ "devDependencies": {
30
+ "tsup": "^8.4.0",
31
+ "tsx": "^4.19.0",
32
+ "typescript": "^5.8.0"
33
+ }
34
+ }