mcp-intervals 1.5.0 → 1.6.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.
@@ -0,0 +1,171 @@
1
+ import { z } from "zod";
2
+ import { idOrList, limitParam, offsetParam, registerEndpoints, } from "./helpers.js";
3
+ const localTaskId = z
4
+ .number()
5
+ .describe("The local task ID (as shown in the Intervals web UI)");
6
+ // Resolve the local task ID shown in the UI to the internal API ID.
7
+ const resolveLocalTask = (field, target) => async (args, client) => {
8
+ const { [field]: localId, ...rest } = args;
9
+ // localId must be required in the tool schema; without it the request would
10
+ // hit a URL with no task id.
11
+ if (localId === undefined)
12
+ return rest;
13
+ return { ...rest, [target]: await client.resolveTaskId(Number(localId)) };
14
+ };
15
+ const taskFields = {
16
+ projectid: z.number().describe("Project ID the task belongs to (use get_projects)"),
17
+ moduleid: z.number().describe("Module ID the task belongs to (use get_project_modules)"),
18
+ title: z.string().describe("Task title"),
19
+ statusid: z.number().describe("Status ID (use get_task_statuses)"),
20
+ priorityid: z.number().describe("Priority ID (use get_task_priorities)"),
21
+ ownerid: idOrList.describe("Person ID of the task owner, or comma-delimited list of IDs (e.g. '22,334') (use get_people)"),
22
+ dateopen: z.string().describe("Date the task was opened (YYYY-MM-DD)"),
23
+ milestoneid: z.number().describe("Milestone ID to associate the task with (use get_milestones)"),
24
+ summary: z.string().describe("Task description/summary (HTML accepted)"),
25
+ assigneeid: idOrList.describe("Person ID to assign the task to, or comma-delimited list of IDs"),
26
+ followerid: idOrList.describe("Person ID to add as a follower, or comma-delimited list of IDs"),
27
+ estimate: z.number().describe("Estimated hours to complete"),
28
+ datedue: z.string().describe("Due date (YYYY-MM-DD)"),
29
+ dateclosed: z.string().describe("Date closed (YYYY-MM-DD)"),
30
+ };
31
+ const filterFields = {
32
+ personid: z.number().describe("Person ID to save the filter for"),
33
+ title: z.string().describe("Filter name (max 255 characters)"),
34
+ params: z.string().describe("Serialized filter parameters string"),
35
+ date: z.string().describe("Filter date"),
36
+ };
37
+ const specs = [
38
+ // --- Tasks ---
39
+ {
40
+ name: "get_tasks",
41
+ description: "List tasks with optional filters. Filter by project, module, milestone, assignee, status, or priority. Each ID filter accepts one ID or a comma-delimited list. To filter open vs. closed tasks use statusid (IDs from get_task_statuses).",
42
+ method: "GET",
43
+ path: "/task",
44
+ schema: {
45
+ projectid: idOrList.optional().describe("Project ID(s)"),
46
+ moduleid: idOrList.optional().describe("Module ID(s)"),
47
+ milestoneid: idOrList.optional().describe("Milestone ID(s)"),
48
+ assigneeid: idOrList.optional().describe("Assignee person ID(s)"),
49
+ statusid: idOrList.optional().describe("Status ID(s) (use get_task_statuses)"),
50
+ priorityid: idOrList.optional().describe("Priority ID(s) (use get_task_priorities)"),
51
+ limit: limitParam,
52
+ offset: offsetParam,
53
+ },
54
+ },
55
+ {
56
+ name: "create_task",
57
+ description: "Create a new task in a project. Requires project, module, title, status, priority, owner, and open date. Emails are not sent unless send_notifications is true.",
58
+ method: "POST",
59
+ path: "/task",
60
+ schema: {
61
+ projectid: taskFields.projectid,
62
+ moduleid: taskFields.moduleid,
63
+ title: taskFields.title,
64
+ statusid: taskFields.statusid,
65
+ priorityid: taskFields.priorityid,
66
+ ownerid: taskFields.ownerid,
67
+ dateopen: taskFields.dateopen,
68
+ milestoneid: taskFields.milestoneid.optional(),
69
+ summary: taskFields.summary.optional(),
70
+ assigneeid: taskFields.assigneeid.optional(),
71
+ followerid: taskFields.followerid.optional(),
72
+ estimate: taskFields.estimate.optional(),
73
+ datedue: taskFields.datedue.optional(),
74
+ dateclosed: taskFields.dateclosed.optional(),
75
+ },
76
+ },
77
+ {
78
+ name: "delete_task",
79
+ description: "Delete a task by its local ID (as shown in the Intervals web UI). Permanently deletes; cannot be undone.",
80
+ method: "DELETE",
81
+ path: "/task",
82
+ idParam: "id",
83
+ schema: { taskId: localTaskId },
84
+ prepare: resolveLocalTask("taskId", "id"),
85
+ },
86
+ // --- Task notes (get_task_notes / add_task_note live in core) ---
87
+ {
88
+ name: "update_task_note",
89
+ description: "Update a task note by note ID (Administrator only). Only provide fields you want to change.",
90
+ method: "PUT",
91
+ path: "/tasknote",
92
+ idParam: "taskNoteId",
93
+ schema: {
94
+ taskNoteId: z.number().describe("Task note ID to update"),
95
+ note: z.string().optional().describe("Note body (HTML supported)"),
96
+ public: z.boolean().optional().describe("Whether the note is publicly visible"),
97
+ },
98
+ },
99
+ {
100
+ name: "delete_task_note",
101
+ description: "Delete a task note by note ID (Administrator only). Permanently deletes; cannot be undone.",
102
+ method: "DELETE",
103
+ path: "/tasknote",
104
+ idParam: "taskNoteId",
105
+ schema: { taskNoteId: z.number().describe("Task note ID to delete") },
106
+ },
107
+ // --- Saved task list filters ---
108
+ {
109
+ name: "get_task_filters",
110
+ description: "List saved task list filters.",
111
+ method: "GET",
112
+ path: "/tasklistfilter",
113
+ schema: { limit: limitParam, offset: offsetParam },
114
+ },
115
+ {
116
+ name: "get_task_filter",
117
+ description: "Get a single saved task list filter by ID.",
118
+ method: "GET",
119
+ path: "/tasklistfilter",
120
+ idParam: "taskFilterId",
121
+ schema: { taskFilterId: z.number().describe("Task filter ID") },
122
+ },
123
+ {
124
+ name: "create_task_filter",
125
+ description: "Save a new task list filter.",
126
+ method: "POST",
127
+ path: "/tasklistfilter",
128
+ schema: {
129
+ ...filterFields,
130
+ subfilterid: z.number().optional().describe("Sub-filter ID"),
131
+ shared: z.boolean().optional().describe("Whether the filter is shared"),
132
+ scheduled: z.boolean().optional().describe("Whether the filter is scheduled"),
133
+ },
134
+ },
135
+ {
136
+ name: "update_task_filter",
137
+ description: "Update a saved task list filter. Only provide fields you want to change.",
138
+ method: "PUT",
139
+ path: "/tasklistfilter",
140
+ idParam: "taskFilterId",
141
+ schema: {
142
+ taskFilterId: z.number().describe("Task filter ID to update"),
143
+ personid: filterFields.personid.optional(),
144
+ title: filterFields.title.optional(),
145
+ params: filterFields.params.optional(),
146
+ date: filterFields.date.optional(),
147
+ subfilterid: z.number().optional().describe("Sub-filter ID"),
148
+ shared: z.boolean().optional().describe("Whether the filter is shared"),
149
+ scheduled: z.boolean().optional().describe("Whether the filter is scheduled"),
150
+ },
151
+ },
152
+ // --- Lookups ---
153
+ {
154
+ name: "get_task_statuses",
155
+ description: "List all task statuses with their IDs. Use these IDs when creating, updating or filtering tasks.",
156
+ method: "GET",
157
+ path: "/taskstatus",
158
+ schema: {},
159
+ },
160
+ {
161
+ name: "get_task_priorities",
162
+ description: "List all task priorities with their IDs. Use these IDs when creating, updating or filtering tasks.",
163
+ method: "GET",
164
+ path: "/taskpriority",
165
+ schema: {},
166
+ },
167
+ ];
168
+ export function registerTaskTools(server, client) {
169
+ registerEndpoints(server, client, specs);
170
+ }
171
+ export { localTaskId, resolveLocalTask };
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { IntervalsClient } from "../client.js";
3
+ export declare function registerTimeTools(server: McpServer, client: IntervalsClient): void;
@@ -0,0 +1,109 @@
1
+ import { z } from "zod";
2
+ import { registerEndpoints } from "./helpers.js";
3
+ import { localTaskId, resolveLocalTask } from "./tasks.js";
4
+ const timeFields = {
5
+ worktypeid: z.number().describe("Work type ID (use get_worktypes)"),
6
+ personid: z.number().describe("Person ID (use get_me or get_people)"),
7
+ date: z.string().describe("Date of the work (YYYY-MM-DD)"),
8
+ time: z.number().describe("Hours worked in decimal (e.g. 1.5 for 1h 30m)"),
9
+ billable: z.boolean().describe("Whether the time is billable"),
10
+ projectid: z.number().describe("Project ID to associate the time with"),
11
+ moduleid: z.number().describe("Module ID to associate the time with (use get_project_modules)"),
12
+ description: z.string().describe("Description of the work performed"),
13
+ };
14
+ const specs = [
15
+ // --- Timers ---
16
+ {
17
+ name: "get_timers",
18
+ description: "List all active timers for the currently authenticated user.",
19
+ method: "GET",
20
+ path: "/timer",
21
+ schema: {},
22
+ },
23
+ {
24
+ name: "start_timer",
25
+ description: "Start a timer for a person, optionally on a task. Use get_me to get the current user's person ID.",
26
+ method: "POST",
27
+ path: "/timer",
28
+ schema: {
29
+ personid: z.number().describe("Person ID to start the timer for (use get_me or get_people)"),
30
+ taskId: localTaskId.optional(),
31
+ },
32
+ prepare: resolveLocalTask("taskId", "taskid"),
33
+ },
34
+ {
35
+ name: "stop_timer",
36
+ description: "Stop a running timer by its timer ID (use get_timers).",
37
+ method: "PUT",
38
+ path: "/timer",
39
+ idParam: "timerId",
40
+ schema: { timerId: z.number().describe("Timer ID to stop") },
41
+ fixedBody: { stop: true },
42
+ },
43
+ // --- Time entries (add_time_entry / get_time_entries live in core) ---
44
+ {
45
+ name: "update_time_entry",
46
+ description: "Update an existing time entry. Only provide fields you want to change.",
47
+ method: "PUT",
48
+ path: "/time",
49
+ idParam: "timeEntryId",
50
+ schema: {
51
+ timeEntryId: z.number().describe("Time entry ID to update"),
52
+ worktypeid: timeFields.worktypeid.optional(),
53
+ personid: timeFields.personid.optional(),
54
+ date: timeFields.date.optional(),
55
+ time: timeFields.time.optional(),
56
+ billable: timeFields.billable.optional(),
57
+ projectid: timeFields.projectid.optional(),
58
+ moduleid: timeFields.moduleid.optional(),
59
+ taskId: localTaskId.optional(),
60
+ description: timeFields.description.optional(),
61
+ },
62
+ prepare: resolveLocalTask("taskId", "taskid"),
63
+ },
64
+ {
65
+ name: "delete_time_entry",
66
+ description: "Delete a time entry by ID. Permanently deletes; cannot be undone.",
67
+ method: "DELETE",
68
+ path: "/time",
69
+ idParam: "timeEntryId",
70
+ schema: { timeEntryId: z.number().describe("Time entry ID to delete") },
71
+ },
72
+ // --- Work types ---
73
+ {
74
+ name: "get_worktypes",
75
+ description: "List all available work types with their IDs. Use these IDs when adding time entries.",
76
+ method: "GET",
77
+ path: "/worktype",
78
+ schema: {},
79
+ },
80
+ // --- Expenses ---
81
+ {
82
+ name: "get_expenses",
83
+ description: "List expense entries, optionally filtered by project or date range.",
84
+ method: "GET",
85
+ path: "/expense",
86
+ schema: {
87
+ projectid: z.number().optional().describe("Filter by project ID"),
88
+ datebegin: z.string().optional().describe("Start date (YYYY-MM-DD)"),
89
+ dateend: z.string().optional().describe("End date (YYYY-MM-DD)"),
90
+ },
91
+ },
92
+ {
93
+ name: "create_expense",
94
+ description: "Create an expense entry for a project. Requires project, date, expense amount, and fee amount.",
95
+ method: "POST",
96
+ path: "/expense",
97
+ schema: {
98
+ projectid: z.number().describe("Project ID (use get_projects)"),
99
+ date: z.string().describe("Expense date (YYYY-MM-DD)"),
100
+ expense: z.number().describe("Expense amount"),
101
+ fee: z.number().describe("Fee amount"),
102
+ personid: z.number().optional().describe("Person ID (defaults to the authenticated user)"),
103
+ note: z.string().optional().describe("Expense note (max 255 characters)"),
104
+ },
105
+ },
106
+ ];
107
+ export function registerTimeTools(server, client) {
108
+ registerEndpoints(server, client, specs);
109
+ }
package/dist/tools.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { PDFParse } from "pdf-parse";
3
3
  import { parseTaskIdFromUrl, getMimeTypeFromFilename, isImageMimeType, } from "./utils.js";
