mcp-intervals 1.0.2 → 1.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Edu Calvo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -5,7 +5,22 @@
5
5
 
6
6
  MCP server for [Intervals](https://www.myintervals.com/) task management. Lets Claude read and update tasks, add notes, and browse projects and milestones directly from your Intervals account.
7
7
 
8
- ## Setup
8
+ ## Quick Start
9
+
10
+ Run the interactive installer:
11
+
12
+ ```bash
13
+ npx mcp-intervals init
14
+ ```
15
+
16
+ This will:
17
+ 1. Detect installed MCP clients (Claude Code, Claude Desktop, Cursor, Windsurf)
18
+ 2. Let you select which clients to configure
19
+ 3. Prompt for your Intervals API token
20
+ 4. Validate the token with your Intervals account
21
+ 5. Save the configuration automatically
22
+
23
+ ## Manual Setup
9
24
 
10
25
  ### 1. Get your Intervals API token
11
26
 
@@ -93,21 +108,24 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
93
108
 
94
109
  ## Available Tools
95
110
 
96
- | Tool | Description |
97
- | ---------------- | ----------------------------------------------------------------- |
98
- | `get_task` | Get task details by local ID or Intervals URL |
99
- | `update_task` | Update task status, assignee, priority, title, due date, or owner |
100
- | `add_task_note` | Add a comment/note to a task (supports HTML) |
101
- | `get_task_notes` | Retrieve all comments/notes on a task |
102
- | `get_project` | Get project details (name, client, dates, budget) |
103
- | `get_milestone` | Get milestone details (title, due date, progress) |
111
+ | Tool | Description |
112
+ | ------------------ | ---------------------------------------------------------------------------- |
113
+ | `get_task` | Get task details by local ID or Intervals URL |
114
+ | `update_task` | Update task status, assignee, priority, title, description, due date, owner |
115
+ | `add_task_note` | Add a comment/note to a task (supports HTML) |
116
+ | `get_task_notes` | Retrieve all comments/notes on a task |
117
+ | `add_time_entry` | Add a time entry to a task (billable/unbillable with work type) |
118
+ | `get_time_entries` | Retrieve time entries (filter by task, date range) |
119
+ | `get_project` | Get project details (name, client, dates, budget) |
120
+ | `get_milestone` | Get milestone details (title, due date, progress) |
104
121
 
105
122
  ## Resources
106
123
 
107
- | Resource | URI | Description |
108
- | --------------- | ------------------------ | --------------------------------------------------- |
109
- | Task Statuses | `intervals://statuses` | List of all status IDs for use with `update_task` |
110
- | Task Priorities | `intervals://priorities` | List of all priority IDs for use with `update_task` |
124
+ | Resource | URI | Description |
125
+ | --------------- | ------------------------ | ----------------------------------------------------- |
126
+ | Task Statuses | `intervals://statuses` | List of all status IDs for use with `update_task` |
127
+ | Task Priorities | `intervals://priorities` | List of all priority IDs for use with `update_task` |
128
+ | Work Types | `intervals://worktypes` | List of all work type IDs for use with time entries |
111
129
 
112
130
  ## Example Usage
113
131
 
@@ -115,8 +133,12 @@ Once installed, you can ask Claude things like:
115
133
 
116
134
  - "Get the details of task 1234"
117
135
  - "Update task 1234 status to closed"
136
+ - "Update the description of task 1234 to explain the new requirements"
118
137
  - "Add a note to task 1234 saying the fix has been deployed"
119
138
  - "Show me all notes on task 1234"
139
+ - "Log 2 hours of billable time on task 1234 for today"
140
+ - "Add 30 minutes of unbillable time to task 1234 for code review"
141
+ - "Show me all time entries for task 1234"
120
142
  - "What are the details of project 5?"
121
143
 
122
144
  ## License
@@ -0,0 +1,6 @@
1
+ export interface ValidationResult {
2
+ valid: boolean;
3
+ workspace?: string;
4
+ error?: string;
5
+ }
6
+ export declare function validateToken(token: string): Promise<ValidationResult>;
@@ -0,0 +1,28 @@
1
+ export async function validateToken(token) {
2
+ try {
3
+ const authHeader = "Basic " + Buffer.from(`${token}:X`).toString("base64");
4
+ const response = await fetch("https://api.myintervals.com/me/", {
5
+ headers: {
6
+ Authorization: authHeader,
7
+ Accept: "application/json",
8
+ },
9
+ });
10
+ if (response.ok) {
11
+ const data = (await response.json());
12
+ return {
13
+ valid: true,
14
+ workspace: data.me?.company || "Unknown workspace",
15
+ };
16
+ }
17
+ if (response.status === 401) {
18
+ return { valid: false, error: "Invalid API token" };
19
+ }
20
+ return { valid: false, error: `API error: ${response.status}` };
21
+ }
22
+ catch (error) {
23
+ if (error instanceof Error && error.message.includes("fetch")) {
24
+ return { valid: false, error: "Network error - could not reach Intervals API" };
25
+ }
26
+ return { valid: false, error: String(error) };
27
+ }
28
+ }
@@ -0,0 +1,21 @@
1
+ export interface McpClient {
2
+ id: string;
3
+ name: string;
4
+ configPath: string;
5
+ detected: boolean;
6
+ }
7
+ interface McpConfig {
8
+ mcpServers?: Record<string, McpServerConfig>;
9
+ [key: string]: unknown;
10
+ }
11
+ interface McpServerConfig {
12
+ command: string;
13
+ args: string[];
14
+ env?: Record<string, string>;
15
+ }
16
+ export declare function detectClients(): McpClient[];
17
+ export declare function readConfig(configPath: string): McpConfig;
18
+ export declare function writeConfig(configPath: string, config: McpConfig): void;
19
+ export declare function configureClient(configPath: string, token: string): void;
20
+ export declare function hasExistingConfig(configPath: string): boolean;
21
+ export {};
@@ -0,0 +1,136 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as os from "os";
4
+ function getClientPaths() {
5
+ const platform = os.platform();
6
+ const home = os.homedir();
7
+ const clients = [];
8
+ // Claude Code (global)
9
+ clients.push({
10
+ id: "claude-code-global",
11
+ name: "Claude Code (global)",
12
+ path: path.join(home, ".claude.json"),
13
+ });
14
+ // Claude Code (project)
15
+ clients.push({
16
+ id: "claude-code-project",
17
+ name: "Claude Code (project)",
18
+ path: path.join(process.cwd(), ".mcp.json"),
19
+ });
20
+ // Claude Desktop
21
+ if (platform === "darwin") {
22
+ clients.push({
23
+ id: "claude-desktop",
24
+ name: "Claude Desktop",
25
+ path: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
26
+ });
27
+ }
28
+ else if (platform === "win32") {
29
+ clients.push({
30
+ id: "claude-desktop",
31
+ name: "Claude Desktop",
32
+ path: path.join(process.env.APPDATA || "", "Claude", "claude_desktop_config.json"),
33
+ });
34
+ }
35
+ // Cursor
36
+ if (platform === "darwin" || platform === "linux") {
37
+ clients.push({
38
+ id: "cursor",
39
+ name: "Cursor",
40
+ path: path.join(home, ".cursor", "mcp.json"),
41
+ });
42
+ }
43
+ else if (platform === "win32") {
44
+ clients.push({
45
+ id: "cursor",
46
+ name: "Cursor",
47
+ path: path.join(home, ".cursor", "mcp.json"),
48
+ });
49
+ }
50
+ // Windsurf
51
+ if (platform === "darwin" || platform === "linux") {
52
+ clients.push({
53
+ id: "windsurf",
54
+ name: "Windsurf",
55
+ path: path.join(home, ".codeium", "windsurf", "mcp_config.json"),
56
+ });
57
+ }
58
+ else if (platform === "win32") {
59
+ clients.push({
60
+ id: "windsurf",
61
+ name: "Windsurf",
62
+ path: path.join(process.env.APPDATA || "", "Codeium", "windsurf", "mcp_config.json"),
63
+ });
64
+ }
65
+ return clients;
66
+ }
67
+ export function detectClients() {
68
+ const clientPaths = getClientPaths();
69
+ return clientPaths.map((client) => {
70
+ let detected = false;
71
+ // Check if file exists OR if parent directory exists (we can create the file)
72
+ if (fs.existsSync(client.path)) {
73
+ detected = true;
74
+ }
75
+ else {
76
+ const parentDir = path.dirname(client.path);
77
+ if (fs.existsSync(parentDir)) {
78
+ detected = true;
79
+ }
80
+ }
81
+ return {
82
+ id: client.id,
83
+ name: client.name,
84
+ configPath: client.path,
85
+ detected,
86
+ };
87
+ });
88
+ }
89
+ export function readConfig(configPath) {
90
+ if (!fs.existsSync(configPath)) {
91
+ return {};
92
+ }
93
+ try {
94
+ const content = fs.readFileSync(configPath, "utf-8");
95
+ return JSON.parse(content);
96
+ }
97
+ catch {
98
+ throw new Error(`Invalid JSON in ${configPath}`);
99
+ }
100
+ }
101
+ export function writeConfig(configPath, config) {
102
+ const dir = path.dirname(configPath);
103
+ // Create directory if it doesn't exist
104
+ if (!fs.existsSync(dir)) {
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ }
107
+ // Create backup if file exists
108
+ if (fs.existsSync(configPath)) {
109
+ const backupPath = configPath + ".bak";
110
+ fs.copyFileSync(configPath, backupPath);
111
+ }
112
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
113
+ }
114
+ export function configureClient(configPath, token) {
115
+ const config = readConfig(configPath);
116
+ if (!config.mcpServers) {
117
+ config.mcpServers = {};
118
+ }
119
+ config.mcpServers.intervals = {
120
+ command: "npx",
121
+ args: ["-y", "mcp-intervals"],
122
+ env: {
123
+ INTERVALS_API_TOKEN: token,
124
+ },
125
+ };
126
+ writeConfig(configPath, config);
127
+ }
128
+ export function hasExistingConfig(configPath) {
129
+ try {
130
+ const config = readConfig(configPath);
131
+ return config.mcpServers?.intervals !== undefined;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { detectClients, configureClient, hasExistingConfig } from "./clients.js";
5
+ import { validateToken } from "./api.js";
6
+ import { searchMultiselect, cancelSymbol } from "./prompts/search-multiselect.js";
7
+ // Logo ASCII art with gradient grays (256-color)
8
+ const LOGO_LINES = [
9
+ "██╗███╗ ██╗████████╗███████╗██████╗ ██╗ ██╗ █████╗ ██╗ ███████╗",
10
+ "██║████╗ ██║╚══██╔══╝██╔════╝██╔══██╗██║ ██║██╔══██╗██║ ██╔════╝",
11
+ "██║██╔██╗ ██║ ██║ █████╗ ██████╔╝██║ ██║███████║██║ ███████╗",
12
+ "██║██║╚██╗██║ ██║ ██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══██║██║ ╚════██║",
13
+ "██║██║ ╚████║ ██║ ███████╗██║ ██║ ╚████╔╝ ██║ ██║███████╗███████║",
14
+ "╚═╝╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚══════╝╚══════╝",
15
+ ];
16
+ // 256-color grays for gradient effect
17
+ const GRAYS = [
18
+ "\x1b[38;5;250m", // lighter
19
+ "\x1b[38;5;248m",
20
+ "\x1b[38;5;245m",
21
+ "\x1b[38;5;243m",
22
+ "\x1b[38;5;240m",
23
+ "\x1b[38;5;238m", // darker
24
+ ];
25
+ const RESET = "\x1b[0m";
26
+ function showLogo() {
27
+ console.log();
28
+ LOGO_LINES.forEach((line, i) => {
29
+ console.log(`${GRAYS[i]}${line}${RESET}`);
30
+ });
31
+ }
32
+ function shortenPath(fullPath) {
33
+ const home = process.env.HOME || process.env.USERPROFILE || "";
34
+ if (home && fullPath.startsWith(home)) {
35
+ return "~" + fullPath.slice(home.length);
36
+ }
37
+ return fullPath;
38
+ }
39
+ async function main() {
40
+ showLogo();
41
+ console.log();
42
+ p.intro(pc.bgCyan(pc.black(" intervals ")));
43
+ const spinner = p.spinner();
44
+ // Detect clients
45
+ spinner.start("Detecting MCP clients...");
46
+ const clients = detectClients();
47
+ const detectedClients = clients.filter((c) => c.detected);
48
+ const notFoundClients = clients.filter((c) => !c.detected);
49
+ spinner.stop(`${detectedClients.length} clients found`);
50
+ // Show detection results
51
+ console.log();
52
+ p.log.message(pc.bold("Found installed clients:"));
53
+ for (const client of detectedClients) {
54
+ p.log.message(` ${pc.green("✓")} ${client.name}`);
55
+ }
56
+ for (const client of notFoundClients) {
57
+ p.log.message(` ${pc.dim("✗")} ${pc.dim(client.name + " (not found)")}`);
58
+ }
59
+ if (detectedClients.length === 0) {
60
+ p.cancel("No MCP clients detected. Install Claude Code, Claude Desktop, Cursor, or Windsurf first.");
61
+ process.exit(1);
62
+ }
63
+ // Select clients with search multiselect
64
+ const clientChoices = detectedClients.map((client) => {
65
+ const hasExisting = hasExistingConfig(client.configPath);
66
+ return {
67
+ value: client.id,
68
+ label: client.name,
69
+ hint: shortenPath(client.configPath) + (hasExisting ? " - will overwrite" : ""),
70
+ };
71
+ });
72
+ // Pre-select clients that don't have existing config
73
+ const initialSelected = detectedClients
74
+ .filter((c) => !hasExistingConfig(c.configPath))
75
+ .map((c) => c.id);
76
+ console.log();
77
+ const selectedIds = await searchMultiselect({
78
+ message: "Which clients do you want to configure?",
79
+ items: clientChoices,
80
+ initialSelected: initialSelected.length > 0 ? initialSelected : [detectedClients[0]?.id].filter(Boolean),
81
+ required: true,
82
+ });
83
+ if (selectedIds === cancelSymbol || (Array.isArray(selectedIds) && selectedIds.length === 0)) {
84
+ p.cancel("Installation cancelled");
85
+ process.exit(0);
86
+ }
87
+ const selectedClients = detectedClients.filter((c) => Array.isArray(selectedIds) && selectedIds.includes(c.id));
88
+ // Get API token
89
+ console.log();
90
+ const token = await p.password({
91
+ message: "Enter your Intervals API token:",
92
+ });
93
+ if (p.isCancel(token) || !token || token.trim() === "") {
94
+ p.cancel("No token provided");
95
+ process.exit(1);
96
+ }
97
+ // Validate token
98
+ spinner.start("Validating token...");
99
+ const validation = await validateToken(token.trim());
100
+ if (!validation.valid) {
101
+ spinner.stop(pc.red("Token validation failed"));
102
+ p.log.error(validation.error || "Invalid token");
103
+ p.log.message(pc.dim(" Find your API token at: https://[subdomain].myintervals.com/account/api/"));
104
+ console.log();
105
+ const continueAnyway = await p.confirm({
106
+ message: "Save configuration anyway (without validation)?",
107
+ initialValue: false,
108
+ });
109
+ if (p.isCancel(continueAnyway) || !continueAnyway) {
110
+ p.cancel("Installation cancelled");
111
+ process.exit(1);
112
+ }
113
+ }
114
+ else {
115
+ spinner.stop(`Token valid! Connected to "${validation.workspace}"`);
116
+ }
117
+ // Show summary and confirm
118
+ console.log();
119
+ const summaryLines = [];
120
+ for (const client of selectedClients) {
121
+ summaryLines.push(`${pc.cyan(client.name)} ${pc.dim(shortenPath(client.configPath))}`);
122
+ }
123
+ p.note(summaryLines.join("\n"), "Will configure");
124
+ const confirmed = await p.confirm({
125
+ message: "Proceed with configuration?",
126
+ initialValue: true,
127
+ });
128
+ if (p.isCancel(confirmed) || !confirmed) {
129
+ p.cancel("Installation cancelled");
130
+ process.exit(0);
131
+ }
132
+ // Configure each client
133
+ spinner.start("Saving configuration...");
134
+ const results = [];
135
+ for (const client of selectedClients) {
136
+ try {
137
+ configureClient(client.configPath, token.trim());
138
+ results.push({ client: client.name, success: true });
139
+ }
140
+ catch (error) {
141
+ results.push({
142
+ client: client.name,
143
+ success: false,
144
+ error: error instanceof Error ? error.message : String(error)
145
+ });
146
+ }
147
+ }
148
+ const successful = results.filter((r) => r.success);
149
+ const failed = results.filter((r) => !r.success);
150
+ spinner.stop("Configuration complete");
151
+ // Show results
152
+ console.log();
153
+ if (successful.length > 0) {
154
+ const resultLines = successful.map((r) => {
155
+ const client = selectedClients.find((c) => c.name === r.client);
156
+ return `${pc.green("✓")} ${r.client} ${pc.dim(client ? shortenPath(client.configPath) : "")}`;
157
+ });
158
+ p.note(resultLines.join("\n"), pc.green(`Configured ${successful.length} client${successful.length !== 1 ? "s" : ""}`));
159
+ }
160
+ if (failed.length > 0) {
161
+ console.log();
162
+ p.log.error(pc.red(`Failed to configure ${failed.length} client${failed.length !== 1 ? "s" : ""}:`));
163
+ for (const r of failed) {
164
+ p.log.message(` ${pc.red("✗")} ${r.client}: ${pc.dim(r.error)}`);
165
+ }
166
+ }
167
+ console.log();
168
+ p.outro(pc.green("Done! Restart your MCP clients to use mcp-intervals."));
169
+ }
170
+ main().catch((error) => {
171
+ p.log.error(error instanceof Error ? error.message : "Unknown error");
172
+ process.exit(1);
173
+ });
@@ -0,0 +1,14 @@
1
+ export interface SearchItem<T> {
2
+ value: T;
3
+ label: string;
4
+ hint?: string;
5
+ }
6
+ export interface SearchMultiselectOptions<T> {
7
+ message: string;
8
+ items: SearchItem<T>[];
9
+ maxVisible?: number;
10
+ initialSelected?: T[];
11
+ required?: boolean;
12
+ }
13
+ export declare const cancelSymbol: unique symbol;
14
+ export declare function searchMultiselect<T>(options: SearchMultiselectOptions<T>): Promise<T[] | symbol>;
@@ -0,0 +1,190 @@
1
+ import * as readline from "readline";
2
+ import { Writable } from "stream";
3
+ import pc from "picocolors";
4
+ // Silent writable stream to prevent readline from echoing input
5
+ const silentOutput = new Writable({
6
+ write(_chunk, _encoding, callback) {
7
+ callback();
8
+ },
9
+ });
10
+ const S_STEP_ACTIVE = pc.green("◆");
11
+ const S_STEP_CANCEL = pc.red("■");
12
+ const S_STEP_SUBMIT = pc.green("◇");
13
+ const S_RADIO_ACTIVE = pc.green("●");
14
+ const S_RADIO_INACTIVE = pc.dim("○");
15
+ const S_BAR = pc.dim("│");
16
+ export const cancelSymbol = Symbol("cancel");
17
+ export async function searchMultiselect(options) {
18
+ const { message, items, maxVisible = 8, initialSelected = [], required = false } = options;
19
+ return new Promise((resolve) => {
20
+ const rl = readline.createInterface({
21
+ input: process.stdin,
22
+ output: silentOutput,
23
+ terminal: false,
24
+ });
25
+ if (process.stdin.isTTY) {
26
+ process.stdin.setRawMode(true);
27
+ }
28
+ readline.emitKeypressEvents(process.stdin, rl);
29
+ let query = "";
30
+ let cursor = 0;
31
+ const selected = new Set(initialSelected);
32
+ let lastRenderHeight = 0;
33
+ const filter = (item, q) => {
34
+ if (!q)
35
+ return true;
36
+ const lowerQ = q.toLowerCase();
37
+ return (item.label.toLowerCase().includes(lowerQ) ||
38
+ String(item.value).toLowerCase().includes(lowerQ));
39
+ };
40
+ const getFiltered = () => {
41
+ return items.filter((item) => filter(item, query));
42
+ };
43
+ const clearRender = () => {
44
+ if (lastRenderHeight > 0) {
45
+ process.stdout.write(`\x1b[${lastRenderHeight}A`);
46
+ for (let i = 0; i < lastRenderHeight; i++) {
47
+ process.stdout.write("\x1b[2K\x1b[1B");
48
+ }
49
+ process.stdout.write(`\x1b[${lastRenderHeight}A`);
50
+ }
51
+ };
52
+ const render = (state = "active") => {
53
+ clearRender();
54
+ const lines = [];
55
+ const filtered = getFiltered();
56
+ const icon = state === "active" ? S_STEP_ACTIVE : state === "cancel" ? S_STEP_CANCEL : S_STEP_SUBMIT;
57
+ lines.push(`${icon} ${pc.bold(message)}`);
58
+ if (state === "active") {
59
+ const searchLine = `${S_BAR} ${pc.dim("Search:")} ${query}${pc.inverse(" ")}`;
60
+ lines.push(searchLine);
61
+ lines.push(`${S_BAR} ${pc.dim("↑↓ move, space select, enter confirm")}`);
62
+ lines.push(`${S_BAR}`);
63
+ const visibleStart = Math.max(0, Math.min(cursor - Math.floor(maxVisible / 2), filtered.length - maxVisible));
64
+ const visibleEnd = Math.min(filtered.length, visibleStart + maxVisible);
65
+ const visibleItems = filtered.slice(visibleStart, visibleEnd);
66
+ if (filtered.length === 0) {
67
+ lines.push(`${S_BAR} ${pc.dim("No matches found")}`);
68
+ }
69
+ else {
70
+ for (let i = 0; i < visibleItems.length; i++) {
71
+ const item = visibleItems[i];
72
+ const actualIndex = visibleStart + i;
73
+ const isSelected = selected.has(item.value);
74
+ const isCursor = actualIndex === cursor;
75
+ const radio = isSelected ? S_RADIO_ACTIVE : S_RADIO_INACTIVE;
76
+ const label = isCursor ? pc.underline(item.label) : item.label;
77
+ const hint = item.hint ? pc.dim(` (${item.hint})`) : "";
78
+ const prefix = isCursor ? pc.cyan("❯") : " ";
79
+ lines.push(`${S_BAR} ${prefix} ${radio} ${label}${hint}`);
80
+ }
81
+ const hiddenBefore = visibleStart;
82
+ const hiddenAfter = filtered.length - visibleEnd;
83
+ if (hiddenBefore > 0 || hiddenAfter > 0) {
84
+ const parts = [];
85
+ if (hiddenBefore > 0)
86
+ parts.push(`↑ ${hiddenBefore} more`);
87
+ if (hiddenAfter > 0)
88
+ parts.push(`↓ ${hiddenAfter} more`);
89
+ lines.push(`${S_BAR} ${pc.dim(parts.join(" "))}`);
90
+ }
91
+ }
92
+ lines.push(`${S_BAR}`);
93
+ if (selected.size === 0) {
94
+ lines.push(`${S_BAR} ${pc.dim("Selected: (none)")}`);
95
+ }
96
+ else {
97
+ const selectedLabels = items
98
+ .filter((item) => selected.has(item.value))
99
+ .map((item) => item.label);
100
+ const summary = selectedLabels.length <= 3
101
+ ? selectedLabels.join(", ")
102
+ : `${selectedLabels.slice(0, 3).join(", ")} +${selectedLabels.length - 3} more`;
103
+ lines.push(`${S_BAR} ${pc.green("Selected:")} ${summary}`);
104
+ }
105
+ lines.push(`${pc.dim("└")}`);
106
+ }
107
+ else if (state === "submit") {
108
+ const selectedLabels = items
109
+ .filter((item) => selected.has(item.value))
110
+ .map((item) => item.label);
111
+ lines.push(`${S_BAR} ${pc.dim(selectedLabels.join(", "))}`);
112
+ }
113
+ else if (state === "cancel") {
114
+ lines.push(`${S_BAR} ${pc.strikethrough(pc.dim("Cancelled"))}`);
115
+ }
116
+ process.stdout.write(lines.join("\n") + "\n");
117
+ lastRenderHeight = lines.length;
118
+ };
119
+ const cleanup = () => {
120
+ process.stdin.removeListener("keypress", keypressHandler);
121
+ if (process.stdin.isTTY) {
122
+ process.stdin.setRawMode(false);
123
+ }
124
+ rl.close();
125
+ };
126
+ const submit = () => {
127
+ if (required && selected.size === 0) {
128
+ return;
129
+ }
130
+ render("submit");
131
+ cleanup();
132
+ resolve(Array.from(selected));
133
+ };
134
+ const cancel = () => {
135
+ render("cancel");
136
+ cleanup();
137
+ resolve(cancelSymbol);
138
+ };
139
+ const keypressHandler = (_str, key) => {
140
+ if (!key)
141
+ return;
142
+ const filtered = getFiltered();
143
+ if (key.name === "return") {
144
+ submit();
145
+ return;
146
+ }
147
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
148
+ cancel();
149
+ return;
150
+ }
151
+ if (key.name === "up") {
152
+ cursor = Math.max(0, cursor - 1);
153
+ render();
154
+ return;
155
+ }
156
+ if (key.name === "down") {
157
+ cursor = Math.min(filtered.length - 1, cursor + 1);
158
+ render();
159
+ return;
160
+ }
161
+ if (key.name === "space") {
162
+ const item = filtered[cursor];
163
+ if (item) {
164
+ if (selected.has(item.value)) {
165
+ selected.delete(item.value);
166
+ }
167
+ else {
168
+ selected.add(item.value);
169
+ }
170
+ }
171
+ render();
172
+ return;
173
+ }
174
+ if (key.name === "backspace") {
175
+ query = query.slice(0, -1);
176
+ cursor = 0;
177
+ render();
178
+ return;
179
+ }
180
+ if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
181
+ query += key.sequence;
182
+ cursor = 0;
183
+ render();
184
+ return;
185
+ }
186
+ };
187
+ process.stdin.on("keypress", keypressHandler);
188
+ render();
189
+ });
190
+ }
package/dist/client.d.ts CHANGED
@@ -18,4 +18,20 @@ export declare class IntervalsClient {
18
18
  getMilestone(id: number): Promise<Record<string, unknown>>;
19
19
  getTaskStatuses(): Promise<Record<string, unknown>>;
20
20
  getTaskPriorities(): Promise<Record<string, unknown>>;
21
+ getWorkTypes(): Promise<Record<string, unknown>>;
22
+ addTimeEntry(fields: {
23
+ taskid: number;
24
+ worktypeid: number;
25
+ date: string;
26
+ time: number;
27
+ billable: boolean;
28
+ description?: string;
29
+ }): Promise<Record<string, unknown>>;
30
+ getTimeEntries(params?: {
31
+ taskid?: number;
32
+ personid?: number;
33
+ datebegin?: string;
34
+ dateend?: string;
35
+ }): Promise<Record<string, unknown>>;
36
+ getMe(): Promise<Record<string, unknown>>;
21
37
  }
package/dist/client.js CHANGED
@@ -82,4 +82,37 @@ export class IntervalsClient {
82
82
  const data = await this.request(`/taskpriority/`);
83
83
  return data;
84
84
  }
85
+ // --- Work Types ---
86
+ async getWorkTypes() {
87
+ const data = await this.request(`/worktype/`);
88
+ return data;
89
+ }
90
+ // --- Time Entries ---
91
+ async addTimeEntry(fields) {
92
+ // First get the current user's person ID
93
+ const me = await this.getMe();
94
+ const personid = me.personid;
95
+ const data = await this.request(`/time/`, {
96
+ method: "POST",
97
+ body: {
98
+ taskid: fields.taskid,
99
+ worktypeid: fields.worktypeid,
100
+ personid,
101
+ date: fields.date,
102
+ time: fields.time,
103
+ billable: fields.billable,
104
+ ...(fields.description && { description: fields.description }),
105
+ },
106
+ });
107
+ return data;
108
+ }
109
+ async getTimeEntries(params = {}) {
110
+ const data = await this.request(`/time/`, { params: params });
111
+ return data;
112
+ }
113
+ // --- Me (current user) ---
114
+ async getMe() {
115
+ const data = await this.request(`/me/`);
116
+ return data.me;
117
+ }
85
118
  }
