@idea-base/mcp-server 1.1.0 → 1.3.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/README.md +17 -7
- package/package.json +3 -3
- package/src/index.js +514 -42
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
|
|
@@ -85,8 +85,18 @@ Ask Claude to manage your projects:
|
|
|
85
85
|
| Tool | Description |
|
|
86
86
|
|------|-------------|
|
|
87
87
|
| `log_time` | Log time against a task with notes |
|
|
88
|
-
| `start_working` | Mark yourself as actively working on a task |
|
|
89
|
-
| `stop_working` | Stop active work on a task |
|
|
88
|
+
| `start_working` | Mark yourself as actively working on a task (surfaces the saved resume context + recent work notes so a cold session re-orients) |
|
|
89
|
+
| `stop_working` | Stop active work on a task (optionally capture a `note` and/or `resume_context` on the way out) |
|
|
90
|
+
|
|
91
|
+
### Activity & Audit Trail
|
|
92
|
+
|
|
93
|
+
For AI agents, these leave a durable trail of *what was done and why* on each task — so a future session (or a human reviewer) can see the reasoning, not just the final state.
|
|
94
|
+
|
|
95
|
+
| Tool | Description |
|
|
96
|
+
|------|-------------|
|
|
97
|
+
| `add_work_note` | Append a timestamped progress note to a task's activity log (append-only journal) |
|
|
98
|
+
| `add_comment` | Add a comment to a task's discussion thread (customer-visible by default) |
|
|
99
|
+
| `set_resume_context` | Overwrite the task's pinned "where I left off" block, read first on `start_working` |
|
|
90
100
|
|
|
91
101
|
### Products
|
|
92
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idea-base/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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": ">=
|
|
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,244 @@ import {
|
|
|
15
15
|
ListToolsRequestSchema,
|
|
16
16
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
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();
|
|
21
55
|
|
|
22
56
|
if (!API_KEY) {
|
|
23
57
|
console.error('Error: IDEA_BASE_API_KEY environment variable is required');
|
|
24
58
|
process.exit(1);
|
|
25
59
|
}
|
|
26
60
|
|
|
27
|
-
|
|
61
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
62
|
+
const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
63
|
+
const MAX_ERROR_CHARS = 300;
|
|
64
|
+
|
|
65
|
+
// Belt-and-braces: nothing we emit should ever contain the key, but a stray
|
|
66
|
+
// stack frame or an echoing upstream must not be the thing that proves it.
|
|
67
|
+
function redact(text) {
|
|
68
|
+
if (typeof text !== 'string') return '';
|
|
69
|
+
return API_KEY ? text.split(API_KEY).join('[redacted]') : text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Argument validation
|
|
74
|
+
//
|
|
75
|
+
// Tool arguments arrive from a model and must never be able to steer the host
|
|
76
|
+
// or the path. Every argument is checked against the tool's own inputSchema
|
|
77
|
+
// before a handler runs: unknown keys are dropped, ids must be positive
|
|
78
|
+
// integers, enums must match, strings are length-capped.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
const MAX_STRING_LEN = 20000;
|
|
81
|
+
const STRING_LIMITS = { query: 500, date: 32, start_date: 32, due_date: 32 };
|
|
82
|
+
|
|
83
|
+
function validateArgs(toolName, schema, rawArgs) {
|
|
84
|
+
const args = rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) ? rawArgs : {};
|
|
85
|
+
const props = schema?.properties || {};
|
|
86
|
+
const clean = {};
|
|
87
|
+
|
|
88
|
+
for (const name of schema?.required || []) {
|
|
89
|
+
if (args[name] === undefined || args[name] === null || args[name] === '') {
|
|
90
|
+
throw new Error(`${toolName}: "${name}" is required`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [name, value] of Object.entries(args)) {
|
|
95
|
+
const spec = props[name];
|
|
96
|
+
if (!spec) continue; // drop anything the tool does not declare
|
|
97
|
+
if (value === undefined || value === null) continue;
|
|
98
|
+
clean[name] = coerce(toolName, name, spec, value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return clean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function coerce(toolName, name, spec, value) {
|
|
105
|
+
const label = `${toolName}: "${name}"`;
|
|
106
|
+
|
|
107
|
+
if (spec.type === 'number' || spec.type === 'integer') {
|
|
108
|
+
const n = typeof value === 'number' ? value : Number(String(value).trim());
|
|
109
|
+
if (!Number.isFinite(n)) throw new Error(`${label} must be a number`);
|
|
110
|
+
if (name === 'id' || name.endsWith('_id')) {
|
|
111
|
+
if (!Number.isInteger(n) || n <= 0 || n > Number.MAX_SAFE_INTEGER) {
|
|
112
|
+
throw new Error(`${label} must be a positive integer id`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (spec.type === 'boolean') return Boolean(value);
|
|
119
|
+
|
|
120
|
+
if (spec.type === 'array') {
|
|
121
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
122
|
+
if (value.length > 500) throw new Error(`${label} has too many entries (max 500)`);
|
|
123
|
+
return value.map((item, i) => coerce(toolName, `${name}[${i}]`, spec.items || {}, item));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// string (and anything unspecified)
|
|
127
|
+
const str = typeof value === 'string' ? value : String(value);
|
|
128
|
+
if (spec.enum && !spec.enum.includes(str)) {
|
|
129
|
+
throw new Error(`${label} must be one of: ${spec.enum.join(', ')}`);
|
|
130
|
+
}
|
|
131
|
+
const max = STRING_LIMITS[name] ?? MAX_STRING_LEN;
|
|
132
|
+
if (str.length > max) throw new Error(`${label} is too long (max ${max} characters)`);
|
|
133
|
+
return str;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// API helper
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
async function readCapped(response) {
|
|
140
|
+
const declared = Number(response.headers.get('content-length'));
|
|
141
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
142
|
+
throw new Error('API response exceeded the 5 MB size cap');
|
|
143
|
+
}
|
|
144
|
+
const reader = response.body?.getReader?.();
|
|
145
|
+
if (!reader) return '';
|
|
146
|
+
const chunks = [];
|
|
147
|
+
let total = 0;
|
|
148
|
+
while (true) {
|
|
149
|
+
const { done, value } = await reader.read();
|
|
150
|
+
if (done) break;
|
|
151
|
+
total += value.byteLength;
|
|
152
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
153
|
+
await reader.cancel();
|
|
154
|
+
throw new Error('API response exceeded the 5 MB size cap');
|
|
155
|
+
}
|
|
156
|
+
chunks.push(value);
|
|
157
|
+
}
|
|
158
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The API serves an HTML shell for some unknown paths. Never let that body
|
|
162
|
+
// reach the model — report the shape of what came back instead.
|
|
163
|
+
function parseJsonBody(text, response) {
|
|
164
|
+
const contentType = (response.headers.get('content-type') || '').split(';')[0].trim();
|
|
165
|
+
if (!contentType.includes('json')) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`API returned ${response.status} as ${contentType || 'an unknown content type'} rather than JSON` +
|
|
168
|
+
' (the endpoint may not exist)'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(text);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new Error(`API returned ${response.status} with a malformed JSON body`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
28
178
|
async function apiRequest(endpoint, options = {}) {
|
|
29
179
|
const url = `${API_BASE_URL}${endpoint}`;
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
182
|
+
|
|
183
|
+
let response;
|
|
184
|
+
try {
|
|
185
|
+
response = await fetch(url, {
|
|
186
|
+
...options,
|
|
187
|
+
// A redirect could carry the Authorization header somewhere else. There
|
|
188
|
+
// is no legitimate redirect on this API, so refuse rather than follow.
|
|
189
|
+
redirect: 'error',
|
|
190
|
+
signal: controller.signal,
|
|
191
|
+
headers: {
|
|
192
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
'Accept': 'application/json',
|
|
195
|
+
...options.headers,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
if (error?.name === 'AbortError') {
|
|
201
|
+
throw new Error(`API request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`API request failed: ${redact(error?.message || 'network error')}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let text;
|
|
207
|
+
try {
|
|
208
|
+
text = await readCapped(response);
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
}
|
|
38
212
|
|
|
39
213
|
if (!response.ok) {
|
|
40
|
-
|
|
41
|
-
|
|
214
|
+
let message = `API error: ${response.status}`;
|
|
215
|
+
const contentType = (response.headers.get('content-type') || '');
|
|
216
|
+
if (contentType.includes('json')) {
|
|
217
|
+
try {
|
|
218
|
+
const body = JSON.parse(text);
|
|
219
|
+
if (typeof body?.error === 'string' && body.error) {
|
|
220
|
+
message = body.error.slice(0, MAX_ERROR_CHARS);
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// fall through to the generic status message
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
throw new Error(redact(message));
|
|
42
227
|
}
|
|
43
228
|
|
|
44
|
-
return response
|
|
229
|
+
return parseJsonBody(text, response);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build a short description snippet (first ~160 chars) for compact task rows
|
|
233
|
+
// returned by list_tasks when the API response doesn't already provide one
|
|
234
|
+
// (i.e. the plain /projects/:id/tasks endpoint, which is not the compacted
|
|
235
|
+
// tasks/search endpoint).
|
|
236
|
+
function snippetFor(description, maxLen = 160) {
|
|
237
|
+
if (!description) return '';
|
|
238
|
+
const trimmed = description.trim();
|
|
239
|
+
if (trimmed.length <= maxLen) return trimmed;
|
|
240
|
+
return trimmed.slice(0, maxLen).trim() + '…';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Reduce a full task row (as returned by /projects/:id/tasks) down to the
|
|
244
|
+
// compact shape used by list_tasks when verbose is not requested.
|
|
245
|
+
function compactifyTask(task) {
|
|
246
|
+
return {
|
|
247
|
+
id: task.id,
|
|
248
|
+
title: task.title,
|
|
249
|
+
status: task.status,
|
|
250
|
+
priority: task.priority,
|
|
251
|
+
estimated_minutes: task.estimated_minutes,
|
|
252
|
+
time_spent: task.time_spent,
|
|
253
|
+
due_date: task.due_date,
|
|
254
|
+
snippet: snippetFor(task.description),
|
|
255
|
+
};
|
|
45
256
|
}
|
|
46
257
|
|
|
47
258
|
// Define available tools
|
|
@@ -133,7 +344,7 @@ const tools = [
|
|
|
133
344
|
},
|
|
134
345
|
{
|
|
135
346
|
name: 'list_tasks',
|
|
136
|
-
description: 'List all tasks for a specific project. Returns
|
|
347
|
+
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
348
|
inputSchema: {
|
|
138
349
|
type: 'object',
|
|
139
350
|
properties: {
|
|
@@ -143,8 +354,12 @@ const tools = [
|
|
|
143
354
|
},
|
|
144
355
|
status: {
|
|
145
356
|
type: 'string',
|
|
146
|
-
enum: ['todo', 'in_progress', 'done'],
|
|
147
|
-
description: 'Filter by task status. Omit for all tasks.',
|
|
357
|
+
enum: ['todo', 'in_progress', 'blocked', 'done'],
|
|
358
|
+
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).',
|
|
359
|
+
},
|
|
360
|
+
verbose: {
|
|
361
|
+
type: 'boolean',
|
|
362
|
+
description: 'Return full task rows (description, acceptance_criteria, tags, assignments) instead of compact rows. Default false.',
|
|
148
363
|
},
|
|
149
364
|
},
|
|
150
365
|
required: ['project_id'],
|
|
@@ -194,6 +409,22 @@ const tools = [
|
|
|
194
409
|
type: 'number',
|
|
195
410
|
description: 'Priority level (0-5, higher is more important)',
|
|
196
411
|
},
|
|
412
|
+
start_date: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: 'Date work is planned to start, as YYYY-MM-DD.',
|
|
415
|
+
},
|
|
416
|
+
due_date: {
|
|
417
|
+
type: 'string',
|
|
418
|
+
description: 'Date the task is due, as YYYY-MM-DD.',
|
|
419
|
+
},
|
|
420
|
+
assignee_user_id: {
|
|
421
|
+
type: 'string',
|
|
422
|
+
description: 'User ID to assign the task to. Must be a member of the same account. Omit to leave the task unassigned.',
|
|
423
|
+
},
|
|
424
|
+
parent_task_id: {
|
|
425
|
+
type: 'number',
|
|
426
|
+
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.',
|
|
427
|
+
},
|
|
197
428
|
},
|
|
198
429
|
required: ['project_id', 'title'],
|
|
199
430
|
},
|
|
@@ -226,7 +457,28 @@ const tools = [
|
|
|
226
457
|
},
|
|
227
458
|
priority: {
|
|
228
459
|
type: 'number',
|
|
229
|
-
description: 'New priority level',
|
|
460
|
+
description: 'New priority level (0-5, higher is more important)',
|
|
461
|
+
},
|
|
462
|
+
start_date: {
|
|
463
|
+
type: 'string',
|
|
464
|
+
description: 'New start date as YYYY-MM-DD. Pass an empty string to clear it.',
|
|
465
|
+
},
|
|
466
|
+
due_date: {
|
|
467
|
+
type: 'string',
|
|
468
|
+
description: 'New due date as YYYY-MM-DD. Pass an empty string to clear it.',
|
|
469
|
+
},
|
|
470
|
+
assignee_user_id: {
|
|
471
|
+
type: 'string',
|
|
472
|
+
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.',
|
|
473
|
+
},
|
|
474
|
+
blocked_by: {
|
|
475
|
+
type: 'array',
|
|
476
|
+
items: { type: 'number' },
|
|
477
|
+
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.',
|
|
478
|
+
},
|
|
479
|
+
blocked_reason: {
|
|
480
|
+
type: 'string',
|
|
481
|
+
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
482
|
},
|
|
231
483
|
},
|
|
232
484
|
required: ['task_id'],
|
|
@@ -234,7 +486,7 @@ const tools = [
|
|
|
234
486
|
},
|
|
235
487
|
{
|
|
236
488
|
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.',
|
|
489
|
+
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
490
|
inputSchema: {
|
|
239
491
|
type: 'object',
|
|
240
492
|
properties: {
|
|
@@ -244,8 +496,12 @@ const tools = [
|
|
|
244
496
|
},
|
|
245
497
|
status: {
|
|
246
498
|
type: 'string',
|
|
247
|
-
enum: ['todo', 'in_progress', 'done'],
|
|
248
|
-
description: 'New status for the task',
|
|
499
|
+
enum: ['todo', 'in_progress', 'blocked', 'done'],
|
|
500
|
+
description: 'New status for the task. "ready_to_complete" is NOT settable — it is derived when every subtask is done.',
|
|
501
|
+
},
|
|
502
|
+
blocked_reason: {
|
|
503
|
+
type: 'string',
|
|
504
|
+
description: 'Why the task is blocked. Only meaningful with status "blocked"; cleared automatically on any other status.',
|
|
249
505
|
},
|
|
250
506
|
},
|
|
251
507
|
required: ['task_id', 'status'],
|
|
@@ -279,7 +535,7 @@ const tools = [
|
|
|
279
535
|
},
|
|
280
536
|
{
|
|
281
537
|
name: 'search_tasks',
|
|
282
|
-
description: 'Search for tasks across all projects by title or description.',
|
|
538
|
+
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
539
|
inputSchema: {
|
|
284
540
|
type: 'object',
|
|
285
541
|
properties: {
|
|
@@ -289,9 +545,29 @@ const tools = [
|
|
|
289
545
|
},
|
|
290
546
|
status: {
|
|
291
547
|
type: 'string',
|
|
292
|
-
enum: ['todo', 'in_progress', 'done'],
|
|
548
|
+
enum: ['todo', 'in_progress', 'blocked', 'done'],
|
|
293
549
|
description: 'Filter results by status',
|
|
294
550
|
},
|
|
551
|
+
project_id: {
|
|
552
|
+
type: 'number',
|
|
553
|
+
description: 'Restrict results to this project',
|
|
554
|
+
},
|
|
555
|
+
product_id: {
|
|
556
|
+
type: 'number',
|
|
557
|
+
description: 'Restrict results to this product',
|
|
558
|
+
},
|
|
559
|
+
customer_id: {
|
|
560
|
+
type: 'number',
|
|
561
|
+
description: 'Restrict results to this customer',
|
|
562
|
+
},
|
|
563
|
+
verbose: {
|
|
564
|
+
type: 'boolean',
|
|
565
|
+
description: 'Return full task rows instead of compact rows. Default false.',
|
|
566
|
+
},
|
|
567
|
+
limit: {
|
|
568
|
+
type: 'number',
|
|
569
|
+
description: 'Max results to return. Default 20, capped at 100.',
|
|
570
|
+
},
|
|
295
571
|
},
|
|
296
572
|
required: ['query'],
|
|
297
573
|
},
|
|
@@ -346,7 +622,7 @@ const tools = [
|
|
|
346
622
|
},
|
|
347
623
|
{
|
|
348
624
|
name: 'stop_working',
|
|
349
|
-
description: 'Mark that you have stopped working on a task.
|
|
625
|
+
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".',
|
|
350
626
|
inputSchema: {
|
|
351
627
|
type: 'object',
|
|
352
628
|
properties: {
|
|
@@ -354,10 +630,95 @@ const tools = [
|
|
|
354
630
|
type: 'number',
|
|
355
631
|
description: 'The ID of the task to stop working on',
|
|
356
632
|
},
|
|
633
|
+
note: {
|
|
634
|
+
type: 'string',
|
|
635
|
+
description: 'Optional work note to append before stopping (what you did, what is next).',
|
|
636
|
+
},
|
|
637
|
+
resume_context: {
|
|
638
|
+
type: 'string',
|
|
639
|
+
description: 'Optional resume-context block to overwrite before stopping (current state / next steps / where to look).',
|
|
640
|
+
},
|
|
357
641
|
},
|
|
358
642
|
required: ['task_id'],
|
|
359
643
|
},
|
|
360
644
|
},
|
|
645
|
+
{
|
|
646
|
+
name: 'add_work_note',
|
|
647
|
+
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.',
|
|
648
|
+
inputSchema: {
|
|
649
|
+
type: 'object',
|
|
650
|
+
properties: {
|
|
651
|
+
task_id: {
|
|
652
|
+
type: 'number',
|
|
653
|
+
description: 'The ID of the task to add a work note to',
|
|
654
|
+
},
|
|
655
|
+
note: {
|
|
656
|
+
type: 'string',
|
|
657
|
+
description: 'The work note / status update text',
|
|
658
|
+
},
|
|
659
|
+
author_kind: {
|
|
660
|
+
type: 'string',
|
|
661
|
+
enum: ['ai', 'human'],
|
|
662
|
+
description: 'Who authored this note: "ai" (default, your own update) or "human" (you are relaying something the user said).',
|
|
663
|
+
},
|
|
664
|
+
is_internal: {
|
|
665
|
+
type: 'boolean',
|
|
666
|
+
description: 'Whether the note is internal-only (hidden from customers). Defaults to true for work notes.',
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
required: ['task_id', 'note'],
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
name: 'add_comment',
|
|
674
|
+
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.',
|
|
675
|
+
inputSchema: {
|
|
676
|
+
type: 'object',
|
|
677
|
+
properties: {
|
|
678
|
+
task_id: {
|
|
679
|
+
type: 'number',
|
|
680
|
+
description: 'The ID of the task to comment on',
|
|
681
|
+
},
|
|
682
|
+
comment: {
|
|
683
|
+
type: 'string',
|
|
684
|
+
description: 'The comment text',
|
|
685
|
+
},
|
|
686
|
+
author_kind: {
|
|
687
|
+
type: 'string',
|
|
688
|
+
enum: ['ai', 'human'],
|
|
689
|
+
description: 'Who authored this comment: "ai" (default) or "human" (relaying the user).',
|
|
690
|
+
},
|
|
691
|
+
is_internal: {
|
|
692
|
+
type: 'boolean',
|
|
693
|
+
description: 'Whether the comment is internal-only (hidden from customers). Defaults to false.',
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
required: ['task_id', 'comment'],
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
name: 'set_resume_context',
|
|
701
|
+
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".',
|
|
702
|
+
inputSchema: {
|
|
703
|
+
type: 'object',
|
|
704
|
+
properties: {
|
|
705
|
+
task_id: {
|
|
706
|
+
type: 'number',
|
|
707
|
+
description: 'The ID of the task to set resume context for',
|
|
708
|
+
},
|
|
709
|
+
context: {
|
|
710
|
+
type: 'string',
|
|
711
|
+
description: 'The resume-context text (markdown ok): current state / next steps / where to look.',
|
|
712
|
+
},
|
|
713
|
+
author_kind: {
|
|
714
|
+
type: 'string',
|
|
715
|
+
enum: ['ai', 'human'],
|
|
716
|
+
description: 'Who authored this context: "ai" (default) or "human".',
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
required: ['task_id', 'context'],
|
|
720
|
+
},
|
|
721
|
+
},
|
|
361
722
|
// Products tools
|
|
362
723
|
{
|
|
363
724
|
name: 'list_products',
|
|
@@ -509,16 +870,17 @@ const toolHandlers = {
|
|
|
509
870
|
};
|
|
510
871
|
},
|
|
511
872
|
|
|
512
|
-
async list_tasks({ project_id, status }) {
|
|
873
|
+
async list_tasks({ project_id, status, verbose }) {
|
|
513
874
|
let endpoint = `/projects/${project_id}/tasks`;
|
|
514
|
-
if (status) endpoint += `?status=${status}`;
|
|
875
|
+
if (status) endpoint += `?status=${encodeURIComponent(status)}`;
|
|
515
876
|
|
|
516
877
|
const tasks = await apiRequest(endpoint);
|
|
878
|
+
const result = verbose ? tasks : (Array.isArray(tasks) ? tasks.map(compactifyTask) : tasks);
|
|
517
879
|
return {
|
|
518
880
|
content: [
|
|
519
881
|
{
|
|
520
882
|
type: 'text',
|
|
521
|
-
text: JSON.stringify(
|
|
883
|
+
text: JSON.stringify(result, null, 2),
|
|
522
884
|
},
|
|
523
885
|
],
|
|
524
886
|
};
|
|
@@ -536,7 +898,7 @@ const toolHandlers = {
|
|
|
536
898
|
};
|
|
537
899
|
},
|
|
538
900
|
|
|
539
|
-
async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority }) {
|
|
901
|
+
async create_task({ project_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, parent_task_id }) {
|
|
540
902
|
const task = await apiRequest(`/projects/${project_id}/tasks`, {
|
|
541
903
|
method: 'POST',
|
|
542
904
|
body: JSON.stringify({
|
|
@@ -545,6 +907,10 @@ const toolHandlers = {
|
|
|
545
907
|
acceptance_criteria,
|
|
546
908
|
estimated_minutes,
|
|
547
909
|
priority,
|
|
910
|
+
start_date,
|
|
911
|
+
due_date,
|
|
912
|
+
assignee_user_id,
|
|
913
|
+
parent_task_id,
|
|
548
914
|
}),
|
|
549
915
|
});
|
|
550
916
|
return {
|
|
@@ -557,13 +923,22 @@ const toolHandlers = {
|
|
|
557
923
|
};
|
|
558
924
|
},
|
|
559
925
|
|
|
560
|
-
async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority }) {
|
|
926
|
+
async update_task({ task_id, title, description, acceptance_criteria, estimated_minutes, priority, start_date, due_date, assignee_user_id, blocked_by, blocked_reason }) {
|
|
927
|
+
// Only forward what the caller actually named. An absent key leaves the
|
|
928
|
+
// field alone; an explicitly empty string is how a date or the assignee is
|
|
929
|
+
// cleared, and blocked_by is a REPLACE so an explicit [] must reach the
|
|
930
|
+
// API — which is why these are `!== undefined` rather than truthiness.
|
|
561
931
|
const updates = {};
|
|
562
932
|
if (title !== undefined) updates.title = title;
|
|
563
933
|
if (description !== undefined) updates.description = description;
|
|
564
934
|
if (acceptance_criteria !== undefined) updates.acceptance_criteria = acceptance_criteria;
|
|
565
935
|
if (estimated_minutes !== undefined) updates.estimated_minutes = estimated_minutes;
|
|
566
936
|
if (priority !== undefined) updates.priority = priority;
|
|
937
|
+
if (start_date !== undefined) updates.start_date = start_date;
|
|
938
|
+
if (due_date !== undefined) updates.due_date = due_date;
|
|
939
|
+
if (assignee_user_id !== undefined) updates.assignee_user_id = assignee_user_id;
|
|
940
|
+
if (blocked_by !== undefined) updates.blocked_by = blocked_by;
|
|
941
|
+
if (blocked_reason !== undefined) updates.blocked_reason = blocked_reason;
|
|
567
942
|
|
|
568
943
|
const task = await apiRequest(`/tasks/${task_id}`, {
|
|
569
944
|
method: 'PUT',
|
|
@@ -579,10 +954,10 @@ const toolHandlers = {
|
|
|
579
954
|
};
|
|
580
955
|
},
|
|
581
956
|
|
|
582
|
-
async update_task_status({ task_id, status }) {
|
|
957
|
+
async update_task_status({ task_id, status, blocked_reason }) {
|
|
583
958
|
const task = await apiRequest(`/tasks/${task_id}/status`, {
|
|
584
959
|
method: 'PUT',
|
|
585
|
-
body: JSON.stringify({ status }),
|
|
960
|
+
body: JSON.stringify({ status, blocked_reason }),
|
|
586
961
|
});
|
|
587
962
|
return {
|
|
588
963
|
content: [
|
|
@@ -613,9 +988,14 @@ const toolHandlers = {
|
|
|
613
988
|
};
|
|
614
989
|
},
|
|
615
990
|
|
|
616
|
-
async search_tasks({ query, status }) {
|
|
991
|
+
async search_tasks({ query, status, project_id, product_id, customer_id, verbose, limit }) {
|
|
617
992
|
let endpoint = `/tasks/search?q=${encodeURIComponent(query)}`;
|
|
618
|
-
if (status) endpoint += `&status=${status}`;
|
|
993
|
+
if (status) endpoint += `&status=${encodeURIComponent(status)}`;
|
|
994
|
+
if (project_id) endpoint += `&project_id=${encodeURIComponent(project_id)}`;
|
|
995
|
+
if (product_id) endpoint += `&product_id=${encodeURIComponent(product_id)}`;
|
|
996
|
+
if (customer_id) endpoint += `&customer_id=${encodeURIComponent(customer_id)}`;
|
|
997
|
+
if (verbose) endpoint += `&verbose=1`;
|
|
998
|
+
if (limit) endpoint += `&limit=${encodeURIComponent(limit)}`;
|
|
619
999
|
|
|
620
1000
|
const tasks = await apiRequest(endpoint);
|
|
621
1001
|
return {
|
|
@@ -677,26 +1057,116 @@ const toolHandlers = {
|
|
|
677
1057
|
body: JSON.stringify({ is_active: true }),
|
|
678
1058
|
});
|
|
679
1059
|
|
|
1060
|
+
// Orient a cold-starting session: surface the resume context + recent work notes.
|
|
1061
|
+
let orientation = '';
|
|
1062
|
+
try {
|
|
1063
|
+
const task = await apiRequest(`/tasks/${task_id}`);
|
|
1064
|
+
const lines = [];
|
|
1065
|
+
if (task.resume_context) {
|
|
1066
|
+
const by = task.resume_context_author_kind === 'ai' ? 'AI Agent' : 'human';
|
|
1067
|
+
lines.push(`\n--- Resume context (last set by ${by}) ---\n${task.resume_context}`);
|
|
1068
|
+
}
|
|
1069
|
+
const notes = task.recent_work_notes || [];
|
|
1070
|
+
if (notes.length) {
|
|
1071
|
+
lines.push('\n--- Recent work notes (newest first) ---');
|
|
1072
|
+
for (const n of notes) {
|
|
1073
|
+
let text = '';
|
|
1074
|
+
try { text = JSON.parse(n.event_data || '{}').note || ''; } catch {}
|
|
1075
|
+
const who = n.author_kind === 'ai' ? 'AI' : (n.author_name || 'human');
|
|
1076
|
+
lines.push(`• [${n.created_at}] (${who}) ${text}`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (lines.length) {
|
|
1080
|
+
orientation = `\n\nHere is where you left off:\n${lines.join('\n')}`;
|
|
1081
|
+
} else {
|
|
1082
|
+
orientation = `\n\nNo resume context or work notes yet — consider leaving some with add_work_note / set_resume_context before you stop.`;
|
|
1083
|
+
}
|
|
1084
|
+
} catch {
|
|
1085
|
+
// Non-fatal: assignment already succeeded.
|
|
1086
|
+
}
|
|
1087
|
+
|
|
680
1088
|
return {
|
|
681
1089
|
content: [
|
|
682
1090
|
{
|
|
683
1091
|
type: 'text',
|
|
684
|
-
text: `Started working on task #${task_id}. Other team members can now see you are actively working on this task
|
|
1092
|
+
text: `Started working on task #${task_id}. Other team members can now see you are actively working on this task.${orientation}`,
|
|
685
1093
|
},
|
|
686
1094
|
],
|
|
687
1095
|
};
|
|
688
1096
|
},
|
|
689
1097
|
|
|
690
|
-
async stop_working({ task_id }) {
|
|
1098
|
+
async stop_working({ task_id, note, resume_context }) {
|
|
1099
|
+
const captured = [];
|
|
1100
|
+
if (note && note.trim()) {
|
|
1101
|
+
await apiRequest(`/tasks/${task_id}/activity`, {
|
|
1102
|
+
method: 'POST',
|
|
1103
|
+
body: JSON.stringify({ note, author_kind: 'ai', is_internal: true }),
|
|
1104
|
+
});
|
|
1105
|
+
captured.push('work note');
|
|
1106
|
+
}
|
|
1107
|
+
if (resume_context !== undefined && resume_context !== null) {
|
|
1108
|
+
await apiRequest(`/tasks/${task_id}`, {
|
|
1109
|
+
method: 'PUT',
|
|
1110
|
+
body: JSON.stringify({ resume_context, resume_context_author_kind: 'ai' }),
|
|
1111
|
+
});
|
|
1112
|
+
captured.push('resume context');
|
|
1113
|
+
}
|
|
1114
|
+
|
|
691
1115
|
await apiRequest(`/tasks/${task_id}/assignments?stop_only=true`, {
|
|
692
1116
|
method: 'DELETE',
|
|
693
1117
|
});
|
|
694
1118
|
|
|
1119
|
+
const capturedText = captured.length ? ` Saved ${captured.join(' and ')} for next time.` : '';
|
|
1120
|
+
return {
|
|
1121
|
+
content: [
|
|
1122
|
+
{
|
|
1123
|
+
type: 'text',
|
|
1124
|
+
text: `Stopped working on task #${task_id}. You are still assigned to the task but no longer marked as actively working.${capturedText}`,
|
|
1125
|
+
},
|
|
1126
|
+
],
|
|
1127
|
+
};
|
|
1128
|
+
},
|
|
1129
|
+
|
|
1130
|
+
async add_work_note({ task_id, note, author_kind = 'ai', is_internal = true }) {
|
|
1131
|
+
const saved = await apiRequest(`/tasks/${task_id}/activity`, {
|
|
1132
|
+
method: 'POST',
|
|
1133
|
+
body: JSON.stringify({ note, author_kind, is_internal }),
|
|
1134
|
+
});
|
|
1135
|
+
return {
|
|
1136
|
+
content: [
|
|
1137
|
+
{
|
|
1138
|
+
type: 'text',
|
|
1139
|
+
text: `Work note added to task #${task_id} (as ${author_kind === 'ai' ? 'AI Agent' : 'human'}). Note id: ${saved.id}.`,
|
|
1140
|
+
},
|
|
1141
|
+
],
|
|
1142
|
+
};
|
|
1143
|
+
},
|
|
1144
|
+
|
|
1145
|
+
async add_comment({ task_id, comment, author_kind = 'ai', is_internal = false }) {
|
|
1146
|
+
const saved = await apiRequest(`/tasks/${task_id}/comments`, {
|
|
1147
|
+
method: 'POST',
|
|
1148
|
+
body: JSON.stringify({ comment, author_kind, is_internal }),
|
|
1149
|
+
});
|
|
1150
|
+
return {
|
|
1151
|
+
content: [
|
|
1152
|
+
{
|
|
1153
|
+
type: 'text',
|
|
1154
|
+
text: `Comment added to task #${task_id} (as ${author_kind === 'ai' ? 'AI Agent' : 'human'}). Comment id: ${saved.id}.`,
|
|
1155
|
+
},
|
|
1156
|
+
],
|
|
1157
|
+
};
|
|
1158
|
+
},
|
|
1159
|
+
|
|
1160
|
+
async set_resume_context({ task_id, context, author_kind = 'ai' }) {
|
|
1161
|
+
await apiRequest(`/tasks/${task_id}`, {
|
|
1162
|
+
method: 'PUT',
|
|
1163
|
+
body: JSON.stringify({ resume_context: context, resume_context_author_kind: author_kind }),
|
|
1164
|
+
});
|
|
695
1165
|
return {
|
|
696
1166
|
content: [
|
|
697
1167
|
{
|
|
698
1168
|
type: 'text',
|
|
699
|
-
text: `
|
|
1169
|
+
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.`,
|
|
700
1170
|
},
|
|
701
1171
|
],
|
|
702
1172
|
};
|
|
@@ -705,7 +1175,7 @@ const toolHandlers = {
|
|
|
705
1175
|
// Products handlers
|
|
706
1176
|
async list_products({ status }) {
|
|
707
1177
|
let endpoint = '/products';
|
|
708
|
-
if (status) endpoint += `?status=${status}`;
|
|
1178
|
+
if (status) endpoint += `?status=${encodeURIComponent(status)}`;
|
|
709
1179
|
|
|
710
1180
|
const products = await apiRequest(endpoint);
|
|
711
1181
|
return {
|
|
@@ -792,18 +1262,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
792
1262
|
const { name, arguments: args } = request.params;
|
|
793
1263
|
|
|
794
1264
|
const handler = toolHandlers[name];
|
|
795
|
-
|
|
1265
|
+
const tool = tools.find((t) => t.name === name);
|
|
1266
|
+
if (!handler || !tool) {
|
|
796
1267
|
throw new Error(`Unknown tool: ${name}`);
|
|
797
1268
|
}
|
|
798
1269
|
|
|
799
1270
|
try {
|
|
800
|
-
|
|
1271
|
+
const safeArgs = validateArgs(name, tool.inputSchema, args);
|
|
1272
|
+
return await handler(safeArgs);
|
|
801
1273
|
} catch (error) {
|
|
802
1274
|
return {
|
|
803
1275
|
content: [
|
|
804
1276
|
{
|
|
805
1277
|
type: 'text',
|
|
806
|
-
text: `Error: ${error
|
|
1278
|
+
text: `Error: ${redact(error?.message || 'unknown error')}`,
|
|
807
1279
|
},
|
|
808
1280
|
],
|
|
809
1281
|
isError: true,
|
|
@@ -819,6 +1291,6 @@ async function main() {
|
|
|
819
1291
|
}
|
|
820
1292
|
|
|
821
1293
|
main().catch((error) => {
|
|
822
|
-
console.error(
|
|
1294
|
+
console.error(`Fatal error: ${redact(error?.message || 'unknown error')}`);
|
|
823
1295
|
process.exit(1);
|
|
824
1296
|
});
|