@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.
- package/CHANGELOG.md +57 -0
- package/README.de.md +118 -29
- package/README.md +118 -29
- package/dist/client.js +4 -0
- package/dist/coerce.js +8 -0
- package/dist/config.js +3 -0
- package/dist/deprecation.js +9 -0
- package/dist/index.js +20 -31
- package/dist/issue-enrich.js +23 -0
- package/dist/issue-envelope.js +4 -0
- package/dist/prompts/index.js +4 -4
- package/dist/resources/index.js +4 -8
- package/dist/search/embedder.js +49 -14
- package/dist/search/index.js +18 -11
- package/dist/search/store.js +30 -7
- package/dist/search/sync-lock.js +66 -0
- package/dist/search/sync.js +67 -9
- package/dist/search/tools.js +21 -27
- package/dist/tools/config.js +9 -4
- package/dist/tools/files.js +12 -5
- package/dist/tools/index.js +34 -0
- package/dist/tools/issues.js +285 -147
- package/dist/tools/metadata.js +99 -40
- package/dist/tools/monitors.js +145 -20
- package/dist/tools/notes.js +67 -9
- package/dist/tools/project-versions.js +40 -18
- package/dist/tools/projects.js +109 -37
- package/dist/tools/users.js +3 -1
- package/dist/tools/version.js +61 -35
- package/dist/toolsets.js +104 -0
- package/docs/cookbook.de.md +605 -184
- package/docs/cookbook.md +605 -184
- package/docs/examples.de.md +27 -3
- package/docs/examples.md +27 -3
- package/package.json +2 -1
- package/server.json +2 -2
package/dist/tools/metadata.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
225
|
+
description: `Return the cached MantisBT metadata (projects with their users, versions and categories, plus all tags).
|
|
198
226
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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,
|
|
363
|
+
content: [{ type: 'text', text: JSON.stringify({ fields, fields_source: source, enums }, null, 2) }],
|
|
305
364
|
};
|
|
306
365
|
}
|
|
307
366
|
catch (error) {
|
package/dist/tools/monitors.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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: '
|
|
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('
|
|
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:
|
|
173
|
+
idempotentHint: true,
|
|
58
174
|
},
|
|
59
175
|
}, async ({ issue_id, username }) => {
|
|
60
176
|
try {
|
|
61
|
-
await client
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/notes.js
CHANGED
|
@@ -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: '
|
|
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
|
|
45
|
-
|
|
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
|
|
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
|
|
103
|
-
note_id: z.coerce.number().int().positive().describe('Numeric note ID to delete — obtain from get_issue (notes[].id)
|
|
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,
|