package/dist/index.js CHANGED
@@ -1,26 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { IntervalsClient } from "./client.js";
5
- import { registerTools } from "./tools.js";
6
- import { registerResources } from "./resources.js";
7
- const API_TOKEN = process.env.INTERVALS_API_TOKEN;
8
- if (!API_TOKEN) {
9
- console.error("Error: INTERVALS_API_TOKEN environment variable is required.");
10
- process.exit(1);
2
+ // Handle CLI subcommands
3
+ if (process.argv[2] === "init") {
4
+ import("./cli/init.js");
11
5
  }
12
- const client = new IntervalsClient(API_TOKEN);
13
- const server = new McpServer({
14
- name: "mcp-intervals",
15
- version: "1.0.0",
16
- });
17
- registerTools(server, client);
18
- registerResources(server, client);
19
- async function main() {
6
+ else {
7
+ // MCP Server mode
8
+ startServer();
9
+ }
10
+ async function startServer() {
11
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
12
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
13
+ const { IntervalsClient } = await import("./client.js");
14
+ const { registerTools } = await import("./tools.js");
15
+ const { registerResources } = await import("./resources.js");
16
+ const API_TOKEN = process.env.INTERVALS_API_TOKEN;
17
+ if (!API_TOKEN) {
18
+ console.error("Error: INTERVALS_API_TOKEN environment variable is required.");
19
+ process.exit(1);
20
+ }
21
+ const client = new IntervalsClient(API_TOKEN);
22
+ const server = new McpServer({
23
+ name: "mcp-intervals",
24
+ version: "1.0.0",
25
+ });
26
+ registerTools(server, client);
27
+ registerResources(server, client);
20
28
  const transport = new StdioServerTransport();
21
29
  await server.connect(transport);
22
30
  }
23
- main().catch((error) => {
24
- console.error("Fatal error:", error);
25
- process.exit(1);
26
- });
31
+ export {};
package/dist/resources.js CHANGED
@@ -31,4 +31,20 @@ export function registerResources(server, client) {
31
31
  ],
32
32
  };
33
33
  });
