@idea-base/mcp-server 1.2.0 → 2.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 +5 -5
  2. package/package.json +3 -3
  3. package/src/index.js +364 -68
package/README.md CHANGED
@@ -72,12 +72,12 @@ Ask Claude to manage your projects:
72
72
 
73
73
  | Tool | Description |
74
74
  |------|-------------|
75
- | `list_tasks` | List tasks for a project (filter by status) |
75
+ | `list_tasks` | List tasks for a project (filter by status). Compact rows by default (`verbose:true` for full rows) |
76
76
  | `get_task` | Get task details, acceptance criteria, and time entries |
77
- | `create_task` | Create a task with title, description, estimate |
78
- | `update_task` | Update task details |
79
- | `update_task_status` | Change task status (todo/in_progress/done) |
80
- | `search_tasks` | Search tasks across all projects |
77
+ | `create_task` | Create a task with title, description, estimate, priority, start/due dates, assignee; `parent_task_id` makes it a subtask |
78
+ | `update_task` | Update task details, priority, start/due dates, assignee; `blocked_by: [ids]` replaces its dependencies |
79
+ | `update_task_status` | Change task status (todo/in_progress/blocked/done) |
80
+ | `search_tasks` | Search tasks across all projects, ranked title-first then description then recency. Compact rows by default; filter by `project_id`/`product_id`/`customer_id`/`status`; `verbose:true` for full rows |
81
81
  | `quick_log` | Create + complete + log time in one step |
82
82
 
83
83
  ### Time Tracking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idea-base/mcp-server",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "MCP Server for IDEA Base - AI-powered project management. Integrates with Claude Code, Cursor, and Windsurf.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -37,14 +37,14 @@
37
37
  "url": "https://github.com/IDEAManagement/idea-base-mcp/issues"
38
38
  },
39
39
  "engines": {
40
- "node": ">=18.0.0"
40
+ "node": ">=20.0.0"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public",
44
44
  "provenance": true
45
45
  },
46
46
  "files": [
47
- "src/",
47
+ "src/index.js",
48
48
  "README.md",
49
49
  "LICENSE"
50
50
  ]
package/src/index.js CHANGED
@@ -15,33 +15,258 @@ import {
15
15
  ListToolsRequestSchema,
16
16
  } from '@modelcontextprotocol/sdk/types.js';
17
17
 
18
- // Configuration from environment
19
- const API_BASE_URL = process.env.IDEA_BASE_API_URL || 'https://app.idea-base.us/api';
20
- const API_KEY = process.env.IDEA_BASE_API_KEY;
18
+ // ---------------------------------------------------------------------------
19
+ // Configuration
20
+ //
21
+ // The API key comes from the environment and nowhere else: it is never a tool
22
+ // argument, never logged, and never allowed into an error message or a tool
23
+ // result (see redact() below). The base URL is a constant unless the operator
24
+ // deliberately overrides it, and an override must still be https.
25
+ // ---------------------------------------------------------------------------
26
+ const DEFAULT_API_BASE_URL = 'https://app.idea-base.us/api';
27
+
28
+ function resolveBaseUrl() {
29
+ const override = (process.env.IDEA_BASE_API_URL || '').trim();
30
+ if (!override) return DEFAULT_API_BASE_URL;
31
+
32
+ let parsed;
33
+ try {
34
+ parsed = new URL(override);
35
+ } catch {
36
+ console.error('Error: IDEA_BASE_API_URL is not a valid URL');
37
+ process.exit(1);
38
+ }
39
+ if (parsed.protocol !== 'https:') {
40
+ console.error('Error: IDEA_BASE_API_URL must use https');
41
+ process.exit(1);
42
+ }
43
+ if (parsed.username || parsed.password) {
44
+ console.error('Error: IDEA_BASE_API_URL must not embed credentials');
45
+ process.exit(1);
46
+ }
47
+ // Announce a non-default host once at startup so a poisoned environment is
48
+ // visible in the client log rather than silently exfiltrating the API key.
49
+ console.error(`IDEA Base MCP: API host overridden to ${parsed.host} via IDEA_BASE_API_URL`);
50
+ return override.replace(/\/+$/, '');
51
+ }
52
+
53
+ const API_BASE_URL = resolveBaseUrl();
54
+ const API_KEY = (process.env.IDEA_BASE_API_KEY || '').trim();
55
+
56
+ // The agent identity this server declares on every request (X-On-Behalf-Of).
57
+ //
58
+ // It answers "who did this?" and nothing else — it carries no permission, no
59
+ // delegation and no elevation. Authorization stays entirely with the API key and its
60
+ // owner. Without it, work done by an agent is recorded as the key owner, which makes
61
+ // the owner the actor of his own notifications and silently suppresses them.
62
+ //
63
+ // Freeform by design, so a session may declare a different agent. The identity must
64
+ // already exist server-side as a non-human user, or EVERY call fails closed with 403
65
+ // AGENT_IDENTITY_NOT_RESOLVABLE / AGENT_IDENTITY_NOT_PERMITTED — deliberately loud,
66
+ // never a silent fallback to the key owner.
67
+ const AGENT_ID = (process.env.IDEA_BASE_AGENT_ID || 'claude_ai').trim();
21
68
 
