@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.
@@ -41,12 +41,24 @@ export class SearchSyncService {
41
41
  }
42
42
  }
43
43
  async syncUnlocked(projectId, full) {
44
- if (full) {
44
+ // Vectors made with another model or token limit are not comparable with
45
+ // new query vectors, and an incremental sync never replaces them — their
46
+ // issues are unchanged. Such an index is rebuilt, for all projects, since
47
+ // it is useless as it stands. An empty index only takes the signature.
48
+ const signature = this.embedder.signature;
49
+ const mismatch = (await this.store.getEmbeddingSignature()) !== signature;
50
+ const outdated = mismatch && (await this.store.count()) > 0;
51
+ const scope = outdated ? undefined : projectId;
52
+ if (full || outdated) {
45
53
  await this.store.clear();
46
54
  await this.store.resetLastSyncedAt();
47
55
  }
56
+ // Stored before indexing: an interrupted rebuild then resumes on the next
57
+ // sync instead of starting over.
58
+ if (full || mismatch)
59
+ await this.store.setEmbeddingSignature(signature);
48
60
  const lastSyncedAt = await this.store.getLastSyncedAt();
49
- const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
61
+ const { issues: allIssues, totalFromApi } = await this.fetchAllIssues(lastSyncedAt ?? undefined, scope);
50
62
  let indexed = 0;
51
63
  let skipped = 0;
52
64
  let indexedSinceCheckpoint = 0;
@@ -62,21 +74,20 @@ export class SearchSyncService {
62
74
  if (issue.updated_at && (await this.store.getItem(issue.id))?.metadata.updated_at === issue.updated_at) {
63
75
  continue; // already indexed in this exact version
64
76
  }
65
- const text = `${issue.summary}\n${issue.description ?? ''}`.trim();
66
- toEmbed.push({ issue, text });
77
+ toEmbed.push({ issue, doc: { summary: issue.summary, description: issue.description } });
67
78
  }
68
79
  if (toEmbed.length === 0)
69
80
  continue;
70
- const vectors = await this.embedder.embedBatch(toEmbed.map(e => e.text));
71
- const batchItems = toEmbed.map((e, j) => ({
72
- id: e.issue.id,
73
- vector: vectors[j],
74
- metadata: {
75
- summary: e.issue.summary,
76
- description: e.issue.description,
77
- updated_at: e.issue.updated_at,
78
- },
79
- }));
81
+ const vectors = await this.embedder.embedDocuments(toEmbed.map(e => e.doc));
82
+ const batchItems = toEmbed.map(({ issue, doc }, j) => {
83
+ const [vector, ...chunks] = vectors[j];
84
+ return {
85
+ id: issue.id,
86
+ vector: vector,
87
+ ...(chunks.length > 0 && { chunks }),
88
+ metadata: { ...doc, updated_at: issue.updated_at },
89
+ };
90
+ });
80
91
  await this.store.addBatch(batchItems);
81
92
  indexed += batchItems.length;
82
93
  indexedSinceCheckpoint += batchItems.length;
@@ -96,7 +107,7 @@ export class SearchSyncService {
96
107
  // changes for good. A project-scoped sync must not move the shared watermark,
97
108
  // since changes in the other projects were not looked at.
98
109
  const newestUpdatedAt = allIssues.find(issue => issue.updated_at)?.updated_at;
99
- if (projectId === undefined && newestUpdatedAt && newestUpdatedAt !== lastSyncedAt) {
110
+ if (scope === undefined && newestUpdatedAt && newestUpdatedAt !== lastSyncedAt) {
100
111
  await this.store.setLastSyncedAt(newestUpdatedAt);
101
112
  }
102
113
  // Persist the best known total for get_search_index_status.
@@ -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
@@ -33,7 +46,10 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
33
46
  server.registerTool('search_issues', {
34
47
  title: 'Semantic Issue Search',
35
48
  description: 'Search MantisBT issues using natural language. Returns the most relevant issues ' +
36
- 'by semantic similarity. The search index must be populated first via rebuild_search_index.',
49
+ 'by combining semantic similarity with keyword matching, so exact terms such as identifiers, ' +
50
+ 'error codes or version numbers are found as well. "score" is the semantic similarity; results ' +
51
+ 'are ordered by the combined ranking, so a keyword match can precede a higher score. ' +
52
+ 'The search index must be populated first via rebuild_search_index.',
37
53
  inputSchema: z.object({
38
54
  query: z.string().describe('Natural language search query'),
39
55
  top_n: z
@@ -79,7 +95,7 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
79
95
  embedder.embed(query),
80
96
  client.getBaseUrl(),
81
97
  ]);
82
- const results = await store.search(queryVector, top_n);
98
+ const results = await store.search(queryVector, top_n, query);
83
99
  if (!select) {
84
100
  // For filtering or highlighting we need store metadata per result
85
101
  if (!filterActive && !terms.length) {
@@ -107,7 +123,7 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
107
123
  const enriched = await Promise.all(results.map(async ({ id, score }) => {
108
124
  try {
109
125
  const issueResult = await client.get(`issues/${id}`);
110
- const issue = issueResult.issues?.[0] ?? {};
126
+ const issue = (firstIssue(issueResult) ?? {});
111
127
  if (filterActive && !matchesDateFilter(issue, dateFilter)) {
112
128
  return null;
113
129
  }
@@ -153,7 +169,8 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
153
169
  // ---------------------------------------------------------------------------
154
170
  server.registerTool('get_search_index_status', {
155
171
  title: 'Search Index Status',
156
- description: 'Returns the current fill level of the semantic search index: how many issues are ' +
172
+ description: `${deprecationNote('get_search_index_status', 'get_metadata')}\n\n` +
173
+ 'Returns the current fill level of the semantic search index: how many issues are ' +
157
174
  'indexed vs. the total number of issues in MantisBT, plus the timestamp of the last sync.',
158
175
  inputSchema: z.object({}),
159
176
  annotations: {
@@ -163,24 +180,8 @@ export function registerSearchTools(server, client, store, embedder, lockDir) {
163
180
  },
164
181
  }, async () => {
165
182
  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
183
  return {
178
- content: [
179
- {
180
- type: 'text',
181
- text: JSON.stringify({ summary, indexed, total, percent, last_synced_at: lastSyncedAt }, null, 2),
182
- },
183
- ],
184
+ content: [{ type: 'text', text: JSON.stringify(await getSearchIndexStatus(store), null, 2) }],
184
185
  };
185
186
  }
186
187
  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
+ }