34
+ // --- Work Types ---
35
+ server.resource("work-types", "intervals://worktypes", {
36
+ description: "List of all work types with their IDs. Use these IDs when adding a time entry.",
37
+ mimeType: "application/json",
38
+ }, async () => {
39
+ const data = await client.getWorkTypes();
40
+ return {
41
+ contents: [
42
+ {
43
+ uri: "intervals://worktypes",
44
+ mimeType: "application/json",
45
+ text: JSON.stringify(data, null, 2),
46
+ },
47
+ ],
48
+ };
49
+ });
34
50
  }
package/dist/tools.js CHANGED
@@ -16,7 +16,7 @@ export function registerTools(server, client) {
16
16
  };
17
17
  });
18
18
  // --- update_task ---
19
- server.tool("update_task", "Update fields on an Intervals task (status, assignee, priority, title, due date, owner).", {
19
+ server.tool("update_task", "Update fields on an Intervals task (status, assignee, priority, title, description, due date, owner).", {
20
20
  taskId: z.number().describe("The local task ID (as shown in the Intervals web UI)"),
21
21
  statusid: z
22
22
  .number()
@@ -31,6 +31,10 @@ export function registerTools(server, client) {
31
31
  .optional()
32
32
  .describe("New priority ID (use intervals://priorities resource for valid IDs)"),
33
33
  title: z.string().optional().describe("New task title"),
34
+ summary: z
35
+ .string()
36
+ .optional()
37
+ .describe("New task description/summary (HTML is accepted)"),
34
38
  datedue: z
35
39
  .string()
36
40
  .optional()
@@ -124,4 +128,71 @@ export function registerTools(server, client) {
124
128
  ],
125
129
  };
126
130
  });
131
+ // --- add_time_entry ---
132
+ 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.", {
133
+ taskId: z
134
+ .number()
135
+ .describe("The local task ID (as shown in the Intervals web UI)"),
136
+ worktypeid: z
137
+ .number()
138
+ .describe("Work type ID (use intervals://worktypes resource for valid IDs)"),
139
+ date: z
140
+ .string()
141
+ .describe("Date of the time entry in YYYY-MM-DD format"),
142
+ time: z
143
+ .number()
144
+ .describe("Time worked in decimal hours (e.g., 1.5 for 1 hour 30 minutes)"),
145
+ billable: z
146
+ .boolean()
147
+ .describe("Whether the time is billable (true) or unbillable (false)"),
148
+ description: z
149
+ .string()
150
+ .optional()
151
+ .describe("Optional description of work performed"),
152
+ }, async ({ taskId, worktypeid, date, time, billable, description }) => {
153
+ const internalId = await client.resolveTaskId(taskId);
154
+ const data = await client.addTimeEntry({
155
+ taskid: internalId,
156
+ worktypeid,
157
+ date,
158
+ time,
159
+ billable,
160
+ description,
161
+ });
162
+ return {
163
+ content: [
164
+ { type: "text", text: JSON.stringify(data, null, 2) },
165
+ ],
166
+ };
167
+ });
168
+ // --- get_time_entries ---
169
+ server.tool("get_time_entries", "Retrieve time entries from Intervals. Can filter by task, person, or date range.", {
170
+ taskId: z
171
+ .number()
172
+ .optional()
173
+ .describe("Filter by local task ID (as shown in the Intervals web UI)"),
174
+ datebegin: z
175
+ .string()
176
+ .optional()
177
+ .describe("Start date filter in YYYY-MM-DD format"),
178
+ dateend: z
179
+ .string()
180
+ .optional()
181
+ .describe("End date filter in YYYY-MM-DD format"),
182
+ }, async ({ taskId, datebegin, dateend }) => {
183
+ let internalTaskId;
184
+ if (taskId) {
185
+ internalTaskId = await client.resolveTaskId(taskId);
186
+ }
187
+ const data = await client.getTimeEntries({
188
+ taskid: internalTaskId,
189
+ datebegin,
190
+ dateend,
191
+ });
192
+ return {
193
+ content: [
194
+ { type: "text", text: JSON.stringify(data, null, 2) },
195
+ ],
196
+ };
197
+ });
127
198
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "mcp-intervals",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for Intervals task management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
- "mcp-intervals": "dist/index.js"
8
+ "mcp-intervals": "dist/index.js",
9
+ "mcp-intervals-init": "dist/cli/init.js"
9
10
  },
10
11
  "files": [
11
12
  "dist"
@@ -28,7 +29,9 @@
28
29
  "url": "https://github.com/educlopez/mcp-intervals.git"
29
30
  },
30
31
  "dependencies": {
32
+ "@clack/prompts": "^1.0.0",
31
33
  "@modelcontextprotocol/sdk": "^1.12.1",
34
+ "picocolors": "^1.1.1",
32
35
  "zod": "^3.24.2"
33
36
  },
34
37
  "devDependencies": {