@dpesch/mantisbt-mcp-server 1.9.0 → 1.10.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/CHANGELOG.md CHANGED
@@ -7,7 +7,19 @@ This project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
- ## [Unreleased]
10
+ ## [1.10.0] – 2026-04-11
11
+
12
+ ### Added
13
+ - All issue and note responses now include a `view_url` field with the absolute MantisBT web URL for the respective item. The URL is built by the MCP server from `MANTIS_BASE_URL` — it is not provided by the MantisBT REST API. Pattern: `{baseUrl}/view.php?id={issueId}` for issues; `{baseUrl}/view.php?id={issueId}#bugnoteN` for notes. The field is present in all tools that return issues or notes: `get_issue`, `get_issues`, `list_issues`, `create_issue`, `update_issue`, `list_notes`, `add_note`, and `search_issues`. It is always included regardless of the `select` parameter.
14
+ - `MantisClient` gains a new public `getBaseUrl()` method (returns the resolved, normalised base URL).
15
+
16
+ ---
17
+
18
+ ## [1.9.1] – 2026-03-30
19
+
20
+ ### Fixed
21
+ - Boolean parameters (`dry_run`, `highlight`, `check_latest`, `obsolete`, `inherit`) now accept the strings `"true"` and `"false"` in addition to native booleans. MCP clients that serialize all parameters as JSON strings no longer receive error -32602. Note: `z.coerce.boolean()` was intentionally not used — it would silently convert the string `"false"` to `true` via JavaScript's `Boolean()`.
22
+ - `update_issue`: the `fields` parameter now accepts a JSON-encoded string in addition to a plain object. Invalid JSON is caught and surfaced as a Zod validation error instead of an uncaught `SyntaxError`.
11
23
 
12
24
  ---
13
25
 
package/README.de.md CHANGED
@@ -87,6 +87,7 @@ npm run build
87
87
  | Tool | Beschreibung |
88
88
  |---|---|
89
89
  | `get_issue` | Ein Issue anhand seiner ID abrufen |
90
+ | `get_issues` | Mehrere Issues per ID in einem Aufruf abrufen (1–50 IDs); nicht zugängliche IDs liefern `null` an ihrer Position, statt den gesamten Aufruf abzubrechen |
90
91
  | `list_issues` | Issues nach Projekt, Status, Autor u.v.m. filtern; optionales `select` für Feldprojektion und `status` für clientseitige Statusfilterung — kanonische englische Statusnamen (z.B. `"new"`, `"resolved"`) werden per ID abgeglichen und funktionieren damit sprachunabhängig auf lokalisierten Installationen |
91
92
  | `create_issue` | Neues Issue anlegen; `severity` und `priority` müssen kanonische englische Namen sein (z.B. `minor`, `major`, `normal`, `high`) — `get_issue_enums` aufrufen, um alle gültigen Werte und deren lokalisierte Bezeichnungen zu sehen; optionaler `handler`-Parameter akzeptiert einen Benutzernamen als Alternative zu `handler_id` (wird gegen die Projektmitglieder aufgelöst) |
92
93
  | `update_issue` | Bestehendes Issue bearbeiten; Enum-Felder (`status`, `priority`, `severity`, `resolution`, `reproducibility`) akzeptieren kanonische englische Namen, lokalisierte Namen oder numerische IDs — der Server löst Namen automatisch zu IDs auf |
package/README.md CHANGED
@@ -87,6 +87,7 @@ npm run build
87
87
  | Tool | Description |
88
88
  |---|---|
89
89
  | `get_issue` | Retrieve an issue by its numeric ID |
90
+ | `get_issues` | Retrieve multiple issues by ID in one call (1–50 IDs); missing or inaccessible IDs return `null` at their position instead of failing the call |
90
91
  | `list_issues` | Filter issues by project, status, author, and more; optional `select` for field projection and `status` for client-side status filtering — canonical English status names (e.g. `"new"`, `"resolved"`) are matched by ID, making the filter language-independent on localized installations |
91
92
  | `create_issue` | Create a new issue; `severity` and `priority` must be canonical English names (e.g. `minor`, `major`, `normal`, `high`) — call `get_issue_enums` to see all valid values and their localized labels; optional `handler` parameter accepts a username as alternative to `handler_id` (resolved against project members) |
92
93
  | `update_issue` | Update an existing issue; enum fields (`status`, `priority`, `severity`, `resolution`, `reproducibility`) accept canonical English names, localized names, or numeric IDs — the server resolves names to IDs automatically |
package/dist/client.js CHANGED
@@ -10,6 +10,12 @@
10
10
  export function normalizeBaseUrl(url) {
11
11
  return url.replace(/\/api\/rest\/?$/, '').replace(/\/$/, '');
12
12
  }
13
+ export function buildIssueViewUrl(baseUrl, issueId) {
14
+ return `${baseUrl}/view.php?id=${issueId}`;
15
+ }
16
+ export function buildNoteViewUrl(baseUrl, issueId, noteId) {
17
+ return `${baseUrl}/view.php?id=${issueId}#bugnote${noteId}`;
18
+ }
13
19
  // ---------------------------------------------------------------------------
14
20
  // MantisApiError
15
21
  // ---------------------------------------------------------------------------
@@ -45,6 +51,9 @@ export class MantisClient {
45
51
  }
46
52
  return this.resolvedCredentials;
47
53
  }
