@dpesch/mantisbt-mcp-server 1.14.0 → 1.15.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.
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBool } from '../coerce.js';
2
3
  import { getVersionHint } from '../version-hint.js';
3
4
  import { ALL_PROJECTS_PREFIX } from '../constants.js';
5
+ import { deprecationNote } from '../deprecation.js';
6
+ import { fetchIssueEnumsWithCache } from './config.js';
4
7
  // Fields MantisBT strips from issue responses when the array is empty.
5
8
  // They are always valid 'select' values even if absent in a sample issue.
6
9
  const EMPTY_STRIPPED_FIELDS = [
@@ -89,6 +92,13 @@ function normalizeIdName(o) {
89
92
  return { id: o.id, name: o.name, ...(o.label !== undefined && { label: o.label }) };
90
93
  }
91
94
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ /** MantisBT prefixes global categories with "[All Projects] "; stripped so the names work in create_issue. */
96
+ export function stripAllProjectsPrefix(categories) {
97
+ return categories.map(cat => ({
98
+ ...cat,
99
+ name: cat.name.startsWith(ALL_PROJECTS_PREFIX) ? cat.name.slice(ALL_PROJECTS_PREFIX.length) : cat.name,
100
+ }));
101
+ }
92
102
  export function normalizeProject(p) {
93
103
  return {
94
104
  id: p.id,
@@ -123,12 +133,7 @@ async function fetchAndCacheMetadata(client, cache) {
123
133
  const rawCategories = projectDetailResult.status === 'fulfilled'
124
134
  ? (projectDetailResult.value.projects?.[0]?.categories ?? [])
125
135
  : [];
126
- const categories = rawCategories.map((cat) => ({
127
- ...cat,
128
- name: cat.name.startsWith(ALL_PROJECTS_PREFIX)
129
- ? cat.name.slice(ALL_PROJECTS_PREFIX.length)
130
- : cat.name,
131
- }));
136
+ const categories = stripAllProjectsPrefix(rawCategories);
132
137
  byProject[project.id] = { users, versions, categories };
133
138
  }));
134
139
  // Fetch all tags — try the dedicated endpoint first, fall back to collecting
@@ -150,13 +155,36 @@ async function fetchAndCacheMetadata(client, cache) {
150
155
  await cache.save(data);
151
156
  return data;
152
157
  }
153
- export function registerMetadataTools(server, client, cache) {
158
+ export function registerMetadataTools(server, client, cache, searchIndexStatus) {
159
+ /** Metadata from the cache, synced when missing, expired or when refresh is set. */
160
+ async function loadMetadata(refresh) {
161
+ const cached = refresh ? null : await cache.loadIfValid();
162
+ return cached ?? fetchAndCacheMetadata(client, cache);
163
+ }
164
+ /**
165
+ * Valid "select" field names: from the cache, else discovered from a sample
166
+ * issue plus the fields MantisBT strips when empty, else a static list.
167
+ */
168
+ async function loadIssueFields(projectId) {
169
+ const cached = await cache.loadIssueFields();
170
+ if (cached)
171
+ return { fields: cached, source: 'cache' };
172
+ const result = await client.get('issues', { page: 1, page_size: 1, project_id: projectId });
173
+ const sample = result.issues?.[0];
174
+ const fields = sample
175
+ ? Array.from(new Set([...Object.keys(sample), ...EMPTY_STRIPPED_FIELDS])).sort()
176
+ : STATIC_ISSUE_FIELDS;
177
+ await cache.saveIssueFields(fields);
178
+ return { fields, source: sample ? 'live' : 'static' };
179
+ }
154
180
  // ---------------------------------------------------------------------------
155
181
  // sync_metadata
156
182
  // ---------------------------------------------------------------------------
157
183
  server.registerTool('sync_metadata', {
158
184
  title: 'Sync Metadata Cache',
159
- description: `Fetch all projects and their associated users, versions, categories, and tags from MantisBT and store them in the local metadata cache.
185
+ description: `${deprecationNote('sync_metadata', 'get_metadata with refresh: true')}
186
+
187
+ Fetch all projects and their associated users, versions, categories, and tags from MantisBT and store them in the local metadata cache.
160
188
 
161
189
  Tags are fetched via the dedicated GET /tags endpoint when available. On installations where that endpoint is missing (MantisBT < 2.26), tags are collected by scanning all issues across all projects.
162
190
 
@@ -194,19 +222,33 @@ Use this tool to refresh stale data.`,
194
222
  // ---------------------------------------------------------------------------
195
223
  server.registerTool('get_metadata', {
196
224
  title: 'Get Cached Metadata',
197
- description: `Return a compact summary of cached MantisBT metadata: project count, tag count, and per-project counts of users, versions, and categories.
225
+ description: `Return the cached MantisBT metadata (projects with their users, versions and categories, plus all tags).
198
226
 
199
- If the cache does not exist or has expired (default TTL: 24 hours), it will automatically sync first.
200
- Use sync_metadata to force a refresh. For full lists use: list_projects (projects), get_project_users / get_project_versions / get_project_categories (per-project data), list_tags (tags).`,
201
- inputSchema: z.object({}),
227
+ - detail: "summary" (default) — compact counts: project count, tag count, per-project counts of users, versions and categories, cached_at, ttl_seconds.
228
+ - detail: "full" — the complete raw cache with all lists (large; minified JSON).
229
+ - refresh: true — re-fetch everything from MantisBT before answering (otherwise the cache is used and only synced when missing or expired; default TTL 24 hours).
230
+
231
+ When semantic search is enabled, the response also contains "search_index": how many issues are indexed vs. the total, and the time of the last sync.
232
+
233
+ For lists of a single project use get_project with include; for tags use list_tags.`,
234
+ inputSchema: z.object({
235
+ detail: z.enum(['summary', 'full']).default('summary').describe('"summary" (default): counts only; "full": the complete raw cache'),
236
+ refresh: z.preprocess(coerceBool, z.boolean().default(false)).describe('Re-fetch all metadata from MantisBT first (default: false)'),
237
+ }),
202
238
  annotations: {
203
239
  readOnlyHint: true,
204
240
  destructiveHint: false,
205
241
  idempotentHint: true,
206
242
  },
207
- }, async () => {
243
+ }, async ({ detail, refresh }) => {
208
244
  try {
209
- const data = await cache.loadIfValid() ?? await fetchAndCacheMetadata(client, cache);
245
+ const [data, searchIndex] = await Promise.all([
246
+ loadMetadata(refresh),
247
+ searchIndexStatus?.(),
248
+ ]);
249
+ if (detail === 'full') {
250
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, search_index: searchIndex }) }] };
251
+ }
210
252
  const summary = {
211
253
  projects: data.projects.length,
212
254
  tags: data.tags.length,
@@ -221,6 +263,7 @@ Use sync_metadata to force a refresh. For full lists use: list_projects (project
221
263
  })),
222
264
  cached_at: new Date(data.timestamp).toISOString(),
223
265
  ttl_seconds: cache.getRemainingTtlSeconds(data.timestamp),
266
+ search_index: searchIndex,
224
267
  };
225
268
  return {
226
269
  content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
@@ -236,10 +279,12 @@ Use sync_metadata to force a refresh. For full lists use: list_projects (project
236
279
  // ---------------------------------------------------------------------------
237
280
  server.registerTool('get_metadata_full', {
238
281
  title: 'Get Full Cached Metadata',
239
- description: `Return the complete raw MantisBT metadata cache: all projects with full fields, and per-project lists of users, versions, categories, plus all tags.
282
+ description: `${deprecationNote('get_metadata_full', 'get_metadata with detail: "full"')}
283
+
284
+ Return the complete raw MantisBT metadata cache: all projects with full fields, and per-project lists of users, versions, categories, plus all tags.
240
285
 
241
286
  If the cache does not exist or has expired (default TTL: 24 hours), it will automatically sync first.
242
- Use sync_metadata to force a refresh. For a lightweight overview use get_metadata instead.`,
287
+ Use get_metadata with refresh: true to force a refresh.`,
243
288
  inputSchema: z.object({}),
244
289
  annotations: {
245
290
  readOnlyHint: true,
@@ -248,7 +293,7 @@ Use sync_metadata to force a refresh. For a lightweight overview use get_metadat
248
293
  },
249
294
  }, async () => {
250
295
  try {
251
- const data = await cache.loadIfValid() ?? await fetchAndCacheMetadata(client, cache);
296
+ const data = await loadMetadata(false);
252
297
  return {
253
298
  content: [{ type: 'text', text: JSON.stringify(data) }],
254
299
  };
@@ -263,7 +308,9 @@ Use sync_metadata to force a refresh. For a lightweight overview use get_metadat
263
308
  // ---------------------------------------------------------------------------
264
309
  server.registerTool('get_issue_fields', {
265
310
  title: 'Get Issue Fields',
266
- description: `Return all field names that are valid for the "select" parameter of list_issues and get_issue.
311
+ description: `${deprecationNote('get_issue_fields', 'get_issue_schema')}
312
+
313
+ Return all field names that are valid for the "select" parameter of list_issues and get_issue.
267
314
 
268
315
  Fields are discovered by fetching a sample issue from MantisBT (which reflects the server's active configuration — e.g. whether eta, projection, or profile fields are enabled) and merging the result with fields that MantisBT omits when empty (notes, attachments, relationships, etc.). The result is cached with the same TTL as the metadata cache.
269
316
 
@@ -278,30 +325,42 @@ Use this tool before constructing a "select" string to ensure you only request f
278
325
  },
279
326
  }, async ({ project_id }) => {
280
327
  try {
281
- const cached = await cache.loadIssueFields();
282
- if (cached) {
283
- return {
284
- content: [{ type: 'text', text: JSON.stringify({ fields: cached, source: 'cache' }, null, 2) }],
285
- };
286
- }
287
- const params = {
288
- page: 1,
289
- page_size: 1,
290
- project_id,
328
+ return {
329
+ content: [{ type: 'text', text: JSON.stringify(await loadIssueFields(project_id), null, 2) }],
291
330
  };
292
- const result = await client.get('issues', params);
293
- const issues = result.issues ?? [];
294
- let fields;
295
- if (issues.length === 0) {
296
- fields = STATIC_ISSUE_FIELDS;
297
- }
298
- else {
299
- const discovered = Object.keys(issues[0]);
300
- fields = Array.from(new Set([...discovered, ...EMPTY_STRIPPED_FIELDS])).sort();
301
- }
302
- await cache.saveIssueFields(fields);
331
+ }
332
+ catch (error) {
333
+ const msg = error instanceof Error ? error.message : String(error);
334
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
335
+ }
336
+ });
337
+ // ---------------------------------------------------------------------------
338
+ // get_issue_schema
339
+ // ---------------------------------------------------------------------------
340
+ server.registerTool('get_issue_schema', {
341
+ title: 'Get Issue Schema',
342
+ description: `Return what is valid for issues on this MantisBT server, in one response:
343
+
344
+ - fields: all field names valid for the "select" parameter of get_issue, list_issues and search_issues. Discovered from a sample issue (reflects the server's active configuration) plus fields MantisBT omits when empty (notes, attachments, relationships, …); cached with the metadata TTL. fields_source tells where they came from: "cache", "live" or "static".
345
+ - enums: ID, name and (if available) localized label for severity, status, priority, resolution and reproducibility. "name" or "id" is what create_issue / update_issue accept; "canonical_name" appears on localized installations.
346
+
347
+ Call it before building a "select" string or before setting enum fields.`,
348
+ inputSchema: z.object({
349
+ project_id: z.coerce.number().int().positive().optional().describe('Optional project ID to scope the sample issue used for field discovery'),
350
+ }),
351
+ annotations: {
352
+ readOnlyHint: true,
353
+ destructiveHint: false,
354
+ idempotentHint: true,
355
+ },
356
+ }, async ({ project_id }) => {
357
+ try {
358
+ const [{ fields, source }, enums] = await Promise.all([
359
+ loadIssueFields(project_id),
360
+ fetchIssueEnumsWithCache(client),
361
+ ]);
303
362
  return {
304
- content: [{ type: 'text', text: JSON.stringify({ fields, source: issues.length > 0 ? 'live' : 'static' }, null, 2) }],
363
+ content: [{ type: 'text', text: JSON.stringify({ fields, fields_source: source, enums }, null, 2) }],
305
364
  };
306
365
  }
307
366
  catch (error) {
@@ -1,27 +1,138 @@
1
1
  import { z } from 'zod';
2
+ import { firstIssue } from '../issue-envelope.js';
2
3
  import { getVersionHint } from '../version-hint.js';
4
+ import { deprecationNote } from '../deprecation.js';
3
5
  function errorText(msg) {
4
6
  const vh = getVersionHint();
5
7
  vh?.triggerLatestVersionFetch();
6
8
  const hint = vh?.getUpdateHint();
7
9
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
10
  }
11
+ function jsonResult(value) {
12
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
13
+ }
14
+ function errorResult(msg) {
15
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
16
+ }
17
+ // ---------------------------------------------------------------------------
18
+ // Monitor helpers
19
+ // ---------------------------------------------------------------------------
20
+ /** MantisBT omits the "monitors" key when the issue has no monitors. */
21
+ const monitorsOf = (response) => firstIssue(response)?.monitors ?? [];
22
+ /** Lower-cased login names of the given monitors (MantisBT login names are case-insensitive). */
23
+ const loginsOf = (monitors) => new Set(monitors.map(m => m.name.toLowerCase()));
24
+ /**
25
+ * Adds users as monitors of an issue.
26
+ *
27
+ * MantisBT reads only a "users" array; without it, MonitorAddCommand adds the
28
+ * logged-in user (the API-key owner) instead. The POST response contains the
29
+ * issue, so the result is checked without an extra request. Adding an existing
30
+ * monitor is a no-op and counts as added.
31
+ */
32
+ export async function addMonitors(client, issueId, usernames) {
33
+ const monitors = monitorsOf(await client.post(`issues/${issueId}/monitors`, {
34
+ users: usernames.map(name => ({ name })),
35
+ }));
36
+ const present = loginsOf(monitors);
37
+ return {
38
+ added: usernames.filter(u => present.has(u.toLowerCase())),
39
+ notAdded: usernames.filter(u => !present.has(u.toLowerCase())),
40
+ monitors,
41
+ };
42
+ }
43
+ /**
44
+ * Removes users from an issue's monitor list.
45
+ *
46
+ * MantisBT 2.28 has no DELETE route for monitors. PATCH /issues/{id} replaces
47
+ * the monitor list as a whole, so the list is sent without the given users.
48
+ * MantisBT skips monitors the caller may not remove without reporting an
49
+ * error — those end up in `stillPresent`. Usernames are matched
50
+ * case-insensitively. No PATCH is sent when none of the users is a monitor.
51
+ *
52
+ * `knownMonitors` skips the initial GET when the caller already has the
53
+ * current monitor list (e.g. from a preceding addMonitors call).
54
+ */
55
+ export async function removeMonitors(client, issueId, usernames, knownMonitors) {
56
+ const wanted = new Set(usernames.map(u => u.toLowerCase()));
57
+ const isWanted = (m) => wanted.has(m.name.toLowerCase());
58
+ const current = knownMonitors ?? monitorsOf(await client.get(`issues/${issueId}`, { select: 'id,monitors' }));
59
+ const present = loginsOf(current.filter(isWanted));
60
+ const notMonitoring = usernames.filter(u => !present.has(u.toLowerCase()));
61
+ if (present.size === 0) {
62
+ return { removed: [], notMonitoring, stillPresent: [], monitors: current };
63
+ }
64
+ const after = monitorsOf(await client.patch(`issues/${issueId}`, {
65
+ monitors: current.filter(m => !isWanted(m)).map(m => ({ id: m.id })),
66
+ }));
67
+ const kept = loginsOf(after);
68
+ const requested = usernames.filter(u => present.has(u.toLowerCase()));
69
+ return {
70
+ removed: requested.filter(u => !kept.has(u.toLowerCase())),
71
+ notMonitoring,
72
+ stillPresent: requested.filter(u => kept.has(u.toLowerCase())),
73
+ monitors: after,
74
+ };
75
+ }
76
+ const NOT_ADDED_HINT = 'adding other users requires the monitor_add_others_bug_threshold access level';
77
+ const STILL_PRESENT_HINT = 'removing other users requires the monitor_delete_others_bug_threshold access level';
78
+ /**
79
+ * Applies a combined add/remove of monitors (update_issue add_monitors /
80
+ * remove_monitors). Never throws: API errors and changes MantisBT silently
81
+ * skipped are returned as `problems`. Adds run first; the resulting monitor
82
+ * list is reused for the removal, so no extra GET is needed. `knownMonitors`
83
+ * (e.g. from a preceding field PATCH) skips the GET when only removing.
84
+ */
85
+ export async function applyMonitorChanges(client, issueId, add, remove, knownMonitors) {
86
+ const changes = { current: [] };
87
+ const problems = [];
88
+ try {
89
+ let known = knownMonitors;
90
+ if (add) {
91
+ const result = await addMonitors(client, issueId, add);
92
+ changes.added = result.added;
93
+ changes.current = result.monitors.map(m => m.name);
94
+ known = result.monitors;
95
+ if (result.notAdded.length > 0) {
96
+ problems.push(`MantisBT did not add ${result.notAdded.join(', ')} as monitor(s) — ${NOT_ADDED_HINT}.`);
97
+ }
98
+ }
99
+ if (remove) {
100
+ const result = await removeMonitors(client, issueId, remove, known);
101
+ changes.removed = result.removed;
102
+ changes.not_monitoring = result.notMonitoring;
103
+ changes.current = result.monitors.map(m => m.name);
104
+ if (result.stillPresent.length > 0) {
105
+ problems.push(`${result.stillPresent.join(', ')} still monitor(s) the issue — ${STILL_PRESENT_HINT}.`);
106
+ }
107
+ }
108
+ return { changes, problems };
109
+ }
110
+ catch (error) {
111
+ problems.push(error instanceof Error ? error.message : String(error));
112
+ return { problems };
113
+ }
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Tools
117
+ // ---------------------------------------------------------------------------
9
118
  export function registerMonitorTools(server, client) {
10
119
  // ---------------------------------------------------------------------------
11
120
  // add_monitor
12
121
  // ---------------------------------------------------------------------------
13
122
  server.registerTool('add_monitor', {
14
123
  title: 'Add Issue Monitor',
15
- description: `Add a user as a monitor (watcher) of a MantisBT issue. Monitors receive email notifications whenever the issue is updated. Returns a success confirmation object.
124
+ description: `${deprecationNote('add_monitor', 'update_issue with add_monitors')}
125
+
126
+ Add a user as a monitor (watcher) of a MantisBT issue. Monitors receive email notifications whenever the issue is updated. Returns { success, monitors } — "monitors" lists the login names now monitoring the issue.
16
127
 
17
- Use add_monitor to subscribe team members to issue updates without assigning them as the handler. To unsubscribe a user, call remove_monitor with the same parameters.
128
+ Use it to subscribe team members to issue updates without assigning them as the handler.
18
129
 
19
130
  Adding a user who is already a monitor is a no-op — the operation succeeds without creating duplicates.
20
131
 
21
- Prerequisites: obtain issue_id from list_issues or get_issue; use find_project_member or get_project_users to look up valid MantisBT login names.`,
132
+ Prerequisites: obtain issue_id from list_issues or get_issue; use get_project with member_query to look up valid MantisBT login names.`,
22
133
  inputSchema: z.object({
23
134
  issue_id: z.coerce.number().int().positive().describe('Numeric issue ID — use list_issues or get_issue to obtain issue IDs'),
24
- username: z.string().min(1).describe('MantisBT login name (not the display name) of the user to add as monitor. Use find_project_member or get_project_users to discover valid login names for a project.'),
135
+ username: z.string().min(1).describe('MantisBT login name (not the display name) of the user to add as monitor. Use get_project with member_query to discover valid login names for a project.'),
25
136
  }),
26
137
  annotations: {
27
138
  readOnlyHint: false,
@@ -30,15 +141,14 @@ Prerequisites: obtain issue_id from list_issues or get_issue; use find_project_m
30
141
  },
31
142
  }, async ({ issue_id, username }) => {
32
143
  try {
33
- const body = { name: username };
34
- const result = await client.post(`issues/${issue_id}/monitors`, body);
35
- return {
36
- content: [{ type: 'text', text: JSON.stringify(result ?? { success: true }, null, 2) }],
37
- };
144
+ const result = await addMonitors(client, issue_id, [username]);
145
+ if (result.notAdded.length > 0) {
146
+ return errorResult(`MantisBT accepted the request, but user "${username}" is not a monitor of issue ${issue_id}.`);
147
+ }
148
+ return jsonResult({ success: true, monitors: result.monitors.map(m => m.name) });
38
149
  }
39
150
  catch (error) {
40
- const msg = error instanceof Error ? error.message : String(error);
41
- return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
151
+ return errorResult(error instanceof Error ? error.message : String(error));
42
152
  }
43
153
  });
44
154
  // ---------------------------------------------------------------------------
@@ -46,26 +156,41 @@ Prerequisites: obtain issue_id from list_issues or get_issue; use find_project_m
46
156
  // ---------------------------------------------------------------------------
47
157
  server.registerTool('remove_monitor', {
48
158
  title: 'Remove Issue Monitor',
49
- description: 'Remove a user from the monitor list of a MantisBT issue. The user will no longer receive email notifications for updates to this issue.',
159
+ description: `${deprecationNote('remove_monitor', 'update_issue with remove_monitors')}
160
+
161
+ Remove a user from the monitor list of a MantisBT issue. The user will no longer receive email notifications for updates to this issue.
162
+
163
+ Returns { success, removed, monitors } — "monitors" lists the login names still monitoring the issue. If the user was not a monitor, the call succeeds with removed=false and nothing is changed.
164
+
165
+ Removing another user requires the monitor_delete_others_bug_threshold access level; without it the call returns an error.`,
50
166
  inputSchema: z.object({
51
167
  issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
52
- username: z.string().min(1).describe('Username of the monitor to remove'),
168
+ username: z.string().min(1).describe('MantisBT login name (not the display name) of the monitor to remove'),
53
169
  }),
54
170
  annotations: {
55
171
  readOnlyHint: false,
56
172
  destructiveHint: true,
57
- idempotentHint: false,
173
+ idempotentHint: true,
58
174
  },
59
175
  }, async ({ issue_id, username }) => {
60
176
  try {
61
- await client.delete(`issues/${issue_id}/monitors/${username}`);
62
- return {
63
- content: [{ type: 'text', text: JSON.stringify({ success: true }, null, 2) }],
64
- };
177
+ const result = await removeMonitors(client, issue_id, [username]);
178
+ const monitors = result.monitors.map(m => m.name);
179
+ if (result.stillPresent.length > 0) {
180
+ return errorResult(`User "${username}" is still a monitor of issue ${issue_id}. MantisBT silently skipped the removal — ${STILL_PRESENT_HINT}.`);
181
+ }
182
+ if (result.removed.length === 0) {
183
+ return jsonResult({
184
+ success: true,
185
+ removed: false,
186
+ message: `User "${username}" is not a monitor of issue ${issue_id} — nothing changed.`,
187
+ monitors,
188
+ });
189
+ }
190
+ return jsonResult({ success: true, removed: true, monitors });
65
191
  }
66
192
  catch (error) {
67
- const msg = error instanceof Error ? error.message : String(error);
68
- return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
193
+ return errorResult(error instanceof Error ? error.message : String(error));
69
194
  }
70
195
  });
71
196
  }
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { buildNoteViewUrl } from '../client.js';
3
3
  import { getVersionHint } from '../version-hint.js';
4
+ import { firstIssue } from '../issue-envelope.js';
5
+ import { deprecationNote } from '../deprecation.js';
6
+ import { enrichIssue } from '../issue-enrich.js';
4
7
  function errorText(msg) {
5
8
  const vh = getVersionHint();
6
9
  vh?.triggerLatestVersionFetch();
@@ -20,13 +23,70 @@ export async function postNote(client, issueId, text, viewState) {
20
23
  const note = result.note ?? result;
21
24
  return { ...note, view_url: buildNoteViewUrl(baseUrl, issueId, note.id) };
22
25
  }
26
+ // ---------------------------------------------------------------------------
27
+ // Note edits via PATCH /issues/{id} (update_issue fields.notes)
28
+ // ---------------------------------------------------------------------------
29
+ // Edit of an existing note. Without id MantisBT would create a new note, so
30
+ // id is required here.
31
+ const noteEdit = z.object({
32
+ id: z.coerce.number().int().positive().describe('ID of the note to edit (from get_issue)'),
33
+ text: z.string().min(1).optional().describe('New note text'),
34
+ view_state: z.enum(['public', 'private']).optional().describe('New visibility; omitted = keep the current one'),
35
+ }).strict().refine(e => e.text !== undefined || e.view_state !== undefined, { message: "A note edit needs 'text' or 'view_state'" });
36
+ /** Schema of update_issue fields.notes. */
37
+ export const noteEditsSchema = z.array(noteEdit).min(1).superRefine((edits, ctx) => {
38
+ const ids = edits.map(e => e.id);
39
+ const twice = ids.filter((noteId, i) => ids.indexOf(noteId) !== i);
40
+ if (twice.length > 0)
41
+ ctx.addIssue({ code: 'custom', message: `Note ${twice.join(', ')} listed more than once` });
42
+ });
43
+ /**
44
+ * Turns note edits into the PATCH payload. MantisBT has no REST route for
45
+ * editing a note, but PATCH /issues/{id} edits notes by id. The current notes
46
+ * are read first: unknown IDs throw before anything is written, and a
47
+ * missing view_state is filled with the note's current one, so an edit keeps
48
+ * the note's visibility unless the caller changes it.
49
+ */
50
+ export async function prepareNoteEdits(client, issueId, edits) {
51
+ const result = await client.get(`issues/${issueId}`, { select: 'id,notes' });
52
+ const current = new Map((firstIssue(result)?.notes ?? []).map(n => [n.id, n]));
53
+ const unknown = edits.filter(e => !current.has(e.id)).map(e => e.id);
54
+ if (unknown.length > 0) {
55
+ throw new Error(`Note(s) ${unknown.join(', ')} do not belong to issue ${issueId} or are not visible to you — use get_issue to look up note IDs.`);
56
+ }
57
+ return edits.map(({ id, text, view_state }) => ({
58
+ id,
59
+ ...(text !== undefined && { text }),
60
+ view_state: { name: view_state ?? current.get(id).view_state?.name ?? 'public' },
61
+ }));
62
+ }
63
+ const normalizeText = (text) => text.replace(/\r\n/g, '\n').trim();
64
+ /** Compares the notes in the PATCH response with the edits; returns one message per edit MantisBT did not apply. */
65
+ export function verifyNoteEdits(edits, notes) {
66
+ const byId = new Map((notes ?? []).map(n => [n.id, n]));
67
+ return edits.flatMap(edit => {
68
+ const note = byId.get(edit.id);
69
+ if (!note)
70
+ return [`note ${edit.id} is missing from the updated issue`];
71
+ const problems = [];
72
+ if (edit.text !== undefined && normalizeText(note.text) !== normalizeText(edit.text)) {
73
+ problems.push(`the text of note ${edit.id} was not changed`);
74
+ }
75
+ if (note.view_state?.name !== edit.view_state.name) {
76
+ problems.push(`note ${edit.id} is ${note.view_state?.name ?? 'unknown'} instead of ${edit.view_state.name}`);
77
+ }
78
+ return problems;
79
+ });
80
+ }
23
81
  export function registerNoteTools(server, client) {
24
82
  // ---------------------------------------------------------------------------
25
83
  // list_notes
26
84
  // ---------------------------------------------------------------------------
27
85
  server.registerTool('list_notes', {
28
86
  title: 'List Issue Notes',
29
- description: 'List all notes (comments) attached to a MantisBT issue. Note: get_issue already includes notes in its response — use list_notes only when you need notes without fetching the full issue.',
87
+ description: `${deprecationNote('list_notes', 'get_issue with select: "notes"')}
88
+
89
+ List all notes (comments) attached to a MantisBT issue.`,
30
90
  inputSchema: z.object({
31
91
  issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
32
92
  }),
@@ -38,13 +98,11 @@ export function registerNoteTools(server, client) {
38
98
  }, async ({ issue_id }) => {
39
99
  try {
40
100
  const [result, baseUrl] = await Promise.all([
41
- client.get(`issues/${issue_id}`),
101
+ client.get(`issues/${issue_id}`, { select: 'id,notes' }),
42
102
  client.getBaseUrl(),
43
103
  ]);
44
- const notes = (result.issues?.[0]?.notes ?? []).map(note => ({
45
- ...note,
46
- view_url: buildNoteViewUrl(baseUrl, issue_id, note.id),
47
- }));
104
+ const issue = firstIssue(result);
105
+ const notes = (issue && enrichIssue(issue, baseUrl, issue_id).notes) ?? [];
48
106
  return {
49
107
  content: [{ type: 'text', text: JSON.stringify(notes, null, 2) }],
50
108
  };
@@ -97,10 +155,10 @@ Prerequisites: obtain issue_id from list_issues, get_issue, or search_issues.`,
97
155
 
98
156
  Returns a plain-text confirmation message on success. Returns an error if the note does not exist or the current user lacks permission to delete it (MantisBT enforces access control: users can typically only delete their own notes unless they have manager-level access or higher).
99
157
 
100
- Prerequisites: obtain note_id from list_notes or from get_issue (notes[].id); obtain issue_id from the same source.`,
158
+ Prerequisites: obtain note_id from get_issue (notes[].id).`,
101
159
  inputSchema: z.object({
102
- issue_id: z.coerce.number().int().positive().describe('Numeric issue ID that owns the note — use get_issue or list_notes to identify this value'),
103
- note_id: z.coerce.number().int().positive().describe('Numeric note ID to delete — obtain from get_issue (notes[].id) or list_notes'),
160
+ issue_id: z.coerce.number().int().positive().describe('Numeric issue ID that owns the note'),
161
+ note_id: z.coerce.number().int().positive().describe('Numeric note ID to delete — obtain from get_issue (notes[].id)'),
104
162
  }),
105
163
  annotations: {
106
164
  readOnlyHint: false,