@dpesch/mantisbt-mcp-server 1.14.1 → 1.16.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,20 +1,21 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBool } from '../coerce.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
  }
9
- const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
10
11
  /** Drops undefined values so PATCH only touches the fields the caller set. */
11
12
  function definedFields(fields) {
12
13
  return Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
13
14
  }
14
15
  const PERMISSION_NOTE = 'Requires the manage_project_threshold access level in the project (MantisBT default: manager).';
15
- const OWNING_PROJECT_NOTE = 'The version must belong to project_id itself: versions inherited from a parent project can only be changed via the parent\'s project_id (otherwise MantisBT answers "Version not found"). get_project_versions with inherit=false (the default) lists the project\'s own versions.';
16
+ const OWNING_PROJECT_NOTE = 'The version must belong to project_id itself: versions inherited from a parent project can only be changed via the parent\'s project_id (otherwise MantisBT answers "Version not found"). get_project with include: ["versions"] and without inherit lists the project\'s own versions.';
16
17
  const projectIdSchema = z.coerce.number().int().positive().describe('Numeric ID of the project the version belongs to — use list_projects to discover project IDs');
17
- const versionIdSchema = z.coerce.number().int().positive().describe('Numeric version ID — use get_project_versions to discover version IDs');
18
+ const versionIdSchema = z.coerce.number().int().positive().describe('Numeric version ID — use get_project with include: ["versions"] to discover version IDs');
18
19
  const timestampSchema = z.string().min(1).describe('Version date as ISO 8601 string, e.g. "2026-09-27" or "2026-09-27T10:00:00+02:00". Determines the order in roadmap and changelog.');
19
20
  const boolFlag = z.preprocess(coerceBool, z.boolean());
20
21
  export function registerProjectVersionTools(server, client, cache) {
@@ -39,6 +40,18 @@ export function registerProjectVersionTools(server, client, cache) {
39
40
  await patchCachedVersions((_pid, versions) => versions.map((v) => (v.id === versionId ? result.version : v)));
40
41
  return result.version;
41
42
  }
43
+ /**
44
+ * Creates the follow-up version after a release. A failure must not hide the
45
+ * release that already succeeded, so it is reported as next_version_error.
46
+ */
47
+ async function createFollowUpVersion(projectId, name) {
48
+ try {
49
+ return { next_version: await createVersion(projectId, { name }) };
50
+ }
51
+ catch (error) {
52
+ return { next_version_error: error instanceof Error ? error.message : String(error) };
53
+ }
54
+ }
42
55
  // ---------------------------------------------------------------------------
43
56
  // create_version
44
57
  // ---------------------------------------------------------------------------
@@ -83,7 +96,9 @@ ${PERMISSION_NOTE}`,
83
96
 
84
97
  Renaming a version also rewrites the version, target_version and fixed_in_version fields of all issues that reference it (including subprojects if versions are inherited).
85
98
 
86
- Setting released=true does not change the version date — pass timestamp as well, or use release_version, which does both.
99
+ Setting released=true does not change the version date — pass timestamp as well (e.g. the release date).
100
+
101
+ To release a version: released=true, timestamp=<release date>, and optionally next_version=<name> to create the follow-up version in the same call. With next_version the response is { version: <updated>, next_version: <created> } — or next_version_error: <message> if creating the follow-up failed (the update itself stays in effect). Without next_version the response is the updated version object.
87
102
 
88
103
  ${OWNING_PROJECT_NOTE}
89
104
 
@@ -96,36 +111,48 @@ ${PERMISSION_NOTE}`,
96
111
  released: boolFlag.optional().describe('Released flag'),
97
112
  obsolete: boolFlag.optional().describe('Obsolete flag'),
98
113
  timestamp: timestampSchema.optional(),
114
+ next_version: z.string().trim().min(1).optional().describe('Only with released=true: name of a follow-up version to create as unreleased placeholder, e.g. "1.2.1". Used as-is — no automatic numbering.'),
99
115
  }),
100
116
  annotations: {
101
117
  readOnlyHint: false,
102
118
  destructiveHint: false,
103
119
  idempotentHint: true,
104
120
  },
