@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.
package/dist/index.js CHANGED
@@ -10,19 +10,8 @@ import { getConfig, getStartupConfig, assertHttpAuthConfigured } from './config.
10
10
  import { MantisClient } from './client.js';
11
11
  import { VersionHintService, setGlobalVersionHint } from './version-hint.js';
12
12
  import { MetadataCache } from './cache.js';
13
- import { registerIssueTools } from './tools/issues.js';
14
- import { registerNoteTools } from './tools/notes.js';
15
- import { registerFileTools } from './tools/files.js';
16
- import { registerRelationshipTools } from './tools/relationships.js';
17
- import { registerMonitorTools } from './tools/monitors.js';
18
- import { registerProjectTools } from './tools/projects.js';
19
- import { registerProjectVersionTools } from './tools/project-versions.js';
20
- import { registerUserTools } from './tools/users.js';
21
- import { registerFilterTools } from './tools/filters.js';
22
- import { registerConfigTools } from './tools/config.js';
23
- import { registerMetadataTools } from './tools/metadata.js';
24
- import { registerTagTools } from './tools/tags.js';
25
- import { registerVersionTools } from './tools/version.js';
13
+ import { registerAllTools } from './tools/index.js';
14
+ import { filterToolRegistration } from './toolsets.js';
26
15
  import { registerPrompts } from './prompts/index.js';
27
16
  import { registerResources } from './resources/index.js';
28
17
  // ---------------------------------------------------------------------------
@@ -55,26 +44,26 @@ async function createMcpServer(transport) {
55
44
  name: 'mantisbt-mcp-server',
56
45
  version,
57
46
  });
58
- registerIssueTools(server, client, cache);
59
- registerNoteTools(server, client);
60
- registerFileTools(server, client, startupConfig.uploadDir, transport);
61
- registerRelationshipTools(server, client);
62
- registerMonitorTools(server, client);
63
- registerProjectTools(server, client, cache);
64
- registerProjectVersionTools(server, client, cache);
65
- registerUserTools(server, client);
66
- registerFilterTools(server, client);
67
- registerConfigTools(server, client, cache);
68
- registerMetadataTools(server, client, cache);
69
- registerTagTools(server, client);
70
- registerVersionTools(server, client, versionHint, version);
47
+ // Tools register through a filter for MANTIS_TOOLSETS / MANTIS_DISABLE_DESTRUCTIVE;
48
+ // prompts and resources use the server directly.
49
+ const tools = filterToolRegistration(server, startupConfig);
50
+ // Optional: Semantic search module — needs MANTIS_SEARCH_ENABLED=true and the
51
+ // "search" tool group (skipping it avoids loading the model and the index).
52
+ // Initialized first so get_metadata can report the index status.
53
+ const searchIndexStatus = startupConfig.search.enabled && startupConfig.toolsets.has('search')
54
+ ? await (await import('./search/index.js')).initializeSearchModule(tools, client, startupConfig.search)
55
+ : undefined;
56
+ registerAllTools(tools, {
57
+ client,
58
+ cache,
59
+ versionHint,
60
+ version,
61
+ uploadDir: startupConfig.uploadDir,
62
+ transport,
63
+ searchIndexStatus,
64
+ });
71
65
  registerPrompts(server);
72
66
  registerResources(server, client, cache, startupConfig.testEnvironment);
73
- // Optional: Semantic search module
74
- if (startupConfig.search.enabled) {
75
- const { initializeSearchModule } = await import('./search/index.js');
76
- await initializeSearchModule(server, client, startupConfig.search);
77
- }
78
67
  return server;
79
68
  }
80
69
  // ---------------------------------------------------------------------------