54
+ async getBaseUrl() {
55
+ return (await this.getCredentials()).baseUrl;
56
+ }
48
57
  async buildUrl(path, params) {
49
58
  const { baseUrl } = await this.getCredentials();
50
59
  const url = new URL(`${baseUrl}/api/rest/${path}`);
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { buildIssueViewUrl } from '../client.js';
2
3
  import { SearchSyncService } from './sync.js';
3
4
  import { getVersionHint } from '../version-hint.js';
4
5
  import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
@@ -24,6 +25,7 @@ function errorText(msg) {
24
25
  // ---------------------------------------------------------------------------
25
26
  // registerSearchTools
26
27
  // ---------------------------------------------------------------------------
28
+ const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
27
29
  export function registerSearchTools(server, client, store, embedder) {
28
30
  // ---------------------------------------------------------------------------
29
31
  // search_issues
@@ -45,7 +47,7 @@ export function registerSearchTools(server, client, store, embedder) {
45
47
  'When provided, each matching issue is fetched from MantisBT and enriched with the requested fields. ' +
46
48
  'The relevance score is always included. Without this parameter only id and score are returned.'),
47
49
  highlight: z
48
- .boolean()
50
+ .preprocess(coerceBool, z.boolean())
49
51
  .default(false)
50
52
  .describe('If true, adds a "highlights" field per result with query terms bolded (**term**) ' +
51
53
  'in the issue summary and a short description snippet. ' +
@@ -74,20 +76,23 @@ export function registerSearchTools(server, client, store, embedder) {
74
76
  const dateFilter = { updated_after, updated_before, created_after, created_before };
75
77
  const filterActive = hasDateFilter(dateFilter);
76
78
  const terms = highlight ? extractTerms(query) : [];
77
- const queryVector = await embedder.embed(query);
79
+ const [queryVector, baseUrl] = await Promise.all([
80
+ embedder.embed(query),
81
+ client.getBaseUrl(),
82
+ ]);
78
83
  const results = await store.search(queryVector, top_n);
79
84
  if (!select) {
80
85
  // For filtering or highlighting we need store metadata per result
81
86
  if (!filterActive && !terms.length) {
82
87
  return {
83
- content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
88
+ content: [{ type: 'text', text: JSON.stringify(results.map(({ id, score }) => ({ id, score, view_url: buildIssueViewUrl(baseUrl, id) })), null, 2) }],
84
89
  };
85
90
  }
86
91
  const filtered = await Promise.all(results.map(async ({ id, score }) => {
87
92
  const item = await store.getItem(id);
88
93
  if (filterActive && !matchesDateFilter(item?.metadata ?? {}, dateFilter))
89
94
  return null;
90
- const result = { id, score };
95
+ const result = { id, score, view_url: buildIssueViewUrl(baseUrl, id) };
91
96
  if (terms.length > 0 && item) {
92
97
  const h = buildHighlights(item.metadata.summary, item.metadata.description, terms);
93
98
  if (h)
@@ -107,7 +112,7 @@ export function registerSearchTools(server, client, store, embedder) {
107
112
  if (filterActive && !matchesDateFilter(issue, dateFilter)) {
108
113
  return null;
109
114
  }
110
- const projected = { id, score };
115
+ const projected = { id, score, view_url: buildIssueViewUrl(baseUrl, id) };
111
116
  for (const field of fields) {
112
117
  if (field !== 'id' && field in issue) {
113
118
  projected[field] = issue[field];
@@ -123,7 +128,7 @@ export function registerSearchTools(server, client, store, embedder) {
123
128
  return projected;
124
129
  }
125
130
  catch {
126
- const result = { id, score };
131
+ const result = { id, score, view_url: buildIssueViewUrl(baseUrl, id) };
127
132
  if (terms.length > 0) {
128
133
  const item = await store.getItem(id);
129
134
  if (item) {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { buildIssueViewUrl, buildNoteViewUrl } from '../client.js';
2
3
  import { getVersionHint } from '../version-hint.js';
3
4
  import { MANTIS_CANONICAL_ENUM_NAMES, MANTIS_RESOLVED_STATUS_ID, resolveEnumId } from '../constants.js';
4
5
  import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
@@ -9,6 +10,16 @@ function errorText(msg) {
9
10
  const hint = vh?.getUpdateHint();
10
11
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
11
12
  }
13
+ function enrichIssue(issue, baseUrl) {
14
+ return {
15
+ ...issue,
16
+ view_url: buildIssueViewUrl(baseUrl, issue.id),
17
+ notes: issue.notes?.map(note => ({
18
+ ...note,
19
+ view_url: buildNoteViewUrl(baseUrl, issue.id, note.id),
20
+ })),
21
+ };
22
+ }
12
23
  const GET_ISSUES_CONCURRENCY = 5;
13
24
  // Worker-pool: runs `fn` over all `items` with at most `concurrency` in-flight at once.
14
25
  // nextIndex is only incremented inside microtasks, so the ++ is safe without a lock.
@@ -63,8 +74,9 @@ export function registerIssueTools(server, client, cache) {
63
74
  try {
64
75
  const result = await client.get(`issues/${id}`);
65
76
  const issue = result.issues?.[0] ?? result;
77
+ const baseUrl = await client.getBaseUrl();
66
78
  return {
67
- content: [{ type: 'text', text: JSON.stringify(issue, null, 2) }],
79
+ content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl), null, 2) }],
68
80
  };
69
81
  }
70
82
  catch (error) {
@@ -95,15 +107,19 @@ export function registerIssueTools(server, client, cache) {
95
107
  idempotentHint: true,
96
108
  },
97
109
  }, async ({ ids }) => {
98
- const results = await runWithConcurrency(ids, GET_ISSUES_CONCURRENCY, async (id) => {
99
- try {
100
- const result = await client.get(`issues/${id}`);
101
- return result.issues?.[0] ?? result;
102
- }
103
- catch {
104
- return null;
105
- }
106
- });
110
+ const [rawResults, baseUrl] = await Promise.all([
111
+ runWithConcurrency(ids, GET_ISSUES_CONCURRENCY, async (id) => {
112
+ try {
113
+ const result = await client.get(`issues/${id}`);
114
+ return result.issues?.[0] ?? result;
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }),
120
+ client.getBaseUrl(),
121
+ ]);
122
+ const results = rawResults.map(issue => issue !== null ? enrichIssue(issue, baseUrl) : null);
107
123
  const found = results.filter((r) => r !== null).length;
108
124
  return {
109
125
  content: [{
@@ -149,11 +165,13 @@ export function registerIssueTools(server, client, cache) {
149
165
  };
150
166
  const dateFilter = { updated_after, updated_before, created_after, created_before };
151
167
  const needsClientFilter = status !== undefined || assigned_to !== undefined || reporter_id !== undefined || hasDateFilter(dateFilter);
168
+ const baseUrl = await client.getBaseUrl();
152
169
  if (!needsClientFilter) {
153
170
  // No client-side filtering — single API call, pass pagination as-is
154
171
  const result = await client.get('issues', { ...baseParams, page, page_size });
172
+ const enriched = { ...result, issues: result.issues?.map(i => enrichIssue(i, baseUrl)) };
155
173
  return {
156
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
174
+ content: [{ type: 'text', text: JSON.stringify(enriched, null, 2) }],
157
175
  };
158
176
  }
159
177
  // Client-side filtering active: scan multiple API pages until we have
@@ -214,10 +232,11 @@ export function registerIssueTools(server, client, cache) {
214
232
  serverPage++;
215
233
  }
216
234
  const start = (page - 1) * page_size;
235
+ const pageIssues = matching.slice(start, start + page_size).map(i => enrichIssue(i, baseUrl));
217
236
  return {
218
237
  content: [{
219
238
  type: 'text',
220
- text: JSON.stringify({ issues: matching.slice(start, start + page_size) }, null, 2),
239
+ text: JSON.stringify({ issues: pageIssues }, null, 2),
221
240
  }],
222
241
  };
223
242
  }
@@ -330,8 +349,9 @@ export function registerIssueTools(server, client, cache) {
330
349
  // unable to fetch details — return minimal object
331
350
  }
332
351
  }
352
+ const baseUrl = await client.getBaseUrl();
333
353
  return {
334
- content: [{ type: 'text', text: JSON.stringify(issue, null, 2) }],
354
+ content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl), null, 2) }],
335
355
  };
336
356
  }
337
357
  catch (error) {
@@ -342,6 +362,7 @@ export function registerIssueTools(server, client, cache) {
342
362
  // ---------------------------------------------------------------------------
343
363
  // update_issue
344
364
  // ---------------------------------------------------------------------------
365
+ const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
345
366
  // MantisBT reference shape: at least one of id or name must be provided
346
367
  const ref = z.object({ id: z.number().optional(), name: z.string().optional() })
347
368
  .refine(o => o.id !== undefined || o.name !== undefined, { message: "At least one of 'id' or 'name' must be provided" });
@@ -369,8 +390,17 @@ The "fields" object accepts any combination of:
369
390
  Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".`,
370
391
  inputSchema: z.object({
371
392
  id: z.coerce.number().int().positive().describe('Numeric issue ID to update'),
372
- dry_run: z.boolean().optional().describe('If true, return the patch payload that would be sent without actually updating the issue. Useful for previewing changes before committing them.'),
373
- fields: z.object({
393
+ dry_run: z.preprocess(coerceBool, z.boolean().optional()).describe('If true, return the patch payload that would be sent without actually updating the issue. Useful for previewing changes before committing them.'),
394
+ fields: z.preprocess((v) => {
395
+ if (typeof v !== 'string')
396
+ return v;
397
+ try {
398
+ return JSON.parse(v);
399
+ }
400
+ catch {
401
+ return v;
402
+ }
403
+ }, z.object({
374
404
  summary: z.string().optional(),
375
405
  description: z.string().optional(),
376
406
  steps_to_reproduce: z.string().optional(),
@@ -386,7 +416,7 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
386
416
  target_version: ref.optional(),
387
417
  fixed_in_version: ref.optional(),
388
418
  view_state: ref.optional(),
389
- }).strict().describe('Fields to update (partial update — only provided fields are changed; unknown keys are rejected)'),
419
+ }).strict().describe('Fields to update (partial update — only provided fields are changed; unknown keys are rejected)')),
390
420
  }),
391
421
  annotations: {
392
422
  readOnlyHint: false,
@@ -409,9 +439,13 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
409
439
  patch[field] = resolved;
410
440
  }
411
441
  }
412
- const result = await client.patch(`issues/${id}`, patch);
442
+ const [result, baseUrl] = await Promise.all([
443
+ client.patch(`issues/${id}`, patch),
444
+ client.getBaseUrl(),
445
+ ]);
446
+ const issue = result.issue ?? result;
413
447
  return {
414
- content: [{ type: 'text', text: JSON.stringify(result.issue ?? result, null, 2) }],
448
+ content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl), null, 2) }],
415
449
  };
416
450
  }
417
451
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { buildNoteViewUrl } from '../client.js';
2
3
  import { getVersionHint } from '../version-hint.js';
3
4
  function errorText(msg) {
4
5
  const vh = getVersionHint();
@@ -23,8 +24,14 @@ export function registerNoteTools(server, client) {
23
24
  },
24
25
  }, async ({ issue_id }) => {
25
26
  try {
26
- const result = await client.get(`issues/${issue_id}`);
27
- const notes = result.issues?.[0]?.notes ?? [];
27
+ const [result, baseUrl] = await Promise.all([
28
+ client.get(`issues/${issue_id}`),
29
+ client.getBaseUrl(),
30
+ ]);
31
+ const notes = (result.issues?.[0]?.notes ?? []).map(note => ({
32
+ ...note,
33
+ view_url: buildNoteViewUrl(baseUrl, issue_id, note.id),
34
+ }));
28
35
  return {
29
36
  content: [{ type: 'text', text: JSON.stringify(notes, null, 2) }],
30
37
  };
@@ -56,9 +63,13 @@ export function registerNoteTools(server, client) {
56
63
  text,
57
64
  view_state: { name: view_state },
58
65
  };
59
- const result = await client.post(`issues/${issue_id}/notes`, body);
66
+ const [result, baseUrl] = await Promise.all([
67
+ client.post(`issues/${issue_id}/notes`, body),
68
+ client.getBaseUrl(),
69
+ ]);
70
+ const note = result.note ?? result;
60
71
  return {
61
- content: [{ type: 'text', text: JSON.stringify(result.note ?? result, null, 2) }],
72
+ content: [{ type: 'text', text: JSON.stringify({ ...note, view_url: buildNoteViewUrl(baseUrl, issue_id, note.id) }, null, 2) }],
62
73
  };
63
74
  }
64
75
  catch (error) {
@@ -8,6 +8,7 @@ function errorText(msg) {
8
8
  const hint = vh?.getUpdateHint();
9
9
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
10
10
  }
11
+ const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
11
12
  export function registerProjectTools(server, client, cache) {
12
13
  // ---------------------------------------------------------------------------
13
14
  // list_projects
@@ -72,8 +73,8 @@ export function registerProjectTools(server, client, cache) {
72
73
  description: 'List all versions defined for a MantisBT project.',
73
74
  inputSchema: z.object({
74
75
  project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
75
- obsolete: z.boolean().default(false).describe('Include obsolete (deprecated) versions (default: false)'),
76
- inherit: z.boolean().default(false).describe('Include versions inherited from parent projects (default: false)'),
76
+ obsolete: z.preprocess(coerceBool, z.boolean()).default(false).describe('Include obsolete (deprecated) versions (default: false)'),
77
+ inherit: z.preprocess(coerceBool, z.boolean()).default(false).describe('Include versions inherited from parent projects (default: false)'),
77
78
  }),
78
79
  annotations: {
79
80
  readOnlyHint: true,
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { parseVersion, compareVersions } from '../version-hint.js';
3
+ const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
3
4
  export function registerVersionTools(server, client, versionHint, mcpVersion) {
4
5
  // ---------------------------------------------------------------------------
5
6
  // get_mcp_version
@@ -26,7 +27,7 @@ export function registerVersionTools(server, client, versionHint, mcpVersion) {
26
27
  The version is read from the X-Mantis-Version response header sent by every API call.
27
28
  The GitHub comparison requires an outbound HTTPS request to the GitHub API.`,
28
29
  inputSchema: z.object({
29
- check_latest: z.boolean().default(true).describe('Whether to fetch the latest release from GitHub and compare (default: true)'),
30
+ check_latest: z.preprocess(coerceBool, z.boolean()).default(true).describe('Whether to fetch the latest release from GitHub and compare (default: true)'),
30
31
  }),
31
32
  annotations: {
32
33
  readOnlyHint: true,
@@ -10,6 +10,7 @@ Tool-orientierte Rezepte für den MantisBT MCP Server — jedes Rezept zeigt gen
10
10
  - [Gültige Feldnamen für `select` ermitteln](#gültige-feldnamen-für-select-ermitteln)
11
11
  - [Issues](#issues)
12
12
  - [Einzelnes Issue abrufen](#einzelnes-issue-abrufen)
13
+ - [Mehrere Issues in einem Aufruf abrufen](#mehrere-issues-in-einem-aufruf-abrufen)
13
14
  - [Issues auflisten (paginiert)](#issues-auflisten-paginiert)
14
15
  - [Antwortgröße mit `select` reduzieren](#antwortgröße-mit-select-reduzieren)
15
16
  - [Nach Status filtern](#nach-status-filtern)
@@ -222,12 +223,59 @@ Ruft ein einzelnes Issue anhand seiner numerischen ID ab, inklusive Notizen, Anh
222
223
  "tags": [],
223
224
  "notes": [],
224
225
  "attachments": [],
225
- "relationships": []
226
+ "relationships": [],
227
+ "view_url": "https://mantis.example.com/view.php?id=1042"
226
228
  }
227
229
  ```
228
230
 
229
231
  ---
230
232
 
233
+ ### Mehrere Issues in einem Aufruf abrufen
234
+
235
+ Ruft bis zu 50 Issues in einem einzigen MCP-Aufruf ab. Die Anfragen laufen parallel (max. 5 gleichzeitig). Nicht zugängliche IDs liefern `null` an ihrer Position — der Aufruf schlägt nie wegen einzelner fehlender IDs fehl.
236
+
237
+ **Tool:** `get_issues`
238
+
239
+ **Parameter:**
240
+ - `ids` — Array numerischer Issue-IDs (1–50)
241
+
242
+ **Request:**
243
+
244
+ ```json
245
+ {
246
+ "ids": [1042, 1041, 9999]
247
+ }
248
+ ```
249
+
250
+ **Response:**
251
+
252
+ ```json
253
+ {
254
+ "issues": [
255
+ {
256
+ "id": 1042,
257
+ "summary": "Login-Button auf mobilem Safari reagiert nicht",
258
+ "status": { "id": 50, "name": "assigned" },
259
+ "view_url": "https://mantis.example.com/view.php?id=1042"
260
+ },
261
+ {
262
+ "id": 1041,
263
+ "summary": "Checkout-Gesamtbetrag wird falsch gerundet",
264
+ "status": { "id": 40, "name": "confirmed" },
265
+ "view_url": "https://mantis.example.com/view.php?id=1041"
266
+ },
267
+ null
268
+ ],
269
+ "requested": 3,
270
+ "found": 2,
271
+ "failed": 1
272
+ }
273
+ ```
274
+
275
+ > **Hinweis:** `null`-Einträge zeigen IDs an, die nicht gefunden oder nicht zugänglich waren. `failed` gibt an, wie viele IDs nicht abgerufen werden konnten.
276
+
277
+ ---
278
+
231
279
  ### Issues auflisten (paginiert)
232
280
 
233
281
  Gibt eine paginierte Liste von Issues zurück, optional auf ein Projekt beschränkt.
@@ -258,13 +306,15 @@ Gibt eine paginierte Liste von Issues zurück, optional auf ein Projekt beschrä
258
306
  "id": 1042,
259
307
  "summary": "Login button unresponsive on mobile Safari",
260
308
  "status": { "id": 50, "name": "assigned" },
261
- "handler": { "id": 7, "name": "jdoe" }
309
+ "handler": { "id": 7, "name": "jdoe" },
310
+ "view_url": "https://mantis.example.com/view.php?id=1042"
262
311
  },
263
312
  {
264
313
  "id": 1041,
265
314
  "summary": "Checkout total rounds incorrectly",
266
315
  "status": { "id": 40, "name": "confirmed" },
267
- "handler": { "id": 4, "name": "jsmith" }
316
+ "handler": { "id": 4, "name": "jsmith" },
317
+ "view_url": "https://mantis.example.com/view.php?id=1041"
268
318
  }
269
319
  // ...
270
320
  ]
@@ -301,7 +351,8 @@ Eine kommagetrennte Liste von Feldnamen übergeben, um nur die benötigten Felde
301
351
  "id": 1042,
302
352
  "summary": "Login button unresponsive on mobile Safari",
303
353
  "status": { "id": 50, "name": "assigned" },
304
- "handler": { "id": 7, "name": "jdoe" }
354
+ "handler": { "id": 7, "name": "jdoe" },
355
+ "view_url": "https://mantis.example.com/view.php?id=1042"
305
356
  }
306
357
  // ...
307
358
  ]
@@ -310,6 +361,8 @@ Eine kommagetrennte Liste von Feldnamen übergeben, um nur die benötigten Felde
310
361
 
311
362
  > **Hinweis:** Mit `get_issue_fields()` lassen sich alle verfügbaren Feldnamen anzeigen.
312
363
 
364
+ > **Hinweis:** `view_url` ist in allen Issue-Responses immer vorhanden — es wird vom MCP-Server injiziert und wird durch den `select`-Parameter nicht beeinflusst.
365
+
313
366
  ---
314
367
 
315
368
  ### Nach Status filtern
@@ -496,7 +549,8 @@ Legt ein neues Issue in MantisBT an.
496
549
  "tags": [],
497
550
  "notes": [],
498
551
  "attachments": [],
499
- "relationships": []
552
+ "relationships": [],
553
+ "view_url": "https://mantis.example.com/view.php?id=1042"
500
554
  }
501
555
  ```
502
556
 
@@ -543,7 +597,8 @@ Löst ein Issue auf und schließt es. **Immer beide Felder** `status` und `resol
543
597
  "summary": "Login-Button auf mobilem Safari reagiert nicht",
544
598
  "status": { "id": 80, "name": "resolved" },
545
599
  "resolution": { "id": 20, "name": "fixed" },
546
- "updated_at": "2024-11-06T10:30:00+00:00"
600
+ "updated_at": "2024-11-06T10:30:00+00:00",
601
+ "view_url": "https://mantis.example.com/view.php?id=1042"
547
602
  }
548
603
  ```
549
604
 
@@ -580,7 +635,8 @@ Löst ein Issue auf und schließt es. **Immer beide Felder** `status` und `resol
580
635
  "summary": "Login-Button auf mobilem Safari reagiert nicht",
581
636
  "status": { "id": 50, "name": "assigned" },
582
637
  "handler": { "id": 7, "name": "jdoe" },
583
- "updated_at": "2024-11-06T11:00:00+00:00"
638
+ "updated_at": "2024-11-06T11:00:00+00:00",
639
+ "view_url": "https://mantis.example.com/view.php?id=1042"
584
640
  }
585
641
  ```
586
642
 
@@ -614,7 +670,8 @@ Setzt das Feld `fixed_in_version` eines Issues.
614
670
  "id": 1042,
615
671
  "summary": "Login-Button auf mobilem Safari reagiert nicht",
616
672
  "fixed_in_version": { "name": "2.1.0" },
617
- "updated_at": "2024-11-06T11:15:00+00:00"
673
+ "updated_at": "2024-11-06T11:15:00+00:00",
674
+ "view_url": "https://mantis.example.com/view.php?id=1042"
618
675
  }
619
676
  ```
620
677
 
@@ -652,7 +709,8 @@ Fügt eine öffentlich sichtbare Notiz zu einem Issue hinzu.
652
709
  "reporter": { "id": 7, "name": "jdoe" },
653
710
  "text": "In Version 2.0.3 reproduziert. Ursache in der Auth-Middleware identifiziert.",
654
711
  "view_state": { "id": 10, "name": "public" },
655
- "created_at": "2024-11-05T14:02:11+00:00"
712
+ "created_at": "2024-11-05T14:02:11+00:00",
713
+ "view_url": "https://mantis.example.com/view.php?id=1042#bugnote88"
656
714
  }
657
715
  ```
658
716
 
@@ -687,7 +745,8 @@ Fügt eine Notiz hinzu, die nur für Entwickler und Manager sichtbar ist.
687
745
  "reporter": { "id": 7, "name": "jdoe" },
688
746
  "text": "Intern: Ursache ist das nicht erneuerte Session-Token.",
689
747
  "view_state": { "id": 50, "name": "private" },
690
- "created_at": "2024-11-05T14:05:00+00:00"
748
+ "created_at": "2024-11-05T14:05:00+00:00",
749
+ "view_url": "https://mantis.example.com/view.php?id=1042#bugnote89"
691
750
  }
692
751
  ```
693
752
 
@@ -1265,9 +1324,9 @@ Findet Issues, die einem natürlichsprachigen Suchbegriff semantisch ähnlich si
1265
1324
 
1266
1325
  ```json
1267
1326
  [
1268
- { "id": 1042, "score": 0.91 },
1269
- { "id": 987, "score": 0.84 },
1270
- { "id": 1015, "score": 0.79 }
1327
+ { "id": 1042, "score": 0.91, "view_url": "https://mantis.example.com/view.php?id=1042" },
1328
+ { "id": 987, "score": 0.84, "view_url": "https://mantis.example.com/view.php?id=987" },
1329
+ { "id": 1015, "score": 0.79, "view_url": "https://mantis.example.com/view.php?id=1015" }
1271
1330
  ]
1272
1331
  ```
1273
1332
 
@@ -1306,7 +1365,8 @@ Reichert Suchergebnisse mit bestimmten Feldern aus MantisBT an. Ohne `select` we
1306
1365
  "score": 0.91,
1307
1366
  "summary": "Login button unresponsive on mobile Safari",
1308
1367
  "status": { "id": 50, "name": "assigned" },
1309
- "handler": { "id": 7, "name": "jdoe" }
1368
+ "handler": { "id": 7, "name": "jdoe" },
1369
+ "view_url": "https://mantis.example.com/view.php?id=1042"
1310
1370
  }
1311
1371
  // ...
1312
1372
  ]
@@ -1344,6 +1404,7 @@ Zeigt, welcher Teil eines Issues mit der Suchanfrage übereinstimmt. Jedes Ergeb
1344
1404
  {
1345
1405
  "id": 1042,
1346
1406
  "score": 0.91,
1407
+ "view_url": "https://mantis.example.com/view.php?id=1042",
1347
1408
  "highlights": {
1348
1409
  "summary": "**Login**-Button reagiert nach **Passwort**-**Reset** auf Mobile Safari nicht",
1349
1410
  "description": "…Benutzer tippt auf **Login** und es passiert nichts. Reproduzierbar nach einem **Passwort**-**Reset**-Vorgang…"
@@ -1352,13 +1413,15 @@ Zeigt, welcher Teil eines Issues mit der Suchanfrage übereinstimmt. Jedes Ergeb
1352
1413
  {
1353
1414
  "id": 987,
1354
1415
  "score": 0.84,
1416
+ "view_url": "https://mantis.example.com/view.php?id=987",
1355
1417
  "highlights": {
1356
1418
  "summary": "**Login** schlägt mit 401 fehl — Token ungültig"
1357
1419
  }
1358
1420
  },
1359
1421
  {
1360
1422
  "id": 1015,
1361
- "score": 0.79
1423
+ "score": 0.79,
1424
+ "view_url": "https://mantis.example.com/view.php?id=1015"
1362
1425
  }
1363
1426
  ]
1364
1427
  ```
package/docs/cookbook.md CHANGED
@@ -10,6 +10,7 @@ Tool-oriented recipes for the MantisBT MCP server — each recipe shows exactly
10
10
  - [Discover valid field names for `select`](#discover-valid-field-names-for-select)
11
11
  - [Issues](#issues)
12
12
  - [Fetch a single issue](#fetch-a-single-issue)
13
+ - [Fetch multiple issues in one call](#fetch-multiple-issues-in-one-call)
13
14
  - [List issues (paginated)](#list-issues-paginated)
14
15
  - [Reduce response size with `select`](#reduce-response-size-with-select)
15
16
  - [Filter by status](#filter-by-status)
@@ -222,12 +223,59 @@ Retrieves a single issue by its numeric ID including notes, attachments, tags, a
222
223
  "tags": [],
223
224
  "notes": [],
224
225
  "attachments": [],
225
- "relationships": []
226
+ "relationships": [],
227
+ "view_url": "https://mantis.example.com/view.php?id=1042"
226
228
  }
227
229
  ```
228
230
 
229
231
  ---
230
232
 
233
+ ### Fetch multiple issues in one call
234
+
235
+ Fetches up to 50 issues in a single MCP call. Requests run in parallel (max 5 concurrent). Missing or inaccessible IDs return `null` at their position — the call never fails due to individual missing IDs.
236
+
237
+ **Tool:** `get_issues`
238
+
239
+ **Parameters:**
240
+ - `ids` — array of numeric issue IDs (1–50)
241
+
242
+ **Request:**
243
+
244
+ ```json
245
+ {
246
+ "ids": [1042, 1041, 9999]
247
+ }
248
+ ```
249
+
250
+ **Response:**
251
+
252
+ ```json
253
+ {
254
+ "issues": [
255
+ {
256
+ "id": 1042,
257
+ "summary": "Login button unresponsive on mobile Safari",
258
+ "status": { "id": 50, "name": "assigned" },
259
+ "view_url": "https://mantis.example.com/view.php?id=1042"
260
+ },
261
+ {
262
+ "id": 1041,
263
+ "summary": "Checkout total rounds incorrectly",
264
+ "status": { "id": 40, "name": "confirmed" },
265
+ "view_url": "https://mantis.example.com/view.php?id=1041"
266
+ },
267
+ null
268
+ ],
269
+ "requested": 3,
270
+ "found": 2,
271
+ "failed": 1
272
+ }
273
+ ```
274
+
275
+ > **Note:** `null` entries indicate IDs that were not found or could not be accessed. Check `failed` to see how many IDs could not be retrieved.
276
+
277
+ ---
278
+
231
279
  ### List issues (paginated)
232
280
 
233
281
  Returns a paginated list of issues, optionally scoped to a project.
@@ -258,13 +306,15 @@ Returns a paginated list of issues, optionally scoped to a project.
258
306
  "id": 1042,
259
307
  "summary": "Login button unresponsive on mobile Safari",
260
308
  "status": { "id": 50, "name": "assigned" },
261
- "handler": { "id": 7, "name": "jdoe" }
309
+ "handler": { "id": 7, "name": "jdoe" },
310
+ "view_url": "https://mantis.example.com/view.php?id=1042"
262
311
  },
263
312
  {
264
313
  "id": 1041,
265
314
  "summary": "Checkout total rounds incorrectly",
266
315
  "status": { "id": 40, "name": "confirmed" },
267
- "handler": { "id": 4, "name": "jsmith" }
316
+ "handler": { "id": 4, "name": "jsmith" },
317
+ "view_url": "https://mantis.example.com/view.php?id=1041"
268
318
  }
269
319
  // ...
270
320
  ]
@@ -301,7 +351,8 @@ Pass a comma-separated list of field names to receive only the fields you need.
301
351
  "id": 1042,
302
352
  "summary": "Login button unresponsive on mobile Safari",
303
353
  "status": { "id": 50, "name": "assigned" },
304
- "handler": { "id": 7, "name": "jdoe" }
354
+ "handler": { "id": 7, "name": "jdoe" },
355
+ "view_url": "https://mantis.example.com/view.php?id=1042"
305
356
  }
306
357
  // ...
307
358
  ]
@@ -310,6 +361,8 @@ Pass a comma-separated list of field names to receive only the fields you need.
310
361
 
311
362
  > **Note:** Use `get_issue_fields()` to see all available field names.
312
363
 
364
+ > **Note:** `view_url` is always present in all issue responses — it is injected by the MCP server and is not affected by the `select` parameter.
365
+
313
366
  ---
314
367
 
315
368
  ### Filter by status
@@ -496,7 +549,8 @@ Creates a new issue in MantisBT.
496
549
  "tags": [],
497
550
  "notes": [],
498
551
  "attachments": [],
499
- "relationships": []
552
+ "relationships": [],
553
+ "view_url": "https://mantis.example.com/view.php?id=1042"
500
554
  }