105
- }, async ({ project_id, version_id, ...fields }) => {
121
+ }, async ({ project_id, version_id, next_version, ...fields }) => {
106
122
  if (Object.keys(definedFields(fields)).length === 0) {
107
123
  return {
108
124
  content: [{ type: 'text', text: errorText('No fields to update — pass at least one of name, description, released, obsolete, timestamp.') }],
109
125
  isError: true,
110
126
  };
111
127
  }
112
- try {
113
- const updated = await updateVersion(project_id, version_id, fields);
128
+ if (next_version !== undefined && fields.released !== true) {
114
129
  return {
115
- content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }],
130
+ content: [{ type: 'text', text: errorText('next_version is only allowed together with released=true.') }],
131
+ isError: true,
116
132
  };
117
133
  }
134
+ let updated;
135
+ try {
136
+ updated = await updateVersion(project_id, version_id, fields);
137
+ }
118
138
  catch (error) {
119
139
  const msg = error instanceof Error ? error.message : String(error);
120
140
  return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
121
141
  }
142
+ if (next_version === undefined) {
143
+ return { content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }] };
144
+ }
145
+ const result = { version: updated, ...(await createFollowUpVersion(project_id, next_version)) };
146
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
122
147
  });
123
148
  // ---------------------------------------------------------------------------
124
149
  // release_version
125
150
  // ---------------------------------------------------------------------------
