@idea-base/mcp-server 1.2.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.
Files changed (3) hide show
  1. package/README.md +5 -5
  2. package/package.json +3 -3
  3. package/src/index.js +335 -38
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": "1.2.0",
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": ">=18.0.0"
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
- // Configuration from environment
19
- const API_BASE_URL = process.env.IDEA_BASE_API_URL || 'https://app.idea-base.us/api';
20
- const API_KEY = process.env.IDEA_BASE_API_KEY;
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
- // API helper function
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 response = await fetch(url, {
31
- ...options,
32
- headers: {
33
- 'Authorization': `Bearer ${API_KEY}`,
34
- 'Content-Type': 'application/json',
35
- ...options.headers,
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
- const error = await response.json().catch(() => ({ error: response.statusText }));
41
- throw new Error(error.error || `API error: ${response.status}`);
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.json();
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 task titles, status, estimates, and time logged.',
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
  },
@@ -594,16 +870,17 @@ const toolHandlers = {
594
870
  };
595
871
  },
596
872
 
597
- async list_tasks({ project_id, status }) {
873
+ async list_tasks({ project_id, status, verbose }) {
598
874
  let endpoint = `/projects/${project_id}/tasks`;
599
- if (status) endpoint += `?status=${status}`;
875
+ if (status) endpoint += `?status=${encodeURIComponent(status)}`;
600
876
 
601
877
  const tasks = await apiRequest(endpoint);
878
+ const result = verbose ? tasks : (Array.isArray(tasks) ? tasks.map(compactifyTask) : tasks);
602
879
  return {
603
880
  content: [
604
881
  {
605
882
  type: 'text',
606
- text: JSON.stringify(tasks, null, 2),
883
+ text: JSON.stringify(result, null, 2),
607
884
  },
608
885
  ],
609
886
  };
@@ -621,7 +898,7 @@ const toolHandlers = {
621
898
  };
622
899
  },
623
900
 
624
- 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 }) {
625
902
  const task = await apiRequest(`/projects/${project_id}/tasks`, {
626
903
  method: 'POST',
627
904
  body: JSON.stringify({
@@ -630,6 +907,10 @@ const toolHandlers = {
630
907
  acceptance_criteria,
631
908
  estimated_minutes,
632
909
  priority,
910
+ start_date,
911
+ due_date,
912
+ assignee_user_id,
913
+ parent_task_id,
633
914
  }),
634
915
  });
635
916
  return {
@@ -642,13 +923,22 @@ const toolHandlers = {
642
923
  };
643
924
  },
644
925
 
645
- 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.
646
931
  const updates = {};
647
932
  if (title !== undefined) updates.title = title;
648
933
  if (description !== undefined) updates.description = description;
649
934
  if (acceptance_criteria !== undefined) updates.acceptance_criteria = acceptance_criteria;
650
935
  if (estimated_minutes !== undefined) updates.estimated_minutes = estimated_minutes;
651
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;
652
942
 
653
943
  const task = await apiRequest(`/tasks/${task_id}`, {
654
944
  method: 'PUT',
@@ -664,10 +954,10 @@ const toolHandlers = {
664
954
  };
665
955
  },
666
956
 
667
- async update_task_status({ task_id, status }) {
957
+ async update_task_status({ task_id, status, blocked_reason }) {
668
958
  const task = await apiRequest(`/tasks/${task_id}/status`, {
669
959
  method: 'PUT',
670
- body: JSON.stringify({ status }),
960
+ body: JSON.stringify({ status, blocked_reason }),
671
961
  });
672
962
  return {
673
963
  content: [
@@ -698,9 +988,14 @@ const toolHandlers = {
698
988
  };
699
989
  },
700
990
 
701
- async search_tasks({ query, status }) {
991
+ async search_tasks({ query, status, project_id, product_id, customer_id, verbose, limit }) {
702
992
  let endpoint = `/tasks/search?q=${encodeURIComponent(query)}`;
703
- 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)}`;
704
999
 
705
1000
  const tasks = await apiRequest(endpoint);
706
1001
  return {
@@ -880,7 +1175,7 @@ const toolHandlers = {
880
1175
  // Products handlers
881
1176
  async list_products({ status }) {
882
1177
  let endpoint = '/products';
883
- if (status) endpoint += `?status=${status}`;
1178
+ if (status) endpoint += `?status=${encodeURIComponent(status)}`;
884
1179
 
885
1180
  const products = await apiRequest(endpoint);
886
1181
  return {
@@ -967,18 +1262,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
967
1262
  const { name, arguments: args } = request.params;
968
1263
 
969
1264
  const handler = toolHandlers[name];
970
- if (!handler) {
1265
+ const tool = tools.find((t) => t.name === name);
1266
+ if (!handler || !tool) {
971
1267
  throw new Error(`Unknown tool: ${name}`);
972
1268
  }
973
1269
 
974
1270
  try {
975
- return await handler(args);
1271
+ const safeArgs = validateArgs(name, tool.inputSchema, args);
1272
+ return await handler(safeArgs);
976
1273
  } catch (error) {
977
1274
  return {
978
1275
  content: [
979
1276
  {
980
1277
  type: 'text',
981
- text: `Error: ${error.message}`,
1278
+ text: `Error: ${redact(error?.message || 'unknown error')}`,
982
1279
  },
983
1280
  ],
984
1281
  isError: true,
@@ -994,6 +1291,6 @@ async function main() {
994
1291
  }
995
1292
 
996
1293
  main().catch((error) => {
997
- console.error('Fatal error:', error);
1294
+ console.error(`Fatal error: ${redact(error?.message || 'unknown error')}`);
998
1295
  process.exit(1);
999
1296
  });