@@ -0,0 +1,23 @@
1
+ import { buildAttachmentDownloadUrl, buildIssueViewUrl, buildNoteViewUrl } from './client.js';
2
+ /**
3
+ * Adds the links MantisBT's REST API leaves out: view_url for the issue and
4
+ * each note, download_url for each attachment (REST sends it only via SOAP).
5
+ * issueId is the fallback when a select projection excluded the id field.
6
+ */
7
+ export function enrichIssue(issue, baseUrl, issueId) {
8
+ const id = issue.id ?? issueId;
9
+ if (id === undefined)
10
+ return issue;
11
+ return {
12
+ ...issue,
13
+ view_url: buildIssueViewUrl(baseUrl, id),
14
+ notes: issue.notes?.map(note => ({
15
+ ...note,
16
+ view_url: buildNoteViewUrl(baseUrl, id, note.id),
17
+ })),
18
+ attachments: issue.attachments?.map(file => ({
19
+ ...file,
20
+ download_url: buildAttachmentDownloadUrl(baseUrl, file.id),
21
+ })),
22
+ };
23
+ }
@@ -0,0 +1,4 @@
1
+ /** The issue from a MantisBT issue response, whichever of the two envelopes it uses. */
2
+ export function firstIssue(response) {
3
+ return response?.issues?.[0] ?? response?.issue;
4
+ }
@@ -16,7 +16,7 @@ export function registerPrompts(server) {
16
16
  description: 'Guide the creation of a complete bug report in MantisBT. Project, category, summary, and description are required.',
17
17
  argsSchema: {
18
18
  project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
19
- category: z.string().describe('Issue category (call get_project_categories to list valid values)'),
19
+ category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
20
20
  summary: z.string().describe('Short, descriptive summary of the bug'),
21
21
  description: z.string().describe('Detailed description of the bug'),
22
22
  steps_to_reproduce: z.string().optional().describe('Step-by-step reproduction instructions'),
@@ -46,7 +46,7 @@ export function registerPrompts(server) {
46
46
  if (environment) {
47
47
  lines.push(``, `Environment:`, environment);
48
48
  }
49
- lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call get_issue_enums first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
49
+ lines.push(``, `Use the create_issue tool to submit this bug report.`, `Call get_issue_schema first if you need to look up valid severity and priority values.`, `Default to severity "minor" and priority "normal" unless the user specifies otherwise.`);
50
50
  return userMessage(lines.join('\n'));
51
51
  });
52
52
  // ---------------------------------------------------------------------------
@@ -57,7 +57,7 @@ export function registerPrompts(server) {
57
57
  description: 'Guide the creation of a feature request in MantisBT. Project, category, summary, and description are required.',
58
58
  argsSchema: {
59
59
  project_id: z.coerce.number().int().positive().describe('Numeric project ID'),
60
- category: z.string().describe('Issue category (call get_project_categories to list valid values)'),
60
+ category: z.string().describe('Issue category (call get_project with include: ["categories"] to list valid values)'),
61
61
  summary: z.string().describe('Short description of the requested feature'),
62
62
  description: z.string().describe('Detailed description of the feature'),
63
63
  use_case: z.string().optional().describe('Business use case or motivation for the feature'),
@@ -75,7 +75,7 @@ export function registerPrompts(server) {
75
75
  if (use_case) {
76
76
  lines.push(``, `Use case / motivation:`, use_case);
77
77
  }
78
- lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call get_issue_enums first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
78
+ lines.push(``, `Use the create_issue tool to submit this feature request.`, `Call get_issue_schema first to look up valid severity values — use severity "feature" and priority "normal" unless the user specifies otherwise.`);
79
79
  return userMessage(lines.join('\n'));
80
80
  });
81
81
  // ---------------------------------------------------------------------------
@@ -1,7 +1,6 @@
1
1
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { fetchIssueEnums } from '../tools/config.js';
3
- import { normalizeProject } from '../tools/metadata.js';
4
- import { ALL_PROJECTS_PREFIX } from '../constants.js';
3
+ import { normalizeProject, stripAllProjectsPrefix } from '../tools/metadata.js';
5
4
  function jsonResource(uri, data) {
6
5
  return {
7
6
  contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(data) }],
@@ -21,7 +20,7 @@ export function registerResources(server, client, cache, testEnvironment = false
21
20
  }, async (uri) => jsonResource(uri, await client.get('users/me')));
22
21
  server.registerResource('projects', 'mantis://projects', {
23
22
  title: 'Projects',
24
- description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via the sync_metadata tool.',
23
+ description: 'All MantisBT projects accessible to the current API user. Served from local cache when fresh; falls back to live fetch. Refresh via get_metadata with refresh: true.',
25
24
  mimeType: 'application/json',
26
25
  }, async (uri) => jsonResource(uri, await loadProjects()));
27
26
  server.registerResource('project-detail', new ResourceTemplate('mantis://projects/{id}', {
@@ -40,7 +39,7 @@ export function registerResources(server, client, cache, testEnvironment = false
40
39
  },
41
40
  }), {
42
41
  title: 'Project Detail',
43
- description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via the sync_metadata tool.',
42
+ description: 'Combined project view: fields (status, view_state, access_level, description) plus all associated users, versions, and categories. Served from local cache when fresh; falls back to live API fetch. Refresh via get_metadata with refresh: true.',
44
43
  mimeType: 'application/json',
45
44
  }, async (uri, { id }) => {
46
45
  const numId = Number(id);
@@ -66,10 +65,7 @@ export function registerResources(server, client, cache, testEnvironment = false
66
65
  project = raw ? normalizeProject(raw) : undefined;
67
66
  users = usersResult.users ?? [];
68
67
  versions = versionsResult.versions ?? [];
69
- categories = (raw?.categories ?? []).map((c) => ({
70
- ...c,
71
- name: c.name.startsWith(ALL_PROJECTS_PREFIX) ? c.name.slice(ALL_PROJECTS_PREFIX.length) : c.name,
72
- }));
68
+ categories = stripAllProjectsPrefix(raw?.categories ?? []);
73
69
  }
74
70
  if (!project) {
75
71
  throw new Error(`Project ${numId} not found`);
@@ -1,21 +1,22 @@
1
1
  import { createVectorStore } from './store.js';
2
2
  import { Embedder } from './embedder.js';
3
- import { registerSearchTools } from './tools.js';
3
+ import { registerSearchTools, getSearchIndexStatus } from './tools.js';
4
4
  import { SearchSyncService, SyncLockedError } from './sync.js';
5
5
  // ---------------------------------------------------------------------------
6
6
  // initializeSearchModule
7
7
  // ---------------------------------------------------------------------------
8
8
  export async function initializeSearchModule(server, client, config) {
9
- if (!config.enabled)
10
- return;
11
9
  const store = createVectorStore(config.backend, config.dir);
12
10
  const embedder = new Embedder(config.modelName, config.numThreads);
13
11
  registerSearchTools(server, client, store, embedder, config.dir);
14
- // Pre-initialize lastKnownTotal so get_search_index_status shows a value
12
+ // Pre-initialize lastKnownTotal so the index status (get_metadata) shows a value
15
13
  // immediately on startup, even while the background sync is still running.
16
- const [startupCount, startupTotal] = await Promise.all([store.count(), store.getLastKnownTotal()]);
17
- if (startupTotal === null && startupCount > 0) {
18
- await store.setLastKnownTotal(startupCount);
14
+ // count() loads the whole index into memory, and this runs before the server
15
+ // connects — so only do it when there is no stored total yet.
16
+ if (await store.getLastKnownTotal() === null) {
17
+ const startupCount = await store.count();
18
+ if (startupCount > 0)
19
+ await store.setLastKnownTotal(startupCount);
19
20
  }
20
21
  // Non-blocking background sync on startup. If another server process (another
21
22
  // client session) is already syncing the shared index, this one skips it.
@@ -27,4 +28,5 @@ export async function initializeSearchModule(server, client, config) {
27
28
  }
28
29
  console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
29
30
  });
31
+ return () => getSearchIndexStatus(store);
30
32
  }
@@ -1,7 +1,10 @@
1
1
  import { z } from 'zod';
2
+ import { coerceBool } from '../coerce.js';
2
3
  import { buildIssueViewUrl } from '../client.js';
3
4
  import { SearchSyncService } from './sync.js';
4
5
  import { getVersionHint } from '../version-hint.js';
6
+ import { firstIssue } from '../issue-envelope.js';
7
+ import { deprecationNote } from '../deprecation.js';
5
8
  import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
6
9
  import { extractTerms, highlightText, extractSnippet, hasTermMatch } from './highlight.js';
7
10
  function buildHighlights(summary, description, terms) {
@@ -25,7 +28,17 @@ function errorText(msg) {
25
28
  // ---------------------------------------------------------------------------
26
29
  // registerSearchTools
27
30
  // ---------------------------------------------------------------------------
28
- const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
31
+ /** Fill level of the search index (shared by get_metadata and get_search_index_status). */
32
+ export async function getSearchIndexStatus(store) {
33
+ const [indexed, lastSyncedAt, total] = await Promise.all([
34
+ store.count(),
35
+ store.getLastSyncedAt(),
36
+ store.getLastKnownTotal(),
37
+ ]);
38
+ const percent = total !== null ? (total > 0 ? Math.round((indexed / total) * 100) : 0) : null;
39
+ const summary = total !== null ? `${indexed}/${total} (${percent} %)` : `${indexed}/? (total unknown)`;
40
+ return { summary, indexed, total, percent, last_synced_at: lastSyncedAt };
41
+ }
29
42
  export function registerSearchTools(server, client, store, embedder, lockDir) {
30
43
  // ---------------------------------------------------------------------------
31
44
  // search_issues
@@ -107,7 +120,7 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
107
120
  const enriched = await Promise.all(results.map(async ({ id, score }) => {
108
121
  try {
109
122
  const issueResult = await client.get(`issues/${id}`);
110
- const issue = issueResult.issues?.[0] ?? {};
123
+ const issue = (firstIssue(issueResult) ?? {});
111
124
  if (filterActive && !matchesDateFilter(issue, dateFilter)) {
112
125
  return null;
113
126
  }
@@ -153,7 +166,8 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
153
166
  // ---------------------------------------------------------------------------
154
167
  server.registerTool('get_search_index_status', {
155
168
  title: 'Search Index Status',
156
- description: 'Returns the current fill level of the semantic search index: how many issues are ' +
169
+ description: `${deprecationNote('get_search_index_status', 'get_metadata')}\n\n` +
170
+ 'Returns the current fill level of the semantic search index: how many issues are ' +
157
171
  'indexed vs. the total number of issues in MantisBT, plus the timestamp of the last sync.',
158
172
  inputSchema: z.object({}),
159
173
  annotations: {
@@ -163,24 +177,8 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
163
177
  },
164
178
  }, async () => {
165
179
  try {
166
- const [indexed, lastSyncedAt, total] = await Promise.all([
167
- store.count(),
168
- store.getLastSyncedAt(),
169
- store.getLastKnownTotal(),
170
- ]);
171
- const percent = total !== null
172
- ? (total > 0 ? Math.round((indexed / total) * 100) : 0)
173
- : null;
174
- const summary = total !== null
175
- ? `${indexed}/${total} (${percent} %)`
176
- : `${indexed}/? (total unknown)`;
177
180
  return {
178
- content: [
179
- {
180
- type: 'text',
181
- text: JSON.stringify({ summary, indexed, total, percent, last_synced_at: lastSyncedAt }, null, 2),
182
- },
183
- ],
181
+ content: [{ type: 'text', text: JSON.stringify(await getSearchIndexStatus(store), null, 2) }],
184
182
  };
185
183
  }
186
184
  catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { ISSUE_ENUM_OPTIONS, MANTIS_CANONICAL_ENUM_NAMES } from '../constants.js';
3
3
  import { getVersionHint } from '../version-hint.js';
4
+ import { deprecationNote } from '../deprecation.js';
4
5
  function errorText(msg) {
5
6
  const vh = getVersionHint();
6
7
  vh?.triggerLatestVersionFetch();
@@ -127,7 +128,9 @@ Common option names:
127
128
  // ---------------------------------------------------------------------------
128
129
  server.registerTool('get_issue_enums', {
129
130
  title: 'Get Issue Enum Values',
130
- description: `Return valid ID, name, and (if available) localized label for all issue enum fields.
131
+ description: `${deprecationNote('get_issue_enums', 'get_issue_schema')}
132
+
133
+ Return valid ID, name, and (if available) localized label for all issue enum fields.
131
134
 
132
135
  Use this tool before creating or updating issues to look up the correct value
133
136
  for severity, status, priority, resolution, or reproducibility.
@@ -189,7 +192,9 @@ because there is no separate English original.`,
189
192
  // ---------------------------------------------------------------------------
190
193
  server.registerTool('list_languages', {
191
194
  title: 'List Supported Languages',
192
- description: 'List all languages supported by the MantisBT installation.',
195
+ description: `${deprecationNote('list_languages', 'get_current_user (field "language")')}
196
+
197
+ Despite its name, MantisBT's GET /lang returns only the language of the current user ({ strings, language }), not a list of all languages.`,
193
198
  inputSchema: z.object({}),
194
199
  annotations: {
195
200
  readOnlyHint: true,
@@ -217,7 +222,7 @@ because there is no separate English original.`,
217
222
 
218
223
  The MantisBT REST API exposes a GET /tags endpoint on some installations.
219
224
  If that endpoint is not available, this tool falls back to the local metadata
220
- cache populated by sync_metadata.`,
225
+ cache populated by get_metadata.`,
221
226
  inputSchema: z.object({
222
227
  page: z.coerce.number().int().positive().default(1).describe('Page number (default: 1)'),
223
228
  page_size: z.coerce.number().int().min(1).max(200).default(50).describe('Tags per page (default: 50)'),
@@ -252,7 +257,7 @@ cache populated by sync_metadata.`,
252
257
  return {
253
258
  content: [{
254
259
  type: 'text',
255
- text: `Error: ${msg}\n\nThe GET /tags endpoint is not available in this MantisBT installation. No cached tags found either — run sync_metadata to populate the cache if your installation provides this endpoint.`,
260
+ text: `Error: ${msg}\n\nThe GET /tags endpoint is not available in this MantisBT installation. No cached tags found either — run get_metadata with refresh: true to populate the cache if your installation provides this endpoint.`,
256
261
  }],
257
262
  isError: true,
258
263
  };
@@ -1,7 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, resolve, sep } from 'node:path';
3
3
  import { z } from 'zod';
4
+ import { enrichIssue } from '../issue-enrich.js';
4
5
  import { getVersionHint } from '../version-hint.js';
6
+ import { firstIssue } from '../issue-envelope.js';
7
+ import { deprecationNote } from '../deprecation.js';
5
8
  function errorText(msg) {
6
9
  const vh = getVersionHint();
7
10
  vh?.triggerLatestVersionFetch();
@@ -21,9 +24,9 @@ export function registerFileTools(server, client, uploadDir, transport = 'stdio'
21
24
  // ---------------------------------------------------------------------------
22
25
  server.registerTool('list_issue_files', {
23
26
  title: 'List Issue File Attachments',
24
- description: `List all file attachments of a MantisBT issue. Returns an array of attachment objects, each containing id, filename, size in bytes, content_type, and download_url. Returns an empty array if the issue has no attachments.
27
+ description: `${deprecationNote('list_issue_files', 'get_issue with select: "attachments"')}
25
28
 
26
- Use this tool when you need to inspect or enumerate files attached to an issue. To add a new attachment, use upload_file instead. To retrieve full issue details that include attachments alongside other fields, use get_issue instead.`,
29
+ List all file attachments of a MantisBT issue. Returns an array of attachment objects, each containing id, filename, size in bytes, content_type, and download_url. Returns an empty array if the issue has no attachments.`,
27
30
  inputSchema: z.object({
28
31
  issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
29
32
  }),
@@ -34,8 +37,12 @@ Use this tool when you need to inspect or enumerate files attached to an issue.
34
37
  },
35
38
  }, async ({ issue_id }) => {
36
39
  try {
37
- const result = await client.get(`issues/${issue_id}`);
38
- const attachments = result.issues?.[0]?.attachments ?? [];
40
+ const [result, baseUrl] = await Promise.all([
41
+ client.get(`issues/${issue_id}`, { select: 'id,attachments' }),
42
+ client.getBaseUrl(),
43
+ ]);
44
+ const issue = firstIssue(result);
45
+ const attachments = (issue && enrichIssue(issue, baseUrl, issue_id).attachments) ?? [];
39
46
  return {
40
47
  content: [{ type: 'text', text: JSON.stringify(attachments, null, 2) }],
41
48
  };
@@ -58,7 +65,7 @@ Provide exactly one of the two input modes:
58
65
 
59
66
  The optional content_type sets the MIME type (e.g. "image/png"); defaults to "application/octet-stream". Use the optional description to annotate the attachment.
60
67
 
61
- Use this tool to attach files such as logs, screenshots, or patches to an existing issue. To list existing attachments, use list_issue_files. To retrieve issue details, use get_issue.`,
68
+ Use this tool to attach files such as logs, screenshots, or patches to an existing issue. To list existing attachments, use get_issue with select: "attachments".`,
62
69
  inputSchema: z.object({
63
70
  issue_id: z.coerce.number().int().positive().describe('Numeric issue ID'),
64
71
  file_path: z.string().min(1).optional().describe('Preferred: absolute path to the local file to upload — use this whenever the file exists on disk (mutually exclusive with content)'),
@@ -0,0 +1,34 @@
1
+ import { registerIssueTools } from './issues.js';
2
+ import { registerNoteTools } from './notes.js';
3
+ import { registerFileTools } from './files.js';
4
+ import { registerRelationshipTools } from './relationships.js';
5
+ import { registerMonitorTools } from './monitors.js';
6
+ import { registerProjectTools } from './projects.js';
7
+ import { registerProjectVersionTools } from './project-versions.js';
8
+ import { registerUserTools } from './users.js';
9
+ import { registerFilterTools } from './filters.js';
10
+ import { registerConfigTools } from './config.js';
11
+ import { registerMetadataTools } from './metadata.js';
12
+ import { registerTagTools } from './tags.js';
13
+ import { registerVersionTools } from './version.js';
14
+ /**
15
+ * Registers every tool except the optional search tools, which the search
16
+ * module registers itself. Shared by createMcpServer and the test that checks
17
+ * TOOL_GROUPS against the registered tools, so both see the same wiring.
18
+ */
19
+ export function registerAllTools(server, deps) {
20
+ const { client, cache } = deps;
21
+ registerIssueTools(server, client, cache);
22
+ registerNoteTools(server, client);
23
+ registerFileTools(server, client, deps.uploadDir, deps.transport);
24
+ registerRelationshipTools(server, client);
25
+ registerMonitorTools(server, client);
26
+ registerProjectTools(server, client, cache);
27
+ registerProjectVersionTools(server, client, cache);
28
+ registerUserTools(server, client);
29
+ registerFilterTools(server, client);
30
+ registerConfigTools(server, client, cache);
31
+ registerMetadataTools(server, client, cache, deps.searchIndexStatus);
32
+ registerTagTools(server, client);
33
+ registerVersionTools(server, client, deps.versionHint, deps.version);
34
+ }