@dpesch/mantisbt-mcp-server 1.5.9 → 1.6.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/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.dpesch/mantisbt-mcp-server",
4
4
  "title": "MantisBT MCP Server",
5
5
  "description": "MantisBT MCP server – manage issues, notes, files, tags, and relationships. With semantic search.",
6
- "version": "1.5.9",
6
+ "version": "1.6.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@dpesch/mantisbt-mcp-server",
11
- "version": "1.5.9",
11
+ "version": "1.6.0",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"
@@ -209,6 +209,76 @@ describe('MantisClient – HTTP methods', () => {
209
209
  });
210
210
  });
211
211
 
212
+ // ---------------------------------------------------------------------------
213
+ // Credential factory (lazy constructor)
214
+ // ---------------------------------------------------------------------------
215
+
216
+ describe('MantisClient – credential factory', () => {
217
+ it('does not call the factory until the first API method', async () => {
218
+ const factory = vi.fn().mockResolvedValue({
219
+ baseUrl: 'https://lazy.example.com',
220
+ apiKey: 'lazy-key',
221
+ });
222
+
223
+ new MantisClient(factory);
224
+
225
+ expect(factory).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('calls the factory on first API method and uses the returned credentials', async () => {
229
+ const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
230
+ vi.stubGlobal('fetch', fetchMock);
231
+
232
+ const factory = vi.fn().mockResolvedValue({
233
+ baseUrl: 'https://lazy.example.com',
234
+ apiKey: 'lazy-key',
235
+ });
236
+
237
+ const client = new MantisClient(factory);
238
+ await client.get('issues');
239
+
240
+ expect(factory).toHaveBeenCalledOnce();
241
+ const calledUrl: string = fetchMock.mock.calls[0][0] as string;
242
+ expect(calledUrl).toBe('https://lazy.example.com/api/rest/issues');
243
+ const options = fetchMock.mock.calls[0][1] as RequestInit;
244
+ expect((options.headers as Record<string, string>)['Authorization']).toBe('lazy-key');
245
+ });
246
+
247
+ it('caches credentials after the first call and does not call the factory again', async () => {
248
+ const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
249
+ vi.stubGlobal('fetch', fetchMock);
250
+
251
+ const factory = vi.fn().mockResolvedValue({
252
+ baseUrl: 'https://lazy.example.com',
253
+ apiKey: 'lazy-key',
254
+ });
255
+
256
+ const client = new MantisClient(factory);
257
+ await client.get('issues');
258
+ await client.get('projects');
259
+
260
+ expect(factory).toHaveBeenCalledOnce();
261
+ });
262
+
263
+ it('forwards the responseObserver when using factory constructor', async () => {
264
+ const observer = vi.fn();
265
+ const fakeResponse = makeResponse(200, '{}');
266
+ const fetchMock = vi.fn().mockResolvedValue(fakeResponse);
267
+ vi.stubGlobal('fetch', fetchMock);
268
+
269
+ const factory = vi.fn().mockResolvedValue({
270
+ baseUrl: 'https://lazy.example.com',
271
+ apiKey: 'lazy-key',
272
+ });
273
+
274
+ const client = new MantisClient(factory, observer);
275
+ await client.get('issues');
276
+
277
+ expect(observer).toHaveBeenCalledOnce();
278
+ expect(observer).toHaveBeenCalledWith(fakeResponse);
279
+ });
280
+ });
281
+
212
282
  // ---------------------------------------------------------------------------
213
283
  // responseObserver
214
284
  // ---------------------------------------------------------------------------
@@ -19,6 +19,12 @@ async function freshGetConfig(): Promise<(typeof import('../src/config.js'))['ge
19
19
  return mod.getConfig;
20
20
  }
21
21
 
22
+ async function freshGetStartupConfig(): Promise<(typeof import('../src/config.js'))['getStartupConfig']> {
23
+ vi.resetModules();
24
+ const mod = await import('../src/config.js');
25
+ return mod.getStartupConfig;
26
+ }
27
+
22
28
  // ---------------------------------------------------------------------------
23
29
  // Setup
24
30
  // ---------------------------------------------------------------------------
@@ -87,60 +93,26 @@ describe('getConfig() – ENV variables', () => {
87
93
  });
88
94
  });