22
69
  if (!API_KEY) {
23
70
  console.error('Error: IDEA_BASE_API_KEY environment variable is required');
24
71
  process.exit(1);
25
72
  }
26
73
 
27
- // API helper function
74
+ const REQUEST_TIMEOUT_MS = 30_000;
75
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
76
+ const MAX_ERROR_CHARS = 300;
77
+
78
+ // Belt-and-braces: nothing we emit should ever contain the key, but a stray
79
+ // stack frame or an echoing upstream must not be the thing that proves it.
80
+ function redact(text) {
81
+ if (typeof text !== 'string') return '';
82
+ return API_KEY ? text.split(API_KEY).join('[redacted]') : text;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Argument validation
87
+ //
88
+ // Tool arguments arrive from a model and must never be able to steer the host
89
+ // or the path. Every argument is checked against the tool's own inputSchema
90
+ // before a handler runs: unknown keys are dropped, ids must be positive
91
+ // integers, enums must match, strings are length-capped.
92
+ // ---------------------------------------------------------------------------
93
+ const MAX_STRING_LEN = 20000;
94
+ const STRING_LIMITS = { query: 500, date: 32, start_date: 32, due_date: 32 };
95
+
96
+ function validateArgs(toolName, schema, rawArgs) {
97
+ const args = rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) ? rawArgs : {};
98
+ const props = schema?.properties || {};
99
+ const clean = {};
100
+
101
+ for (const name of schema?.required || []) {
102
+ if (args[name] === undefined || args[name] === null || args[name] === '') {
103
+ throw new Error(`${toolName}: "${name}" is required`);
104
+ }
105
+ }
106
+
107
+ for (const [name, value] of Object.entries(args)) {
108
+ const spec = props[name];
109
+ if (!spec) continue; // drop anything the tool does not declare
110
+ if (value === undefined || value === null) continue;
111
+ clean[name] = coerce(toolName, name, spec, value);
112
+ }
113
+
114
+ return clean;
115
+ }
116
+
117
+ function coerce(toolName, name, spec, value) {
118
+ const label = `${toolName}: "${name}"`;
119
+
120
+ if (spec.type === 'number' || spec.type === 'integer') {
121
+ const n = typeof value === 'number' ? value : Number(String(value).trim());
122
+ if (!Number.isFinite(n)) throw new Error(`${label} must be a number`);
123
+ if (name === 'id' || name.endsWith('_id')) {
124
+ if (!Number.isInteger(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER) {
125
+ throw new Error(`${label} must be a positive integer id`);
126
+ }
127
+ }
128
+ return n;
129
+ }
130
+
131
+ if (spec.type === 'boolean') return Boolean(value);
132
+
133
+ if (spec.type === 'array') {
134
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
135
+ if (value.length > 500) throw new Error(`${label} has too many entries (max 500)`);
136
+ return value.map((item, i) => coerce(toolName, `${name}[${i}]`, spec.items || {}, item));
137
+ }
138
+
139
+ // string (and anything unspecified)
140
+ const str = typeof value === 'string' ? value : String(value);
141
+ if (spec.enum && !spec.enum.includes(str)) {
142
+ throw new Error(`${label} must be one of: ${spec.enum.join(', ')}`);
143
+ }
144
+ const max = STRING_LIMITS[name] ?? MAX_STRING_LEN;
145
+ if (str.length > max) throw new Error(`${label} is too long (max ${max} characters)`);
146
+ return str;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // API helper
151
+ // ---------------------------------------------------------------------------
152
+ async function readCapped(response) {
153
+ const declared = Number(response.headers.get('content-length'));
154
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
155
+ throw new Error('API response exceeded the 5 MB size cap');
156
+ }
157
+ const reader = response.body?.getReader?.();
158
+ if (!reader) return '';
159
+ const chunks = [];
160
+ let total = 0;
161
+ while (true) {
162
+ const { done, value } = await reader.read();
163
+ if (done) break;
164
+ total += value.byteLength;
165
+ if (total > MAX_RESPONSE_BYTES) {
166
+ await reader.cancel();
167
+ throw new Error('API response exceeded the 5 MB size cap');
168
+ }
169
+ chunks.push(value);
170
+ }
171
+ return Buffer.concat(chunks).toString('utf8');
172
+ }
173
+
174
+ // The API serves an HTML shell for some unknown paths. Never let that body
175
+ // reach the model — report the shape of what came back instead.
176
+ function parseJsonBody(text, response) {
177
+ const contentType = (response.headers.get('content-type') || '').split(';')[0].trim();
178
+ if (!contentType.includes('json')) {
179
+ throw new Error(
180
+ `API returned ${response.status} as ${contentType || 'an unknown content type'} rather than JSON` +
181
+ ' (the endpoint may not exist)'
182
+ );
183
+ }
184
+ try {
185
+ return JSON.parse(text);
186
+ } catch {
187
+ throw new Error(`API returned ${response.status} with a malformed JSON body`);
188
+ }
189
+ }
190
+
28
191
  async function apiRequest(endpoint, options = {}) {
29
192
  const url = `${API_BASE_URL}${endpoint}`;
30
- const response = await fetch(url, {
31
- ...options,
32
- headers: {
33
- 'Authorization': `Bearer ${API_KEY}`,
34
- 'Content-Type': 'application/json',
35
- ...options.headers,
36
- },
37
- });
193
+ const controller = new AbortController();
194
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
195
+
196
+ let response;
197
+ try {
198
+ response = await fetch(url, {
199
+ ...options,
200
+ // A redirect could carry the Authorization header somewhere else. There
201
+ // is no legitimate redirect on this API, so refuse rather than follow.
202
+ redirect: 'error',
203
+ signal: controller.signal,
204
+ headers: {
205
+ 'Authorization': `Bearer ${API_KEY}`,
206
+ 'X-On-Behalf-Of': AGENT_ID,
207
+ 'Content-Type': 'application/json',
208
+ 'Accept': 'application/json',
209
+ ...options.headers,
210
+ },
211
+ });
212
+ } catch (error) {
213
+ clearTimeout(timer);
214
+ if (error?.name === 'AbortError') {
215
+ throw new Error(`API request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
216
+ }
217
+ throw new Error(`API request failed: ${redact(error?.message || 'network error')}`);
218
+ }
219
+
220
+ let text;
221
+ try {
222
+ text = await readCapped(response);
223
+ } finally {
224
+ clearTimeout(timer);
225
+ }
38
226
 
39
227
  if (!response.ok) {
40
- const error = await response.json().catch(() => ({ error: response.statusText }));
41
- throw new Error(error.error || `API error: ${response.status}`);
228
+ let message = `API error: ${response.status}`;
229
+ const contentType = (response.headers.get('content-type') || '');
230
+ if (contentType.includes('json')) {
231
+ try {
232
+ const body = JSON.parse(text);
233
+ if (typeof body?.error === 'string' && body.error) {
234
+ message = body.error.slice(0, MAX_ERROR_CHARS);
235
+ }
236
+ } catch {
237
+ // fall through to the generic status message
238
+ }
239
+ }
240
+ throw new Error(redact(message));
42
241
  }
43
242
 
44
- return response.json();
243
+ return parseJsonBody(text, response);
244
+ }
245
+
246
+ // Build a short description snippet (first ~160 chars) for compact task rows
247
+ // returned by list_tasks when the API response doesn't already provide one
248
+ // (i.e. the plain /projects/:id/tasks endpoint, which is not the compacted
249
+ // tasks/search endpoint).
250
+ function snippetFor(description, maxLen = 160) {
251
+ if (!description) return '';
252
+ const trimmed = description.trim();
253
+ if (trimmed.length <= maxLen) return trimmed;
254
+ return trimmed.slice(0, maxLen).trim() + '…';
255
+ }
256
+
257
+ // Reduce a full task row (as returned by /projects/:id/tasks) down to the
258
+ // compact shape used by list_tasks when verbose is not requested.
259
+ function compactifyTask(task) {
260
+ return {
261
+ id: task.id,
262
+ title: task.title,
263
+ status: task.status,
264
+ priority: task.priority,
265
+ estimated_minutes: task.estimated_minutes,
266
+ time_spent: task.time_spent,
267
+ due_date: task.due_date,
268
+ snippet: snippetFor(task.description),
269
+ };
45
270
  }
46
271
 
47
272
  // Define available tools
@@ -133,7 +358,7 @@ const tools = [
133
358
  },
134
359
  {
135
360
  name: 'list_tasks',
136
- description: 'List all tasks for a specific project. Returns task titles, status, estimates, and time logged.',
361
+ description: 'List all tasks for a specific project. Returns compact rows (id, title, status, priority, estimated_minutes, time_spent, due_date, snippet) by default to keep payloads small; pass verbose:true for full rows including description, acceptance_criteria, tags, and assignments.',
137
362
  inputSchema: {
138
363
  type: 'object',
139
364
  properties: {
@@ -143,8 +368,12 @@ const tools = [
143
368
  },
144
369
  status: {
145
370
  type: 'string',
146
- enum: ['todo', 'in_progress', 'done'],
147
- description: 'Filter by task status. Omit for all tasks.',
371
+ enum: ['todo', 'in_progress', 'blocked', 'done'],
372
+ description: 'Filter by task status. Omit for all tasks. Note this filters the STORED status; a task can also be showing as blocked because of an unmet dependency (see effective_status on each row).',
373
+ },
374
+ verbose: {
375
+ type: 'boolean',
376
+ description: 'Return full task rows (description, acceptance_criteria, tags, assignments) instead of compact rows. Default false.',
148
377
  },
149
378
  },
150
379
  required: ['project_id'],
@@ -194,6 +423,22 @@ const tools = [
194
423
  type: 'number',
195
424
  description: 'Priority level (0-5, higher is more important)',
196
425
  },
426
+ start_date: {
427
+ type: 'string',
428
+ description: 'Date work is planned to start, as YYYY-MM-DD.',
429
+ },
430
+ due_date: {
431
+ type: 'string',
432
+ description: 'Date the task is due, as YYYY-MM-DD.',
433
+ },
434
+ assignee_user_id: {
435
+ type: 'string',
436
+ description: 'User ID to assign the task to. Must be a member of the same account. Omit to leave the task unassigned.',
437
+ },
438
+ parent_task_id: {
439
+ type: 'number',
440
+ description: 'Make this a SUBTASK of the given task. The parent must be in the same project, and a subtask cannot itself have subtasks (one level only). A parent with subtasks takes its status from them: any subtask in progress makes the parent in progress, and all subtasks done makes it ready to complete.',
441
+ },
197
442
  },
198
443
  required: ['project_id', 'title'],
199
444
  },
@@ -226,7 +471,28 @@ const tools = [
226
471
  },
227
472
  priority: {
228
473
  type: 'number',
229
- description: 'New priority level',
474
+ description: 'New priority level (0-5, higher is more important)',
475
+ },
476
+ start_date: {
477
+ type: 'string',
478
+ description: 'New start date as YYYY-MM-DD. Pass an empty string to clear it.',
479
+ },
480
+ due_date: {
481
+ type: 'string',
482
+ description: 'New due date as YYYY-MM-DD. Pass an empty string to clear it.',
483
+ },
484
+ assignee_user_id: {
485
+ type: 'string',
486
+ description: 'User ID to assign the task to, replacing any existing assignee. Must be a member of the same account. Pass an empty string to unassign.',
487
+ },
488
+ blocked_by: {
489
+ type: 'array',
490
+ items: { type: 'number' },
491
+ description: 'REPLACES the full set of tasks this task is blocked by. Pass [] to clear all of them. Every id must be a task in the same product; a circular chain (A blocked by B blocked by A) is rejected. While any of these is not done, the task shows as blocked whatever its stored status says.',
492
+ },
493
+ blocked_reason: {
494
+ type: 'string',
495
+ description: 'Free-text reason shown next to a manually-set Blocked status ("waiting on the client"). Cleared automatically when the task leaves the blocked status.',
230
496
  },
231
497
  },
232
498
  required: ['task_id'],
@@ -234,7 +500,7 @@ const tools = [
234
500
  },
235
501
  {
236
502
  name: 'update_task_status',
237
- description: 'Change the status of a task (todo, in_progress, done). This triggers a real-time notification to users viewing the project.',
503
+ description: 'Change the status of a task (todo, in_progress, blocked, done). This triggers a real-time notification to users viewing the project. Note that a task with subtasks or dependencies also has a DERIVED status which can differ from the one set here — see effective_status / status_reason on get_task.',
238
504
  inputSchema: {
239
505
  type: 'object',
240
506
  properties: {
@@ -244,8 +510,12 @@ const tools = [
244
510
  },
245
511
  status: {
246
512
  type: 'string',
247
- enum: ['todo', 'in_progress', 'done'],
248
- description: 'New status for the task',
513
+ enum: ['todo', 'in_progress', 'blocked', 'done'],
514
+ description: 'New status for the task. "ready_to_complete" is NOT settable — it is derived when every subtask is done.',
515
+ },
516
+ blocked_reason: {
517
+ type: 'string',
518
+ description: 'Why the task is blocked. Only meaningful with status "blocked"; cleared automatically on any other status.',
249
519
  },
250
520
  },
251
521
  required: ['task_id', 'status'],
@@ -279,7 +549,7 @@ const tools = [
279
549
  },
280
550
  {
281
551
  name: 'search_tasks',
282
- description: 'Search for tasks across all projects by title or description.',
552
+ description: 'Search for tasks across all projects by title or description. Returns compact rows (id, title, status, priority, project_id, project_name, product_name, customer_name, estimated_minutes, time_logged, due_date, snippet) ranked with title matches first, then description matches, then recency. Pass verbose:true for full rows.',
283
553
  inputSchema: {
284
554
  type: 'object',
285
555
  properties: {
@@ -289,9 +559,29 @@ const tools = [
289
559
  },
290
560
  status: {
291
561
  type: 'string',
292
- enum: ['todo', 'in_progress', 'done'],
562
+ enum: ['todo', 'in_progress', 'blocked', 'done'],
293
563
  description: 'Filter results by status',
294
564
  },
565
+ project_id: {
566
+ type: 'number',
567
+ description: 'Restrict results to this project',
568
+ },
569
+ product_id: {
570
+ type: 'number',
571
+ description: 'Restrict results to this product',
572
+ },
573
+ customer_id: {
574
+ type: 'number',
575
+ description: 'Restrict results to this customer',
576
+ },
577
+ verbose: {
578
+ type: 'boolean',
579
+ description: 'Return full task rows instead of compact rows. Default false.',
580
+ },
581
+ limit: {
582
+ type: 'number',
583
+ description: 'Max results to return. Default 20, capped at 100.',
584
+ },
295
585
  },
296
586
  required: ['query'],
297
587
  },
@@ -346,7 +636,7 @@ const tools = [
346
636
  },
347
637
  {
348
638
  name: 'stop_working',
349
- description: 'Mark that you have stopped working on a task (break or switching tasks). Optionally capture your state on the way out: pass `note` to append a work note and/or `resume_context` to overwrite the resume block, so the next session can pick up where you left off. Both are recorded as author_kind="ai".',
639
+ description: 'Mark that you have stopped working on a task (break or switching tasks). Optionally capture your state on the way out: pass `note` to append a work note and/or `resume_context` to overwrite the resume block, so the next session can pick up where you left off.',
350
640
  inputSchema: {
351
641
  type: 'object',
352
642
  properties: {
@@ -368,7 +658,7 @@ const tools = [
368
658
  },
369
659
  {
370
660
  name: 'add_work_note',
371
- description: 'Append a timestamped work note (status update) to a task\'s activity log. Use this to record progress so a future session can pick up where you left off (e.g. "finished auth handler, tests green, next: wire the callback"). Notes are append-only and never overwrite each other. Set author_kind="human" ONLY when you are relaying a note dictated by the human user; use "ai" (the default) when leaving your own status update.',
661
+ description: 'Append a timestamped work note (status update) to a task\'s activity log. Use this to record progress so a future session can pick up where you left off (e.g. "finished auth handler, tests green, next: wire the callback"). Notes are append-only and never overwrite each other.',
372
662
  inputSchema: {
373
663
  type: 'object',
374
664
  properties: {
@@ -380,11 +670,6 @@ const tools = [
380
670
  type: 'string',
381
671
  description: 'The work note / status update text',
382
672
  },
383
- author_kind: {
384
- type: 'string',
385
- enum: ['ai', 'human'],
386
- description: 'Who authored this note: "ai" (default, your own update) or "human" (you are relaying something the user said).',
387
- },
388
673
  is_internal: {
389
674
  type: 'boolean',
390
675
  description: 'Whether the note is internal-only (hidden from customers). Defaults to true for work notes.',
@@ -395,7 +680,7 @@ const tools = [
395
680
  },
396
681
  {
397
682
  name: 'add_comment',
398
- description: 'Add a comment to a task\'s discussion thread. Unlike work notes (progress journal), comments are for communication and are customer-visible by default. Set author_kind="human" when relaying the user\'s words; use "ai" (default) for your own.',
683
+ description: 'Add a comment to a task\'s discussion thread. Unlike work notes (progress journal), comments are for communication and are customer-visible by default.',
399
684
  inputSchema: {
400
685
  type: 'object',
401
686
  properties: {
@@ -407,11 +692,6 @@ const tools = [
407
692
  type: 'string',
408
693
  description: 'The comment text',
409
694
  },
410
- author_kind: {
411
- type: 'string',
412
- enum: ['ai', 'human'],
413
- description: 'Who authored this comment: "ai" (default) or "human" (relaying the user).',
414
- },
415
695
  is_internal: {
416
696
  type: 'boolean',
417
697
  description: 'Whether the comment is internal-only (hidden from customers). Defaults to false.',
@@ -422,7 +702,7 @@ const tools = [
422
702
  },
423
703
  {
424
704
  name: 'set_resume_context',
425
- description: 'Set (overwrite) the task\'s resume context — a single pinned block describing the current state, what is done, what is next, and where to look. This is read first when a work session restarts cold (e.g. via start_working) so you can re-orient immediately. Update it at the end of a work session. Set author_kind="human" only when relaying the user\'s words; default "ai".',
705
+ description: 'Set (overwrite) the task\'s resume context — a single pinned block describing the current state, what is done, what is next, and where to look. This is read first when a work session restarts cold (e.g. via start_working) so you can re-orient immediately. Update it at the end of a work session.',
426
706
  inputSchema: {
427
707
  type: 'object',
428
708
  properties: {
@@ -434,11 +714,6 @@ const tools = [
434
714
  type: 'string',
435
715
  description: 'The resume-context text (markdown ok): current state / next steps / where to look.',
436
716
  },
437
- author_kind: {
438
- type: 'string',
439
- enum: ['ai', 'human'],
440
- description: 'Who authored this context: "ai" (default) or "human".',
441
- },
442
717
  },
443
718
  required: ['task_id', 'context'],
444
719
  },
@@ -594,16 +869,17 @@ const toolHandlers = {
594
869
  };
595
870
  },
596
871
 
597
- async list_tasks({ project_id, status }) {
872
+ async list_tasks({ project_id, status, verbose }) {
598
873
  let endpoint = `/projects/${project_id}/tasks`;
599
- if (status) endpoint += `?status=${status}`;
874
+ if (status) endpoint += `?status=${encodeURIComponent(status)}`;
600
875
 
601
876
  const tasks = await apiRequest(endpoint);
877
+ const result = verbose ? tasks : (Array.isArray(tasks) ? tasks.map(compactifyTask) : tasks);
602
878
  return {
603
879
  content: [
604
880
  {
605
881
  type: 'text',
606
- text: JSON.stringify(tasks, null, 2),
882
+ text: JSON.stringify(result, null, 2),
607
883
  },
608
884
  ],
609
885
  };
@@ -621,7 +897,7 @@ const toolHandlers = {
621
897
  };
622
898
  },
623
899
 
624
- async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority }) {
900
+ async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, parent_task_id }) {
625
901
  const task = await apiRequest(`/projects/${project_id}/tasks`, {
626
902
  method: 'POST',
627
903
  body: JSON.stringify({
@@ -630,6 +906,10 @@ const toolHandlers = {
630
906
  acceptance_criteria,
631
907
  estimated_minutes,
632
908
  priority,
909
+ start_date,
910
+ due_date,
911
+ assignee_user_id,
912
+ parent_task_id,
633
913
  }),
634
914
  });
635
915
  return {
@@ -642,13 +922,22 @@ const toolHandlers = {
642
922
  };
643
923
  },
644
924
 
645
- async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority }) {
925
+ async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, blocked_by, blocked_reason }) {
926
+ // Only forward what the caller actually named. An absent key leaves the
927
+ // field alone; an explicitly empty string is how a date or the assignee is
928
+ // cleared, and blocked_by is a REPLACE so an explicit [] must reach the
929
+ // API — which is why these are `!== undefined` rather than truthiness.
646
930
  const updates = {};
647
931
  if (title !== undefined) updates.title = title;
648
932
  if (description !== undefined) updates.description = description;
649
933
  if (acceptance_criteria !== undefined) updates.acceptance_criteria = acceptance_criteria;
650
934
  if (estimated_minutes !== undefined) updates.estimated_minutes = estimated_minutes;
651
935
  if (priority !== undefined) updates.priority = priority;
936
+ if (start_date !== undefined) updates.start_date = start_date;
937
+ if (due_date !== undefined) updates.due_date = due_date;
938
+ if (assignee_user_id !== undefined) updates.assignee_user_id = assignee_user_id;
939
+ if (blocked_by !== undefined) updates.blocked_by = blocked_by;
940
+ if (blocked_reason !== undefined) updates.blocked_reason = blocked_reason;
652
941
 
653
942
  const task = await apiRequest(`/tasks/${task_id}`, {
654
943
  method: 'PUT',
@@ -664,10 +953,10 @@ const toolHandlers = {
664
953
  };
665
954
  },
666
955
 
667
- async update_task_status({ task_id, status }) {
956
+ async update_task_status({ task_id, status, blocked_reason }) {
668
957
  const task = await apiRequest(`/tasks/${task_id}/status`, {
669
958
  method: 'PUT',
670
- body: JSON.stringify({ status }),
959
+ body: JSON.stringify({ status, blocked_reason }),
671
960
  });
672
961
  return {
673
962
  content: [
@@ -698,9 +987,14 @@ const toolHandlers = {
698
987
  };
699
988
  },
700
989
 
701
- async search_tasks({ query, status }) {
990
+ async search_tasks({ query, status, project_id, product_id, customer_id, verbose, limit }) {
702
991
  let endpoint = `/tasks/search?q=${encodeURIComponent(query)}`;
703
- if (status) endpoint += `&status=${status}`;
992
+ if (status) endpoint += `&status=${encodeURIComponent(status)}`;
993
+ if (project_id) endpoint += `&project_id=${encodeURIComponent(project_id)}`;
994
+ if (product_id) endpoint += `&product_id=${encodeURIComponent(product_id)}`;
995
+ if (customer_id) endpoint += `&customer_id=${encodeURIComponent(customer_id)}`;
996
+ if (verbose) endpoint += `&verbose=1`;
997
+ if (limit) endpoint += `&limit=${encodeURIComponent(limit)}`;
704
998
 
705
999
  const tasks = await apiRequest(endpoint);
706
1000
  return {
@@ -805,14 +1099,14 @@ const toolHandlers = {
805
1099
  if (note && note.trim()) {
806
1100
  await apiRequest(`/tasks/${task_id}/activity`, {
807
1101
  method: 'POST',
808
- body: JSON.stringify({ note, author_kind: 'ai', is_internal: true }),
1102
+ body: JSON.stringify({ note, is_internal: true }),
809
1103
  });
810
1104
  captured.push('work note');
811
1105
  }
812
1106
  if (resume_context !== undefined && resume_context !== null) {
813
1107
  await apiRequest(`/tasks/${task_id}`, {
814
1108
  method: 'PUT',
815
- body: JSON.stringify({ resume_context, resume_context_author_kind: 'ai' }),
1109
+ body: JSON.stringify({ resume_context }),
816
1110
  });
817
1111
  captured.push('resume context');
818
1112
  }
@@ -832,46 +1126,46 @@ const toolHandlers = {
832
1126
  };
833
1127
  },
834
1128
 
835
- async add_work_note({ task_id, note, author_kind = 'ai', is_internal = true }) {
1129
+ async add_work_note({ task_id, note, is_internal = true }) {
836
1130
  const saved = await apiRequest(`/tasks/${task_id}/activity`, {
837
1131
  method: 'POST',
838
- body: JSON.stringify({ note, author_kind, is_internal }),
1132
+ body: JSON.stringify({ note, is_internal }),
839
1133
  });
840
1134
  return {
841
1135
  content: [
842
1136
  {
843
1137
  type: 'text',
844
- text: `Work note added to task #${task_id} (as ${author_kind === 'ai' ? 'AI Agent' : 'human'}). Note id: ${saved.id}.`,
1138
+ text: `Work note added to task #${task_id} (as ${AGENT_ID}). Note id: ${saved.id}.`,
845
1139
  },
846
1140
  ],
847
1141
  };
848
1142
  },
849
1143
 
850
- async add_comment({ task_id, comment, author_kind = 'ai', is_internal = false }) {
1144
+ async add_comment({ task_id, comment, is_internal = false }) {
851
1145
  const saved = await apiRequest(`/tasks/${task_id}/comments`, {
852
1146
  method: 'POST',
853
- body: JSON.stringify({ comment, author_kind, is_internal }),
1147
+ body: JSON.stringify({ comment, is_internal }),
854
1148
  });
855
1149
  return {
856
1150
  content: [
857
1151
  {
858
1152
  type: 'text',
859
- text: `Comment added to task #${task_id} (as ${author_kind === 'ai' ? 'AI Agent' : 'human'}). Comment id: ${saved.id}.`,
1153
+ text: `Comment added to task #${task_id} (as ${AGENT_ID}). Comment id: ${saved.id}.`,
860
1154
  },
861
1155
  ],
862
1156
  };
863
1157
  },
864
1158
 
865
- async set_resume_context({ task_id, context, author_kind = 'ai' }) {
1159
+ async set_resume_context({ task_id, context }) {
866
1160
  await apiRequest(`/tasks/${task_id}`, {
867
1161
  method: 'PUT',
868
- body: JSON.stringify({ resume_context: context, resume_context_author_kind: author_kind }),
1162
+ body: JSON.stringify({ resume_context: context }),
869
1163
  });
870
1164
  return {
871
1165
  content: [
872
1166
  {
873
1167
  type: 'text',
874
- text: `Resume context updated for task #${task_id} (as ${author_kind === 'ai' ? 'AI Agent' : 'human'}). The next session that calls start_working will read this first.`,
1168
+ text: `Resume context updated for task #${task_id} (as ${AGENT_ID}). The next session that calls start_working will read this first.`,
875
1169
  },
876
1170
  ],
877
1171
  };
@@ -880,7 +1174,7 @@ const toolHandlers = {
880
1174
  // Products handlers
881
1175
  async list_products({ status }) {
882
1176
  let endpoint = '/products';
883
- if (status) endpoint += `?status=${status}`;
1177
+ if (status) endpoint += `?status=${encodeURIComponent(status)}`;
884
1178
 
885
1179
  const products = await apiRequest(endpoint);
886
1180
  return {
@@ -967,18 +1261,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
967
1261
  const { name, arguments: args } = request.params;
968
1262
 
969
1263
  const handler = toolHandlers[name];
970
- if (!handler) {
1264
+ const tool = tools.find((t) => t.name === name);
1265
+ if (!handler || !tool) {
971
1266
  throw new Error(`Unknown tool: ${name}`);
972
1267
  }
973
1268
 
974
1269
  try {
975
- return await handler(args);
1270
+ const safeArgs = validateArgs(name, tool.inputSchema, args);
1271
+ return await handler(safeArgs);
976
1272
  } catch (error) {
977
1273
  return {
978
1274
  content: [
979
1275
  {
980
1276
  type: 'text',
981
- text: `Error: ${error.message}`,
1277
+ text: `Error: ${redact(error?.message || 'unknown error')}`,
982
1278
  },
983
1279
  ],
984
1280
  isError: true,
@@ -994,6 +1290,6 @@ async function main() {
994
1290
  }
995
1291
 
996
1292
  main().catch((error) => {
997
- console.error('Fatal error:', error);
1293
+ console.error(`Fatal error: ${redact(error?.message || 'unknown error')}`);
998
1294
  process.exit(1);
999
1295
  });