mcp-intervals 1.4.1 → 1.5.1

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.
package/dist/cli/api.js CHANGED
@@ -11,7 +11,7 @@ export async function validateToken(token) {
11
11
  const data = (await response.json());
12
12
  return {
13
13
  valid: true,
14
- workspace: data.me?.company || "Unknown workspace",
14
+ workspace: data.me?.[0]?.company || "Unknown workspace",
15
15
  };
16
16
  }
17
17
  if (response.status === 401) {
package/dist/cli/init.js CHANGED
File without changes
package/dist/client.js CHANGED
@@ -27,7 +27,7 @@ export class IntervalsClient {
27
27
  const response = await fetch(url, fetchOptions);
28
28
  if (!response.ok) {
29
29
  const text = await response.text();
30
- throw new Error(`Intervals API error ${response.status}: ${text}`);
30
+ throw new Error(`Intervals API error ${response.status}: ${text.slice(0, 500)}`);
31
31
  }
32
32
  return response.json();
33
33
  }
@@ -127,6 +127,9 @@ export class IntervalsClient {
127
127
  // First get the current user's person ID
128
128
  const me = await this.getMe();
129
129
  const personid = me.personid;
130
+ if (typeof personid !== "number") {
131
+ throw new Error("Could not determine current user's person ID from Intervals /me/ response.");
132
+ }
130
133
  const data = await this.request(`/time/`, {
131
134
  method: "POST",
132
135
  body: {
@@ -149,6 +152,6 @@ export class IntervalsClient {
149
152
  async getMe() {
150
153
  const data = await this.request(`/me/`);
151
154
  // Return the first user object with personid from the top-level response
152
- return { ...data.me[0], personid: data.personid };
155
+ return { ...(data.me?.[0] ?? {}), personid: data.personid };
153
156
  }
154
157
  }
package/dist/index.js CHANGED
@@ -13,6 +13,10 @@ async function startServer() {
13
13
  const { IntervalsClient } = await import("./client.js");
14
14
  const { registerTools } = await import("./tools.js");
15
15
  const { registerResources } = await import("./resources.js");
16
+ const { createRequire } = await import("node:module");
17
+ // Read version from package.json so it never drifts from the published one.
18
+ const require = createRequire(import.meta.url);
19
+ const { version } = require("../package.json");
16
20
  const API_TOKEN = process.env.INTERVALS_API_TOKEN;
17
21
  if (!API_TOKEN) {
18
22
  console.error("Error: INTERVALS_API_TOKEN environment variable is required.");
@@ -21,7 +25,7 @@ async function startServer() {
21
25
  const client = new IntervalsClient(API_TOKEN);
22
26
  const server = new McpServer({
23
27
  name: "mcp-intervals",
24
- version: "1.0.0",
28
+ version,
25
29
  });
26
30
  registerTools(server, client);
27
31
  registerResources(server, client);
package/dist/tools.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { PDFParse } from "pdf-parse";
2
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
3
8
  export function registerTools(server, client) {
4
9
  // --- get_task ---
5
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).", {
@@ -241,7 +246,7 @@ export function registerTools(server, client) {
241
246
  };
242
247
  });
243
248
  // --- download_document ---
244
- 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 other file types, returns the document metadata.", {
249
+ 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.", {
245
250
  documentId: z
246
251
  .number()
247
252
  .describe("The numeric document ID from Intervals"),
@@ -251,6 +256,17 @@ export function registerTools(server, client) {
251
256
  const docField = docData.document;
252
257
  let document;
253
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
+ }
254
270
  document = docField[0];
255
271
  }
256
272
  else if (docField && typeof docField === "object") {
@@ -261,10 +277,35 @@ export function registerTools(server, client) {
261
277
  }
262
278
  const filename = document.filename || document.title || "unknown";
263
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
+ }
264
294
  const imageMimeType = getMimeTypeFromFilename(filename);
265
295
  if (imageMimeType && isImageMimeType(imageMimeType)) {
266
296
  try {
267
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
+ }
268
309
  const base64 = Buffer.from(buffer).toString("base64");
269
310
  const sizeMB = buffer.byteLength / (1024 * 1024);
270
311
  const content = [
@@ -301,7 +342,35 @@ export function registerTools(server, client) {
301
342
  };
302
343
  }
303
344
  }
304
- // Non-image file: return metadata only
345
+ // PDF: extract text content
346
+ if (filename.toLowerCase().endsWith(".pdf")) {
347
+ try {
348
+ const { buffer } = await client.downloadDocument(documentId);
349
+ const sizeMB = buffer.byteLength / (1024 * 1024);
350
+ const pdf = new PDFParse({ data: new Uint8Array(buffer) });
351
+ const textResult = await pdf.getText();
352
+ return {
353
+ content: [
354
+ {
355
+ type: "text",
356
+ text: `Document: ${title} (${filename}, ${sizeMB.toFixed(2)} MB, ${textResult.total} pages)\n\n${textResult.text}`,
357
+ },
358
+ ],
359
+ };
360
+ }
361
+ catch (err) {
362
+ return {
363
+ content: [
364
+ {
365
+ type: "text",
366
+ text: `Document "${title}" (${filename}) is a PDF but text extraction failed: ${err instanceof Error ? err.message : String(err)}`,
367
+ },
368
+ ],
369
+ isError: true,
370
+ };
371
+ }
372
+ }
373
+ // Other file types: return metadata only
305
374
  return {
306
375
  content: [
307
376
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-intervals",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "MCP server for Intervals task management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,6 +31,7 @@
31
31
  "dependencies": {
32
32
  "@clack/prompts": "^1.0.0",
33
33
  "@modelcontextprotocol/sdk": "^1.12.1",
34
+ "pdf-parse": "^2.4.5",
34
35
  "picocolors": "^1.1.1",
35
36
  "zod": "^3.24.2"
36
37
  },