501
555
  ```
502
556
 
@@ -543,7 +597,8 @@ Resolves and closes an issue. Always set **both** `status` and `resolution` —
543
597
  "summary": "Login button unresponsive on mobile Safari",
544
598
  "status": { "id": 80, "name": "resolved" },
545
599
  "resolution": { "id": 20, "name": "fixed" },
546
- "updated_at": "2024-11-06T10:30:00+00:00"
600
+ "updated_at": "2024-11-06T10:30:00+00:00",
601
+ "view_url": "https://mantis.example.com/view.php?id=1042"
547
602
  }
548
603
  ```
549
604
 
@@ -580,7 +635,8 @@ Changes the handler (assignee) of an existing issue.
580
635
  "summary": "Login button unresponsive on mobile Safari",
581
636
  "status": { "id": 50, "name": "assigned" },
582
637
  "handler": { "id": 7, "name": "jdoe" },
583
- "updated_at": "2024-11-06T11:00:00+00:00"
638
+ "updated_at": "2024-11-06T11:00:00+00:00",
639
+ "view_url": "https://mantis.example.com/view.php?id=1042"
584
640
  }
585
641
  ```
586
642
 
@@ -614,7 +670,8 @@ Sets the `fixed_in_version` field on an issue.
614
670
  "id": 1042,
615
671
  "summary": "Login button unresponsive on mobile Safari",
616
672
  "fixed_in_version": { "name": "2.1.0" },
617
- "updated_at": "2024-11-06T11:15:00+00:00"
673
+ "updated_at": "2024-11-06T11:15:00+00:00",
674
+ "view_url": "https://mantis.example.com/view.php?id=1042"
618
675
  }
619
676
  ```