89
95
 
90
- // ---------------------------------------------------------------------------
91
- // JSON fallback
92
- // ---------------------------------------------------------------------------
93
-
94
- describe('getConfig() – mantis.json fallback', () => {
95
- it('falls back to ~/.claude/mantis.json when ENV vars are missing', async () => {
96
- const json = JSON.stringify({ base_url: 'https://from-json.example.com', api_key: 'json-key' });
97
- vi.mocked(readFile).mockResolvedValue(json as any);
98
-
99
- const getConfig = await freshGetConfig();
100
- const config = await getConfig();
101
-
102
- expect(config.baseUrl).toBe('https://from-json.example.com');
103
- expect(config.apiKey).toBe('json-key');
104
- });
105
-
106
- it('prefers ENV vars over mantis.json values', async () => {
107
- vi.stubEnv('MANTIS_BASE_URL', 'https://from-env.example.com');
108
- vi.stubEnv('MANTIS_API_KEY', 'env-key');
109
- const json = JSON.stringify({ base_url: 'https://from-json.example.com', api_key: 'json-key' });
110
- vi.mocked(readFile).mockResolvedValue(json as any);
111
-
112
- const getConfig = await freshGetConfig();
113
- const config = await getConfig();
114
-
115
- expect(config.baseUrl).toBe('https://from-env.example.com');
116
- expect(config.apiKey).toBe('env-key');
117
- });
118
- });
119
-
120
96
  // ---------------------------------------------------------------------------
121
97
  // Error cases
122
98
  // ---------------------------------------------------------------------------
123
99
 
