@dpesch/mantisbt-mcp-server 1.1.0 → 1.2.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 CHANGED
@@ -7,6 +7,24 @@ This project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.2.0] – 2026-03-16
11
+
12
+ ### Added
13
+ - New tool `remove_relationship`: removes a relationship from an issue. The `relationship_id` is the numeric `id` field on the relationship object returned by `get_issue` (not the type id).
14
+ - New tool `remove_monitor`: removes a user as a monitor of an issue by username.
15
+ - New tool `upload_file`: uploads a file to an issue via multipart/form-data. Supports two input modes: a local `file_path` (filename derived from path) or Base64-encoded `content` with an explicit `filename`. Optional parameters: `filename` (overrides derived name), `content_type` (default: `application/octet-stream`), `description`.
16
+ - New optional semantic search module (`MANTIS_SEARCH_ENABLED=true`): indexes all MantisBT issues as local vector embeddings using `@huggingface/transformers` (ONNX, no external API required). Two new tools:
17
+ - `search_issues` — natural language search over all indexed issues, returns top-N results by cosine similarity score.
18
+ - `rebuild_search_index` — build or incrementally update the search index; `full: true` clears and rebuilds from scratch.
19
+ - Vector store: `vectra` (pure JS, default) or `sqlite-vec` (optional, requires manual installation).
20
+ - Incremental sync on every server start via `updated_at` timestamp.
21
+ - Configuration: `MANTIS_SEARCH_ENABLED`, `MANTIS_SEARCH_BACKEND`, `MANTIS_SEARCH_DIR`, `MANTIS_SEARCH_MODEL`.
22
+
23
+ ### Fixed
24
+ - `list_issues` recorded-fixture tests were fragile: status filter counts are now derived dynamically from the fixture instead of hardcoded assumptions.
25
+
26
+ ---
27
+
10
28
  ## [1.1.0] – 2026-03-15
11
29
 
12
30
  ### Added
package/README.de.md CHANGED
@@ -69,6 +69,10 @@ npm run build
69
69
  | `MANTIS_CACHE_TTL` | – | `3600` | Cache-Lebensdauer in Sekunden |
70
70
  | `TRANSPORT` | – | `stdio` | Transport-Modus: `stdio` oder `http` |
71
71
  | `PORT` | – | `3000` | Port für HTTP-Modus |
72
+ | `MANTIS_SEARCH_ENABLED` | – | `false` | Auf `true` setzen, um die semantische Suche zu aktivieren |
73
+ | `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vektorspeicher: `vectra` (reines JS) oder `sqlite-vec` (manuelle Installation erforderlich) |
74
+ | `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Verzeichnis für den Suchindex |
75
+ | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding-Modell (wird beim ersten Start einmalig heruntergeladen, ~80 MB) |
72
76
 
73
77
  ### Config-Datei (Fallback)
74
78
 
@@ -106,18 +110,21 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
106
110
  | Tool | Beschreibung |
107
111
  |---|---|
108
112
  | `list_issue_files` | Anhänge eines Issues auflisten |
113
+ | `upload_file` | Datei an ein Issue anhängen – entweder per lokalem `file_path` oder Base64-kodiertem `content` + `filename` |
109
114
 
110
115
  ### Beziehungen
111
116
 
112
117
  | Tool | Beschreibung |
113
118
  |---|---|
114
119
  | `add_relationship` | Beziehung zwischen zwei Issues erstellen |
120
+ | `remove_relationship` | Beziehung von einem Issue entfernen (die `id` aus dem Beziehungsobjekt verwenden, nicht die type-ID) |
115
121
 
116
122
  ### Beobachter
117
123
 
118
124
  | Tool | Beschreibung |
119
125
  |---|---|
120
126
  | `add_monitor` | Sich selbst als Beobachter eines Issues eintragen |
127
+ | `remove_monitor` | Benutzer als Beobachter eines Issues austragen |
121
128
 
122
129
  ### Tags
123
130
 
@@ -136,6 +143,21 @@ Falls keine Umgebungsvariablen gesetzt sind, wird `~/.claude/mantis.json` ausgel
136
143
  | `get_project_categories` | Kategorien eines Projekts abrufen |
137
144
  | `get_project_users` | Benutzer eines Projekts abrufen |
138
145
 
