@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.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/package.json +1 -1
  3. 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 status |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idea-base/mcp-server",
3
- "version": "2.2.0",
3
+ "version": "2.4.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",
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 || value === null) continue;
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 (spec.type === 'number' || spec.type === 'integer') {
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 (spec.type === 'boolean') return Boolean(value);
153
+ if (type === 'boolean') return Boolean(value);
132
154
 
133
- if (spec.type === 'array') {
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
- message = body.error.slice(0, MAX_ERROR_CHARS);
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 details like name, description, or status.',
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 {