620
677
 
@@ -652,7 +709,8 @@ Adds a publicly visible note to an issue.
652
709
  "reporter": { "id": 7, "name": "jdoe" },
653
710
  "text": "Reproduced on version 2.0.3. Root cause identified in the auth middleware.",
654
711
  "view_state": { "id": 10, "name": "public" },
655
- "created_at": "2024-11-05T14:02:11+00:00"
712
+ "created_at": "2024-11-05T14:02:11+00:00",
713
+ "view_url": "https://mantis.example.com/view.php?id=1042#bugnote88"
656
714
  }
657
715
  ```
658
716
 
@@ -687,7 +745,8 @@ Adds a note visible only to developers and managers.
687
745
  "reporter": { "id": 7, "name": "jdoe" },
688
746
  "text": "Internal: this is caused by the session token not being refreshed.",
689
747
  "view_state": { "id": 50, "name": "private" },
690
- "created_at": "2024-11-05T14:05:00+00:00"
748
+ "created_at": "2024-11-05T14:05:00+00:00",
749
+ "view_url": "https://mantis.example.com/view.php?id=1042#bugnote89"
691
750
  }
692
751
  ```
693
752
 
@@ -1265,9 +1324,9 @@ Finds issues semantically similar to a natural language query. Returns issue IDs
1265
1324
 