146
+ ### Semantische Suche *(optional)*
147
+
148
+ Aktivierung mit `MANTIS_SEARCH_ENABLED=true`. Beim ersten Start wird das Embedding-Modell (~80 MB) heruntergeladen und lokal gecacht. Alle Issues werden danach bei jedem Serverstart inkrementell indexiert.
149
+
150
+ | Tool | Beschreibung |
151
+ |---|---|
152
+ | `search_issues` | Natürlichsprachige Suche über alle indizierten Issues – liefert Top-N-Ergebnisse mit Cosine-Similarity-Score |
153
+ | `rebuild_search_index` | Suchindex aufbauen oder aktualisieren; `full: true` löscht und baut ihn vollständig neu |
154
+
155
+ **`sqlite-vec`-Backend** (optional, schneller bei großen Instanzen):
156
+ ```bash
157
+ npm install sqlite-vec better-sqlite3
158
+ # dann MANTIS_SEARCH_BACKEND=sqlite-vec setzen
159
+ ```
160
+
139
161
  ### Metadaten & System
140
162
 
141
163
  | Tool | Beschreibung |
package/README.md CHANGED
@@ -69,6 +69,10 @@ npm run build
69
69
  | `MANTIS_CACHE_TTL` | – | `3600` | Cache lifetime in seconds |
70
70
  | `TRANSPORT` | – | `stdio` | Transport mode: `stdio` or `http` |
71
71
  | `PORT` | – | `3000` | Port for HTTP mode |
72
+ | `MANTIS_SEARCH_ENABLED` | – | `false` | Set to `true` to enable semantic search |
73
+ | `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vector store backend: `vectra` (pure JS) or `sqlite-vec` (requires manual install) |
74
+ | `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Directory for the search index |
75
+ | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding model name (downloaded once on first use, ~80 MB) |
72
76
 
73
77
  ### Config file (fallback)
74
78
 
@@ -106,18 +110,21 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
106
110
  | Tool | Description |
107
111
  |---|---|
108
112
  | `list_issue_files` | List attachments of an issue |
113
+ | `upload_file` | Upload a file to an issue — either by local `file_path` or Base64-encoded `content` + `filename` |
109
114
 
110
115
  ### Relationships
111
116
 
112
117
  | Tool | Description |
113
118
  |---|---|
114
119
  | `add_relationship` | Create a relationship between two issues |
120
+ | `remove_relationship` | Remove a relationship from an issue (use the `id` from the relationship object, not the type) |
115
121
 
116
122
  ### Monitors
117
123
 
118
124
  | Tool | Description |
119
125
  |---|---|
120
126
  | `add_monitor` | Add yourself as a monitor of an issue |
127
+ | `remove_monitor` | Remove a user as a monitor of an issue |
121
128
 
122
129
  ### Tags
123
130
 
@@ -136,6 +143,21 @@ If no environment variables are set, `~/.claude/mantis.json` is read:
136
143
  | `get_project_categories` | Get categories of a project |
137
144
  | `get_project_users` | Get users of a project |
138
145
 
146
+ ### Semantic search *(optional)*
147
+
148
+ Activate with `MANTIS_SEARCH_ENABLED=true`. On first start the embedding model (~80 MB) is downloaded and cached locally. All issues are then indexed incrementally on each server start.
149
+
150
+ | Tool | Description |
151
+ |---|---|
152
+ | `search_issues` | Natural language search over all indexed issues — returns top-N results with cosine similarity score |
153
+ | `rebuild_search_index` | Build or update the search index; `full: true` clears and rebuilds from scratch |
154
+
155
+ **`sqlite-vec` backend** (optional, faster for large instances):
156
+ ```bash
157
+ npm install sqlite-vec better-sqlite3
158
+ # then set MANTIS_SEARCH_BACKEND=sqlite-vec
159
+ ```
160
+
139
161
  ### Metadata & system
140
162
 
141
163
  | Tool | Description |
package/dist/client.js CHANGED
@@ -101,6 +101,19 @@ export class MantisClient {
101
101
  });
102
102
  return this.handleResponse(response);
103
103
  }
