@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.
- package/README.md +5 -5
- package/package.json +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": "
|
|
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": ">=
|
|
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
|
-
//
|
|
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();
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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 ${
|
|
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,
|
|
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,
|
|
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 ${
|
|
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
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
1293
|
+
console.error(`Fatal error: ${redact(error?.message || 'unknown error')}`);
|
|
998
1294
|
process.exit(1);
|
|
999
1295
|
});
|