1266
1325
  ```json
1267
1326
  [
1268
- { "id": 1042, "score": 0.91 },
1269
- { "id": 987, "score": 0.84 },
1270
- { "id": 1015, "score": 0.79 }
1327
+ { "id": 1042, "score": 0.91, "view_url": "https://mantis.example.com/view.php?id=1042" },
1328
+ { "id": 987, "score": 0.84, "view_url": "https://mantis.example.com/view.php?id=987" },
1329
+ { "id": 1015, "score": 0.79, "view_url": "https://mantis.example.com/view.php?id=1015" }
1271
1330
  ]
1272
1331
  ```
1273
1332
 
@@ -1306,7 +1365,8 @@ Enriches search results with specific fields fetched from MantisBT. Without `sel
1306
1365
  "score": 0.91,
1307
1366
  "summary": "Login button unresponsive on mobile Safari",
1308
1367
  "status": { "id": 50, "name": "assigned" },
1309
- "handler": { "id": 7, "name": "jdoe" }
1368
+ "handler": { "id": 7, "name": "jdoe" },
1369
+ "view_url": "https://mantis.example.com/view.php?id=1042"
1310
1370
  }
1311
1371
  // ...
1312
1372
  ]
@@ -1344,6 +1404,7 @@ Shows which part of an issue matched the search query. Each result that has keyw
1344
1404
  {
1345
1405
  "id": 1042,
1346
1406
  "score": 0.91,
1407
+ "view_url": "https://mantis.example.com/view.php?id=1042",
1347
1408
  "highlights": {
1348
1409
  "summary": "**Login** button unresponsive after **password** **reset** on mobile Safari",
1349
1410
  "description": "…user taps **login** and nothing happens. Reproducible after a **password** **reset** flow…"
@@ -1352,13 +1413,15 @@ Shows which part of an issue matched the search query. Each result that has keyw
1352
1413
  {
1353
1414
  "id": 987,
1354
1415
  "score": 0.84,
1416
+ "view_url": "https://mantis.example.com/view.php?id=987",
1355
1417
  "highlights": {
1356
1418
  "summary": "**Login** fails with 401 — token invalidated"
1357
1419
  }
1358
1420
  },
1359
1421
  {
1360
1422
  "id": 1015,
1361
- "score": 0.79
1423
+ "score": 0.79,
1424
+ "view_url": "https://mantis.example.com/view.php?id=1015"
1362
1425
  }
1363
1426
  ]
1364
1427
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "mcpName": "io.github.dpesch/mantisbt-mcp-server",
5
5
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
6
6
  "author": "Dominik Pesch",
package/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.dpesch/mantisbt-mcp-server",
4
4
  "title": "MantisBT MCP Server",
5
5
  "description": "MantisBT MCP server – manage issues, notes, files, tags, and relationships. With semantic search.",
6
- "version": "1.9.0",
6
+ "version": "1.10.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@dpesch/mantisbt-mcp-server",
11
- "version": "1.9.0",
11
+ "version": "1.10.0",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { MantisClient, MantisApiError } from '../src/client.js';
2
+ import { MantisClient, MantisApiError, buildIssueViewUrl, buildNoteViewUrl } from '../src/client.js';
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // Helpers
@@ -331,3 +331,42 @@ describe('MantisClient – responseObserver', () => {
331
331
  expect(observer).not.toHaveBeenCalled();
332
332
  });
333
333
  });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // URL helpers
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('buildIssueViewUrl', () => {
340
+ it('builds the correct MantisBT issue view URL', () => {
341
+ expect(buildIssueViewUrl('https://mantis.example.com', 42))
342
+ .toBe('https://mantis.example.com/view.php?id=42');
343
+ });
344
+
345
+ it('works with base URLs that have a path prefix', () => {
346
+ expect(buildIssueViewUrl('https://example.com/mantis', 1))
347
+ .toBe('https://example.com/mantis/view.php?id=1');
348
+ });
349
+ });
350
+
351
+ describe('buildNoteViewUrl', () => {
352
+ it('builds the correct MantisBT note anchor URL', () => {
353
+ expect(buildNoteViewUrl('https://mantis.example.com', 42, 99))
354
+ .toBe('https://mantis.example.com/view.php?id=42#bugnote99');
355
+ });
356
+ });
357
+
358
+ describe('MantisClient – getBaseUrl', () => {
359
+ it('returns the normalized base URL (direct constructor)', async () => {
360
+ const client = new MantisClient('https://mantis.example.com', 'token');
361
+ expect(await client.getBaseUrl()).toBe('https://mantis.example.com');
362
+ });
363
+
364
+ it('returns the base URL from the credential factory', async () => {
365
+ const factory = vi.fn().mockResolvedValue({
366
+ baseUrl: 'https://lazy.example.com',
367
+ apiKey: 'key',
368
+ });
369
+ const client = new MantisClient(factory);
370
+ expect(await client.getBaseUrl()).toBe('https://lazy.example.com');
371
+ });
372
+ });
@@ -164,16 +164,17 @@ describe('rebuild_search_index – full: false', () => {
164
164
  // ---------------------------------------------------------------------------
165
165
 
166
166
  describe('search_issues – select parameter', () => {
167
- it('returns plain {id, score} array when select is not provided', async () => {
167
+ it('returns plain {id, score, view_url} array when select is not provided', async () => {
168
168
  const store = makeMockStore({ itemCount: 2 });
169
169
  registerSearchTools(mockServer as never, client, store, embedder);
170
170
 
171
171
  const result = await mockServer.callTool('search_issues', { query: 'test', top_n: 2 });
172
172
 
173
173
  expect(result.isError).toBeUndefined();
174
- const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number }>;
174
+ const parsed = JSON.parse(result.content[0]!.text) as Array<{ id: number; score: number; view_url: string }>;
175
175
  expect(parsed[0]).toEqual(expect.objectContaining({ id: expect.any(Number), score: expect.any(Number) }));
176
- expect(Object.keys(parsed[0]!)).toEqual(['id', 'score']);
176
+ expect(Object.keys(parsed[0]!)).toEqual(['id', 'score', 'view_url']);
177
+ expect(parsed[0]!.view_url).toBe(`https://mantis.example.com/view.php?id=${parsed[0]!.id}`);
177
178
  });