124
100
  describe('getConfig() – errors', () => {
125
- it('throws when neither ENV nor mantis.json provides baseUrl', async () => {
101
+ it('throws when MANTIS_BASE_URL is not set', async () => {
126
102
  vi.stubEnv('MANTIS_API_KEY', 'some-key');
127
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
128
103
 
129
104
  const getConfig = await freshGetConfig();
130
105
  await expect(getConfig()).rejects.toThrow('MANTIS_BASE_URL');
131
106
  });
132
107
 
133
- it('throws when neither ENV nor mantis.json provides apiKey', async () => {
108
+ it('throws when MANTIS_API_KEY is not set', async () => {
134
109
  vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
135
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
136
110
 
137
111
  const getConfig = await freshGetConfig();
138
112
  await expect(getConfig()).rejects.toThrow('MANTIS_API_KEY');
139
113
  });
140
114
 
141
- it('throws when no configuration is available at all', async () => {
142
- vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
143
-
115
+ it('throws when no configuration is provided at all', async () => {
144
116
  const getConfig = await freshGetConfig();
145
117
  await expect(getConfig()).rejects.toThrow('Missing required MantisBT configuration');
146
118
  });
@@ -215,6 +187,44 @@ describe('getConfig() – HTTP transport', () => {
215
187
  });
216
188
  });
217
189
 
190
+ // ---------------------------------------------------------------------------
191
+ // getStartupConfig — never throws without credentials
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe('getStartupConfig()', () => {
195
+ it('succeeds without MANTIS_BASE_URL and MANTIS_API_KEY', async () => {
196
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
197
+
198
+ const getStartupConfig = await freshGetStartupConfig();
199
+ const config = await getStartupConfig();
200
+
201
+ expect(config).toBeDefined();
202
+ expect(config.cacheTtl).toBe(3600);
203
+ expect(config.httpHost).toBe('127.0.0.1');
204
+ expect(config.httpPort).toBe(3000);
205
+ });
206
+
207
+ it('does not include baseUrl or apiKey', async () => {
208
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
209
+
210
+ const getStartupConfig = await freshGetStartupConfig();
211
+ const config = await getStartupConfig();
212
+
213
+ expect(config).not.toHaveProperty('baseUrl');
214
+ expect(config).not.toHaveProperty('apiKey');
215
+ });
216
+
217
+ it('reads MANTIS_CACHE_DIR from environment', async () => {
218
+ vi.stubEnv('MANTIS_CACHE_DIR', '/tmp/test-cache');
219
+ vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
220
+
221
+ const getStartupConfig = await freshGetStartupConfig();
222
+ const config = await getStartupConfig();
223
+
224
+ expect(config.cacheDir).toBe('/tmp/test-cache');
225
+ });
226
+ });
227
+
218
228
  // ---------------------------------------------------------------------------
219
229
  // Singleton caching
220
230
  // ---------------------------------------------------------------------------
@@ -24,9 +24,33 @@ interface ToolDefinition {
24
24
  [key: string]: unknown;
25
25
  }
26
26
 
27
+ export interface PromptMessage {
28
+ role: string;
29
+ content: { type: string; text: string };
30
+ }
31
+
32
+ export interface PromptResult {
33
+ messages: PromptMessage[];
34
+ }
35
+
36
+ export interface ResourceResult {
37
+ contents: Array<{ uri: string; mimeType?: string; text: string }>;
38
+ }
39
+
40
+ type PromptHandler = (args: Record<string, unknown>) => PromptResult;
41
+
42
+ type ResourceHandler = (uri: URL) => Promise<ResourceResult>;
43
+
44
+ interface PromptDefinition {
45
+ argsSchema?: Record<string, z.ZodTypeAny>;
46
+ [key: string]: unknown;
47
+ }
48
+
27
49
  export class MockMcpServer {
28
50
  private readonly handlers = new Map<string, ToolHandler>();
29
51
  private readonly schemas = new Map<string, z.ZodTypeAny>();
52
+ private readonly promptHandlers = new Map<string, PromptHandler>();
53
+ private readonly resourceHandlers = new Map<string, ResourceHandler>();
30
54
 
31
55
  // Nachahmt McpServer.registerTool – fängt Handler und Schema ein
32
56
  registerTool(name: string, definition: ToolDefinition, handler: ToolHandler): void {
@@ -36,6 +60,11 @@ export class MockMcpServer {
36
60
  }
37
61
  }
38
62
 
63
+ // Nachahmt McpServer.registerPrompt
64
+ registerPrompt(name: string, _definition: PromptDefinition, handler: PromptHandler): void {
65
+ this.promptHandlers.set(name, handler);
66
+ }
67
+
39
68
  /**
40
69
  * Ruft den Handler auf. Wenn `validate: true`, wird der Input zuerst
41
70
  * durch das Zod-Schema geparst (wie der echte MCP-Server es tut).
@@ -66,11 +95,43 @@ export class MockMcpServer {
66
95
  return handler(args);
67
96
  }
68
97
 
98
+ callPrompt(name: string, args: Record<string, unknown> = {}): PromptResult {
99
+ const handler = this.promptHandlers.get(name);
100
+ if (!handler) throw new Error(`Prompt not registered: ${name}`);
101
+ return handler(args);
102
+ }
103
+
69
104
  hasToolRegistered(name: string): boolean {
70
105
  return this.handlers.has(name);
71
106
  }
72
107
 
108
+ hasPromptRegistered(name: string): boolean {
109
+ return this.promptHandlers.has(name);
110
+ }
111
+
73
112
  registeredToolNames(): string[] {
74
113
  return [...this.handlers.keys()];
75
114
  }
115
+
116
+ registeredPromptNames(): string[] {
117
+ return [...this.promptHandlers.keys()];
118
+ }
119
+
120
+ registerResource(name: string, uri: string, _config: unknown, handler: ResourceHandler): void {
121
+ this.resourceHandlers.set(uri, handler);
122
+ }
123
+
124
+ async callResource(uri: string): Promise<ResourceResult> {
125
+ const handler = this.resourceHandlers.get(uri);
126
+ if (!handler) throw new Error(`Resource not registered: ${uri}`);
127
+ return handler(new URL(uri));
128
+ }
129
+
130
+ hasResourceRegistered(uri: string): boolean {
131
+ return this.resourceHandlers.has(uri);
132
+ }
133
+
134
+ registeredResourceUris(): string[] {
135
+ return [...this.resourceHandlers.keys()];
136
+ }
76
137
  }
@@ -0,0 +1,242 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { registerPrompts } from '../../src/prompts/index.js';
3
+ import { MockMcpServer } from '../helpers/mock-server.js';
4
+
5
+ let mockServer: MockMcpServer;
6
+
7
+ beforeEach(() => {
8
+ mockServer = new MockMcpServer();
9
+ registerPrompts(mockServer as never);
10
+ });
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Registration
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe('prompt registration', () => {
17
+ it('registers create-bug-report', () => {
18
+ expect(mockServer.hasPromptRegistered('create-bug-report')).toBe(true);
19
+ });
20
+
21
+ it('registers create-feature-request', () => {
22
+ expect(mockServer.hasPromptRegistered('create-feature-request')).toBe(true);
23
+ });
24
+
25
+ it('registers summarize-issue', () => {
26
+ expect(mockServer.hasPromptRegistered('summarize-issue')).toBe(true);
27
+ });
28
+
29
+ it('registers project-status', () => {
30
+ expect(mockServer.hasPromptRegistered('project-status')).toBe(true);
31
+ });
32
+ });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // create-bug-report
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe('create-bug-report', () => {
39
+ it('returns a single user message', () => {
40
+ const result = mockServer.callPrompt('create-bug-report', {
41
+ project_id: 1,
42
+ category: 'General',
43
+ summary: 'Login fails',
44
+ description: 'Cannot log in after password reset',
45
+ });
46
+
47
+ expect(result.messages).toHaveLength(1);
48
+ expect(result.messages[0]!.role).toBe('user');
49
+ expect(result.messages[0]!.content.type).toBe('text');
50
+ });
51
+
52
+ it('includes project_id, category, summary, and description in the text', () => {
53
+ const result = mockServer.callPrompt('create-bug-report', {
54
+ project_id: 7,
55
+ category: 'Authentication',
56
+ summary: 'Session expires too early',
57
+ description: 'Session is invalidated after 5 minutes of inactivity',
58
+ });
59
+
60
+ const text = result.messages[0]!.content.text;
61
+ expect(text).toContain('project 7');
62
+ expect(text).toContain('Authentication');
63
+ expect(text).toContain('Session expires too early');
64
+ expect(text).toContain('Session is invalidated after 5 minutes');
65
+ });
66
+
67
+ it('includes optional steps_to_reproduce when provided', () => {
68
+ const result = mockServer.callPrompt('create-bug-report', {
69
+ project_id: 1,
70
+ category: 'General',
71
+ summary: 'Bug',
72
+ description: 'Desc',
73
+ steps_to_reproduce: '1. Open app\n2. Click login',
74
+ });
75
+
76
+ expect(result.messages[0]!.content.text).toContain('Steps to reproduce');
77
+ expect(result.messages[0]!.content.text).toContain('1. Open app');
78
+ });
79
+
80
+ it('omits optional sections when not provided', () => {
81
+ const result = mockServer.callPrompt('create-bug-report', {
82
+ project_id: 1,
83
+ category: 'General',
84
+ summary: 'Bug',
85
+ description: 'Desc',
86
+ });
87
+
88
+ const text = result.messages[0]!.content.text;
89
+ expect(text).not.toContain('Steps to reproduce');
90
+ expect(text).not.toContain('Expected behavior');
91
+ expect(text).not.toContain('Actual behavior');
92
+ expect(text).not.toContain('Environment');
93
+ });
94
+
95
+ it('includes all optional fields when provided', () => {
96
+ const result = mockServer.callPrompt('create-bug-report', {
97
+ project_id: 1,
98
+ category: 'UI',
99
+ summary: 'Button broken',
100
+ description: 'Save button does nothing',
101
+ steps_to_reproduce: 'Click Save',
102
+ expected: 'Form is saved',
103
+ actual: 'Nothing happens',
104
+ environment: 'Chrome 120, Windows 11',
105
+ });
106
+
107
+ const text = result.messages[0]!.content.text;
108
+ expect(text).toContain('Steps to reproduce');
109
+ expect(text).toContain('Expected behavior');
110
+ expect(text).toContain('Actual behavior');
111
+ expect(text).toContain('Environment');
112
+ expect(text).toContain('Chrome 120');
113
+ });
114
+
115
+ it('mentions create_issue tool', () => {
116
+ const result = mockServer.callPrompt('create-bug-report', {
117
+ project_id: 1,
118
+ category: 'General',
119
+ summary: 'Bug',
120
+ description: 'Desc',
121
+ });
122
+
123
+ expect(result.messages[0]!.content.text).toContain('create_issue');
124
+ });
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // create-feature-request
129
+ // ---------------------------------------------------------------------------
130
+
131
+ describe('create-feature-request', () => {
132
+ it('returns a single user message', () => {
133
+ const result = mockServer.callPrompt('create-feature-request', {
134
+ project_id: 2,
135
+ category: 'General',
136
+ summary: 'Dark mode',
137
+ description: 'Add a dark mode toggle to the UI',
138
+ });
139
+
140
+ expect(result.messages).toHaveLength(1);
141
+ expect(result.messages[0]!.role).toBe('user');
142
+ });
143
+
144
+ it('includes project_id, category, summary, and description', () => {
145
+ const result = mockServer.callPrompt('create-feature-request', {
146
+ project_id: 5,
147
+ category: 'UX',
148
+ summary: 'Export to CSV',
149
+ description: 'Allow exporting issue lists as CSV',
150
+ });
151
+
152
+ const text = result.messages[0]!.content.text;
153
+ expect(text).toContain('project 5');
154
+ expect(text).toContain('UX');
155
+ expect(text).toContain('Export to CSV');
156
+ expect(text).toContain('Allow exporting issue lists');
157
+ });
158
+
159
+ it('includes use_case when provided', () => {
160
+ const result = mockServer.callPrompt('create-feature-request', {
161
+ project_id: 1,
162
+ category: 'General',
163
+ summary: 'Feature',
164
+ description: 'Desc',
165
+ use_case: 'Needed for monthly reporting',
166
+ });
167
+
168
+ expect(result.messages[0]!.content.text).toContain('Needed for monthly reporting');
169
+ });
170
+
171
+ it('omits use_case section when not provided', () => {
172
+ const result = mockServer.callPrompt('create-feature-request', {
173
+ project_id: 1,
174
+ category: 'General',
175
+ summary: 'Feature',
176
+ description: 'Desc',
177
+ });
178
+
179
+ expect(result.messages[0]!.content.text).not.toContain('Use case');
180
+ });
181
+
182
+ it('mentions create_issue tool', () => {
183
+ const result = mockServer.callPrompt('create-feature-request', {
184
+ project_id: 1,
185
+ category: 'General',
186
+ summary: 'Feature',
187
+ description: 'Desc',
188
+ });
189
+
190
+ expect(result.messages[0]!.content.text).toContain('create_issue');
191
+ });
192
+ });
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // summarize-issue
196
+ // ---------------------------------------------------------------------------
197
+
198
+ describe('summarize-issue', () => {
199
+ it('returns a single user message', () => {
200
+ const result = mockServer.callPrompt('summarize-issue', { issue_id: 42 });
201
+
202
+ expect(result.messages).toHaveLength(1);
203
+ expect(result.messages[0]!.role).toBe('user');
204
+ });
205
+
206
+ it('includes the issue ID in the text', () => {
207
+ const result = mockServer.callPrompt('summarize-issue', { issue_id: 1234 });
208
+
209
+ expect(result.messages[0]!.content.text).toContain('1234');
210
+ });
211
+
212
+ it('mentions get_issue tool', () => {
213
+ const result = mockServer.callPrompt('summarize-issue', { issue_id: 1 });
214
+
215
+ expect(result.messages[0]!.content.text).toContain('get_issue');
216
+ });
217
+ });
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // project-status
221
+ // ---------------------------------------------------------------------------
222
+
223
+ describe('project-status', () => {
224
+ it('returns a single user message', () => {
225
+ const result = mockServer.callPrompt('project-status', { project_id: 3 });
226
+
227
+ expect(result.messages).toHaveLength(1);
228
+ expect(result.messages[0]!.role).toBe('user');
229
+ });
230
+
231
+ it('includes the project ID in the text', () => {
232
+ const result = mockServer.callPrompt('project-status', { project_id: 99 });
233
+
234
+ expect(result.messages[0]!.content.text).toContain('99');
235
+ });
236
+
237
+ it('mentions list_issues tool', () => {
238
+ const result = mockServer.callPrompt('project-status', { project_id: 1 });
239
+
240
+ expect(result.messages[0]!.content.text).toContain('list_issues');
241
+ });
242
+ });