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,430 @@
1
+ import { z } from "zod";
2
+ import { PDFParse } from "pdf-parse";
3
+ import { idOrList, limitParam, notificationShape, offsetParam, splitNotifications, } from "./helpers.js";
4
+ import { parseTaskIdFromUrl, getMimeTypeFromFilename, isImageMimeType, } from "../utils.js";
5
+ // Cap on inline-rendered downloads (images/PDFs). Base64 inflates payload
6
+ // ~33% and the result is fed into the model context, so guard against
7
+ // flooding memory/context with oversized attachments.
8
+ const MAX_INLINE_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
9
+ const NOTIFICATION_NOTE = " Emails are not sent unless send_notifications is true.";
10
+ export function registerCoreTools(server, client) {
11
+ // --- get_task ---
12
+ 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).", {
13
+ task: z
14
+ .string()
15
+ .describe("Intervals task URL or numeric local task ID (as shown in the web UI)"),
16
+ }, async ({ task }) => {
17
+ const localId = parseTaskIdFromUrl(task);
18
+ const data = await client.getTaskByLocalId(localId);
19
+ const internalId = Number(data.id);
20
+ const content = [{ type: "text", text: JSON.stringify(data, null, 2) }];
21
+ // Fetch associated documents
22
+ try {
23
+ const docsData = await client.getDocuments({ taskid: internalId });
24
+ const documents = docsData.document;
25
+ if (Array.isArray(documents) && documents.length > 0) {
26
+ const docSummary = documents.map((doc) => ({
27
+ id: doc.id,
28
+ filename: doc.filename,
29
+ title: doc.title,
30
+ dateCreated: doc.datecreated,
31
+ size: doc.filesize,
32
+ }));
33
+ content.push({
34
+ type: "text",
35
+ text: `\n--- Attached Documents (${documents.length}) ---\n` +
36
+ JSON.stringify(docSummary, null, 2) +
37
+ `\n\nUse the download_document tool with the document ID to view image attachments.`,
38
+ });
39
+ }
40
+ }
41
+ catch {
42
+ // Silently ignore document fetch failures
43
+ }
44
+ return { content };
45
+ });
46
+ // --- update_task ---
47
+ server.tool("update_task", "Update fields on an Intervals task (status, assignee, priority, title, description, dates, owner, project, module, milestone, followers, estimate)." +
48
+ NOTIFICATION_NOTE, {
49
+ taskId: z.number().describe("The local task ID (as shown in the Intervals web UI)"),
50
+ statusid: z
51
+ .number()
52
+ .optional()
53
+ .describe("New status ID (use intervals://statuses resource for valid IDs)"),
54
+ assigneeid: idOrList
55
+ .optional()
56
+ .describe("New assignee person ID, or comma-delimited list of IDs"),
57
+ priorityid: z
58
+ .number()
59
+ .optional()
60
+ .describe("New priority ID (use intervals://priorities resource for valid IDs)"),
61
+ title: z.string().optional().describe("New task title"),
62
+ summary: z
63
+ .string()
64
+ .optional()
65
+ .describe('New task description/summary (HTML is accepted). IMPORTANT: For checklists, use Intervals-specific HTML — do NOT use emojis (✅, ☐) or markdown checkboxes. Use: <ul class="checklist"><li class="checklist-checked">Done item</li><li>Pending item</li></ul>. Checked items get class="checklist-checked", unchecked items have no class.'),
66
+ datedue: z
67
+ .string()
68
+ .optional()
69
+ .describe("New due date in YYYY-MM-DD format"),
70
+ ownerid: idOrList
71
+ .optional()
72
+ .describe("New owner person ID, or comma-delimited list of IDs"),
73
+ projectid: z.number().optional().describe("New project ID"),
74
+ moduleid: z.number().optional().describe("New module ID"),
75
+ milestoneid: z.number().optional().describe("New milestone ID"),
76
+ followerid: idOrList
77
+ .optional()
78
+ .describe("Follower person ID, or comma-delimited list of IDs"),
79
+ estimate: z.number().optional().describe("Estimated hours"),
80
+ dateopen: z.string().optional().describe("Open date in YYYY-MM-DD format"),
81
+ dateclosed: z
82
+ .string()
83
+ .optional()
84
+ .describe("Closed date in YYYY-MM-DD format (set to close the task)"),
85
+ ...notificationShape,
86
+ }, async ({ taskId, ...args }) => {
87
+ const { fields, notify } = splitNotifications(args);
88
+ // Remove undefined fields
89
+ const updateData = {};
90
+ for (const [key, value] of Object.entries(fields)) {
91
+ if (value !== undefined) {
92
+ updateData[key] = value;
93
+ }
94
+ }
95
+ if (Object.keys(updateData).length === 0) {
96
+ return {
97
+ content: [
98
+ {
99
+ type: "text",
100
+ text: "No fields provided to update.",
101
+ },
102
+ ],
103
+ };
104
+ }
105
+ const internalId = await client.resolveTaskId(taskId);
106
+ const data = await client.updateTask(internalId, updateData, notify);
107
+ return {
108
+ content: [
109
+ { type: "text", text: JSON.stringify(data, null, 2) },
110
+ ],
111
+ };
112
+ });
113
+ // --- add_task_note ---
114
+ server.tool("add_task_note", "Add a comment/note to an Intervals task." + NOTIFICATION_NOTE, {
115
+ taskId: z.number().describe("The local task ID (as shown in the Intervals web UI)"),
116
+ note: z
117
+ .string()
118
+ .describe('The note content (HTML is accepted). IMPORTANT: For checklists, use Intervals-specific HTML — do NOT use emojis (✅, ☐) or markdown checkboxes. Use: <ul class="checklist"><li class="checklist-checked">Done item</li><li>Pending item</li></ul>. Checked items get class="checklist-checked", unchecked items have no class.'),
119
+ isPublic: z
120
+ .boolean()
121
+ .default(true)
122
+ .describe("Whether the note is visible to executive users (defaults to true)"),
123
+ ...notificationShape,
124
+ }, async ({ taskId, note, isPublic, ...flags }) => {
125
+ const internalId = await client.resolveTaskId(taskId);
126
+ const data = await client.addTaskNote(internalId, note, isPublic, splitNotifications(flags).notify);
127
+ return {
128
+ content: [
129
+ { type: "text", text: JSON.stringify(data, null, 2) },
130
+ ],
131
+ };
132
+ });
133
+ // --- get_task_notes ---
134
+ server.tool("get_task_notes", "Retrieve comments/notes on an Intervals task.", {
135
+ taskId: z
136
+ .number()
137
+ .describe("The local task ID (as shown in the Intervals web UI)"),
138
+ authorid: z.number().optional().describe("Filter by author person ID"),
139
+ public: z.boolean().optional().describe("Filter by public notes"),
140
+ datebegin: z.string().optional().describe("Start date filter in YYYY-MM-DD format"),
141
+ dateend: z.string().optional().describe("End date filter in YYYY-MM-DD format"),
142
+ limit: limitParam,
143
+ offset: offsetParam,
144
+ }, async ({ taskId, ...filters }) => {
145
+ const internalId = await client.resolveTaskId(taskId);
146
+ const data = await client.getTaskNotes(internalId, filters);
147
+ return {
148
+ content: [
149
+ { type: "text", text: JSON.stringify(data, null, 2) },
150
+ ],
151
+ };
152
+ });
153
+ // --- get_project ---
154
+ server.tool("get_project", "Retrieve details of an Intervals project (name, client, dates, budget, description).", {
155
+ projectId: z
156
+ .number()
157
+ .describe("The numeric project ID"),
158
+ }, async ({ projectId }) => {
159
+ const data = await client.getProject(projectId);
160
+ return {
161
+ content: [
162
+ { type: "text", text: JSON.stringify(data, null, 2) },
163
+ ],
164
+ };
165
+ });
166
+ // --- get_milestone ---
167
+ server.tool("get_milestone", "Retrieve details of an Intervals milestone (title, due date, progress, description).", {
168
+ milestoneId: z
169
+ .number()
170
+ .describe("The numeric milestone ID"),
171
+ }, async ({ milestoneId }) => {
172
+ const data = await client.getMilestone(milestoneId);
173
+ return {
174
+ content: [
175
+ { type: "text", text: JSON.stringify(data, null, 2) },
176
+ ],
177
+ };
178
+ });
179
+ // --- add_time_entry ---
180
+ server.tool("add_time_entry", "Add a time entry to an Intervals task. Records time worked as billable or unbillable with a specific work type. Defaults to the current user." + NOTIFICATION_NOTE, {
181
+ taskId: z
182
+ .number()
183
+ .describe("The local task ID (as shown in the Intervals web UI)"),
184
+ worktypeid: z
185
+ .number()
186
+ .describe("Work type ID (use intervals://worktypes resource for valid IDs)"),
187
+ date: z
188
+ .string()
189
+ .describe("Date of the time entry in YYYY-MM-DD format"),
190
+ time: z
191
+ .number()
192
+ .describe("Time worked in decimal hours (e.g., 1.5 for 1 hour 30 minutes)"),
193
+ billable: z
194
+ .boolean()
195
+ .describe("Whether the time is billable (true) or unbillable (false)"),
196
+ description: z
197
+ .string()
198
+ .optional()
199
+ .describe("Optional description of work performed"),
200
+ personid: z
201
+ .number()
202
+ .optional()
203
+ .describe("Person ID to log the time for (defaults to the current user)"),
204
+ moduleid: z.number().optional().describe("Optional module ID"),
205
+ ...notificationShape,
206
+ }, async ({ taskId, worktypeid, date, time, billable, description, personid, moduleid, ...flags }) => {
207
+ const internalId = await client.resolveTaskId(taskId);
208
+ const data = await client.addTimeEntry({
209
+ taskid: internalId,
210
+ worktypeid,
211
+ date,
212
+ time,
213
+ billable,
214
+ description,
215
+ personid,
216
+ moduleid,
217
+ }, splitNotifications(flags).notify);
218
+ return {
219
+ content: [
220
+ { type: "text", text: JSON.stringify(data, null, 2) },
221
+ ],
222
+ };
223
+ });
224
+ // --- get_time_entries ---
225
+ server.tool("get_time_entries", "Retrieve time entries from Intervals. Can filter by task, person, project, billable status, or date range.", {
226
+ taskId: z
227
+ .number()
228
+ .optional()
229
+ .describe("Filter by local task ID (as shown in the Intervals web UI)"),
230
+ datebegin: z
231
+ .string()
232
+ .optional()
233
+ .describe("Start date filter in YYYY-MM-DD format"),
234
+ dateend: z
235
+ .string()
236
+ .optional()
237
+ .describe("End date filter in YYYY-MM-DD format"),
238
+ personid: z.number().optional().describe("Filter by person ID"),
239
+ projectid: z.number().optional().describe("Filter by project ID"),
240
+ billable: z.boolean().optional().describe("Filter by billable status"),
241
+ limit: limitParam,
242
+ offset: offsetParam,
243
+ }, async ({ taskId, ...filters }) => {
244
+ let internalTaskId;
245
+ if (taskId) {
246
+ internalTaskId = await client.resolveTaskId(taskId);
247
+ }
248
+ const data = await client.getTimeEntries({
249
+ taskid: internalTaskId,
250
+ ...filters,
251
+ });
252
+ return {
253
+ content: [
254
+ { type: "text", text: JSON.stringify(data, null, 2) },
255
+ ],
256
+ };
257
+ });
258
+ // --- get_documents ---
259
+ server.tool("get_documents", "List documents/attachments from Intervals. Can filter by task, project, or person. Returns document metadata (filename, title, size, dates) but not file contents. Use download_document to retrieve file contents.", {
260
+ taskId: z
261
+ .number()
262
+ .optional()
263
+ .describe("Filter by local task ID (as shown in the Intervals web UI)"),
264
+ projectId: z.number().optional().describe("Filter by project ID"),
265
+ personId: z.number().optional().describe("Filter by person ID"),
266
+ milestoneid: z.number().optional().describe("Filter by milestone ID"),
267
+ clientid: z.number().optional().describe("Filter by client ID"),
268
+ search: z.string().optional().describe("Search text"),
269
+ public: z.boolean().optional().describe("Filter by public documents"),
270
+ limit: limitParam,
271
+ offset: offsetParam,
272
+ }, async ({ taskId, projectId, personId, ...filters }) => {
273
+ let internalTaskId;
274
+ if (taskId) {
275
+ internalTaskId = await client.resolveTaskId(taskId);
276
+ }
277
+ const data = await client.getDocuments({
278
+ taskid: internalTaskId,
279
+ projectid: projectId,
280
+ personid: personId,
281
+ ...filters,
282
+ });
283
+ return {
284
+ content: [
285
+ { type: "text", text: JSON.stringify(data, null, 2) },
286
+ ],
287
+ };
288
+ });
289
+ // --- download_document ---
290
+ server.tool("download_document", "Download a document from Intervals. For images (png, jpg, gif, webp, bmp), returns the image inline so it can be viewed directly. For PDFs, extracts and returns the text content. For other file types, returns the document metadata.", {
291
+ documentId: z
292
+ .number()
293
+ .describe("The numeric document ID from Intervals"),
294
+ }, async ({ documentId }) => {
295
+ // Get document metadata to know the filename
296
+ const docData = await client.getDocument(documentId);
297
+ const docField = docData.document;
298
+ let document;
299
+ if (Array.isArray(docField)) {
300
+ if (docField.length === 0) {
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text",
305
+ text: `No document found with ID ${documentId}.`,
306
+ },
307
+ ],
308
+ isError: true,
309
+ };
310
+ }
311
+ document = docField[0];
312
+ }
313
+ else if (docField && typeof docField === "object") {
314
+ document = docField;
315
+ }
316
+ else {
317
+ document = docData;
318
+ }
319
+ const filename = document.filename || document.title || "unknown";
320
+ const title = document.title || filename;
321
+ // Reject oversized files before downloading them into memory.
322
+ const reportedSize = Number(document.filesize);
323
+ if (Number.isFinite(reportedSize) &&
324
+ reportedSize > MAX_INLINE_DOWNLOAD_BYTES) {
325
+ return {
326
+ content: [
327
+ {
328
+ type: "text",
329
+ 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.`,
330
+ },
331
+ ],
332
+ isError: true,
333
+ };
334
+ }
335
+ const imageMimeType = getMimeTypeFromFilename(filename);
336
+ if (imageMimeType && isImageMimeType(imageMimeType)) {
337
+ try {
338
+ const { buffer } = await client.downloadDocument(documentId);
339
+ if (buffer.byteLength > MAX_INLINE_DOWNLOAD_BYTES) {
340
+ return {
341
+ content: [
342
+ {
343
+ type: "text",
344
+ 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.`,
345
+ },
346
+ ],
347
+ isError: true,
348
+ };
349
+ }
350
+ const base64 = Buffer.from(buffer).toString("base64");
351
+ const sizeMB = buffer.byteLength / (1024 * 1024);
352
+ const content = [
353
+ {
354
+ type: "text",
355
+ text: `Document: ${title} (${filename}, ${sizeMB.toFixed(2)} MB)`,
356
+ },
357
+ ];
358
+ if (imageMimeType === "image/svg+xml") {
359
+ const svgText = new TextDecoder().decode(buffer);
360
+ content.push({
361
+ type: "text",
362
+ text: `SVG content:\n${svgText}`,
363
+ });
364
+ }
365
+ else {
366
+ content.push({
367
+ type: "image",
368
+ data: base64,
369
+ mimeType: imageMimeType,
370
+ });
371
+ }
372
+ return { content };
373
+ }
374
+ catch (err) {
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: `Document "${title}" (${filename}) is an image but could not be downloaded: ${err instanceof Error ? err.message : String(err)}`,
380
+ },
381
+ ],
382
+ isError: true,
383
+ };
384
+ }
385
+ }
386
+ // PDF: extract text content
387
+ if (filename.toLowerCase().endsWith(".pdf")) {
388
+ try {
389
+ const { buffer } = await client.downloadDocument(documentId);
390
+ const sizeMB = buffer.byteLength / (1024 * 1024);
391
+ const pdf = new PDFParse({ data: new Uint8Array(buffer) });
392
+ const textResult = await pdf.getText();
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: `Document: ${title} (${filename}, ${sizeMB.toFixed(2)} MB, ${textResult.total} pages)\n\n${textResult.text}`,
398
+ },
399
+ ],
400
+ };
401
+ }
402
+ catch (err) {
403
+ return {
404
+ content: [
405
+ {
406
+ type: "text",
407
+ text: `Document "${title}" (${filename}) is a PDF but text extraction failed: ${err instanceof Error ? err.message : String(err)}`,
408
+ },
409
+ ],
410
+ isError: true,
411
+ };
412
+ }
413
+ }
414
+ // Other file types: return metadata only
415
+ return {
416
+ content: [
417
+ {
418
+ type: "text",
419
+ text: JSON.stringify({
420
+ documentId,
421
+ filename,
422
+ title,
423
+ message: "This file type cannot be rendered inline.",
424
+ ...document,
425
+ }, null, 2),
426
+ },
427
+ ],
428
+ };
429
+ });
430
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { IntervalsClient } from "../client.js";
3
+ export declare function registerDocumentTools(server: McpServer, client: IntervalsClient): void;
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ import { registerEndpoints } from "./helpers.js";
3
+ const documentFields = {
4
+ personid: z.number().describe("Person ID of the document owner (use get_people)"),
5
+ title: z.string().describe("Document title (max 255 characters)"),
6
+ notes: z.string().describe("Document body/notes"),
7
+ public: z.boolean().describe("Whether the document is publicly visible"),
8
+ active: z.boolean().describe("Whether the document is active"),
9
+ };
10
+ // get_documents and download_document (binary) live in core.
11
+ const specs = [
12
+ {
13
+ name: "get_document",
14
+ description: "Get a single document's metadata by ID. Use download_document to retrieve file contents.",
15
+ method: "GET",
16
+ path: "/document",
17
+ idParam: "documentId",
18
+ schema: { documentId: z.number().describe("Document ID") },
19
+ },
20
+ {
21
+ name: "create_document",
22
+ description: "Create a document record (title/notes). Note: the Intervals API does not support uploading file attachments.",
23
+ method: "POST",
24
+ path: "/document",
25
+ schema: {
26
+ personid: documentFields.personid,
27
+ title: documentFields.title,
28
+ notes: documentFields.notes.optional(),
29
+ public: documentFields.public.optional(),
30
+ active: documentFields.active.optional(),
31
+ },
32
+ },
33
+ {
34
+ name: "update_document",
35
+ description: "Update a document record. Only provide fields you want to change.",
36
+ method: "PUT",
37
+ path: "/document",
38
+ idParam: "documentId",
39
+ schema: {
40
+ documentId: z.number().describe("Document ID to update"),
41
+ personid: documentFields.personid.optional(),
42
+ title: documentFields.title.optional(),
43
+ notes: documentFields.notes.optional(),
44
+ public: documentFields.public.optional(),
45
+ active: documentFields.active.optional(),
46
+ },
47
+ },
48
+ ];
49
+ export function registerDocumentTools(server, client) {
50
+ registerEndpoints(server, client, specs);
51
+ }
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { IntervalsClient, NotificationOptions } from "../client.js";
4
+ export declare function jsonResult(data: unknown): {
5
+ content: {
6
+ type: "text";
7
+ text: string;
8
+ }[];
9
+ };
10
+ /** Person/owner/assignee style fields accept one ID or a comma-delimited list. */
11
+ export declare const idOrList: z.ZodUnion<[z.ZodNumber, z.ZodString]>;
12
+ export declare const limitParam: z.ZodOptional<z.ZodNumber>;
13
+ export declare const offsetParam: z.ZodOptional<z.ZodNumber>;
14
+ /**
15
+ * Notification controls appended to every write tool (POST/PUT/DELETE).
16
+ * Both default to off: no header is sent, so the API sends no emails.
17
+ */
18
+ export declare const notificationShape: {
19
+ send_notifications: z.ZodOptional<z.ZodBoolean>;
20
+ disable_action_notes: z.ZodOptional<z.ZodBoolean>;
21
+ };
22
+ /** Split the notification flags off validated tool args. */
23
+ export declare function splitNotifications<T extends Record<string, unknown>>(args: T & {
24
+ send_notifications?: boolean;
25
+ disable_action_notes?: boolean;
26
+ }): {
27
+ fields: Omit<T, "send_notifications" | "disable_action_notes">;
28
+ notify: NotificationOptions;
29
+ };
30
+ type Args = Record<string, unknown>;
31
+ export interface EndpointSpec {
32
+ name: string;
33
+ description: string;
34
+ method: "GET" | "POST" | "PUT" | "DELETE";
35
+ /** API collection path without trailing slash, e.g. "/client". */
36
+ path: string;
37
+ /**
38
+ * Name of the schema field holding the record ID. It is placed in the URL
39
+ * (`/client/12/`) and never sent in the body or query string.
40
+ */
41
+ idParam?: string;
42
+ schema: z.ZodRawShape;
43
+ /** Extra body fields always sent (e.g. `{ stop: true }`). */
44
+ fixedBody?: Args;
45
+ /** Pre-process validated args (e.g. resolve a local task ID to the internal one). */
46
+ prepare?: (args: Args, client: IntervalsClient) => Promise<Args>;
47
+ }
48
+ export declare function registerEndpoints(server: McpServer, client: IntervalsClient, specs: EndpointSpec[]): void;
49
+ export {};
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ // --- Shared result + schema helpers ---
3
+ export function jsonResult(data) {
4
+ return {
5
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
6
+ };
7
+ }
8
+ /** Person/owner/assignee style fields accept one ID or a comma-delimited list. */
9
+ export const idOrList = z.union([z.number(), z.string()]);
10
+ export const limitParam = z
11
+ .number()
12
+ .optional()
13
+ .describe("Maximum number of results to return");
14
+ export const offsetParam = z
15
+ .number()
16
+ .optional()
17
+ .describe("Number of results to skip (pagination)");
18
+ /**
19
+ * Notification controls appended to every write tool (POST/PUT/DELETE).
20
+ * Both default to off: no header is sent, so the API sends no emails.
21
+ */
22
+ export const notificationShape = {
23
+ send_notifications: z
24
+ .boolean()
25
+ .optional()
26
+ .describe("Send email notifications to the people involved. Emails are NOT sent unless this is true."),
27
+ disable_action_notes: z
28
+ .boolean()
29
+ .optional()
30
+ .describe("Suppress the automatic action notes Intervals adds to tasks/milestones when they change. Action notes are created unless this is true."),
31
+ };
32
+ /** Split the notification flags off validated tool args. */
33
+ export function splitNotifications(args) {
34
+ const { send_notifications, disable_action_notes, ...fields } = args;
35
+ return {
36
+ fields,
37
+ notify: {
38
+ sendNotifications: send_notifications,
39
+ disableActionNotes: disable_action_notes,
40
+ },
41
+ };
42
+ }
43
+ export function registerEndpoints(server, client, specs) {
44
+ for (const spec of specs) {
45
+ const isWrite = spec.method !== "GET";
46
+ const shape = isWrite
47
+ ? { ...spec.schema, ...notificationShape }
48
+ : spec.schema;
49
+ server.tool(spec.name, spec.description, shape, {
50
+ readOnlyHint: !isWrite,
51
+ destructiveHint: spec.method === "DELETE",
52
+ }, async (rawArgs) => {
53
+ const args = spec.prepare
54
+ ? await spec.prepare(rawArgs, client)
55
+ : rawArgs;
56
+ const { fields: withId, notify } = splitNotifications(args);
57
+ // The record ID goes in the URL only; never leak it into body/query.
58
+ let path = spec.path;
59
+ let fields = withId;
60
+ if (spec.idParam) {
61
+ const { [spec.idParam]: id, ...rest } = withId;
62
+ if (id === undefined || id === null) {
63
+ throw new Error(`${spec.name}: missing required parameter "${spec.idParam}"`);
64
+ }
65
+ path = `${spec.path}/${encodeURIComponent(String(id))}`;
66
+ fields = rest;
67
+ }
68
+ switch (spec.method) {
69
+ case "GET":
70
+ return jsonResult(await client.get(path, fields));
71
+ case "DELETE":
72
+ return jsonResult(await client.delete(path, fields, notify));
73
+ case "POST":
74
+ return jsonResult(await client.post(path, { ...fields, ...spec.fixedBody }, notify));
75
+ case "PUT":
76
+ return jsonResult(await client.put(path, { ...fields, ...spec.fixedBody }, notify));
77
+ }
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { IntervalsClient } from "../client.js";
3
+ export declare function registerTools(server: McpServer, client: IntervalsClient): void;
@@ -0,0 +1,16 @@
1
+ import { registerCoreTools } from "./core.js";
2
+ import { registerTaskTools } from "./tasks.js";
3
+ import { registerTimeTools } from "./time.js";
4
+ import { registerProjectTools } from "./projects.js";
5
+ import { registerPeopleTools } from "./people.js";
6
+ import { registerBillingTools } from "./billing.js";
7
+ import { registerDocumentTools } from "./documents.js";
8
+ export function registerTools(server, client) {
9
+ registerCoreTools(server, client);
10
+ registerTaskTools(server, client);
11
+ registerTimeTools(server, client);
12
+ registerProjectTools(server, client);
13
+ registerPeopleTools(server, client);
14
+ registerBillingTools(server, client);
15
+ registerDocumentTools(server, client);
16
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { IntervalsClient } from "../client.js";
3
+ export declare function registerPeopleTools(server: McpServer, client: IntervalsClient): void;