@dpesch/mantisbt-mcp-server 1.14.1 → 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,30 +1,21 @@
1
1
  import { z } from 'zod';
2
- import { buildIssueViewUrl, buildNoteViewUrl } from '../client.js';
2
+ import { coerceBool } from '../coerce.js';
3
+ import { buildIssueViewUrl } from '../client.js';
4
+ import { enrichIssue } from '../issue-enrich.js';
3
5
  import { getVersionHint } from '../version-hint.js';
4
6
  import { MANTIS_CANONICAL_ENUM_NAMES, MANTIS_RESOLVED_STATUS_ID, resolveEnumId } from '../constants.js';
5
- import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
7
+ import { dateFilterSchema, matchesDateFilter } from '../date-filter.js';
6
8
  import { fetchIssueEnumsWithCache } from './config.js';
7
- import { postNote } from './notes.js';
9
+ import { postNote, noteEditsSchema, prepareNoteEdits, verifyNoteEdits } from './notes.js';
10
+ import { applyMonitorChanges } from './monitors.js';
11
+ import { firstIssue } from '../issue-envelope.js';
12
+ import { deprecationNote } from '../deprecation.js';
8
13
  function errorText(msg) {
9
14
  const vh = getVersionHint();
10
15
  vh?.triggerLatestVersionFetch();
11
16
  const hint = vh?.getUpdateHint();
12
17
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
13
18
  }
14
- // issueId: fallback when a select projection excluded the id field from the response
15
- function enrichIssue(issue, baseUrl, issueId) {
16
- const id = issue.id ?? issueId;
17
- if (id === undefined)
18
- return issue;
19
- return {
20
- ...issue,
21
- view_url: buildIssueViewUrl(baseUrl, id),
22
- notes: issue.notes?.map(note => ({
23
- ...note,
24
- view_url: buildNoteViewUrl(baseUrl, id, note.id),
25
- })),
26
- };
27
- }
28
19
  // MantisBT reference shape: at least one of id or name must be provided
29
20
  const ref = z.object({ id: z.number().optional(), name: z.string().optional() })
30
21
  .refine(o => o.id !== undefined || o.name !== undefined, { message: "At least one of 'id' or 'name' must be provided" });
@@ -34,6 +25,55 @@ const customFieldEntry = z.object({
34
25
  value: z.coerce.string().describe('Field value as string'),
35
26
  });
36
27
  const GET_ISSUES_CONCURRENCY = 5;
28
+ const GROUP_BY_FIELDS = ['status', 'handler', 'priority', 'category'];
29
+ // group_by scans every matching issue. MantisBT 2.28 does not cap page_size
30
+ // and returns no total count; 1000 issues per page with a narrow select
31
+ // scanned 7,700 issues in about 1 s. The cap guards against huge instances.
32
+ const GROUP_BY_PAGE_SIZE = 1000;
33
+ const GROUP_BY_MAX_PAGES = 50;
34
+ /**
35
+ * The client-side filters of list_issues (the MantisBT REST API does not apply
36
+ * them server-side), shared by the paged listing and group_by.
37
+ */
38
+ function makeIssueFilter({ status, assigned_to, reporter_id, dateFilter }) {
39
+ const statusLower = status?.toLowerCase();
40
+ const statusId = status ? resolveEnumId('status', status) : undefined;
41
+ // Pre-parse once — avoids repeated new Date() inside the scan loop
42
+ const updatedAfterMs = dateFilter.updated_after ? new Date(dateFilter.updated_after).getTime() : undefined;
43
+ const matchesStatus = (issue) => {
44
+ if (!statusLower)
45
+ return true;
46
+ if (!issue.status)
47
+ return false;
48
+ if (statusLower === 'open')
49
+ return (issue.status.id ?? 0) < MANTIS_RESOLVED_STATUS_ID;
50
+ if (statusId !== undefined)
51
+ return issue.status.id === statusId;
52
+ return issue.status.name?.toLowerCase() === statusLower;
53
+ };
54
+ /** Fields the filters read — needed in a narrow select. */
55
+ const fields = [
56
+ ...(status !== undefined ? ['status'] : []),
57
+ ...(assigned_to !== undefined ? ['handler'] : []),
58
+ ...(reporter_id !== undefined ? ['reporter'] : []),
59
+ ...(dateFilter.updated_after || dateFilter.updated_before ? ['updated_at'] : []),
60
+ ...(dateFilter.created_after || dateFilter.created_before ? ['created_at'] : []),
61
+ ];
62
+ return {
63
+ /** True when any client-side filter is set (every filter reads a field). */
64
+ active: fields.length > 0,
65
+ fields,
66
+ matches: (issue) => matchesStatus(issue)
67
+ && (assigned_to === undefined || issue.handler?.id === assigned_to)
68
+ && (reporter_id === undefined || issue.reporter?.id === reporter_id)
69
+ && matchesDateFilter(issue, dateFilter),
70
+ /**
71
+ * MantisBT returns issues newest-first: once one is at or before
72
+ * updated_after, every following page is older too.
73
+ */
74
+ olderThanUpdatedAfter: (issue) => updatedAfterMs !== undefined && issue.updated_at !== undefined && new Date(issue.updated_at).getTime() <= updatedAfterMs,
75
+ };
76
+ }
37
77
  // Worker-pool: runs `fn` over all `items` with at most `concurrency` in-flight at once.
