@idea-base/mcp-server 2.2.0 → 2.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 CHANGED
@@ -66,7 +66,7 @@ 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
70
 
71
71
  ### Tasks
72
72
 
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.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",
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
  },
@@ -1012,11 +1047,18 @@ const toolHandlers = {
1012
1047
  };
1013
1048
  },
1014
1049
 
1015
- async update_project({ project_id, name, description, status }) {
1050
+ async update_project({ project_id, name, description, status, parent_project_id, product_id }) {
1016
1051
  const updates = {};
1017
1052
  if (name !== undefined) updates.name = name;
1018
1053
  if (description !== undefined) updates.description = description;
1019
1054
  if (status !== undefined) updates.status = status;
1055
+ // These two are forwarded on `!== undefined`, never on truthiness: null is
1056
+ // the MEANINGFUL value for parent_project_id (it detaches the project), and
1057
+ // PUT /api/projects/:id only writes a column it was actually sent — so an
1058
+ // omitted key leaves parent and product exactly as they were, and a
1059
+ // name-only update can never silently detach anything.
1060
+ if (parent_project_id !== undefined) updates.parent_project_id = parent_project_id;
1061
+ if (product_id !== undefined) updates.product_id = product_id;
1020
1062
 
1021
1063
  const project = await apiRequest(`/projects/${project_id}`, {
1022
1064
  method: 'PUT',