@dpesch/mantisbt-mcp-server 1.0.3 → 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.
@@ -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
+ }
@@ -1,3 +1,5 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
1
3
  import { z } from 'zod';
2
4
  import { getVersionHint } from '../version-hint.js';
3
5
  function errorText(msg) {
@@ -34,4 +36,72 @@ export function registerFileTools(server, client) {
34
36
  return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
35
37
  }
36
38
  });
39
+ // ---------------------------------------------------------------------------
40
+ // upload_file
41
+ // ---------------------------------------------------------------------------
42
+ server.registerTool('upload_file', {
43
+ title: 'Upload File Attachment',
44
+ description: `Upload a file as an attachment to a MantisBT issue via multipart/form-data.
45
+
46
+ Two input modes (exactly one must be provided):
47
+ - file_path: absolute path to a local file — filename is derived from the path automatically
48
+ - content: Base64-encoded file content — filename must be supplied explicitly via the filename parameter
49
+
50
+ The optional content_type parameter sets the MIME type (e.g. "image/png"). If omitted, "application/octet-stream" is used.`,
51
+ inputSchema: z.object({
52
+ issue_id: z.number().int().positive().describe('Numeric issue ID'),
53
+ file_path: z.string().min(1).optional().describe('Absolute path to the local file to upload (mutually exclusive with content)'),
54
+ content: z.string().min(1).optional().describe('Base64-encoded file content (mutually exclusive with file_path)'),
55
+ filename: z.string().min(1).optional().describe('File name for the attachment (required when using content; overrides the derived name when using file_path)'),
56
+ content_type: z.string().optional().describe('MIME type of the file, e.g. "image/png" (default: "application/octet-stream")'),
57
+ description: z.string().optional().describe('Optional description for the attachment'),
58
+ }).refine(d => !!(d.file_path ?? d.content), {
59
+ message: 'Either file_path or content must be provided',
60
+ }).refine(d => !(d.file_path && d.content), {
61
+ message: 'Only one of file_path or content may be provided',
62
+ }).refine(d => !d.content || !!d.filename, {
63
+ message: 'filename is required when using content',
64
+ }),
65
+ annotations: {
66
+ readOnlyHint: false,
67
+ destructiveHint: false,
68
+ idempotentHint: false,
69
+ },
70
+ }, async ({ issue_id, file_path, content, filename, content_type, description }) => {
71
+ try {
72
+ if (!file_path && !content) {
73
+ return { content: [{ type: 'text', text: 'Error: Either file_path or content must be provided' }], isError: true };
74
+ }
75
+ if (file_path && content) {
76
+ return { content: [{ type: 'text', text: 'Error: Only one of file_path or content may be provided' }], isError: true };
77
+ }
78
+ let fileBuffer;
79
+ let fileName;
80
+ if (file_path) {
81
+ fileBuffer = await readFile(file_path);
82
+ fileName = filename ?? basename(file_path);
83
+ }
84
+ else {
85
+ if (!filename) {
86
+ return { content: [{ type: 'text', text: 'Error: filename is required when using content' }], isError: true };
87
+ }
88
+ fileBuffer = Buffer.from(content, 'base64');
89
+ fileName = filename;
90
+ }
91
+ const blob = new Blob([new Uint8Array(fileBuffer)], { type: content_type ?? 'application/octet-stream' });
92
+ const formData = new FormData();
93
+ formData.append('file', blob, fileName);
94
+ if (description) {
95
+ formData.append('description', description);
96
+ }
97
+ const result = await client.postFormData(`issues/${issue_id}/files`, formData);
98
+ return {
99
+ content: [{ type: 'text', text: JSON.stringify(result ?? { success: true }, null, 2) }],
100
+ };
101
+ }
102
+ catch (error) {
103
+ const msg = error instanceof Error ? error.message : String(error);
104
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
105
+ }
106
+ });
37
107
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { getVersionHint } from '../version-hint.js';
3
+ import { MANTIS_RESOLVED_STATUS_ID } from '../constants.js';
3
4
  function errorText(msg) {
4
5
  const vh = getVersionHint();
5
6
  vh?.triggerLatestVersionFetch();
@@ -39,7 +40,7 @@ export function registerIssueTools(server, client) {
39
40
  // ---------------------------------------------------------------------------
40
41
  server.registerTool('list_issues', {
41
42
  title: 'List Issues',
42
- description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues.',
43
+ description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues. Use the "select" parameter to limit returned fields and reduce response size significantly.',
43
44
  inputSchema: z.object({
44
45
  project_id: z.number().int().positive().optional().describe('Filter by project ID'),
45
46
  page: z.number().int().positive().default(1).describe('Page number (default: 1)'),
@@ -49,13 +50,15 @@ export function registerIssueTools(server, client) {
49
50
  filter_id: z.number().int().positive().optional().describe('Use a saved MantisBT filter ID'),
50
51
  sort: z.string().optional().describe('Sort field (e.g. "last_updated", "id")'),
51
52
  direction: z.enum(['ASC', 'DESC']).optional().describe('Sort direction'),
53
+ select: z.string().optional().describe('Comma-separated list of fields to include in the response (server-side projection). Significantly reduces response size. Example: "id,summary,status,priority,handler,updated_at"'),
54
+ status: z.string().optional().describe('Filter issues by status name (e.g. "new", "feedback", "acknowledged", "confirmed", "assigned", "resolved", "closed") or use "open" as shorthand for all statuses with id < 80 (i.e. not yet resolved or closed). Applied client-side after fetching — when combined with pagination, a page may contain fewer results than page_size.'),
52
55
  }),
53
56
  annotations: {
54
57
  readOnlyHint: true,
55
58
  destructiveHint: false,
56
59
  idempotentHint: true,
57
60
  },
58
- }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction }) => {
61
+ }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction, select, status }) => {
59
62
  try {
60
63
  const params = {
61
64
  page,
@@ -66,8 +69,19 @@ export function registerIssueTools(server, client) {
66
69
  filter_id,
67
70
  sort,
68
71
  direction,
72
+ select,
69
73
  };
70
74
  const result = await client.get('issues', params);
75
+ if (status && result.issues) {
76
+ const statusLower = status.toLowerCase();
77
+ result.issues = result.issues.filter(issue => {
78
+ if (!issue.status)
79
+ return false;
80
+ if (statusLower === 'open')
81
+ return (issue.status.id ?? 0) < MANTIS_RESOLVED_STATUS_ID;
82
+ return issue.status.name?.toLowerCase() === statusLower;
83
+ });
84
+ }
71
85
  return {
72
86
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
73
87
  };
@@ -1,5 +1,55 @@
1
1
  import { z } from 'zod';
2
2
  import { getVersionHint } from '../version-hint.js';
3
+ // Fields MantisBT strips from issue responses when the array is empty.
4
+ // They are always valid 'select' values even if absent in a sample issue.
5
+ const EMPTY_STRIPPED_FIELDS = [
6
+ 'attachments',
7
+ 'custom_fields',
8
+ 'history',
9
+ 'monitors',
10
+ 'notes',
11
+ 'relationships',
12
+ 'tags',
13
+ ];
14
+ // Fallback static list used when no issues are available to sample.
15
+ const STATIC_ISSUE_FIELDS = [
16
+ 'additional_information',
17
+ 'attachments',
18
+ 'build',
19
+ 'category',
20
+ 'created_at',
21
+ 'custom_fields',
22
+ 'description',
23
+ 'due_date',
24
+ 'eta',
25
+ 'fixed_in_version',
26
+ 'handler',
27
+ 'history',
28
+ 'id',
29
+ 'monitors',
30
+ 'notes',
31
+ 'os',
32
+ 'os_build',
33
+ 'platform',
34
+ 'priority',
35
+ 'profile',
36
+ 'project',
37
+ 'projection',
38
+ 'relationships',
39
+ 'reporter',
40
+ 'reproducibility',
41
+ 'resolution',
42
+ 'severity',
43
+ 'status',
44
+ 'steps_to_reproduce',
45
+ 'sticky',
46
+ 'summary',
47
+ 'tags',
48
+ 'target_version',
49
+ 'updated_at',
50
+ 'version',
51
+ 'view_state',
52
+ ];
3
53
  function errorText(msg) {
4
54
  const vh = getVersionHint();
5
55
  vh?.triggerLatestVersionFetch();
@@ -100,15 +150,60 @@ Use sync_metadata to force a refresh.`,
100
150
  },
101
151
  }, async () => {
102
152
  try {
103
- let data = null;
104
- if (await cache.isValid()) {
105
- data = await cache.load();
153
+ const data = await cache.loadIfValid() ?? await fetchAndCacheMetadata(client, cache);
154
+ return {
155
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
156
+ };
157
+ }
158
+ catch (error) {
159
+ const msg = error instanceof Error ? error.message : String(error);
160
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
161
+ }
162
+ });
163
+ // ---------------------------------------------------------------------------
164
+ // get_issue_fields
165
+ // ---------------------------------------------------------------------------
166
+ server.registerTool('get_issue_fields', {
167
+ title: 'Get Issue Fields',
168
+ description: `Return all field names that are valid for the "select" parameter of list_issues and get_issue.
169
+
170
+ Fields are discovered by fetching a sample issue from MantisBT (which reflects the server's active configuration — e.g. whether eta, projection, or profile fields are enabled) and merging the result with fields that MantisBT omits when empty (notes, attachments, relationships, etc.). The result is cached with the same TTL as the metadata cache.
171
+
172
+ Use this tool before constructing a "select" string to ensure you only request fields that exist on this server.`,
173
+ inputSchema: z.object({
174
+ project_id: z.number().int().positive().optional().describe('Optional project ID to scope the sample issue fetch'),
175
+ }),
176
+ annotations: {
177
+ readOnlyHint: true,
178
+ destructiveHint: false,
179
+ idempotentHint: true,
180
+ },
181
+ }, async ({ project_id }) => {
182
+ try {
183
+ const cached = await cache.loadIssueFields();
184
+ if (cached) {
185
+ return {
186
+ content: [{ type: 'text', text: JSON.stringify({ fields: cached, source: 'cache' }, null, 2) }],
187
+ };
106
188
  }
107
- if (!data) {
108
- data = await fetchAndCacheMetadata(client, cache);
189
+ const params = {
190
+ page: 1,
191
+ page_size: 1,
192
+ project_id,
193
+ };
194
+ const result = await client.get('issues', params);
195
+ const issues = result.issues ?? [];
196
+ let fields;
197
+ if (issues.length === 0) {
198
+ fields = STATIC_ISSUE_FIELDS;
109
199
  }
200
+ else {
201
+ const discovered = Object.keys(issues[0]);
202
+ fields = Array.from(new Set([...discovered, ...EMPTY_STRIPPED_FIELDS])).sort();
203
+ }
204
+ await cache.saveIssueFields(fields);
110
205
  return {
111
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
206
+ content: [{ type: 'text', text: JSON.stringify({ fields, source: issues.length > 0 ? 'live' : 'static' }, null, 2) }],
112
207
  };
113
208
  }
114
209
  catch (error) {
@@ -35,4 +35,31 @@ export function registerMonitorTools(server, client) {
35
35
  return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
36
36
  }
37
37
  });
38
+ // ---------------------------------------------------------------------------
39
+ // remove_monitor
40
+ // ---------------------------------------------------------------------------
41
+ server.registerTool('remove_monitor', {
42
+ title: 'Remove Issue Monitor',
43
+ description: 'Remove a user from the monitor list of a MantisBT issue. The user will no longer receive email notifications for updates to this issue.',
44
+ inputSchema: z.object({
45
+ issue_id: z.number().int().positive().describe('Numeric issue ID'),
46
+ username: z.string().min(1).describe('Username of the monitor to remove'),
47
+ }),
48
+ annotations: {
49
+ readOnlyHint: false,
50
+ destructiveHint: true,
51
+ idempotentHint: false,
52
+ },
53
+ }, async ({ issue_id, username }) => {
54
+ try {
55
+ await client.delete(`issues/${issue_id}/monitors/${username}`);
56
+ return {
57
+ content: [{ type: 'text', text: JSON.stringify({ success: true }, null, 2) }],
58
+ };
59
+ }
60
+ catch (error) {
61
+ const msg = error instanceof Error ? error.message : String(error);
62
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
63
+ }
64
+ });
38
65
  }
@@ -51,4 +51,33 @@ Important: The API only accepts numeric type IDs, not string names.`,
51
51
  return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
52
52
  }
53
53
  });
54
+ // ---------------------------------------------------------------------------
55
+ // remove_relationship
56
+ // ---------------------------------------------------------------------------
57
+ server.registerTool('remove_relationship', {
58
+ title: 'Remove Issue Relationship',
59
+ description: `Remove a relationship from a MantisBT issue.
60
+
61
+ Use get_issue first to retrieve the relationship IDs. The relationship_id is the numeric id field of a relationship object in the issue's relationships array (not the type ID).`,
62
+ inputSchema: z.object({
63
+ issue_id: z.number().int().positive().describe('The issue ID the relationship belongs to'),
64
+ relationship_id: z.number().int().positive().describe('The numeric ID of the relationship to remove (from the relationships array in get_issue)'),
65
+ }),
66
+ annotations: {
67
+ readOnlyHint: false,
68
+ destructiveHint: true,
69
+ idempotentHint: false,
70
+ },
71
+ }, async ({ issue_id, relationship_id }) => {
72
+ try {
73
+ await client.delete(`issues/${issue_id}/relationships/${relationship_id}`);
74
+ return {
75
+ content: [{ type: 'text', text: JSON.stringify({ success: true }, null, 2) }],
76
+ };
77
+ }
78
+ catch (error) {
79
+ const msg = error instanceof Error ? error.message : String(error);
80
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
81
+ }
82
+ });
54
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
5
5
  "author": "Dominik Pesch",
6
6
  "license": "MIT",
@@ -19,13 +19,16 @@
19
19
  "typecheck": "tsc --noEmit",
20
20
  "start": "node dist/index.js",
21
21
  "dev": "tsc -p tsconfig.build.json --watch",
22
+ "dev:start": "npm run typecheck && npm run build && node dist/index.js",
22
23
  "test": "vitest run",
23
24
  "test:watch": "vitest",
24
25
  "test:coverage": "vitest run --coverage",
25
26
  "test:record": "tsx scripts/record-fixtures.ts"
26
27
  },
27
28
  "dependencies": {
29
+ "@huggingface/transformers": "^3.0.0",
28
30
  "@modelcontextprotocol/sdk": "^1.0.0",
31
+ "vectra": "^0.4.0",
29
32
  "zod": "^3.22.4"
30
33
  },
31
34
  "devDependencies": {
@@ -103,6 +103,17 @@ async function recordFixtures(): Promise<void> {
103
103
  console.error('Failed to fetch issues:', err instanceof Error ? err.message : String(err));
104
104
  }
105
105
 
106
+ // GET issues?page=1&page_size=1 — single issue sample for get_issue_fields field discovery
107
+ try {
108
+ const sampleResult = await client.get<{ issues: unknown[] }>('issues', {
109
+ page: 1,
110
+ page_size: 1,
111
+ });
112
+ saveFixture('get_issue_fields_sample.json', sampleResult);
113
+ } catch (err) {
114
+ console.error('Failed to fetch issues sample:', err instanceof Error ? err.message : String(err));
115
+ }
116
+
106
117
  // GET issues/{id}
107
118
  if (firstIssueId !== undefined) {
108
119
  try {
@@ -147,3 +147,118 @@ describe('MetadataCache – invalidate()', () => {
147
147
  await expect(cache.invalidate()).resolves.toBeUndefined();
148
148
  });
149
149
  });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // loadIfValid()
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe('MetadataCache – loadIfValid()', () => {
156
+ it('returns null when file does not exist', async () => {
157
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
158
+
159
+ const cache = makeCache();
160
+ await expect(cache.loadIfValid()).resolves.toBeNull();
161
+ });
162
+
163
+ it('returns data when file is fresh (within TTL)', async () => {
164
+ const metadata = makeSampleMetadata();
165
+ const file = { timestamp: Date.now(), data: metadata };
166
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
167
+
168
+ const cache = makeCache();
169
+ const result = await cache.loadIfValid();
170
+ expect(result).toEqual(metadata);
171
+ });
172
+
173
+ it('returns null when file has expired', async () => {
174
+ const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
175
+ const metadata = makeSampleMetadata();
176
+ const file = { timestamp: expiredTimestamp, data: metadata };
177
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
178
+
179
+ const cache = makeCache();
180
+ await expect(cache.loadIfValid()).resolves.toBeNull();
181
+ });
182
+
183
+ it('reads from metadata.json path', async () => {
184
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
185
+
186
+ const cache = makeCache();
187
+ await cache.loadIfValid();
188
+
189
+ expect(readFile).toHaveBeenCalledOnce();
190
+ const calledPath = vi.mocked(readFile).mock.calls[0][0] as string;
191
+ expect(calledPath).toContain('metadata.json');
192
+ expect(calledPath).not.toContain('issue_fields.json');
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // loadIssueFields()
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe('MetadataCache – loadIssueFields()', () => {
201
+ it('returns null when file does not exist', async () => {
202
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
203
+
204
+ const cache = makeCache();
205
+ await expect(cache.loadIssueFields()).resolves.toBeNull();
206
+ });
207
+
208
+ it('returns fields array when file is fresh', async () => {
209
+ const fields = ['id', 'summary', 'status'];
210
+ const file = { timestamp: Date.now(), fields };
211
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
212
+
213
+ const cache = makeCache();
214
+ const result = await cache.loadIssueFields();
215
+ expect(result).toEqual(fields);
216
+ });
217
+
218
+ it('returns null when file has expired', async () => {
219
+ const expiredTimestamp = Date.now() - (TTL + 1) * 1000;
220
+ const file = { timestamp: expiredTimestamp, fields: ['id', 'summary'] };
221
+ vi.mocked(readFile).mockResolvedValue(JSON.stringify(file) as any);
222
+
223
+ const cache = makeCache();
224
+ await expect(cache.loadIssueFields()).resolves.toBeNull();
225
+ });
226
+ });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // saveIssueFields()
230
+ // ---------------------------------------------------------------------------
231
+
232
+ describe('MetadataCache – saveIssueFields()', () => {
233
+ it('calls mkdir with recursive:true and writes to issue_fields.json', async () => {
234
+ vi.mocked(mkdir).mockResolvedValue(undefined);
235
+ vi.mocked(writeFile).mockResolvedValue(undefined);
236
+
237
+ const cache = makeCache();
238
+ const fields = ['id', 'summary', 'status', 'priority'];
239
+ await cache.saveIssueFields(fields);
240
+
241
+ expect(mkdir).toHaveBeenCalledWith(CACHE_DIR, { recursive: true });
242
+ expect(writeFile).toHaveBeenCalledOnce();
243
+
244
+ const calledPath = vi.mocked(writeFile).mock.calls[0][0] as string;
245
+ expect(calledPath).toContain('issue_fields.json');
246
+ });
247
+
248
+ it('written JSON contains the fields array and a timestamp', async () => {
249
+ vi.mocked(mkdir).mockResolvedValue(undefined);
250
+ vi.mocked(writeFile).mockResolvedValue(undefined);
251
+
252
+ const cache = makeCache();
253
+ const fields = ['id', 'summary', 'status'];
254
+ const before = Date.now();
255
+ await cache.saveIssueFields(fields);
256
+ const after = Date.now();
257
+
258
+ const writtenContent = vi.mocked(writeFile).mock.calls[0][1] as string;
259
+ const parsed = JSON.parse(writtenContent) as { timestamp: number; fields: string[] };
260
+ expect(parsed.fields).toEqual(fields);
261
+ expect(parsed.timestamp).toBeGreaterThanOrEqual(before);
262
+ expect(parsed.timestamp).toBeLessThanOrEqual(after);
263
+ });
264
+ });