4
+ // Cap on inline-rendered downloads (images/PDFs). Base64 inflates payload
5
+ // ~33% and the result is fed into the model context, so guard against
6
+ // flooding memory/context with oversized attachments.
7
+ const MAX_INLINE_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
4
8
  export function registerTools(server, client) {
5
9
  // --- get_task ---
6
10
  server.tool("get_task", "Retrieve full details of an Intervals task. Accepts a task URL (e.g. https://<subdomain>.intervalsonline.com/tasks/view/12345) or a numeric local task ID (the ID shown in the Intervals web UI).", {
@@ -252,6 +256,17 @@ export function registerTools(server, client) {
252
256
  const docField = docData.document;
253
257
  let document;
254
258
  if (Array.isArray(docField)) {
259
+ if (docField.length === 0) {
260
+ return {
261
+ content: [
262
+ {
263
+ type: "text",
264
+ text: `No document found with ID ${documentId}.`,
265
+ },
266
+ ],
267
+ isError: true,
268
+ };
269
+ }
255
270
  document = docField[0];
256
271
  }
257
272
  else if (docField && typeof docField === "object") {
@@ -262,10 +277,35 @@ export function registerTools(server, client) {
262
277
  }
263
278
  const filename = document.filename || document.title || "unknown";
264
279
  const title = document.title || filename;
280
+ // Reject oversized files before downloading them into memory.
281
+ const reportedSize = Number(document.filesize);
282
+ if (Number.isFinite(reportedSize) &&
283
+ reportedSize > MAX_INLINE_DOWNLOAD_BYTES) {
284
+ return {
285
+ content: [
286
+ {
287
+ type: "text",
288
+ text: `Document "${title}" (${filename}) is ${(reportedSize / (1024 * 1024)).toFixed(2)} MB, exceeding the ${MAX_INLINE_DOWNLOAD_BYTES / (1024 * 1024)} MB inline limit. Download it directly from Intervals instead.`,
289
+ },
290
+ ],
291
+ isError: true,
292
+ };
293
+ }
265
294
  const imageMimeType = getMimeTypeFromFilename(filename);
266
295
  if (imageMimeType && isImageMimeType(imageMimeType)) {
267
296
  try {
268
297
  const { buffer } = await client.downloadDocument(documentId);
298
+ if (buffer.byteLength > MAX_INLINE_DOWNLOAD_BYTES) {
299
+ return {
300
+ content: [
301
+ {
302
+ type: "text",
303
+ text: `Document "${title}" (${filename}) is ${(buffer.byteLength / (1024 * 1024)).toFixed(2)} MB, exceeding the ${MAX_INLINE_DOWNLOAD_BYTES / (1024 * 1024)} MB inline limit. Download it directly from Intervals instead.`,
304
+ },
305
+ ],
306
+ isError: true,
307
+ };
308
+ }
269
309
  const base64 = Buffer.from(buffer).toString("base64");
270
310
  const sizeMB = buffer.byteLength / (1024 * 1024);
271
311
  const content = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-intervals",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "MCP server for Intervals task management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",