@dpesch/mantisbt-mcp-server 1.9.1 → 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,6 +7,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
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
+
10
18
  ## [1.9.1] – 2026-03-30
11
19
 
12
20
  ### Fixed
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';
@@ -75,20 +76,23 @@ export function registerSearchTools(server, client, store, embedder) {
75
76
  const dateFilter = { updated_after, updated_before, created_after, created_before };
76
77
  const filterActive = hasDateFilter(dateFilter);
77
78
  const terms = highlight ? extractTerms(query) : [];
78
- const queryVector = await embedder.embed(query);
79
+ const [queryVector, baseUrl] = await Promise.all([
80
+ embedder.embed(query),
81
+ client.getBaseUrl(),
82
+ ]);
79
83
  const results = await store.search(queryVector, top_n);
80
84
  if (!select) {
81
85
  // For filtering or highlighting we need store metadata per result
82
86
  if (!filterActive && !terms.length) {
83
87
  return {
84
- 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) }],
85
89
  };
86
90
  }
87
91
  const filtered = await Promise.all(results.map(async ({ id, score }) => {
88
92
  const item = await store.getItem(id);
89
93
  if (filterActive && !matchesDateFilter(item?.metadata ?? {}, dateFilter))
90
94
  return null;
91
- const result = { id, score };
95
+ const result = { id, score, view_url: buildIssueViewUrl(baseUrl, id) };
92
96
  if (terms.length > 0 && item) {
93
97
  const h = buildHighlights(item.metadata.summary, item.metadata.description, terms);
94
98
  if (h)
@@ -108,7 +112,7 @@ export function registerSearchTools(server, client, store, embedder) {
108
112
  if (filterActive && !matchesDateFilter(issue, dateFilter)) {
109
113
  return null;
110
114
  }
111
- const projected = { id, score };
115
+ const projected = { id, score, view_url: buildIssueViewUrl(baseUrl, id) };
112
116
  for (const field of fields) {
113
117
  if (field !== 'id' && field in issue) {
114
118
  projected[field] = issue[field];
@@ -124,7 +128,7 @@ export function registerSearchTools(server, client, store, embedder) {
124
128
  return projected;
125
129
  }
126
130
  catch {
127
- const result = { id, score };
131
+ const result = { id, score, view_url: buildIssueViewUrl(baseUrl, id) };
128
132
  if (terms.length > 0) {
129
133
  const item = await store.getItem(id);
130
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) {
@@ -419,9 +439,13 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
419
439
  patch[field] = resolved;
420
440
  }
421
441
  }
422
- 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;
423
447
  return {
424
- content: [{ type: 'text', text: JSON.stringify(result.issue ?? result, null, 2) }],
448
+ content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl), null, 2) }],
425
449
  };
426
450
  }
427
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) {
@@ -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.1",
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.1",
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.1",
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 () => {