126
151
  server.registerTool('release_version', {
127
152
  title: 'Release Version',
128
- description: `Mark a version as released and set its date (default: now). Optionally create a follow-up version in the same step.
153
+ description: `${deprecationNote('release_version', 'update_version with released=true, timestamp and (optionally) next_version')}
154
+
155
+ Mark a version as released and set its date (default: now). Optionally create a follow-up version in the same step.
129
156
 
130
157
  The follow-up version is only created when next_version is given; its name is used as-is — no automatic numbering.
131
158
 
@@ -157,15 +184,10 @@ ${PERMISSION_NOTE}`,
157
184
  const msg = error instanceof Error ? error.message : String(error);
158
185
  return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
159
186
  }
160
- const result = { released };
161
- if (next_version !== undefined) {
162
- try {
163
- result.next_version = await createVersion(project_id, { name: next_version });
164
- }
165
- catch (error) {
166
- result.next_version_error = error instanceof Error ? error.message : String(error);
167
- }
168
- }
187
+ const result = {
188
+ released,
189
+ ...(next_version !== undefined ? await createFollowUpVersion(project_id, next_version) : {}),
190
+ };
169
191
  return {
170
192
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
171
193
  };
@@ -1,15 +1,105 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBool } from '../coerce.js';
2
3
  import { getVersionHint } from '../version-hint.js';
3
- import { ALL_PROJECTS_PREFIX } from '../constants.js';
4
- import { normalizeProject } from './metadata.js';
4
+ import { normalizeProject, stripAllProjectsPrefix } from './metadata.js';
5
+ import { deprecationNote } from '../deprecation.js';
5
6
  function errorText(msg) {
6
7
  const vh = getVersionHint();
7
8
  vh?.triggerLatestVersionFetch();
8
9
  const hint = vh?.getUpdateHint();
9
10
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
10
11
  }
11
- const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
12
+ /** Case-insensitive substring match on login name, real name and email. */
13
+ const matchMembers = (users, query) => {
14
+ const q = query.toLowerCase();
15
+ return users.filter(u => u.name.toLowerCase().includes(q) ||
16
+ (u.real_name?.toLowerCase().includes(q) ?? false) ||
17
+ (u.email?.toLowerCase().includes(q) ?? false));
18
+ };
19
+ const PROJECT_INCLUDES = ['categories', 'users', 'versions'];
12
20
  export function registerProjectTools(server, client, cache) {
21
+ async function fetchVersions(projectId, obsolete, inherit) {
22
+ const params = {};
23
+ if (obsolete)
24
+ params.obsolete = 1;
25
+ if (inherit)
26
+ params.inherit = 1;
27
+ const result = await client.get(`projects/${projectId}/versions`, params);
28
+ return result.versions ?? [];
29
+ }
30
+ /**
31
+ * Project users. The metadata cache has no access-level filter, so it is
32
+ * used only when access_level is not given; otherwise the API filters.
33
+ */
34
+ async function fetchUsers(projectId, accessLevel) {
35
+ if (accessLevel === undefined) {
36
+ const cached = cache ? await cache.loadIfValid() : null;
37
+ const cachedUsers = cached?.byProject[projectId]?.users;
38
+ if (cachedUsers)
39
+ return cachedUsers;
40
+ }
41
+ const params = {};
42
+ if (accessLevel !== undefined)
43
+ params.access_level = accessLevel;
44
+ const result = await client.get(`projects/${projectId}/users`, params);
45
+ return result.users ?? [];
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // get_project
49
+ // ---------------------------------------------------------------------------
50
+ server.registerTool('get_project', {
51
+ title: 'Get Project',
52
+ description: `Get one MantisBT project: its master data (id, name, description, status, enabled, view_state, access_level, subprojects) and, on request, its categories, users and versions in the same call.
53
+
54
+ - include: any of "categories", "users", "versions" (default: none — master data only).
55
+ - categories: names come without the "[All Projects] " prefix, ready for create_issue / update_issue.
56
+ - users: id, name (login name), real_name, email, access_level. access_level returns only users at or above that level (10=viewer, 25=reporter, 40=updater, 55=developer, 70=manager, 90=administrator) and implies include "users".
57
+ - member_query: case-insensitive substring search in login name, real name and email; returns all matching users. Implies include "users".
58
+ - versions: id, name, released, obsolete, timestamp. obsolete=true adds obsolete versions, inherit=true adds versions of parent projects; either implies include "versions".
59
+
60
+ Users come from the local metadata cache when it is fresh and no access_level is given. Prerequisites: obtain project_id from list_projects.`,
61
+ inputSchema: z.object({
62
+ project_id: z.coerce.number().int().positive().describe('Numeric project ID — use list_projects to discover project IDs'),
63
+ include: z.array(z.enum(PROJECT_INCLUDES)).optional().describe('Related data to add: "categories", "users", "versions"'),
64
+ access_level: z.coerce.number().int().optional().describe('Only users at or above this access level; implies include "users"'),
65
+ member_query: z.string().min(1).optional().describe('Case-insensitive substring to find users by login name, real name or email; implies include "users"'),
66
+ obsolete: z.preprocess(coerceBool, z.boolean().optional()).describe('Also return obsolete versions; implies include "versions"'),
67
+ inherit: z.preprocess(coerceBool, z.boolean().optional()).describe('Also return versions inherited from parent projects; implies include "versions"'),
68
+ }),
69
+ annotations: {
70
+ readOnlyHint: true,
71
+ destructiveHint: false,
72
+ idempotentHint: true,
73
+ },
74
+ }, async ({ project_id, include, access_level, member_query, obsolete, inherit }) => {
75
+ const wants = new Set(include);
76
+ if (member_query !== undefined || access_level !== undefined)
77
+ wants.add('users');
78
+ if (obsolete !== undefined || inherit !== undefined)
79
+ wants.add('versions');
80
+ try {
81
+ // Categories come with the project itself; users and versions need their own requests.
82
+ const [projectResult, users, versions] = await Promise.all([
83
+ client.get(`projects/${project_id}`),
84
+ wants.has('users') ? fetchUsers(project_id, access_level) : undefined,
85
+ wants.has('versions') ? fetchVersions(project_id, obsolete, inherit) : undefined,
86
+ ]);
87
+ const raw = projectResult.projects?.[0];
88
+ if (!raw)
89
+ throw new Error(`Project ${project_id} not found or not accessible.`);
90
+ const project = {
91
+ ...normalizeProject(raw),
92
+ ...(wants.has('categories') && { categories: stripAllProjectsPrefix(raw.categories ?? []) }),
93
+ ...(users && { users: member_query !== undefined ? matchMembers(users, member_query) : users }),
94
+ ...(versions && { versions }),
95
+ };
96
+ return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
97
+ }
98
+ catch (error) {
99
+ const msg = error instanceof Error ? error.message : String(error);
100
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
101
+ }
102
+ });
13
103
  // ---------------------------------------------------------------------------
14
104
  // list_projects
15
105
  // ---------------------------------------------------------------------------
@@ -40,9 +130,9 @@ export function registerProjectTools(server, client, cache) {
40
130
  // ---------------------------------------------------------------------------
41
131
  server.registerTool('get_project_users', {
42
132
  title: 'Get Project Users',
43
- description: `List all users with access to a specific MantisBT project. Returns an array of user objects, each containing id, name (login name), real_name, email, and access_level fields.
133
+ description: `${deprecationNote('get_project_users', 'get_project with include: ["users"]')}
44
134
 
45
- Use get_project_users when you need the complete user list for a project — for example, to verify who has access or to build a handler list. For name-based lookup of a single user, prefer find_project_member which supports case-insensitive substring search and is significantly faster on large projects.
135
+ List all users with access to a specific MantisBT project. Returns an array of user objects, each containing id, name (login name), real_name, email, and access_level fields.
46
136
 
47
137
  Access level IDs: 10=viewer, 25=reporter, 40=updater, 55=developer, 70=manager, 90=administrator.
48
138
 
@@ -76,7 +166,9 @@ Prerequisites: obtain project_id from list_projects.`,
76
166
  // ---------------------------------------------------------------------------
77
167
  server.registerTool('get_project_versions', {
78
168
  title: 'Get Project Versions',
79
- description: `List all versions defined for a MantisBT project. Returns an array of version objects, each containing id, name, released (boolean), obsolete (boolean), and timestamp (version date). To create, change, release or delete versions use create_version, update_version, release_version and delete_version.
169
+ description: `${deprecationNote('get_project_versions', 'get_project with include: ["versions"]')}
170
+
171
+ List all versions defined for a MantisBT project. Returns an array of version objects, each containing id, name, released (boolean), obsolete (boolean), and timestamp (version date). To create, change, release or delete versions use create_version, update_version, release_version and delete_version.
80
172
 
81
173
  Use the returned version names directly when creating or updating issues via create_issue and update_issue (version, target_version, fixed_in_version fields).
82
174
 
@@ -95,14 +187,9 @@ Prerequisites: obtain project_id from list_projects.`,
95
187
  },
96
188
  }, async ({ project_id, obsolete, inherit }) => {
97
189
  try {
98
- const params = {};
99
- if (obsolete)
100
- params.obsolete = 1;
101
- if (inherit)
102
- params.inherit = 1;
103
- const result = await client.get(`projects/${project_id}/versions`, params);
190
+ const versions = await fetchVersions(project_id, obsolete, inherit);
104
191
  return {
105
- content: [{ type: 'text', text: JSON.stringify(result.versions ?? result, null, 2) }],
192
+ content: [{ type: 'text', text: JSON.stringify(versions, null, 2) }],
106
193
  };
107
194
  }
108
195
  catch (error) {
@@ -115,7 +202,9 @@ Prerequisites: obtain project_id from list_projects.`,
115
202
  // ---------------------------------------------------------------------------
116
203
  server.registerTool('get_project_categories', {
117
204
  title: 'Get Project Categories',
118
- description: `List all categories available for a MantisBT project.
205
+ description: `${deprecationNote('get_project_categories', 'get_project with include: ["categories"]')}
206
+
207
+ List all categories available for a MantisBT project.
119
208
 
120
209
  Note: The MantisBT API returns global (cross-project) categories with a "[All Projects] " prefix.
121
210
  This tool strips that prefix so the returned names can be used directly when creating issues.`,
@@ -130,13 +219,7 @@ This tool strips that prefix so the returned names can be used directly when cre
130
219
  }, async ({ project_id }) => {
131
220
  try {
132
221
  const result = await client.get(`projects/${project_id}`);
133
- const raw = result.projects?.[0]?.categories ?? [];
134
- const categories = raw.map((cat) => ({
135
- ...cat,
136
- name: cat.name.startsWith(ALL_PROJECTS_PREFIX)
137
- ? cat.name.slice(ALL_PROJECTS_PREFIX.length)
138
- : cat.name,
139
- }));
222
+ const categories = stripAllProjectsPrefix(result.projects?.[0]?.categories ?? []);
140
223
  return {
141
224
  content: [{ type: 'text', text: JSON.stringify(categories, null, 2) }],
142
225
  };
@@ -151,7 +234,9 @@ This tool strips that prefix so the returned names can be used directly when cre
151
234
  // ---------------------------------------------------------------------------
152
235
  server.registerTool('find_project_member', {
153
236
  title: 'Find Project Member',
154
- description: `Search for users with access to a MantisBT project by name, display name, or email.
237
+ description: `${deprecationNote('find_project_member', 'get_project with member_query')}
238
+
239
+ Search for users with access to a MantisBT project by name, display name, or email.
155
240
 
156
241
  Returns up to \`limit\` matching users (default: 10, max: 100). Matching is case-insensitive substring search across \`name\`, \`real_name\`, and \`email\` fields. Omit \`query\` to list the first \`limit\` users.
157
242
 
@@ -168,21 +253,8 @@ Data is served from the local metadata cache when fresh; falls back to a live AP
168
253
  },
169
254
  }, async ({ project_id, query, limit }) => {
170
255
  try {
171
- let users;
172
- const cached = cache ? await cache.loadIfValid() : null;
173
- if (cached?.byProject[project_id]) {
174
- users = cached.byProject[project_id].users;
175
- }
176
- else {
177
- const result = await client.get(`projects/${project_id}/users`);
178
- users = result.users ?? [];
179
- }
180
- if (query) {
181
- const q = query.toLowerCase();
182
- users = users.filter((u) => u.name.toLowerCase().includes(q) ||
183
- (u.real_name?.toLowerCase().includes(q) ?? false) ||
184
- (u.email?.toLowerCase().includes(q) ?? false));
185
- }
256
+ const all = await fetchUsers(project_id);
257
+ const users = query ? matchMembers(all, query) : all;
186
258
  return {
187
259
  content: [{ type: 'text', text: JSON.stringify(users.slice(0, limit), null, 2) }],
188
260
  };
@@ -12,7 +12,9 @@ export function registerUserTools(server, client) {
12
12
  // ---------------------------------------------------------------------------
13
13
  server.registerTool('get_current_user', {
14
14
  title: 'Get Current User',
15
- description: 'Retrieve the profile of the user associated with the current API key.',
15
+ description: `Retrieve the profile of the user associated with the current API key: id, name (login name), real_name, email, language, timezone, access_level, created_at and the accessible projects.
16
+
17
+ "language" is the MantisBT language of this user (e.g. "german", "english") — use it as the hint for which language to write issue texts and notes in, and expect localized enum labels in that language.`,
16
18
  inputSchema: z.object({}),
17
19
  annotations: {
18
20
  readOnlyHint: true,
@@ -1,13 +1,68 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBool } from '../coerce.js';
2
3
  import { parseVersion, compareVersions } from '../version-hint.js';
3
- const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
4
+ import { deprecationNote } from '../deprecation.js';
4
5
  export function registerVersionTools(server, client, versionHint, mcpVersion) {
6
+ /**
7
+ * Installed MantisBT version (X-Mantis-Version header), optionally compared
8
+ * with the latest MantisBT release on GitHub.
9
+ */
10
+ async function mantisVersionInfo(checkLatest) {
11
+ // Start the GitHub lookup first so it overlaps with the MantisBT request.
12
+ if (checkLatest)
13
+ versionHint.triggerLatestVersionFetch();
14
+ const installedRaw = await client.getVersion();
15
+ const result = { installed_version: installedRaw };
16
+ if (!checkLatest)
17
+ return result;
18
+ const latestRaw = await versionHint.waitForLatestVersion(5000);
19
+ result.latest_version = latestRaw;
20
+ if (latestRaw === null) {
21
+ result.status = 'unknown';
22
+ result.github_note = 'Could not fetch latest version from GitHub (timeout or network error).';
23
+ return result;
24
+ }
25
+ const installed = parseVersion(installedRaw);
26
+ const latest = parseVersion(latestRaw);
27
+ if (installed && latest) {
28
+ const cmp = compareVersions(installed, latest);
29
+ result.status = cmp === 0 ? 'up-to-date' : cmp > 0 ? 'newer-than-release' : 'update-available';
30
+ }
31
+ else {
32
+ result.status = 'unknown';
33
+ }
34
+ return result;
35
+ }
36
+ const checkLatestSchema = z.preprocess(coerceBool, z.boolean().default(true)).describe('Whether to fetch the latest MantisBT release from GitHub and compare (default: true)');
37
+ // ---------------------------------------------------------------------------
38
+ // get_version
39
+ // ---------------------------------------------------------------------------
40
+ server.registerTool('get_version', {
41
+ title: 'Get Versions',
42
+ description: `Return the version of this mantisbt-mcp-server and of the connected MantisBT installation in one response:
43
+ { mcp_server: { version }, mantis: { installed_version, latest_version?, status?, github_note? } }
44
+
45
+ The MantisBT version is read from the X-Mantis-Version response header. With check_latest (default: true) it is compared against the latest MantisBT release on GitHub (outbound HTTPS request, up to 5 seconds); status is "up-to-date", "update-available", "newer-than-release" or "unknown". If MantisBT cannot be reached, mantis contains "error" instead and mcp_server is still returned.`,
46
+ inputSchema: z.object({ check_latest: checkLatestSchema }),
47
+ annotations: {
48
+ readOnlyHint: true,
49
+ destructiveHint: false,
50
+ idempotentHint: true,
51
+ },
52
+ }, async ({ check_latest }) => {
53
+ const mantis = await mantisVersionInfo(check_latest).catch((error) => ({
54
+ error: error instanceof Error ? error.message : String(error),
55
+ }));
56
+ return {
57
+ content: [{ type: 'text', text: JSON.stringify({ mcp_server: { version: mcpVersion }, mantis }, null, 2) }],
58
+ };
59
+ });
5
60
  // ---------------------------------------------------------------------------
6
61
  // get_mcp_version
7
62
  // ---------------------------------------------------------------------------
8
63
  server.registerTool('get_mcp_version', {
9
64
  title: 'Get MCP Server Version',
10
- description: 'Returns the version of this mantisbt-mcp-server instance.',
65
+ description: `${deprecationNote('get_mcp_version', 'get_version')}\n\nReturns the version of this mantisbt-mcp-server instance.`,
11
66
  inputSchema: z.object({}),
12
67
  annotations: {
13
68
  readOnlyHint: true,
@@ -22,13 +77,10 @@ export function registerVersionTools(server, client, versionHint, mcpVersion) {
22
77
  // ---------------------------------------------------------------------------
23
78
  server.registerTool('get_mantis_version', {
24
79
  title: 'Get MantisBT Version',
25
- description: `Returns the version of the connected MantisBT installation and optionally compares it against the latest official release on GitHub.
80
+ description: `${deprecationNote('get_mantis_version', 'get_version')}
26
81
 
27
- The version is read from the X-Mantis-Version response header sent by every API call.
28
- The GitHub comparison requires an outbound HTTPS request to the GitHub API.`,
29
- inputSchema: z.object({
30
- check_latest: z.preprocess(coerceBool, z.boolean().default(true)).describe('Whether to fetch the latest release from GitHub and compare (default: true)'),
31
- }),
82
+ Returns the version of the connected MantisBT installation and optionally compares it against the latest official release on GitHub.`,
83
+ inputSchema: z.object({ check_latest: checkLatestSchema }),
32
84
  annotations: {
33
85
  readOnlyHint: true,
34
86
  destructiveHint: false,
@@ -36,34 +88,8 @@ The GitHub comparison requires an outbound HTTPS request to the GitHub API.`,
36
88
  },
37
89
  }, async ({ check_latest }) => {
38
90
  try {
39
- const installedRaw = await client.getVersion();
40
- const result = { installed_version: installedRaw };
41
- if (check_latest) {
42
- versionHint.triggerLatestVersionFetch();
43
- const latestRaw = await versionHint.waitForLatestVersion(5000);
44
- result.latest_version = latestRaw;
45
- if (latestRaw === null) {
46
- result.status = 'unknown';
47
- result.github_note = 'Could not fetch latest version from GitHub (timeout or network error).';
48
- }
49
- else {
50
- const installed = parseVersion(installedRaw);
51
- const latest = parseVersion(latestRaw);
52
- if (installed && latest) {
53
- const cmp = compareVersions(installed, latest);
54
- result.status = cmp === 0
55
- ? 'up-to-date'
56
- : cmp > 0
57
- ? 'newer-than-release'
58
- : 'update-available';
59
- }
60
- else {
61
- result.status = 'unknown';
62
- }
63
- }
64
- }
65
91
  return {
66
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
92
+ content: [{ type: 'text', text: JSON.stringify(await mantisVersionInfo(check_latest), null, 2) }],
67
93
  };
68
94
  }
69
95
  catch (error) {
@@ -0,0 +1,104 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Tool groups for MANTIS_TOOLSETS and MANTIS_DISABLE_DESTRUCTIVE (#8500)
3
+ // ---------------------------------------------------------------------------
4
+ export const TOOLSETS = ['issues', 'notes', 'links', 'files', 'projects', 'versions', 'meta', 'search'];
5
+ /**
6
+ * Group of every tool. A deprecated tool belongs to the group of its
7
+ * replacement, so switching a group off hides old and new name together.
8
+ * tests/toolsets.test.ts checks this list against the registered tools in
9
+ * both directions — keep it in sync when adding or removing a tool.
10
+ */
11
+ export const TOOL_GROUPS = {
12
+ // issues
13
+ get_issue: 'issues',
14
+ get_issues: 'issues', // deprecated → get_issue
15
+ list_issues: 'issues',
16
+ create_issue: 'issues',
17
+ update_issue: 'issues',
18
+ delete_issue: 'issues',
19
+ list_notes: 'issues', // deprecated → get_issue
20
+ list_issue_files: 'issues', // deprecated → get_issue
21
+ add_monitor: 'issues', // deprecated → update_issue
22
+ remove_monitor: 'issues', // deprecated → update_issue
23
+ // notes
24
+ add_note: 'notes',
25
+ delete_note: 'notes',
26
+ // links
27
+ add_relationship: 'links',
28
+ remove_relationship: 'links',
29
+ attach_tags: 'links',
30
+ detach_tag: 'links',
31
+ list_tags: 'links',
32
+ // files
33
+ upload_file: 'files',
34
+ // projects
35
+ list_projects: 'projects',
36
+ get_project: 'projects',
37
+ list_filters: 'projects',
38
+ get_project_categories: 'projects', // deprecated → get_project
39
+ get_project_users: 'projects', // deprecated → get_project
40
+ get_project_versions: 'projects', // deprecated → get_project
41
+ find_project_member: 'projects', // deprecated → get_project
42
+ // versions
43
+ create_version: 'versions',
44
+ update_version: 'versions',
45
+ delete_version: 'versions',
46
+ release_version: 'versions', // deprecated → update_version
47
+ // meta
48
+ get_metadata: 'meta',
49
+ get_metadata_full: 'meta', // deprecated → get_metadata
50
+ sync_metadata: 'meta', // deprecated → get_metadata
51
+ get_search_index_status: 'meta', // deprecated → get_metadata
52
+ get_issue_schema: 'meta',
53
+ get_issue_fields: 'meta', // deprecated → get_issue_schema
54
+ get_issue_enums: 'meta', // deprecated → get_issue_schema
55
+ get_config: 'meta',
56
+ get_version: 'meta',
57
+ get_mcp_version: 'meta', // deprecated → get_version
58
+ get_mantis_version: 'meta', // deprecated → get_version
59
+ get_current_user: 'meta',
60
+ list_languages: 'meta', // deprecated → get_current_user
61
+ // search (additionally requires MANTIS_SEARCH_ENABLED=true)
62
+ search_issues: 'search',
63
+ rebuild_search_index: 'search',
64
+ };
65
+ /**
66
+ * Parses MANTIS_TOOLSETS: comma-separated group names, case-insensitive.
67
+ * Unset or empty means all groups. An unknown name throws, listing the valid
68
+ * ones, so a typo does not silently hide tools.
69
+ */
70
+ export function parseToolsets(raw) {
71
+ const names = (raw ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
72
+ if (names.length === 0)
73
+ return new Set(TOOLSETS);
74
+ const unknown = names.find(name => !isToolset(name));
75
+ if (unknown !== undefined) {
76
+ throw new Error(`Unknown MANTIS_TOOLSETS group "${unknown}". Valid groups: ${TOOLSETS.join(', ')}.`);
77
+ }
78
+ return new Set(names.filter(isToolset));
79
+ }
80
+ const isToolset = (name) => TOOLSETS.includes(name);
81
+ /**
82
+ * Returns the server with registerTool filtered: tools of disabled groups and,
83
+ * with disableDestructive, destructive tools are skipped. Everything else
84
+ * (resources, prompts, connect, …) passes through unchanged. The register*Tools
85
+ * functions receive this instead of the server and need no changes.
86
+ */
87
+ export function filterToolRegistration(server, filter) {
88
+ const registerTool = ((name, config, callback) => {
89
+ const group = TOOL_GROUPS[name];
90
+ if (group === undefined) {
91
+ throw new Error(`Tool "${name}" has no group — add it to TOOL_GROUPS in src/toolsets.ts.`);
92
+ }
93
+ // A skipped tool is never registered, so it is neither listed nor callable.
94
+ // No caller uses registerTool's return value.
95
+ if (!filter.toolsets.has(group))
96
+ return undefined;
97
+ if (filter.disableDestructive && config.annotations?.destructiveHint === true)
98
+ return undefined;
99
+ return server.registerTool(name, config, callback);
100
+ });
101
+ return new Proxy(server, {
102
+ get: (target, prop, receiver) => (prop === 'registerTool' ? registerTool : Reflect.get(target, prop, receiver)),
103
+ });
104
+ }