178
179
 
179
180
  it('fetches issues and projects requested fields when select is provided', async () => {
@@ -234,8 +235,8 @@ describe('search_issues – select parameter', () => {
234
235
  expect(parsed).toHaveLength(2);
235
236
  // First item enriched
236
237
  expect(parsed[0]).toHaveProperty('summary');
237
- // Second item fallback — only id and score
238
- expect(Object.keys(parsed[1]!).sort()).toEqual(['id', 'score']);
238
+ // Second item fallback — id, score, and view_url
239
+ expect(Object.keys(parsed[1]!).sort()).toEqual(['id', 'score', 'view_url']);
239
240
  });
240
241
 
241
242
  it('omits non-existent fields silently', async () => {
@@ -630,3 +631,31 @@ describe('search_issues – highlight: true combined with date filter', () => {
630
631
  expect(parsed[0]!.id).toBe(1);
631
632
  });
632
633
  });
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // string-coercion – highlight as string
637
+ // ---------------------------------------------------------------------------
638
+
639
+ describe('string-coercion – search_issues highlight as string', () => {
640
+ it('accepts highlight "true" as boolean true', async () => {
641
+ const store = makeMockStore({
642
+ items: [{ id: 1, score: 0.9 }],
643
+ });
644
+ vi.mocked(store.getItem).mockResolvedValue({
645
+ id: 1,
646
+ vector: [],
647
+ metadata: { summary: 'Login error occurred', description: 'The login fails.' },
648
+ });
649
+ registerSearchTools(mockServer as never, client, store, embedder);
650
+
651
+ const result = await mockServer.callTool(
652
+ 'search_issues',
653
+ { query: 'login error', top_n: 1, highlight: 'true' },
654
+ { validate: true },
655
+ );
656
+
657
+ expect(result.isError).toBeUndefined();
658
+ const parsed = JSON.parse(result.content[0]!.text) as Array<Record<string, unknown>>;
659
+ expect(parsed[0]).toHaveProperty('highlights');
660
+ });
661
+ });
@@ -19,6 +19,8 @@ import { registerMonitorTools } from '../../src/tools/monitors.js';
19
19
  import { registerRelationshipTools } from '../../src/tools/relationships.js';
20
20
  import { registerTagTools } from '../../src/tools/tags.js';
21
21
  import { registerProjectTools } from '../../src/tools/projects.js';
22
+ import { registerVersionTools } from '../../src/tools/version.js';
23
+ import { VersionHintService } from '../../src/version-hint.js';
22
24
  import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
23
25
 
24
26
  // ---------------------------------------------------------------------------
@@ -39,6 +41,7 @@ beforeEach(() => {
39
41
  registerRelationshipTools(mockServer as never, client);
40
42
  registerTagTools(mockServer as never, client);
41
43
  registerProjectTools(mockServer as never, client);
44
+ registerVersionTools(mockServer as never, client, new VersionHintService(), '0.0.0-test');
42
45
  vi.stubGlobal('fetch', vi.fn());
43
46
  });
44
47
 
@@ -251,3 +254,64 @@ describe('string-coercion – get_project_versions', () => {
251
254
  expect(result.isError).toBeUndefined();
252
255
  });
253
256
  });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Boolean-Parameter als String