104
+ async postFormData(path, formData) {
105
+ // Note: Content-Type must NOT be set here — fetch sets it automatically
106
+ // with the correct multipart/form-data boundary.
107
+ const response = await fetch(this.buildUrl(path), {
108
+ method: 'POST',
109
+ headers: {
110
+ 'Authorization': this.apiKey,
111
+ 'Accept': 'application/json',
112
+ },
113
+ body: formData,
114
+ });
115
+ return this.handleResponse(response);
116
+ }
104
117
  async getVersion() {
105
118
  const response = await fetch(this.buildUrl('users/me'), {
106
119
  method: 'GET',
package/dist/config.js CHANGED
@@ -1,6 +1,28 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
- import { join } from 'node:path';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ // ---------------------------------------------------------------------------
6
+ // .env.local loader
7
+ // ---------------------------------------------------------------------------
8
+ async function loadDotEnvLocal() {
9
+ try {
10
+ const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '.env.local');
11
+ const content = await readFile(envPath, 'utf-8');
12
+ for (const line of content.split('\n')) {
13
+ const match = line.match(/^([^#=\s][^=]*)=(.*)/);
14
+ if (match) {
15
+ const key = match[1].trim();
16
+ const value = match[2].trim().replace(/^["']|["']$/g, '');
17
+ if (!process.env[key])
18
+ process.env[key] = value;
19
+ }
20
+ }
21
+ }
22
+ catch {
23
+ // .env.local not present — use environment variables directly
24
+ }
25
+ }
4
26
  // ---------------------------------------------------------------------------
5
27
  // Loader
6
28
  // ---------------------------------------------------------------------------
@@ -18,6 +40,7 @@ let cachedConfig = null;
18
40
  export async function getConfig() {
19
41
  if (cachedConfig)
20
42
  return cachedConfig;
43
+ await loadDotEnvLocal();
21
44
  let baseUrl = process.env.MANTIS_BASE_URL ?? '';
22
45
  let apiKey = process.env.MANTIS_API_KEY ?? '';
23
46
  // If env vars are missing, try ~/.claude/mantis.json as fallback
@@ -44,11 +67,26 @@ export async function getConfig() {
44
67
  const cacheTtl = process.env.MANTIS_CACHE_TTL
45
68
  ? parseInt(process.env.MANTIS_CACHE_TTL, 10)
46
69
  : 3600;
70
+ const searchEnabled = process.env.MANTIS_SEARCH_ENABLED === 'true';
71
+ const searchBackendRaw = process.env.MANTIS_SEARCH_BACKEND ?? 'vectra';
72
+ if (searchBackendRaw !== 'vectra' && searchBackendRaw !== 'sqlite-vec') {
73
+ process.stderr.write(`[mantisbt-config] Unknown MANTIS_SEARCH_BACKEND="${searchBackendRaw}", falling back to "vectra"\n`);
74
+ }
75
+ const searchBackend = searchBackendRaw === 'sqlite-vec' ? 'sqlite-vec' : 'vectra';
76
+ const searchDir = process.env.MANTIS_SEARCH_DIR ?? join(cacheDir, 'search');
77
+ const searchModelName = process.env.MANTIS_SEARCH_MODEL ??
78
+ 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
47
79
  cachedConfig = {
48
80
  baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
49
81
  apiKey,
50
82
  cacheDir,
51
83
  cacheTtl,
84
+ search: {
85
+ enabled: searchEnabled,
86
+ backend: searchBackend,
87
+ dir: searchDir,
88
+ modelName: searchModelName,
89
+ },
52
90
  };
53
91
  return cachedConfig;
54
92
  }
package/dist/index.js CHANGED
@@ -54,6 +54,11 @@ async function createMcpServer() {
54
54
  registerMetadataTools(server, client, cache);
55
55
  registerTagTools(server, client);
56
56
  registerVersionTools(server, client, versionHint);
57
+ // Optional: Semantic search module
58
+ if (config.search.enabled) {
59
+ const { initializeSearchModule } = await import('./search/index.js');
60
+ await initializeSearchModule(server, client, config.search);
61
+ }
57
62
  return server;
58
63
  }
59
64
  // ---------------------------------------------------------------------------
@@ -0,0 +1,67 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Embedder
3
+ // Wraps @huggingface/transformers for local ONNX-based embeddings.
4
+ // Lazy-loaded on first use — no model download at server startup.
5
+ //
6
+ // @huggingface/transformers is an optional dependency. The import is
7
+ // guarded with a try/catch so the server starts without it.
8
+ // ---------------------------------------------------------------------------
9
+ export class Embedder {
10
+ modelName;
11
+ pipe = null;
12
+ constructor(modelName) {
13
+ this.modelName = modelName;
14
+ }
15
+ async load() {
16
+ if (this.pipe)
17
+ return this.pipe;
18
+ process.stderr.write(`[mantisbt-search] Loading embedding model ${this.modelName}...\n`);
19
+ let transformers;
20
+ try {
21
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
22
+ transformers = (await import('@huggingface/transformers'));
23
+ }
24
+ catch (err) {
25
+ const msg = err instanceof Error ? err.message : String(err);
26
+ throw new Error(`Failed to load @huggingface/transformers: ${msg}`);
27
+ }
28
+ this.pipe = await transformers.pipeline('feature-extraction', this.modelName);
29
+ return this.pipe;
30
+ }
31
+ async embed(text) {
32
+ const extractor = await this.load();
33
+ const output = await extractor(text, { pooling: 'mean', normalize: true });
34
+ return extractVector(output);
35
+ }
36
+ async embedBatch(texts) {
37
+ if (texts.length === 0)
38
+ return [];
39
+ const extractor = await this.load();
40
+ const output = await extractor(texts, { pooling: 'mean', normalize: true });
41
+ if (Array.isArray(output)) {
42
+ return output.map(extractVector);
43
+ }
44
+ // Some versions return a single tensor for batch input
45
+ return extractBatchVectors(output, texts.length);
46
+ }
47
+ }
48
+ // ---------------------------------------------------------------------------
49
+ // Helpers
50
+ // ---------------------------------------------------------------------------
51
+ function extractVector(output) {
52
+ const data = output.data;
53
+ return Array.from(data);
54
+ }
55
+ function extractBatchVectors(output, batchSize) {
56
+ const data = Array.from(output.data);
57
+ // dims is typically [batchSize, sequenceLen, hiddenSize] or [batchSize, hiddenSize]
58
+ const vecSize = data.length / batchSize;
59
+ if (!Number.isInteger(vecSize)) {
60
+ throw new Error(`Unexpected batch output shape: ${data.length} elements for batch size ${batchSize}`);
61
+ }
62
+ const result = [];
63
+ for (let i = 0; i < batchSize; i++) {
64
+ result.push(data.slice(i * vecSize, (i + 1) * vecSize));
65
+ }
66
+ return result;
67
+ }
@@ -0,0 +1,19 @@
1
+ import { createVectorStore } from './store.js';
2
+ import { Embedder } from './embedder.js';
3
+ import { registerSearchTools } from './tools.js';
4
+ import { SearchSyncService } from './sync.js';
5
+ // ---------------------------------------------------------------------------
6
+ // initializeSearchModule
7
+ // ---------------------------------------------------------------------------
8
+ export async function initializeSearchModule(server, client, config) {
9
+ if (!config.enabled)
10
+ return;
11
+ const store = createVectorStore(config.backend, config.dir);
12
+ const embedder = new Embedder(config.modelName);
13
+ registerSearchTools(server, client, store, embedder);
14
+ // Non-blocking background sync on startup
15
+ const syncService = new SearchSyncService(client, store, embedder);
16
+ syncService.sync().catch((err) => {
17
+ console.error('[mantisbt-search] sync error:', err instanceof Error ? err.message : String(err));
18
+ });
19
+ }
@@ -0,0 +1,122 @@
1
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ // ---------------------------------------------------------------------------
4
+ // VectraStore
5
+ // ---------------------------------------------------------------------------
6
+ export class VectraStore {
7
+ dir;
8
+ vectraDir;
9
+ lastSyncFile;
10
+ items = new Map();
11
+ loaded = false;
12
+ constructor(dir) {
13
+ this.dir = dir;
14
+ this.vectraDir = join(dir, 'vectra');
15
+ this.lastSyncFile = join(dir, 'last_sync.txt');
16
+ }
17
+ async ensureLoaded() {
18
+ if (this.loaded)
19
+ return;
20
+ await mkdir(this.vectraDir, { recursive: true });
21
+ const indexFile = join(this.vectraDir, 'index.json');
22
+ try {
23
+ const raw = await readFile(indexFile, 'utf-8');
24
+ const parsed = JSON.parse(raw);
25
+ this.items = new Map(parsed.map(item => [item.id, item]));
26
+ }
27
+ catch {
28
+ // No index yet — start empty
29
+ this.items = new Map();
30
+ }
31
+ this.loaded = true;
32
+ }
33
+ async persist() {
34
+ const indexFile = join(this.vectraDir, 'index.json');
35
+ const data = JSON.stringify([...this.items.values()]);
36
+ await writeFile(indexFile, data, 'utf-8');
37
+ }
38
+ async add(item) {
39
+ await this.ensureLoaded();
40
+ this.items.set(item.id, item);
41
+ await this.persist();
42
+ }
43
+ async addBatch(items) {
44
+ await this.ensureLoaded();
45
+ for (const item of items) {
46
+ this.items.set(item.id, item);
47
+ }
48
+ await this.persist();
49
+ }
50
+ async search(vector, topN) {
51
+ await this.ensureLoaded();
52
+ const results = [];
53
+ for (const item of this.items.values()) {
54
+ const score = cosineSimilarity(vector, item.vector);
55
+ results.push({ id: item.id, score });
56
+ }
57
+ results.sort((a, b) => b.score - a.score);
58
+ return results.slice(0, topN);
59
+ }
60
+ async delete(id) {
61
+ await this.ensureLoaded();
62
+ this.items.delete(id);
63
+ await this.persist();
64
+ }
65
+ async count() {
66
+ await this.ensureLoaded();
67
+ return this.items.size;
68
+ }
69
+ async clear() {
70
+ await this.ensureLoaded();
71
+ this.items.clear();
72
+ await this.persist();
73
+ }
74
+ async getLastSyncedAt() {
75
+ try {
76
+ const content = await readFile(this.lastSyncFile, 'utf-8');
77
+ return content.trim() || null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ async setLastSyncedAt(ts) {
84
+ await mkdir(this.dir, { recursive: true });
85
+ await writeFile(this.lastSyncFile, ts, 'utf-8');
86
+ }
87
+ async resetLastSyncedAt() {
88
+ try {
89
+ await unlink(this.lastSyncFile);
90
+ }
91
+ catch (err) {
92
+ if (err.code !== 'ENOENT')
93
+ throw err;
94
+ }
95
+ }
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Cosine similarity helper
99
+ // ---------------------------------------------------------------------------
100
+ function cosineSimilarity(a, b) {
101
+ if (a.length !== b.length || a.length === 0)
102
+ return 0;
103
+ let dot = 0;
104
+ let normA = 0;
105
+ let normB = 0;
106
+ for (let i = 0; i < a.length; i++) {
107
+ dot += a[i] * b[i];
108
+ normA += a[i] * a[i];
109
+ normB += b[i] * b[i];
110
+ }
111
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
112
+ return denom === 0 ? 0 : dot / denom;
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Factory
116
+ // ---------------------------------------------------------------------------
117
+ export function createVectorStore(backend, dir) {
118
+ if (backend === 'sqlite-vec') {
119
+ throw new Error('sqlite-vec backend requires manual installation: npm install sqlite-vec better-sqlite3');
120
+ }
121
+ return new VectraStore(dir);
122
+ }
@@ -0,0 +1,85 @@
1
+ // ---------------------------------------------------------------------------
2
+ // SearchSyncService
3
+ // ---------------------------------------------------------------------------
4
+ const PAGE_SIZE = 50;
5
+ const EMBED_BATCH_SIZE = 10;
6
+ export class SearchSyncService {
7
+ client;
8
+ store;
9
+ embedder;
10
+ constructor(client, store, embedder) {
11
+ this.client = client;
12
+ this.store = store;
13
+ this.embedder = embedder;
14
+ }
15
+ async sync(projectId) {
16
+ const lastSyncedAt = await this.store.getLastSyncedAt();
17
+ const allIssues = await this.fetchAllIssues(lastSyncedAt ?? undefined, projectId);
18
+ let indexed = 0;
19
+ let skipped = 0;
20
+ // Process in batches of EMBED_BATCH_SIZE
21
+ for (let i = 0; i < allIssues.length; i += EMBED_BATCH_SIZE) {
22
+ const batch = allIssues.slice(i, i + EMBED_BATCH_SIZE);
23
+ const toEmbed = [];
24
+ for (const issue of batch) {
25
+ if (!issue.summary) {
26
+ skipped++;
27
+ continue;
28
+ }
29
+ const text = `${issue.summary}\n${issue.description ?? ''}`.trim();
30
+ toEmbed.push({ issue, text });
31
+ }
32
+ if (toEmbed.length === 0)
33
+ continue;
34
+ const vectors = await this.embedder.embedBatch(toEmbed.map(e => e.text));
35
+ const batchItems = toEmbed.map((e, j) => ({
36
+ id: e.issue.id,
37
+ vector: vectors[j],
38
+ metadata: {
39
+ summary: e.issue.summary,
40
+ description: e.issue.description,
41
+ updated_at: e.issue.updated_at,
42
+ },
43
+ }));
44
+ await this.store.addBatch(batchItems);
45
+ indexed += batchItems.length;
46
+ }
47
+ await this.store.setLastSyncedAt(new Date().toISOString());
48
+ return { indexed, skipped };
49
+ }
50
+ async fetchAllIssues(updatedAfter, projectId) {
51
+ const allIssues = [];
52
+ let page = 1;
53
+ while (true) {
54
+ const params = {
55
+ page_size: PAGE_SIZE,
56
+ page,
57
+ sort: 'updated_at',
58
+ direction: 'DESC',
59
+ select: 'id,summary,description,updated_at',
60
+ };
61
+ if (projectId !== undefined) {
62
+ params.project_id = projectId;
63
+ }
64
+ if (updatedAfter) {
65
+ params.updated_after = updatedAfter;
66
+ }
67
+ const response = await this.client.get('issues', params);
68
+ const pageIssues = response.issues ?? [];
69
+ allIssues.push(...pageIssues);
70
+ // Stop when we have fetched all issues:
71
+ // - total_count is provided and reached, or
72
+ // - page returned fewer items than requested (last page)
73
+ const total = response.total_count;
74
+ if (total !== undefined && total !== null) {
75
+ if (allIssues.length >= total)
76
+ break;
77
+ }
78
+ else if (pageIssues.length < PAGE_SIZE) {
79
+ break;
80
+ }
81
+ page++;
82
+ }
83
+ return allIssues;
84
+ }
85
+ }
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ import { SearchSyncService } from './sync.js';
3
+ // ---------------------------------------------------------------------------
4
+ // registerSearchTools
5
+ // ---------------------------------------------------------------------------
6
+ export function registerSearchTools(server, client, store, embedder) {
7
+ // ---------------------------------------------------------------------------
8
+ // search_issues
9
+ // ---------------------------------------------------------------------------
10
+ server.registerTool('search_issues', {
11
+ title: 'Semantic Issue Search',
12
+ description: 'Search MantisBT issues using natural language. Returns the most relevant issues ' +
13
+ 'by semantic similarity. The search index must be populated first via rebuild_search_index.',
14
+ inputSchema: z.object({
15
+ query: z.string().describe('Natural language search query'),
16
+ top_n: z
17
+ .number()
18
+ .int()
19
+ .positive()
20
+ .max(50)
21
+ .default(10)
22
+ .describe('Number of results to return (default: 10, max: 50)'),
23
+ }),
24
+ annotations: {
25
+ readOnlyHint: true,
26
+ destructiveHint: false,
27
+ idempotentHint: true,
28
+ },
29
+ }, async ({ query, top_n }) => {
30
+ try {
31
+ const count = await store.count();
32
+ if (count === 0) {
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: 'Search index is empty. Run rebuild_search_index first.',
38
+ },
39
+ ],
40
+ isError: true,
41
+ };
42
+ }
43
+ const queryVector = await embedder.embed(query);
44
+ const results = await store.search(queryVector, top_n);
45
+ return {
46
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
47
+ };
48
+ }
49
+ catch (error) {
50
+ const msg = error instanceof Error ? error.message : String(error);
51
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
52
+ }
53
+ });
54
+ // ---------------------------------------------------------------------------
55
+ // rebuild_search_index
56
+ // ---------------------------------------------------------------------------
57
+ server.registerTool('rebuild_search_index', {
58
+ title: 'Rebuild Semantic Search Index',
59
+ description: 'Build or update the semantic search index for MantisBT issues. ' +
60
+ 'Use full: true to clear the existing index and rebuild from scratch.',
61
+ inputSchema: z.object({
62
+ project_id: z
63
+ .number()
64
+ .int()
65
+ .positive()
66
+ .optional()
67
+ .describe('Optional: only index issues from this project'),
68
+ full: z
69
+ .boolean()
70
+ .default(false)
71
+ .describe('If true, clears the existing index and rebuilds from scratch'),
72
+ }),
73
+ annotations: {
74
+ readOnlyHint: false,
75
+ destructiveHint: false,
76
+ idempotentHint: true,
77
+ },
78
+ }, async ({ project_id, full }) => {
79
+ try {
80
+ if (full) {
81
+ await store.clear();
82
+ await store.resetLastSyncedAt();
83
+ }
84
+ const startMs = Date.now();
85
+ const syncService = new SearchSyncService(client, store, embedder);
86
+ const { indexed, skipped } = await syncService.sync(project_id);
87
+ const duration_ms = Date.now() - startMs;
88
+ return {
89
+ content: [
90
+ {
91
+ type: 'text',
92
+ text: JSON.stringify({ indexed, skipped, duration_ms }, null, 2),
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ catch (error) {
98
+ const msg = error instanceof Error ? error.message : String(error);
99
+ return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
100
+ }
101
+ });
102
+ }