@idea-base/mcp-server 2.2.0 → 2.4.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 +9 -1
- package/package.json +1 -1
- package/src/index.js +214 -7
package/README.md
CHANGED
|
@@ -66,7 +66,15 @@ Ask Claude to manage your projects:
|
|
|
66
66
|
| `list_projects` | List all projects with task counts and progress |
|
|
67
67
|
| `get_project` | Get project details and statistics |
|
|
68
68
|
| `create_project` | Create a new project or sub-project |
|
|
69
|
-
| `update_project` | Update project name, description, or
|
|
69
|
+
| `update_project` | Update a project name, description, status, parent (re-parent, or `null` to detach) or product |
|
|
70
|
+
| `list_sprints` | List a product's sprints with real task counts and its cadence |
|
|
71
|
+
| `get_sprint` | One sprint: tasks, counts, commitment ledger; optionally candidates + blocked work |
|
|
72
|
+
| `create_sprint` | Open a sprint — next on the cadence, or an explicit window |
|
|
73
|
+
| `add_tasks_to_sprint` | Commit tasks (refuses a task already in another sprint) |
|
|
74
|
+
| `remove_tasks_from_sprint` | Take tasks out of a sprint |
|
|
75
|
+
| `close_sprint` | Close it; unfinished work carries forward. Irreversible |
|
|
76
|
+
| `get_sprint_cadence` | Read a product's sprint rhythm |
|
|
77
|
+
| `set_sprint_cadence` | Set length, start weekday, carry-over, auto-open |
|
|
70
78
|
|
|
71
79
|
### Tasks
|
|
72
80
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -93,6 +93,18 @@ function redact(text) {
|
|
|
93
93
|
const MAX_STRING_LEN = 20000;
|
|
94
94
|
const STRING_LIMITS = { query: 500, date: 32, start_date: 32, due_date: 32 };
|
|
95
95
|
|
|
96
|
+
// A schema type may be a UNION, e.g. ['number', 'null'] — the only way to say
|
|
97
|
+
// "a number, or null meaning clear this". Both the null handling in
|
|
98
|
+
// validateArgs and coerce's branch selection have to read that union rather
|
|
99
|
+
// than compare `spec.type` with ===, or a nullable field silently behaves as a
|
|
100
|
+
// string field and its null never reaches the handler at all.
|
|
101
|
+
const typesOf = (spec) => {
|
|
102
|
+
const t = spec?.type;
|
|
103
|
+
return Array.isArray(t) ? t : (t ? [t] : []);
|
|
104
|
+
};
|
|
105
|
+
const allowsNull = (spec) => typesOf(spec).includes('null');
|
|
106
|
+
const primaryType = (spec) => typesOf(spec).find((t) => t !== 'null');
|
|
107
|
+
|
|
96
108
|
function validateArgs(toolName, schema, rawArgs) {
|
|
97
109
|
const args = rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) ? rawArgs : {};
|
|
98
110
|
const props = schema?.properties || {};
|
|
@@ -107,7 +119,16 @@ function validateArgs(toolName, schema, rawArgs) {
|
|
|
107
119
|
for (const [name, value] of Object.entries(args)) {
|
|
108
120
|
const spec = props[name];
|
|
109
121
|
if (!spec) continue; // drop anything the tool does not declare
|
|
110
|
-
if (value === undefined
|
|
122
|
+
if (value === undefined) continue;
|
|
123
|
+
if (value === null) {
|
|
124
|
+
// null is dropped unless the tool DECLARES it meaningful. For
|
|
125
|
+
// update_project.parent_project_id it means "detach this project", and
|
|
126
|
+
// this unconditional drop is why un-parenting stayed impossible through
|
|
127
|
+
// this server: the field reached here and vanished, so the request body
|
|
128
|
+
// came out empty and the API answered "No fields to update".
|
|
129
|
+
if (allowsNull(spec)) clean[name] = null;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
111
132
|
clean[name] = coerce(toolName, name, spec, value);
|
|
112
133
|
}
|
|
113
134
|
|
|
@@ -116,8 +137,9 @@ function validateArgs(toolName, schema, rawArgs) {
|
|
|
116
137
|
|
|
117
138
|
function coerce(toolName, name, spec, value) {
|
|
118
139
|
const label = `${toolName}: "${name}"`;
|
|
140
|
+
const type = primaryType(spec);
|
|
119
141
|
|
|
120
|
-
if (
|
|
142
|
+
if (type === 'number' || type === 'integer') {
|
|
121
143
|
const n = typeof value === 'number' ? value : Number(String(value).trim());
|
|
122
144
|
if (!Number.isFinite(n)) throw new Error(`${label} must be a number`);
|
|
123
145
|
if (name === 'id' || name.endsWith('_id')) {
|
|
@@ -128,9 +150,9 @@ function coerce(toolName, name, spec, value) {
|
|
|
128
150
|
return n;
|
|
129
151
|
}
|
|
130
152
|
|
|
131
|
-
if (
|
|
153
|
+
if (type === 'boolean') return Boolean(value);
|
|
132
154
|
|
|
133
|
-
if (
|
|
155
|
+
if (type === 'array') {
|
|
134
156
|
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
135
157
|
if (value.length > 500) throw new Error(`${label} has too many entries (max 500)`);
|
|
136
158
|
return value.map((item, i) => coerce(toolName, `${name}[${i}]`, spec.items || {}, item));
|
|
@@ -232,7 +254,12 @@ async function apiRequest(endpoint, options = {}) {
|
|
|
232
254
|
try {
|
|
233
255
|
const body = JSON.parse(text);
|
|
234
256
|
if (typeof body?.error === 'string' && body.error) {
|
|
235
|
-
|
|
257
|
+
// `error` is the headline and `details` is what to do about it — a
|
|
258
|
+
// plan-limit or product-inheritance refusal is not actionable without
|
|
259
|
+
// the second half, which used to be dropped here.
|
|
260
|
+
const parts = [body.error];
|
|
261
|
+
if (typeof body?.details === 'string' && body.details) parts.push(body.details);
|
|
262
|
+
message = parts.join(' — ').slice(0, MAX_ERROR_CHARS);
|
|
236
263
|
}
|
|
237
264
|
// Machine-readable discriminator, when the endpoint supplies one. A
|
|
238
265
|
// handler needs to tell "you have no open session" (a real answer the
|
|
@@ -327,7 +354,7 @@ const tools = [
|
|
|
327
354
|
},
|
|
328
355
|
{
|
|
329
356
|
name: 'update_project',
|
|
330
|
-
description: 'Update project
|
|
357
|
+
description: 'Update a project: its name, description, status, or where it sits in the hierarchy. Omitted fields are left exactly as they are. Set parent_project_id to re-parent the project, or to null to DETACH it into a top-level project; a detached project keeps the product it had inherited, so product_id is only needed to move it to a DIFFERENT product. A sub-project inherits its parent\'s product, so re-parenting under a parent in a different product moves the project there too. The API refuses a move that is circular, crosses accounts, or exceeds the plan\'s nesting depth or sub-project count, and says which.',
|
|
331
358
|
inputSchema: {
|
|
332
359
|
type: 'object',
|
|
333
360
|
properties: {
|
|
@@ -348,6 +375,14 @@ const tools = [
|
|
|
348
375
|
enum: ['active', 'completed', 'archived'],
|
|
349
376
|
description: 'New status for the project',
|
|
350
377
|
},
|
|
378
|
+
parent_project_id: {
|
|
379
|
+
type: ['number', 'null'],
|
|
380
|
+
description: 'Move the project under this parent. Pass null to detach it and make it top-level — it keeps whatever product it had, so no product_id is needed unless you also want to move it. Omit to leave the parent unchanged.',
|
|
381
|
+
},
|
|
382
|
+
product_id: {
|
|
383
|
+
type: 'number',
|
|
384
|
+
description: 'Move the project to this product. Only valid for a project with no parent — a sub-project cannot hold a product of its own, it inherits its parent\'s. Find ids via list_products.',
|
|
385
|
+
},
|
|
351
386
|
},
|
|
352
387
|
required: ['project_id'],
|
|
353
388
|
},
|
|
@@ -908,6 +943,108 @@ const tools = [
|
|
|
908
943
|
},
|
|
909
944
|
},
|
|
910
945
|
},
|
|
946
|
+
{
|
|
947
|
+
name: 'list_sprints',
|
|
948
|
+
description: 'List a product\'s sprints, newest first, with real task counts and its cadence. Sprints are owned by the PRODUCT: the product sets the rhythm and owns the lifecycle, while projects add and remove the work. A sprint can hold tasks from any project under that product, sub-projects included.',
|
|
949
|
+
inputSchema: {
|
|
950
|
+
type: 'object',
|
|
951
|
+
properties: {
|
|
952
|
+
product_id: { type: 'number', description: 'The product whose sprints to list. Find ids via list_products.' },
|
|
953
|
+
},
|
|
954
|
+
required: ['product_id'],
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'get_sprint',
|
|
959
|
+
description: 'Get one sprint: its tasks with derived status, counts taken from real task rows, and — for a CLOSED sprint — the commitment ledger saying what it committed to and what became of each item. Pass candidates:true to also get the open work that could be pulled in, and the work being held back as blocked (with the reason).',
|
|
960
|
+
inputSchema: {
|
|
961
|
+
type: 'object',
|
|
962
|
+
properties: {
|
|
963
|
+
sprint_id: { type: 'number', description: 'The sprint to retrieve' },
|
|
964
|
+
candidates: { type: 'boolean', description: 'Also return work eligible to be added, and blocked work held out of it. Default false.' },
|
|
965
|
+
},
|
|
966
|
+
required: ['sprint_id'],
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
name: 'create_sprint',
|
|
971
|
+
description: 'Open a sprint on a product. With no dates it opens the NEXT one on the product\'s cadence, which is the normal case; pass starts_on/ends_on only to set an explicit window. One sprint per product per period — a second for the same period is refused rather than duplicated.',
|
|
972
|
+
inputSchema: {
|
|
973
|
+
type: 'object',
|
|
974
|
+
properties: {
|
|
975
|
+
product_id: { type: 'number', description: 'The product to open the sprint on' },
|
|
976
|
+
name: { type: 'string', description: 'Optional. Defaults to its date range.' },
|
|
977
|
+
goal: { type: 'string', description: 'What this sprint is meant to achieve' },
|
|
978
|
+
starts_on: { type: 'string', description: 'YYYY-MM-DD. Omit to follow the cadence.' },
|
|
979
|
+
ends_on: { type: 'string', description: 'YYYY-MM-DD. Omit to run one cadence length from starts_on.' },
|
|
980
|
+
state: { type: 'string', enum: ['planned', 'active'], description: 'Default planned. A sprint cannot be created closed.' },
|
|
981
|
+
},
|
|
982
|
+
required: ['product_id'],
|
|
983
|
+
},
|
|
984
|
+
},
|
|
985
|
+
{
|
|
986
|
+
name: 'add_tasks_to_sprint',
|
|
987
|
+
description: 'Commit tasks to a sprint. Each task is checked on its own and the result says which were added and which were refused and why — a task already in another sprint is REFUSED, not moved, because a task belongs to at most one. Tasks must belong to a project under the sprint\'s product.',
|
|
988
|
+
inputSchema: {
|
|
989
|
+
type: 'object',
|
|
990
|
+
properties: {
|
|
991
|
+
sprint_id: { type: 'number', description: 'The sprint to add to' },
|
|
992
|
+
task_ids: { type: 'array', items: { type: 'number' }, description: 'Task ids to commit (max 100)' },
|
|
993
|
+
},
|
|
994
|
+
required: ['sprint_id', 'task_ids'],
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
name: 'remove_tasks_from_sprint',
|
|
999
|
+
description: 'Take tasks out of a sprint. The tasks themselves are untouched — the sprint is the time box, not the owner of the work.',
|
|
1000
|
+
inputSchema: {
|
|
1001
|
+
type: 'object',
|
|
1002
|
+
properties: {
|
|
1003
|
+
sprint_id: { type: 'number', description: 'The sprint to remove from' },
|
|
1004
|
+
task_ids: { type: 'array', items: { type: 'number' }, description: 'Task ids to remove (max 100)' },
|
|
1005
|
+
},
|
|
1006
|
+
required: ['sprint_id', 'task_ids'],
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
name: 'close_sprint',
|
|
1011
|
+
description: 'Close a sprint. Unfinished work moves into the next sprint and its carry count increments (unless the product\'s cadence has carry-over off, in which case it is released); finished work stays attached. Every task held is written to the commitment ledger first, so what the sprint committed to survives the carry. This is an action, not a status edit — closing is irreversible.',
|
|
1012
|
+
inputSchema: {
|
|
1013
|
+
type: 'object',
|
|
1014
|
+
properties: {
|
|
1015
|
+
sprint_id: { type: 'number', description: 'The sprint to close' },
|
|
1016
|
+
carry_to_sprint_id: { type: 'number', description: 'Optional explicit destination for unfinished work. Must be an open sprint in the same product; otherwise the next sprint is used or opened.' },
|
|
1017
|
+
},
|
|
1018
|
+
required: ['sprint_id'],
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
name: 'get_sprint_cadence',
|
|
1023
|
+
description: 'Get a product\'s sprint rhythm: length in days, which weekday it starts, whether unfinished work carries over, and whether sprints open unattended. A product with no cadence row still reports the weekly-Monday default, flagged as not configured.',
|
|
1024
|
+
inputSchema: {
|
|
1025
|
+
type: 'object',
|
|
1026
|
+
properties: {
|
|
1027
|
+
product_id: { type: 'number', description: 'The product whose cadence to read' },
|
|
1028
|
+
},
|
|
1029
|
+
required: ['product_id'],
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
name: 'set_sprint_cadence',
|
|
1034
|
+
description: 'Set a product\'s sprint rhythm. "Mon-Sun weekly" is length_days 7 + start_weekday 1; a Wednesday fortnight is 14 + 3. Changing it does not move sprints that already exist — it applies from the next sprint opened. Setting a cadence is also what opts a product into having sprints opened and closed on the schedule.',
|
|
1035
|
+
inputSchema: {
|
|
1036
|
+
type: 'object',
|
|
1037
|
+
properties: {
|
|
1038
|
+
product_id: { type: 'number', description: 'The product to configure' },
|
|
1039
|
+
length_days: { type: 'number', description: 'Sprint length in days, 1-90. Default 7.' },
|
|
1040
|
+
start_weekday: { type: 'number', description: '0 = Sunday through 6 = Saturday. Default 1 (Monday). Only decides the FIRST sprint; later ones follow the previous sprint\'s end.' },
|
|
1041
|
+
carry_over_unfinished: { type: 'boolean', description: 'Move unfinished work into the next sprint on close. Default true.' },
|
|
1042
|
+
auto_create: { type: 'boolean', description: 'Open the next sprint unattended on the schedule. Default true.' },
|
|
1043
|
+
enabled: { type: 'boolean', description: 'Whether the schedule touches this product at all. Default true.' },
|
|
1044
|
+
},
|
|
1045
|
+
required: ['product_id'],
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
911
1048
|
{
|
|
912
1049
|
name: 'get_product',
|
|
913
1050
|
description: 'Get details of a specific product including linked projects, team members, and statistics.',
|
|
@@ -1012,11 +1149,18 @@ const toolHandlers = {
|
|
|
1012
1149
|
};
|
|
1013
1150
|
},
|
|
1014
1151
|
|
|
1015
|
-
async update_project({ project_id, name, description, status }) {
|
|
1152
|
+
async update_project({ project_id, name, description, status, parent_project_id, product_id }) {
|
|
1016
1153
|
const updates = {};
|
|
1017
1154
|
if (name !== undefined) updates.name = name;
|
|
1018
1155
|
if (description !== undefined) updates.description = description;
|
|
1019
1156
|
if (status !== undefined) updates.status = status;
|
|
1157
|
+
// These two are forwarded on `!== undefined`, never on truthiness: null is
|
|
1158
|
+
// the MEANINGFUL value for parent_project_id (it detaches the project), and
|
|
1159
|
+
// PUT /api/projects/:id only writes a column it was actually sent — so an
|
|
1160
|
+
// omitted key leaves parent and product exactly as they were, and a
|
|
1161
|
+
// name-only update can never silently detach anything.
|
|
1162
|
+
if (parent_project_id !== undefined) updates.parent_project_id = parent_project_id;
|
|
1163
|
+
if (product_id !== undefined) updates.product_id = product_id;
|
|
1020
1164
|
|
|
1021
1165
|
const project = await apiRequest(`/projects/${project_id}`, {
|
|
1022
1166
|
method: 'PUT',
|
|
@@ -1581,6 +1725,69 @@ const toolHandlers = {
|
|
|
1581
1725
|
};
|
|
1582
1726
|
},
|
|
1583
1727
|
|
|
1728
|
+
async list_sprints({ product_id }) {
|
|
1729
|
+
const data = await apiRequest(`/products/${product_id}/sprints`);
|
|
1730
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1731
|
+
},
|
|
1732
|
+
|
|
1733
|
+
async get_sprint({ sprint_id, candidates }) {
|
|
1734
|
+
const data = await apiRequest(`/sprints/${sprint_id}${candidates ? '?candidates=1' : ''}`);
|
|
1735
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1736
|
+
},
|
|
1737
|
+
|
|
1738
|
+
async create_sprint({ product_id, name, goal, starts_on, ends_on, state }) {
|
|
1739
|
+
const data = await apiRequest(`/products/${product_id}/sprints`, {
|
|
1740
|
+
method: 'POST',
|
|
1741
|
+
body: JSON.stringify({ name, goal, starts_on, ends_on, state }),
|
|
1742
|
+
});
|
|
1743
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1744
|
+
},
|
|
1745
|
+
|
|
1746
|
+
async add_tasks_to_sprint({ sprint_id, task_ids }) {
|
|
1747
|
+
const data = await apiRequest(`/sprints/${sprint_id}/tasks`, {
|
|
1748
|
+
method: 'POST',
|
|
1749
|
+
body: JSON.stringify({ task_ids }),
|
|
1750
|
+
});
|
|
1751
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1752
|
+
},
|
|
1753
|
+
|
|
1754
|
+
async remove_tasks_from_sprint({ sprint_id, task_ids }) {
|
|
1755
|
+
const data = await apiRequest(`/sprints/${sprint_id}/tasks`, {
|
|
1756
|
+
method: 'DELETE',
|
|
1757
|
+
body: JSON.stringify({ task_ids }),
|
|
1758
|
+
});
|
|
1759
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1760
|
+
},
|
|
1761
|
+
|
|
1762
|
+
async close_sprint({ sprint_id, carry_to_sprint_id }) {
|
|
1763
|
+
const data = await apiRequest(`/sprints/${sprint_id}/close`, {
|
|
1764
|
+
method: 'POST',
|
|
1765
|
+
body: JSON.stringify({ carry_to_sprint_id }),
|
|
1766
|
+
});
|
|
1767
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1768
|
+
},
|
|
1769
|
+
|
|
1770
|
+
async get_sprint_cadence({ product_id }) {
|
|
1771
|
+
const data = await apiRequest(`/products/${product_id}/sprint-cadence`);
|
|
1772
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1773
|
+
},
|
|
1774
|
+
|
|
1775
|
+
async set_sprint_cadence({ product_id, length_days, start_weekday, carry_over_unfinished, auto_create, enabled }) {
|
|
1776
|
+
const body = {};
|
|
1777
|
+
// Forwarded on `!== undefined` so an omitted field keeps its current value
|
|
1778
|
+
// rather than being reset to a default the caller never asked for.
|
|
1779
|
+
if (length_days !== undefined) body.length_days = length_days;
|
|
1780
|
+
if (start_weekday !== undefined) body.start_weekday = start_weekday;
|
|
1781
|
+
if (carry_over_unfinished !== undefined) body.carry_over_unfinished = carry_over_unfinished;
|
|
1782
|
+
if (auto_create !== undefined) body.auto_create = auto_create;
|
|
1783
|
+
if (enabled !== undefined) body.enabled = enabled;
|
|
1784
|
+
const data = await apiRequest(`/products/${product_id}/sprint-cadence`, {
|
|
1785
|
+
method: 'PUT',
|
|
1786
|
+
body: JSON.stringify(body),
|
|
1787
|
+
});
|
|
1788
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1789
|
+
},
|
|
1790
|
+
|
|
1584
1791
|
async get_product({ product_id }) {
|
|
1585
1792
|
const product = await apiRequest(`/products/${product_id}`);
|
|
1586
1793
|
return {
|