260
+ // ---------------------------------------------------------------------------
261
+
262
+ describe('string-coercion – update_issue fields as JSON string', () => {
263
+ it('accepts fields as JSON string', async () => {
264
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issue: { id: 99, summary: 'Updated' } })));
265
+ const result = await mockServer.callTool(
266
+ 'update_issue',
267
+ { id: 99, fields: '{"summary":"Updated"}' },
268
+ { validate: true },
269
+ );
270
+ expect(result.isError).toBeUndefined();
271
+ });
272
+
273
+ it('rejects invalid JSON in fields with a validation error (not an uncaught exception)', async () => {
274
+ const result = await mockServer.callTool(
275
+ 'update_issue',
276
+ { id: 99, fields: '{invalid json' },
277
+ { validate: true },
278
+ );
279
+ expect(result.isError).toBe(true);
280
+ });
281
+ });
282
+
283
+ describe('string-coercion – update_issue dry_run as string', () => {
284
+ it('accepts dry_run "true" as boolean true', async () => {
285
+ const result = await mockServer.callTool(
286
+ 'update_issue',
287
+ { id: 99, fields: { summary: 'x' }, dry_run: 'true' },
288
+ { validate: true },
289
+ );
290
+ expect(result.isError).toBeUndefined();
291
+ expect(result.content[0]?.text).toContain('"dry_run": true');
292
+ });
293
+ });
294
+
295
+ describe('string-coercion – get_project_versions boolean flags', () => {
296
+ it('accepts obsolete "true" and inherit "false" as booleans', async () => {
297
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ projects: [{ versions: [] }] })));
298
+ const result = await mockServer.callTool(
299
+ 'get_project_versions',
300
+ { project_id: 3, obsolete: 'true', inherit: 'false' },
301
+ { validate: true },
302
+ );
303
+ expect(result.isError).toBeUndefined();
304
+ });
305
+ });
306
+
307
+ describe('string-coercion – get_mantis_version check_latest as string', () => {
308
+ it('accepts check_latest "false" as boolean false', async () => {
309
+ vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ version: '2.26.0' })));
310
+ const result = await mockServer.callTool(
311
+ 'get_mantis_version',
312
+ { check_latest: 'false' },
313
+ { validate: true },
314
+ );
315
+ expect(result.isError).toBeUndefined();
316
+ });
317
+ });