38
78
  // nextIndex is only incremented inside microtasks, so the ++ is safe without a lock.
39
79
  async function runWithConcurrency(items, concurrency, fn) {
@@ -66,17 +106,95 @@ async function resolveEnum(group, value, client) {
66
106
  // localized lookup unavailable — fall through to static error
67
107
  }
68
108
  const valid = Object.values(MANTIS_CANONICAL_ENUM_NAMES[group]).join(', ');
69
- return `Invalid ${group} "${value}". Valid canonical names: ${valid}. Call get_issue_enums to see localized labels.`;
109
+ return `Invalid ${group} "${value}". Valid canonical names: ${valid}. Call get_issue_schema to see localized labels.`;
70
110
  }
71
111
  export function registerIssueTools(server, client, cache) {
112
+ /**
113
+ * Pages through GET /issues and hands every issue matching the client-side
114
+ * filter to onMatch (return false to stop). Ends on a short page, or after
115
+ * the current page once MantisBT's newest-first order shows the rest is
116
+ * older than updated_after. Returns truncated when maxPages ended the scan
117
+ * before either happened.
118
+ */
119
+ async function scanMatchingIssues(params, filter, pageSize, maxPages, onMatch) {
120
+ for (let page = 1; page <= maxPages; page++) {
121
+ const issues = (await client.get('issues', { ...params, page, page_size: pageSize })).issues ?? [];
122
+ let stopAfterBatch = false;
123
+ for (const issue of issues) {
124
+ if (filter.matches(issue)) {
125
+ if (onMatch(issue) === false)
126
+ return { truncated: false };
127
+ }
128
+ else if (filter.olderThanUpdatedAfter(issue)) {
129
+ stopAfterBatch = true; // finish the batch — items within it may still be newer
130
+ }
131
+ }
132
+ if (issues.length < pageSize || stopAfterBatch)
133
+ return { truncated: false };
134
+ }
135
+ return { truncated: true };
136
+ }
137
+ /**
138
+ * Counts all matching issues per group (list_issues group_by), fetching only
139
+ * the fields grouping and filters need.
140
+ */
141
+ async function groupIssues(groupBy, params, filter) {
142
+ const select = [...new Set(['id', groupBy, ...filter.fields])].join(',');
143
+ const groups = new Map();
144
+ let total = 0;
145
+ const { truncated } = await scanMatchingIssues({ ...params, select }, filter, GROUP_BY_PAGE_SIZE, GROUP_BY_MAX_PAGES, issue => {
146
+ const ref = issue[groupBy];
147
+ const key = ref?.id ?? null;
148
+ let group = groups.get(key);
149
+ if (!group) {
150
+ group = {
151
+ id: key,
152
+ name: ref?.name ?? (groupBy === 'handler' ? '(unassigned)' : '(none)'),
153
+ ...(ref?.label !== undefined && { label: ref.label }),
154
+ count: 0,
155
+ };
156
+ groups.set(key, group);
157
+ }
158
+ group.count++;
159
+ total++;
160
+ });
161
+ const sorted = [...groups.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
162
+ return { group_by: groupBy, total, ...(truncated && { truncated: true }), groups: sorted };
163
+ }
164
+ /**
165
+ * Fetches and enriches one issue. The requested ID is passed to enrichIssue
166
+ * so view_url survives a select without "id".
167
+ */
168
+ async function fetchIssue(id, select) {
169
+ const [result, baseUrl] = await Promise.all([
170
+ client.get(`issues/${id}`, { select }),
171
+ client.getBaseUrl(),
172
+ ]);
173
+ return enrichIssue(firstIssue(result) ?? result, baseUrl, id);
174
+ }
175
+ /**
176
+ * Fetches several issues in parallel (max GET_ISSUES_CONCURRENCY). Missing or
177
+ * inaccessible IDs yield null at their position; the call itself never fails.
178
+ */
179
+ async function fetchIssuesByIds(ids, select) {
180
+ const issues = await runWithConcurrency(ids, GET_ISSUES_CONCURRENCY, id => fetchIssue(id, select).catch(() => null));
181
+ const found = issues.filter(i => i !== null).length;
182
+ return { issues, requested: ids.length, found, failed: ids.length - found };
183
+ }
72
184
  // ---------------------------------------------------------------------------
73
185
  // get_issue
74
186
  // ---------------------------------------------------------------------------
75
187
  server.registerTool('get_issue', {
76
188
  title: 'Get Issue',
77
- description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships (unless "select" is given). Notes are always included — no separate list_notes call needed.',
189
+ description: `Retrieve one MantisBT issue by "id", or several by "ids" (1–50). Pass exactly one of the two.
190
+
191
+ With "id": returns the issue with all fields including notes (each with view_url), attachments and relationships, unless "select" is given.
192
+ With "ids": returns { issues, requested, found, failed } — requests run in parallel (max 5 concurrent); missing or inaccessible IDs give null at their position instead of failing the call.
193
+
194
+ Use "select" to fetch only some fields, e.g. select: "notes" for just the notes or select: "attachments" for just the file attachments of an issue.`,
78
195
  inputSchema: z.object({
79
- id: z.coerce.number().int().positive().describe('Numeric issue ID'),
196
+ id: z.coerce.number().int().positive().optional().describe('Numeric issue ID (use either id or ids)'),
197
+ ids: z.array(z.coerce.number().int().positive()).min(1).max(50).optional().describe('Several issue IDs (1–50) to fetch in one call (use either id or ids)'),
80
198
  select: z.string().optional().describe('Comma-separated list of fields to include in the response (server-side projection, same as list_issues). Significantly reduces response size. Example: "id,summary,status,notes"'),
81
199
  }),
82
200
  annotations: {
@@ -84,14 +202,15 @@ export function registerIssueTools(server, client, cache) {
84
202
  destructiveHint: false,
85
203
  idempotentHint: true,
86
204
  },
87
- }, async ({ id, select }) => {
205
+ }, async ({ id, ids, select }) => {
206
+ if ((id === undefined) === (ids === undefined)) {
207
+ return { content: [{ type: 'text', text: errorText('Pass exactly one of "id" or "ids".') }], isError: true };
208
+ }
209
+ if (ids) {
210
+ return { content: [{ type: 'text', text: JSON.stringify(await fetchIssuesByIds(ids, select), null, 2) }] };
211
+ }
88
212
  try {
89
- const result = await client.get(`issues/${id}`, { select });
90
- const issue = result.issues?.[0] ?? result;
91
- const baseUrl = await client.getBaseUrl();
92
- return {
93
- content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl, id), null, 2) }],
94
- };
213
+ return { content: [{ type: 'text', text: JSON.stringify(await fetchIssue(id, select), null, 2) }] };
95
214
  }
96
215
  catch (error) {
97
216
  const msg = error instanceof Error ? error.message : String(error);
@@ -103,7 +222,8 @@ export function registerIssueTools(server, client, cache) {
103
222
  // ---------------------------------------------------------------------------
104
223
  server.registerTool('get_issues', {
105
224
  title: 'Get Multiple Issues',
106
- description: 'Retrieve multiple MantisBT issues by their numeric IDs in a single MCP call. ' +
225
+ description: `${deprecationNote('get_issues', 'get_issue with ids')}\n\n` +
226
+ 'Retrieve multiple MantisBT issues by their numeric IDs in a single MCP call. ' +
107
227
  'Requests run in parallel (max 5 concurrent). ' +
108
228
  'Missing or inaccessible IDs return null at their array position — ' +
109
229
  'the call never fails due to individual missing IDs. ' +
@@ -120,34 +240,15 @@ export function registerIssueTools(server, client, cache) {
120
240
  destructiveHint: false,
121
241
  idempotentHint: true,
122
242
  },
123
- }, async ({ ids }) => {
124
- const [rawResults, baseUrl] = await Promise.all([
125
- runWithConcurrency(ids, GET_ISSUES_CONCURRENCY, async (id) => {
126
- try {
127
- const result = await client.get(`issues/${id}`);
128
- return result.issues?.[0] ?? result;
129
- }
130
- catch {
131
- return null;
132
- }
133
- }),
134
- client.getBaseUrl(),
135
- ]);
136
- const results = rawResults.map(issue => issue !== null ? enrichIssue(issue, baseUrl) : null);
137
- const found = results.filter((r) => r !== null).length;
138
- return {
139
- content: [{
140
- type: 'text',
141
- text: JSON.stringify({ issues: results, requested: ids.length, found, failed: ids.length - found }, null, 2),
142
- }],
143
- };
144
- });
243
+ }, async ({ ids }) => ({
244
+ content: [{ type: 'text', text: JSON.stringify(await fetchIssuesByIds(ids), null, 2) }],
245
+ }));
145
246
  // ---------------------------------------------------------------------------
146
247
  // list_issues
147
248
  // ---------------------------------------------------------------------------
148
249
  server.registerTool('list_issues', {
149
250
  title: 'List Issues',
150
- description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.\n\nNote: "assigned_to", "reporter_id", "status", and date filters are applied client-side (the MantisBT REST API does not support these as server-side filters). When any of these filters are active the tool automatically fetches multiple pages internally until enough matching results are found (up to 500 issues scanned). The "page" and "page_size" parameters refer to the resulting filtered list.\n\nTip for date queries: fetching with select="id,updated_at,created_at" plus a date filter is very compact and efficient.',
251
+ description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.\n\nNote: "assigned_to", "reporter_id", "status", and date filters are applied client-side (the MantisBT REST API does not support these as server-side filters). When any of these filters are active the tool automatically fetches multiple pages internally until enough matching results are found (up to 500 issues scanned); if that limit ends the search before the page is full, the response contains "truncated": true. The "page" and "page_size" parameters refer to the resulting filtered list.\n\nTip for date queries: fetching with select="id,updated_at,created_at" plus a date filter is very compact and efficient.\n\nFor overviews use "group_by" ("status", "handler", "priority" or "category"): the server counts all matching issues itself and returns { group_by, total, groups: [{ id, name, label?, count }] } instead of an issue list — far fewer tokens than listing issues and counting them. All filters still apply. If the scan stops at its limit (' + (GROUP_BY_PAGE_SIZE * GROUP_BY_MAX_PAGES).toLocaleString('en-US') + ' issues), the response contains "truncated": true.',
151
252
  inputSchema: z.object({
152
253
  project_id: z.coerce.number().int().positive().optional().describe('Filter by project ID'),
153
254
  page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
@@ -159,6 +260,7 @@ export function registerIssueTools(server, client, cache) {
159
260
  direction: z.enum(['ASC', 'DESC']).optional().describe('Sort direction'),
160
261
  select: z.string().optional().describe('Comma-separated list of fields to include in the response (server-side projection). Significantly reduces response size. Example: "id,summary,status,priority,handler,updated_at"'),
161
262
  status: z.string().optional().describe('Filter issues by status name (e.g. "new", "feedback", "acknowledged", "confirmed", "assigned", "resolved", "closed") or use "open" as shorthand for all statuses with id < 80 (i.e. not yet resolved or closed). Applied client-side after fetching — when combined with pagination, a page may contain fewer results than page_size.'),
263
+ group_by: z.enum(GROUP_BY_FIELDS).optional().describe('Return counts per group instead of issues: "status", "handler", "priority" or "category". All matching issues are counted (page, page_size and select are ignored).'),
162
264
  ...dateFilterSchema,
163
265
  }),
164
266
  annotations: {
@@ -166,7 +268,7 @@ export function registerIssueTools(server, client, cache) {
166
268
  destructiveHint: false,
167
269
  idempotentHint: true,
168
270
  },
169
- }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status, updated_after, updated_before, created_after, created_before }) => {
271
+ }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status, group_by, updated_after, updated_before, created_after, created_before }) => {
170
272
  try {
171
273
  const baseParams = {
172
274
  project_id,
@@ -178,9 +280,13 @@ export function registerIssueTools(server, client, cache) {
178
280
  select,
179
281
  };
180
282
  const dateFilter = { updated_after, updated_before, created_after, created_before };
181
- const needsClientFilter = status !== undefined || assigned_to !== undefined || reporter_id !== undefined || hasDateFilter(dateFilter);
283
+ const filter = makeIssueFilter({ status, assigned_to, reporter_id, dateFilter });
284
+ if (group_by) {
285
+ const grouped = await groupIssues(group_by, { project_id, assigned_to, reporter_id, filter_id }, filter);
286
+ return { content: [{ type: 'text', text: JSON.stringify(grouped, null, 2) }] };
287
+ }
182
288
  const baseUrl = await client.getBaseUrl();
183
- if (!needsClientFilter) {
289
+ if (!filter.active) {
184
290
  // No client-side filtering — single API call, pass pagination as-is
185
291
  const result = await client.get('issues', { ...baseParams, page, page_size });
186
292
  const enriched = { ...result, issues: result.issues?.map(i => enrichIssue(i, baseUrl)) };
@@ -194,63 +300,17 @@ export function registerIssueTools(server, client, cache) {
194
300
  const MAX_API_PAGES = 10; // hard cap: scan at most 500 issues
195
301
  const neededTotal = page * page_size; // need this many matches to serve page N
196
302
  const matching = [];
197
- let serverPage = 1;
198
- let hasMore = true;
199
- const statusLower = status?.toLowerCase();
200
- const statusId = status ? resolveEnumId('status', status) : undefined;
201
- // Pre-parse date thresholds once — avoids repeated new Date() inside the scan loop
202
- const updatedAfterMs = updated_after ? new Date(updated_after).getTime() : undefined;
203
- while (matching.length < neededTotal && serverPage <= MAX_API_PAGES && hasMore) {
204
- const batch = await client.get('issues', {
205
- ...baseParams,
206
- page: serverPage,
207
- page_size: API_PAGE_SIZE,
208
- });
209
- const issues = batch.issues ?? [];
210
- hasMore = issues.length === API_PAGE_SIZE;
211
- let stopAfterBatch = false;
212
- for (const issue of issues) {
213
- if (statusLower) {
214
- if (!issue.status)
215
- continue;
216
- if (statusLower === 'open') {
217
- if ((issue.status.id ?? 0) >= MANTIS_RESOLVED_STATUS_ID)
218
- continue;
219
- }
220
- else if (statusId !== undefined) {
221
- if (issue.status.id !== statusId)
222
- continue;
223
- }
224
- else if (issue.status.name?.toLowerCase() !== statusLower) {
225
- continue;
226
- }
227
- }
228
- if (assigned_to !== undefined && issue.handler?.id !== assigned_to)
229
- continue;
230
- if (reporter_id !== undefined && issue.reporter?.id !== reporter_id)
231
- continue;
232
- if (!matchesDateFilter(issue, dateFilter)) {
233
- // MantisBT returns results newest-first. Once updated_at drops below
234
- // updated_after, all subsequent pages are guaranteed to be older too.
235
- // Finish the current batch first (items within it may still be newer),
236
- // then stop fetching further pages.
237
- if (updatedAfterMs && issue.updated_at && new Date(issue.updated_at).getTime() <= updatedAfterMs) {
238
- stopAfterBatch = true;
239
- }
240
- continue;
241
- }
242
- matching.push(issue);
243
- }
244
- if (stopAfterBatch)
245
- break;
246
- serverPage++;
247
- }
303
+ const { truncated } = await scanMatchingIssues(baseParams, filter, API_PAGE_SIZE, MAX_API_PAGES, issue => {
304
+ matching.push(issue);
305
+ return matching.length < neededTotal;
306
+ });
248
307
  const start = (page - 1) * page_size;
249
308
  const pageIssues = matching.slice(start, start + page_size).map(i => enrichIssue(i, baseUrl));
309
+ // truncated: the scan limit ended the search before enough matches were found.
250
310
  return {
251
311
  content: [{
252
312
  type: 'text',
253
- text: JSON.stringify({ issues: pageIssues }, null, 2),
313
+ text: JSON.stringify({ issues: pageIssues, ...(truncated && { truncated: true }) }, null, 2),
254
314
  }],
255
315
  };
256
316
  }
@@ -269,30 +329,30 @@ export function registerIssueTools(server, client, cache) {
269
329
  Required fields: summary, description, project_id, category. All other fields are optional with sensible defaults (priority: "normal", severity: "minor").
270
330
 
271
331
  Recommended workflow:
272
- 1. Call get_project_categories to obtain a valid category name
273
- 2. Optionally call get_project_versions to obtain version names
274
- 3. Optionally call find_project_member to resolve the assignee's username
332
+ 1. Call get_project with include: ["categories"] to obtain a valid category name
333
+ 2. Optionally add "versions" to include to obtain version names
334
+ 3. Optionally call get_project with member_query to resolve the assignee's username
275
335
 
276
- Both priority and severity accept canonical English names or localized labels from the connected MantisBT instance — call get_issue_enums to see all available values.
336
+ Both priority and severity accept canonical English names or localized labels from the connected MantisBT instance — call get_issue_schema to see all available values.
277
337
 
278
338
  For the handler, prefer the username field (resolved server-side) over handler_id when working interactively.`,
279
339
  inputSchema: z.object({
280
340
  summary: z.string().min(1).describe('Issue summary/title (required)'),
281
341
  description: z.string().min(1).describe('Detailed issue description (required). Do not create issues without a description. Plain text or Markdown.'),
282
342
  project_id: z.coerce.number().int().positive().describe('Project ID the issue belongs to — use list_projects to discover project IDs'),
283
- category: z.string().min(1).describe('Category name (required). Use get_project_categories to list available categories for the project.'),
284
- priority: z.string().default('normal').describe('Priority level. Canonical English names: none, low, normal, high, urgent, immediate. Default: "normal". Use get_issue_enums to see localized labels.'),
285
- severity: z.string().default('minor').describe('Severity level. Canonical English names: feature, trivial, text, tweak, minor, major, crash, block. Default: "minor". Use get_issue_enums to see localized labels.'),
343
+ category: z.string().min(1).describe('Category name (required). Use get_project with include: ["categories"] to list available categories for the project.'),
344
+ priority: z.string().default('normal').describe('Priority level. Canonical English names: none, low, normal, high, urgent, immediate. Default: "normal". Use get_issue_schema to see localized labels.'),
345
+ severity: z.string().default('minor').describe('Severity level. Canonical English names: feature, trivial, text, tweak, minor, major, crash, block. Default: "minor". Use get_issue_schema to see localized labels.'),
286
346
  handler_id: z.coerce.number().int().positive().optional().describe('Numeric user ID of the assignee. Alternative to the handler field — use one or the other, not both.'),
287
- handler: z.string().optional().describe('MantisBT login name of the assignee. The server resolves the name to a user ID from the project member list. Use find_project_member or get_project_users to look up valid login names.'),
288
- version: z.string().optional().describe('Affected product version name. Use get_project_versions to list available version names for the project.'),
289
- target_version: z.string().optional().describe('Target fix version — version in which the issue is planned to be resolved. Use get_project_versions to list available version names.'),
290
- fixed_in_version: z.string().optional().describe('Version in which the issue was fixed. Use get_project_versions to list available version names.'),
347
+ handler: z.string().optional().describe('MantisBT login name of the assignee. The server resolves the name to a user ID from the project member list. Use get_project with member_query or include: ["users"] to look up valid login names.'),
348
+ version: z.string().optional().describe('Affected product version name. Use get_project with include: ["versions"] to list available version names for the project.'),
349
+ target_version: z.string().optional().describe('Target fix version — version in which the issue is planned to be resolved. Use get_project with include: ["versions"] to list available version names.'),
350
+ fixed_in_version: z.string().optional().describe('Version in which the issue was fixed. Use get_project with include: ["versions"] to list available version names.'),
291
351
  steps_to_reproduce: z.string().optional().describe('Step-by-step instructions to reproduce the issue. Plain text or Markdown.'),
292
352
  additional_information: z.string().optional().describe('Additional context or notes about the issue. Plain text or Markdown.'),
293
- reproducibility: z.string().optional().describe('How reliably the issue reproduces. Canonical English names: always, sometimes, random, have not tried, unable to reproduce, N/A. Use get_issue_enums to see localized labels.'),
353
+ reproducibility: z.string().optional().describe('How reliably the issue reproduces. Canonical English names: always, sometimes, random, have not tried, unable to reproduce, N/A. Use get_issue_schema to see localized labels.'),
294
354
  view_state: z.enum(['public', 'private']).optional().describe('Visibility of the issue: "public" (visible to all, default) or "private" (restricted to higher-access users).'),
295
- custom_fields: z.array(customFieldEntry).optional().describe('Custom field values: [{field: {id|name}, value: "<string>"}]. Use get_issue_fields or get_metadata to discover available custom fields per project.'),
355
+ custom_fields: z.array(customFieldEntry).optional().describe('Custom field values: [{field: {id|name}, value: "<string>"}]. Use get_issue_schema or get_metadata to discover available custom fields per project.'),
296
356
  }),
297
357
  annotations: {
298
358
  readOnlyHint: false,
@@ -318,7 +378,7 @@ For the handler, prefer the username field (resolved server-side) over handler_i
318
378
  if (!user) {
319
379
  const names = users.map(u => u.name).join(', ');
320
380
  return {
321
- content: [{ type: 'text', text: errorText(`User "${handler}" not found in project ${project_id}. Available users: ${names || 'none (run sync_metadata or check project_id)'}`) }],
381
+ content: [{ type: 'text', text: errorText(`User "${handler}" not found in project ${project_id}. Available users: ${names || 'none (run get_metadata with refresh: true or check project_id)'}`) }],
322
382
  isError: true,
323
383
  };
324
384
  }
@@ -362,16 +422,14 @@ For the handler, prefer the username field (resolved server-side) over handler_i
362
422
  if (custom_fields !== undefined)
363
423
  body.custom_fields = custom_fields;
364
424
  const raw = await client.post('issues', body);
365
- const partial = ('issue' in raw && typeof raw['issue'] === 'object' && raw['issue'] !== null)
366
- ? raw['issue']
367
- : raw;
425
+ const partial = firstIssue(raw) ?? raw;
368
426
  let issue = partial;
369
427
  if (!('summary' in partial)) {
370
428
  // Older MantisBT returned only { id: N } — fetch the full issue.
371
429
  // Suppress GET errors: the issue was already created.
372
430
  try {
373
431
  const fetched = await client.get(`issues/${partial.id}`);
374
- issue = fetched.issues?.[0] ?? partial;
432
+ issue = firstIssue(fetched) ?? partial;
375
433
  }
376
434
  catch {
377
435
  // unable to fetch details — return minimal object
@@ -390,7 +448,24 @@ For the handler, prefer the username field (resolved server-side) over handler_i
390
448
  // ---------------------------------------------------------------------------
391
449
  // update_issue
392
450
  // ---------------------------------------------------------------------------
393
- const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
451
+ /** PATCHes the issue fields (enum names resolved to IDs) and returns the enriched issue. */
452
+ async function patchIssueFields(id, fields) {
453
+ const patch = { ...fields };
454
+ for (const field of ['status', 'priority', 'severity', 'resolution', 'reproducibility']) {
455
+ const val = fields[field];
456
+ if (val?.name !== undefined && val.id === undefined) {
457
+ const resolved = await resolveEnum(field, val.name, client);
458
+ if (typeof resolved !== 'string')
459
+ patch[field] = resolved;
460
+ }
461
+ }
462
+ const [result, baseUrl] = await Promise.all([
463
+ client.patch(`issues/${id}`, patch),
464
+ client.getBaseUrl(),
465
+ ]);
466
+ const issue = firstIssue(result) ?? result;
467
+ return enrichIssue(issue, baseUrl, id);
468
+ }
394
469
  server.registerTool('update_issue', {
395
470
  title: 'Update Issue',
396
471
  description: `Update one or more fields of an existing MantisBT issue using a partial PATCH.
@@ -411,13 +486,20 @@ The "fields" object accepts any combination of:
411
486
  - target_version: { name: "<version_name>" }
412
487
  - fixed_in_version: { name: "<version_name>" }
413
488
  - view_state: { name: "public"|"private" }
414
- - custom_fields: [{field: {id|name}, value: "<string>"}] (only the listed custom fields are changed, others stay untouched; use get_issue_fields to discover fields)
489
+ - custom_fields: [{field: {id|name}, value: "<string>"}] (only the listed custom fields are changed, others stay untouched; use get_issue_schema to discover fields)
490
+ - notes: [{ id: <note_id>, text?: "<new text>", view_state?: "public"|"private" }] (edits existing notes; note IDs come from get_issue. An omitted view_state keeps the note's current visibility. To add a new note use the "note" parameter or add_note.)
415
491
 
416
492
  Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".
417
493
 
418
- Use the optional "note" parameter to append a note in the same call (e.g. the reason for a status change) — no separate add_note call needed. For a note without field changes use add_note.`,
494
+ Use the optional "note" parameter to append a note in the same call (e.g. the reason for a status change) — no separate add_note call needed. For a note without field changes use add_note.
495
+
496
+ Use "add_monitors" / "remove_monitors" (lists of MantisBT login names) to change who watches the issue — "fields" may be omitted then. The response contains "monitors" with added/removed/not_monitoring and the current monitor list. Adding or removing other users requires the monitor_add_others_bug_threshold / monitor_delete_others_bug_threshold access level.
497
+
498
+ Steps run in order: fields, monitors, note. Once one step succeeded, a later failure is reported inline ("notes_error" when a note edit was not applied, "monitors_error", "note_error") instead of failing the call — do not retry the steps that went through. Only if every requested step failed is the call an error.`,
419
499
  inputSchema: z.object({
420
500
  id: z.coerce.number().int().positive().describe('Numeric issue ID to update'),
501
+ add_monitors: z.array(z.string().min(1)).min(1).optional().describe('MantisBT login names to add as monitors (watchers) of the issue'),
502
+ remove_monitors: z.array(z.string().min(1)).min(1).optional().describe('MantisBT login names to remove from the monitors of the issue; names that are not monitoring are ignored'),
421
503
  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.'),
422
504
  note: z.string().min(1).optional().describe('Optional note text appended after a successful update (e.g. reason for a status change). Replaces a separate add_note call.'),
423
505
  note_view_state: z.enum(['public', 'private']).default('public').describe('Visibility of the appended note: "public" (default) or "private". Only used when "note" is set.'),
@@ -447,46 +529,102 @@ Use the optional "note" parameter to append a note in the same call (e.g. the re
447
529
  fixed_in_version: ref.optional(),
448
530
  view_state: ref.optional(),
449
531
  custom_fields: z.array(customFieldEntry).optional(),
450
- }).strict().describe('Fields to update (partial update — only provided fields are changed; unknown keys are rejected)')),
532
+ notes: noteEditsSchema.optional(),
533
+ }).strict().optional().describe('Fields to update (partial update — only provided fields are changed; unknown keys are rejected). May be omitted when only add_monitors, remove_monitors or note are given.')),
451
534
  }),
452
535
  annotations: {
453
536
  readOnlyHint: false,
454
537
  destructiveHint: false,
455
538
  idempotentHint: false,
456
539
  },
457
- }, async ({ id, fields, dry_run, note, note_view_state }) => {
458
- if (dry_run) {
540
+ }, async ({ id, fields, dry_run, note, note_view_state, add_monitors, remove_monitors }) => {
541
+ const fieldChanges = fields ?? {};
542
+ const hasFields = Object.keys(fieldChanges).length > 0;
543
+ const hasMonitorChanges = add_monitors !== undefined || remove_monitors !== undefined;
544
+ if (!hasFields && !hasMonitorChanges && note === undefined) {
459
545
  return {
460
- content: [{ type: 'text', text: JSON.stringify({ dry_run: true, id, would_patch: fields, would_add_note: note ?? null }, null, 2) }],
546
+ content: [{ type: 'text', text: errorText('Nothing to update — pass fields, add_monitors, remove_monitors or note.') }],
547
+ isError: true,
548
+ };
549
+ }
550
+ const removing = new Set(remove_monitors?.map(u => u.toLowerCase()));
551
+ const conflicting = add_monitors?.filter(u => removing.has(u.toLowerCase())) ?? [];
552
+ if (conflicting.length > 0) {
553
+ return {
554
+ content: [{ type: 'text', text: errorText(`${conflicting.join(', ')} appear(s) in both add_monitors and remove_monitors.`) }],
555
+ isError: true,
461
556
  };
462
557
  }
463
558
  try {
464
- const patch = { ...fields };
465
- for (const field of ['status', 'priority', 'severity', 'resolution', 'reproducibility']) {
466
- const val = fields[field];
467
- if (val?.name !== undefined && val.id === undefined) {
468
- const resolved = await resolveEnum(field, val.name, client);
469
- if (typeof resolved !== 'string')
470
- patch[field] = resolved;
559
+ // Note edits need the current notes first (unknown IDs, missing view_state).
560
+ const noteEdits = fieldChanges.notes && await prepareNoteEdits(client, id, fieldChanges.notes);
561
+ const patchFields = noteEdits ? { ...fieldChanges, notes: noteEdits } : fieldChanges;
562
+ if (dry_run) {
563
+ return {
564
+ content: [{
565
+ type: 'text',
566
+ text: JSON.stringify({
567
+ dry_run: true,
568
+ id,
569
+ would_patch: patchFields,
570
+ ...(add_monitors && { would_add_monitors: add_monitors }),
571
+ ...(remove_monitors && { would_remove_monitors: remove_monitors }),
572
+ would_add_note: note ?? null,
573
+ }, null, 2),
574
+ }],
575
+ };
576
+ }
577
+ const patched = hasFields ? await patchIssueFields(id, patchFields) : undefined;
578
+ const response = patched
579
+ ? { ...patched }
580
+ : { id, view_url: buildIssueViewUrl(await client.getBaseUrl(), id) };
581
+ // MantisBT skips note edits it does not apply without an error, so the
582
+ // response is checked.
583
+ const noteProblems = noteEdits && patched ? verifyNoteEdits(noteEdits, patched.notes) : [];
584
+ // As soon as one step succeeded, later failures are reported inline —
585
+ // otherwise the caller would retry changes that already went through.
586
+ // Only if every requested step failed is the call an error. The field
587
+ // step succeeded unless note edits failed and no other field was sent.
588
+ let succeeded = hasFields
589
+ && (noteProblems.length === 0 || Object.keys(fieldChanges).some(k => k !== 'notes'));
590
+ const failures = [];
591
+ const describeFailure = (what, detail) => {
592
+ const message = succeeded
593
+ ? `Issue #${id} was updated successfully, but ${what} failed: ${detail}`
594
+ : `${what.charAt(0).toUpperCase()}${what.slice(1)} failed: ${detail}`;
595
+ failures.push(message);
596
+ return message;
597
+ };
598
+ if (noteProblems.length > 0) {
599
+ response['notes_error'] = describeFailure('editing notes', `${noteProblems.join('; ')}.`);
600
+ }
601
+ if (hasMonitorChanges) {
602
+ // The PATCH response already holds the monitor list; MantisBT omits
603
+ // the key when the issue has no monitors.
604
+ const knownMonitors = patched ? patched.monitors ?? [] : undefined;
605
+ const { changes, problems } = await applyMonitorChanges(client, id, add_monitors, remove_monitors, knownMonitors);
606
+ if (changes)
607
+ response['monitors'] = changes;
608
+ if (problems.length > 0) {
609
+ response['monitors_error'] = describeFailure('changing monitors', problems.join(' '));
610
+ }
611
+ else {
612
+ succeeded = true;
471
613
  }
472
614
  }
473
- const [result, baseUrl] = await Promise.all([
474
- client.patch(`issues/${id}`, patch),
475
- client.getBaseUrl(),
476
- ]);
477
- const issue = result.issue ?? result;
478
- const response = { ...enrichIssue(issue, baseUrl, id) };
479
615
  if (note !== undefined) {
480
- // The PATCH already succeeded — a note failure must not look like a failed
481
- // update, or the caller would retry the whole update. Report it inline instead.
482
616
  try {
483
617
  response['note'] = await postNote(client, id, note, note_view_state);
618
+ succeeded = true;
484
619
  }
485
620
  catch (noteError) {
486
621
  const noteMsg = noteError instanceof Error ? noteError.message : String(noteError);
487
- response['note_error'] = `Issue #${id} was updated successfully, but adding the note failed: ${noteMsg}. Retry with add_note.`;
622
+ response['note_error'] = describeFailure('adding the note', `${noteMsg}. Retry with add_note.`);
488
623
  }
489
624
  }
625
+ if (!succeeded) {
626
+ return { content: [{ type: 'text', text: errorText(failures.join(' ')) }], isError: true };
627
+ }
490
628
  return {
491
629
  content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
492
630
  };