@dpesch/mantisbt-mcp-server 1.10.2 → 1.10.6
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 +34 -0
- package/README.de.md +2 -1
- package/README.md +2 -1
- package/dist/client.js +0 -15
- package/dist/tools/files.js +14 -15
- package/docs/cookbook.de.md +1 -1
- package/docs/cookbook.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/.gitea/PULL_REQUEST_TEMPLATE.md +0 -17
- package/.gitea/workflows/ci.yml +0 -95
- package/.github/workflows/ci.yml +0 -32
- package/scripts/hooks/pre-push.mjs +0 -220
- package/scripts/init.mjs +0 -95
- package/scripts/record-fixtures.ts +0 -160
- package/tests/cache.test.ts +0 -265
- package/tests/client.test.ts +0 -372
- package/tests/config.test.ts +0 -263
- package/tests/fixtures/get_current_user.json +0 -40
- package/tests/fixtures/get_issue.json +0 -133
- package/tests/fixtures/get_issue_enums.json +0 -67
- package/tests/fixtures/get_issue_fields_sample.json +0 -76
- package/tests/fixtures/get_project_categories.json +0 -157
- package/tests/fixtures/get_project_versions.json +0 -3
- package/tests/fixtures/get_project_versions_with_data.json +0 -52
- package/tests/fixtures/list_issues.json +0 -95
- package/tests/fixtures/list_projects.json +0 -65
- package/tests/helpers/mock-server.ts +0 -166
- package/tests/helpers/search-mocks.ts +0 -84
- package/tests/prompts/prompts.test.ts +0 -242
- package/tests/resources/resources.test.ts +0 -309
- package/tests/search/embedder.test.ts +0 -81
- package/tests/search/highlight.test.ts +0 -129
- package/tests/search/store.test.ts +0 -193
- package/tests/search/sync.test.ts +0 -249
- package/tests/search/tools.test.ts +0 -661
- package/tests/tools/config.test.ts +0 -212
- package/tests/tools/files.test.ts +0 -344
- package/tests/tools/issues.test.ts +0 -1180
- package/tests/tools/metadata.test.ts +0 -509
- package/tests/tools/monitors.test.ts +0 -101
- package/tests/tools/projects.test.ts +0 -338
- package/tests/tools/relationships.test.ts +0 -177
- package/tests/tools/string-coercion.test.ts +0 -317
- package/tests/tools/users.test.ts +0 -62
- package/tests/utils/date-filter.test.ts +0 -169
- package/tests/version-hint.test.ts +0 -230
- package/tsconfig.build.json +0 -8
- package/vitest.config.ts +0 -8
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
type EnumEntry = { id: number; name: string; label?: string; canonical_name?: string };
|
|
4
|
-
type EnumResult = Record<string, Array<EnumEntry>>;
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { join, dirname } from 'node:path';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { MantisClient } from '../../src/client.js';
|
|
9
|
-
import { MetadataCache } from '../../src/cache.js';
|
|
10
|
-
import { registerConfigTools } from '../../src/tools/config.js';
|
|
11
|
-
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
12
|
-
|
|
13
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
-
const __dirname = dirname(__filename);
|
|
15
|
-
const fixturesDir = join(__dirname, '..', 'fixtures');
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Fixtures
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
const enumFixture = JSON.parse(
|
|
22
|
-
readFileSync(join(fixturesDir, 'get_issue_enums.json'), 'utf-8')
|
|
23
|
-
) as { configs: Array<{ option: string; value: Array<{ id: number; name: string; label: string }> }> };
|
|
24
|
-
|
|
25
|
-
// Legacy: some MantisBT versions return value as a comma-separated "id:name" string
|
|
26
|
-
const ENUM_FIXTURE_STRING = {
|
|
27
|
-
configs: [
|
|
28
|
-
{ option: 'severity_enum_string', value: '10:feature,50:minor,80:block' },
|
|
29
|
-
{ option: 'status_enum_string', value: '10:new,80:resolved,90:closed' },
|
|
30
|
-
{ option: 'priority_enum_string', value: '10:none,30:normal,60:immediate' },
|
|
31
|
-
{ option: 'resolution_enum_string', value: '10:open,20:fixed' },
|
|
32
|
-
{ option: 'reproducibility_enum_string', value: '10:always,70:have not tried' },
|
|
33
|
-
],
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Setup
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
let mockServer: MockMcpServer;
|
|
41
|
-
let client: MantisClient;
|
|
42
|
-
let cache: MetadataCache;
|
|
43
|
-
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
mockServer = new MockMcpServer();
|
|
46
|
-
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
47
|
-
cache = new MetadataCache('/tmp/cache-test', 86400);
|
|
48
|
-
registerConfigTools(mockServer as never, client, cache);
|
|
49
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
afterEach(() => {
|
|
53
|
-
vi.unstubAllGlobals();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// get_issue_enums
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
describe('get_issue_enums', () => {
|
|
61
|
-
it('ist registriert', () => {
|
|
62
|
-
expect(mockServer.hasToolRegistered('get_issue_enums')).toBe(true);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe('mit Array-Format (MantisBT 2.x)', () => {
|
|
66
|
-
let parsed: EnumResult;
|
|
67
|
-
|
|
68
|
-
beforeEach(async () => {
|
|
69
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
70
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
71
|
-
parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('gibt strukturierte Enum-Arrays für alle 5 Felder zurück', () => {
|
|
75
|
-
expect(Array.isArray(parsed.severity)).toBe(true);
|
|
76
|
-
expect(Array.isArray(parsed.status)).toBe(true);
|
|
77
|
-
expect(Array.isArray(parsed.priority)).toBe(true);
|
|
78
|
-
expect(Array.isArray(parsed.resolution)).toBe(true);
|
|
79
|
-
expect(Array.isArray(parsed.reproducibility)).toBe(true);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('id und name korrekt für alle Felder', () => {
|
|
83
|
-
expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 50, name: 'kleinerer Fehler' }));
|
|
84
|
-
expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 80, name: 'Blocker' }));
|
|
85
|
-
expect(parsed.severity).toContainEqual(expect.objectContaining({ id: 200, name: 'Technische Schuld' }));
|
|
86
|
-
expect(parsed.status).toContainEqual(expect.objectContaining({ id: 10, name: 'new' }));
|
|
87
|
-
expect(parsed.status).toContainEqual(expect.objectContaining({ id: 80, name: 'resolved' }));
|
|
88
|
-
expect(parsed.priority).toContainEqual(expect.objectContaining({ id: 30, name: 'normal' }));
|
|
89
|
-
expect(parsed.reproducibility).toContainEqual(expect.objectContaining({ id: 70, name: 'have not tried' }));
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('label wird ausgegeben wenn er sich von name unterscheidet (lokalisierte Installation)', () => {
|
|
93
|
-
// status: name="new", label="neu" → label muss im Output enthalten sein
|
|
94
|
-
expect(parsed.status).toContainEqual({ id: 10, name: 'new', label: 'neu' });
|
|
95
|
-
expect(parsed.status).toContainEqual({ id: 80, name: 'resolved', label: 'erledigt' });
|
|
96
|
-
// priority: name="none", label="keine" → label muss im Output enthalten sein
|
|
97
|
-
expect(parsed.priority).toContainEqual({ id: 10, name: 'none', label: 'keine' });
|
|
98
|
-
// reproducibility: name="always", label="immer" → label muss im Output enthalten sein
|
|
99
|
-
expect(parsed.reproducibility).toContainEqual({ id: 10, name: 'always', label: 'immer' });
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('label wird weggelassen wenn er identisch mit name ist', () => {
|
|
103
|
-
// severity: name === label (z.B. "Feature-Wunsch" === "Feature-Wunsch") → kein label-Feld
|
|
104
|
-
// canonical_name is present because "Feature-Wunsch" differs from English "feature"
|
|
105
|
-
const severityFirst = parsed.severity.find(e => e.id === 10)!;
|
|
106
|
-
expect(severityFirst).toMatchObject({ id: 10, name: 'Feature-Wunsch', canonical_name: 'feature' });
|
|
107
|
-
expect(severityFirst).not.toHaveProperty('label');
|
|
108
|
-
|
|
109
|
-
// priority: name="normal", label="normal" → kein label-Feld
|
|
110
|
-
const priorityNormal = parsed.priority.find(e => e.id === 30)!;
|
|
111
|
-
expect(priorityNormal).toEqual({ id: 30, name: 'normal' });
|
|
112
|
-
expect(priorityNormal).not.toHaveProperty('label');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('fragt alle 5 Enum-Optionen ab', () => {
|
|
116
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
117
|
-
expect(calledUrl).toContain('severity_enum_string');
|
|
118
|
-
expect(calledUrl).toContain('status_enum_string');
|
|
119
|
-
expect(calledUrl).toContain('priority_enum_string');
|
|
120
|
-
expect(calledUrl).toContain('resolution_enum_string');
|
|
121
|
-
expect(calledUrl).toContain('reproducibility_enum_string');
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('parst String-Format (Legacy): "id:name,..."-Strings korrekt', async () => {
|
|
126
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(ENUM_FIXTURE_STRING)));
|
|
127
|
-
|
|
128
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
129
|
-
|
|
130
|
-
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
131
|
-
|
|
132
|
-
expect(parsed.severity).toContainEqual({ id: 50, name: 'minor' });
|
|
133
|
-
expect(parsed.status).toContainEqual({ id: 80, name: 'resolved' });
|
|
134
|
-
expect(parsed.reproducibility).toContainEqual({ id: 70, name: 'have not tried' });
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('canonical_name für lokalisierte Installationen', () => {
|
|
138
|
-
it('fügt canonical_name hinzu wenn name von englischem Standardwert abweicht', async () => {
|
|
139
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
140
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
141
|
-
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
142
|
-
|
|
143
|
-
// severity: names are German in fixture → canonical_name must be present
|
|
144
|
-
const minor = parsed.severity.find(e => e.id === 50)!;
|
|
145
|
-
expect(minor.name).toBe('kleinerer Fehler');
|
|
146
|
-
expect(minor.canonical_name).toBe('minor');
|
|
147
|
-
|
|
148
|
-
const block = parsed.severity.find(e => e.id === 80)!;
|
|
149
|
-
expect(block.name).toBe('Blocker');
|
|
150
|
-
expect(block.canonical_name).toBe('block');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('lässt canonical_name weg wenn name bereits englischer Standardwert ist', async () => {
|
|
154
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
155
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
156
|
-
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
157
|
-
|
|
158
|
-
// status: names are already English → no canonical_name
|
|
159
|
-
const statusNew = parsed.status.find(e => e.id === 10)!;
|
|
160
|
-
expect(statusNew.name).toBe('new');
|
|
161
|
-
expect(statusNew).not.toHaveProperty('canonical_name');
|
|
162
|
-
|
|
163
|
-
// priority: "normal" is already the canonical name
|
|
164
|
-
const priorityNormal = parsed.priority.find(e => e.id === 30)!;
|
|
165
|
-
expect(priorityNormal.name).toBe('normal');
|
|
166
|
-
expect(priorityNormal).not.toHaveProperty('canonical_name');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('lässt canonical_name weg für unbekannte/benutzerdefinierte IDs', async () => {
|
|
170
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(enumFixture)));
|
|
171
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
172
|
-
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
173
|
-
|
|
174
|
-
// severity ID 200 ("Technische Schuld") is a custom entry with no canonical mapping
|
|
175
|
-
const custom = parsed.severity.find(e => e.id === 200)!;
|
|
176
|
-
expect(custom).toBeDefined();
|
|
177
|
-
expect(custom).not.toHaveProperty('canonical_name');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('fügt canonical_name auch im String-Format (Legacy) hinzu', async () => {
|
|
181
|
-
// Override: use German names in legacy string format
|
|
182
|
-
const germanStringFixture = {
|
|
183
|
-
configs: [
|
|
184
|
-
{ option: 'severity_enum_string', value: '50:kleinerer Fehler,80:Blocker' },
|
|
185
|
-
{ option: 'status_enum_string', value: '10:new,80:resolved' },
|
|
186
|
-
{ option: 'priority_enum_string', value: '30:normal' },
|
|
187
|
-
{ option: 'resolution_enum_string', value: '20:fixed' },
|
|
188
|
-
{ option: 'reproducibility_enum_string', value: '10:always' },
|
|
189
|
-
],
|
|
190
|
-
};
|
|
191
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(germanStringFixture)));
|
|
192
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
193
|
-
const parsed = JSON.parse(result.content[0]!.text) as EnumResult;
|
|
194
|
-
|
|
195
|
-
const minor = parsed.severity.find(e => e.id === 50)!;
|
|
196
|
-
expect(minor.canonical_name).toBe('minor');
|
|
197
|
-
|
|
198
|
-
// status "new" is already canonical — no canonical_name
|
|
199
|
-
const statusNew = parsed.status.find(e => e.id === 10)!;
|
|
200
|
-
expect(statusNew).not.toHaveProperty('canonical_name');
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('gibt isError: true bei API-Fehler zurück', async () => {
|
|
205
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Access denied' })));
|
|
206
|
-
|
|
207
|
-
const result = await mockServer.callTool('get_issue_enums', {});
|
|
208
|
-
|
|
209
|
-
expect(result.isError).toBe(true);
|
|
210
|
-
expect(result.content[0]!.text).toContain('Error:');
|
|
211
|
-
});
|
|
212
|
-
});
|
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import { MantisClient } from '../../src/client.js';
|
|
4
|
-
import { registerFileTools } from '../../src/tools/files.js';
|
|
5
|
-
import { MockMcpServer, makeResponse } from '../helpers/mock-server.js';
|
|
6
|
-
|
|
7
|
-
vi.mock('node:fs/promises', () => ({
|
|
8
|
-
readFile: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
// Import after mock so the mock is in place
|
|
12
|
-
import { readFile } from 'node:fs/promises';
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// Setup
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
let mockServer: MockMcpServer;
|
|
19
|
-
let client: MantisClient;
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
mockServer = new MockMcpServer();
|
|
23
|
-
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
24
|
-
registerFileTools(mockServer as never, client, undefined);
|
|
25
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
vi.unstubAllGlobals();
|
|
30
|
-
vi.clearAllMocks();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// list_issue_files
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
describe('list_issue_files', () => {
|
|
38
|
-
it('is registered', () => {
|
|
39
|
-
expect(mockServer.hasToolRegistered('list_issue_files')).toBe(true);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('returns an attachment array', async () => {
|
|
43
|
-
const apiResponse = {
|
|
44
|
-
issues: [{
|
|
45
|
-
id: 42,
|
|
46
|
-
attachments: [
|
|
47
|
-
{ id: 1, file_name: 'screenshot.png', size: 12345, content_type: 'image/png' },
|
|
48
|
-
],
|
|
49
|
-
}],
|
|
50
|
-
};
|
|
51
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
|
|
52
|
-
|
|
53
|
-
const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
|
|
54
|
-
|
|
55
|
-
expect(result.isError).toBeUndefined();
|
|
56
|
-
const parsed = JSON.parse(result.content[0]!.text) as Array<{ file_name: string }>;
|
|
57
|
-
expect(Array.isArray(parsed)).toBe(true);
|
|
58
|
-
expect(parsed[0]!.file_name).toBe('screenshot.png');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('returns an empty array when there are no attachments', async () => {
|
|
62
|
-
const apiResponse = { issues: [{ id: 42 }] };
|
|
63
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
|
|
64
|
-
|
|
65
|
-
const result = await mockServer.callTool('list_issue_files', { issue_id: 42 });
|
|
66
|
-
|
|
67
|
-
expect(result.isError).toBeUndefined();
|
|
68
|
-
const parsed = JSON.parse(result.content[0]!.text) as unknown[];
|
|
69
|
-
expect(parsed).toEqual([]);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('calls the correct endpoint', async () => {
|
|
73
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ issues: [{ id: 42 }] })));
|
|
74
|
-
|
|
75
|
-
await mockServer.callTool('list_issue_files', { issue_id: 42 });
|
|
76
|
-
|
|
77
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
78
|
-
expect(calledUrl).toContain('issues/42');
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
// upload_file – file_path mode
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
describe('upload_file', () => {
|
|
87
|
-
it('is registered', () => {
|
|
88
|
-
expect(mockServer.hasToolRegistered('upload_file')).toBe(true);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('reads the file and posts to the correct endpoint', async () => {
|
|
92
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('file content') as never);
|
|
93
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5, file_name: 'report.pdf' })));
|
|
94
|
-
|
|
95
|
-
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
|
|
96
|
-
|
|
97
|
-
expect(readFile).toHaveBeenCalledWith('/tmp/report.pdf');
|
|
98
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
99
|
-
expect(calledUrl).toContain('issues/42/files');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('sends a POST request with FormData', async () => {
|
|
103
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
104
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
105
|
-
|
|
106
|
-
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
|
|
107
|
-
|
|
108
|
-
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
109
|
-
expect(options.method).toBe('POST');
|
|
110
|
-
expect(options.body).toBeInstanceOf(FormData);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('does not set Content-Type header (set automatically by fetch)', async () => {
|
|
114
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
115
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
116
|
-
|
|
117
|
-
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
|
|
118
|
-
|
|
119
|
-
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
120
|
-
const headers = options.headers as Record<string, string>;
|
|
121
|
-
expect(headers['Content-Type']).toBeUndefined();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('appends description when provided', async () => {
|
|
125
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
126
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
127
|
-
|
|
128
|
-
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt', description: 'My attachment' });
|
|
129
|
-
|
|
130
|
-
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
131
|
-
const formData = options.body as FormData;
|
|
132
|
-
expect(formData.get('description')).toBe('My attachment');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('returns the API response', async () => {
|
|
136
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
137
|
-
const apiResponse = { id: 5, file_name: 'report.pdf', size: 7 };
|
|
138
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify(apiResponse)));
|
|
139
|
-
|
|
140
|
-
const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf' });
|
|
141
|
-
|
|
142
|
-
expect(result.isError).toBeUndefined();
|
|
143
|
-
const parsed = JSON.parse(result.content[0]!.text) as { file_name: string };
|
|
144
|
-
expect(parsed.file_name).toBe('report.pdf');
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('returns isError when the file is not found', async () => {
|
|
148
|
-
vi.mocked(readFile).mockRejectedValue(new Error("ENOENT: no such file or directory, open '/tmp/missing.txt'") as never);
|
|
149
|
-
|
|
150
|
-
const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/missing.txt' });
|
|
151
|
-
|
|
152
|
-
expect(result.isError).toBe(true);
|
|
153
|
-
expect(result.content[0]!.text).toContain('Error:');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('returns isError on API error', async () => {
|
|
157
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
158
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(403, JSON.stringify({ message: 'Forbidden' })));
|
|
159
|
-
|
|
160
|
-
const result = await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/test.txt' });
|
|
161
|
-
|
|
162
|
-
expect(result.isError).toBe(true);
|
|
163
|
-
expect(result.content[0]!.text).toContain('Error:');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('overrides the filename when filename is provided', async () => {
|
|
167
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
168
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
169
|
-
|
|
170
|
-
await mockServer.callTool('upload_file', { issue_id: 42, file_path: '/tmp/report.pdf', filename: 'custom-name.pdf' });
|
|
171
|
-
|
|
172
|
-
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
173
|
-
const formData = options.body as FormData;
|
|
174
|
-
const fileEntry = formData.get('file') as File;
|
|
175
|
-
expect(fileEntry.name).toBe('custom-name.pdf');
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
// upload_file – Base64 mode
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
|
|
183
|
-
describe('upload_file (Base64)', () => {
|
|
184
|
-
it('decodes Base64 content and uploads it', async () => {
|
|
185
|
-
const originalContent = 'Hello, World!';
|
|
186
|
-
const base64Content = Buffer.from(originalContent).toString('base64');
|
|
187
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7, file_name: 'hello.txt' })));
|
|
188
|
-
|
|
189
|
-
const result = await mockServer.callTool('upload_file', {
|
|
190
|
-
issue_id: 42,
|
|
191
|
-
content: base64Content,
|
|
192
|
-
filename: 'hello.txt',
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
expect(result.isError).toBeUndefined();
|
|
196
|
-
expect(readFile).not.toHaveBeenCalled();
|
|
197
|
-
const calledUrl = vi.mocked(fetch).mock.calls[0]![0] as string;
|
|
198
|
-
expect(calledUrl).toContain('issues/42/files');
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('uses the provided filename', async () => {
|
|
202
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
|
|
203
|
-
|
|
204
|
-
await mockServer.callTool('upload_file', {
|
|
205
|
-
issue_id: 42,
|
|
206
|
-
content: Buffer.from('data').toString('base64'),
|
|
207
|
-
filename: 'export.csv',
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
211
|
-
const formData = options.body as FormData;
|
|
212
|
-
const fileEntry = formData.get('file') as File;
|
|
213
|
-
expect(fileEntry.name).toBe('export.csv');
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('sets the content_type when provided', async () => {
|
|
217
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 7 })));
|
|
218
|
-
|
|
219
|
-
await mockServer.callTool('upload_file', {
|
|
220
|
-
issue_id: 42,
|
|
221
|
-
content: Buffer.from('data').toString('base64'),
|
|
222
|
-
filename: 'image.png',
|
|
223
|
-
content_type: 'image/png',
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
const options = vi.mocked(fetch).mock.calls[0]![1] as RequestInit;
|
|
227
|
-
const formData = options.body as FormData;
|
|
228
|
-
const fileEntry = formData.get('file') as File;
|
|
229
|
-
expect(fileEntry.type).toBe('image/png');
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('returns isError when neither file_path nor content is provided', async () => {
|
|
233
|
-
const result = await mockServer.callTool('upload_file', { issue_id: 42 });
|
|
234
|
-
|
|
235
|
-
expect(result.isError).toBe(true);
|
|
236
|
-
expect(result.content[0]!.text).toContain('Either file_path or content');
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('returns isError when both file_path and content are provided', async () => {
|
|
240
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('x') as never);
|
|
241
|
-
|
|
242
|
-
const result = await mockServer.callTool('upload_file', {
|
|
243
|
-
issue_id: 42,
|
|
244
|
-
file_path: '/tmp/test.txt',
|
|
245
|
-
content: Buffer.from('x').toString('base64'),
|
|
246
|
-
filename: 'test.txt',
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
expect(result.isError).toBe(true);
|
|
250
|
-
expect(result.content[0]!.text).toContain('Only one of');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('returns isError when content is provided without filename', async () => {
|
|
254
|
-
const result = await mockServer.callTool('upload_file', {
|
|
255
|
-
issue_id: 42,
|
|
256
|
-
content: Buffer.from('data').toString('base64'),
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
expect(result.isError).toBe(true);
|
|
260
|
-
expect(result.content[0]!.text).toContain('filename is required');
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('returns isError on API error', async () => {
|
|
264
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(500, JSON.stringify({ message: 'Internal Server Error' })));
|
|
265
|
-
|
|
266
|
-
const result = await mockServer.callTool('upload_file', {
|
|
267
|
-
issue_id: 42,
|
|
268
|
-
content: Buffer.from('data').toString('base64'),
|
|
269
|
-
filename: 'test.txt',
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
expect(result.isError).toBe(true);
|
|
273
|
-
expect(result.content[0]!.text).toContain('Error:');
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// ---------------------------------------------------------------------------
|
|
278
|
-
// upload_file – Path Traversal protection (uploadDir)
|
|
279
|
-
// ---------------------------------------------------------------------------
|
|
280
|
-
|
|
281
|
-
describe('upload_file (uploadDir restriction)', () => {
|
|
282
|
-
const uploadDir = path.resolve('/tmp/uploads');
|
|
283
|
-
|
|
284
|
-
beforeEach(() => {
|
|
285
|
-
// Override the server registered in the outer beforeEach with one that
|
|
286
|
-
// has uploadDir set.
|
|
287
|
-
mockServer = new MockMcpServer();
|
|
288
|
-
client = new MantisClient('https://mantis.example.com', 'test-token');
|
|
289
|
-
registerFileTools(mockServer as never, client, uploadDir);
|
|
290
|
-
vi.stubGlobal('fetch', vi.fn());
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('allows file_path inside uploadDir', async () => {
|
|
294
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
295
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
296
|
-
|
|
297
|
-
const result = await mockServer.callTool('upload_file', {
|
|
298
|
-
issue_id: 42,
|
|
299
|
-
file_path: path.join(uploadDir, 'report.pdf'),
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
expect(result.isError).toBeUndefined();
|
|
303
|
-
expect(readFile).toHaveBeenCalled();
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('blocks file_path outside uploadDir', async () => {
|
|
307
|
-
const result = await mockServer.callTool('upload_file', {
|
|
308
|
-
issue_id: 42,
|
|
309
|
-
file_path: '/etc/passwd',
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
expect(result.isError).toBe(true);
|
|
313
|
-
expect(result.content[0]!.text).toContain('not allowed');
|
|
314
|
-
expect(readFile).not.toHaveBeenCalled();
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('blocks path traversal escaping uploadDir', async () => {
|
|
318
|
-
const result = await mockServer.callTool('upload_file', {
|
|
319
|
-
issue_id: 42,
|
|
320
|
-
file_path: path.join(uploadDir, '..', 'secret.txt'),
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
expect(result.isError).toBe(true);
|
|
324
|
-
expect(result.content[0]!.text).toContain('not allowed');
|
|
325
|
-
expect(readFile).not.toHaveBeenCalled();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('allows any file_path when uploadDir is undefined (no restriction)', async () => {
|
|
329
|
-
// This uses the outer beforeEach server (uploadDir = undefined).
|
|
330
|
-
const unrestrictedServer = new MockMcpServer();
|
|
331
|
-
registerFileTools(unrestrictedServer as never, client, undefined);
|
|
332
|
-
|
|
333
|
-
vi.mocked(readFile).mockResolvedValue(Buffer.from('content') as never);
|
|
334
|
-
vi.mocked(fetch).mockResolvedValue(makeResponse(200, JSON.stringify({ id: 5 })));
|
|
335
|
-
|
|
336
|
-
const result = await unrestrictedServer.callTool('upload_file', {
|
|
337
|
-
issue_id: 42,
|
|
338
|
-
file_path: '/etc/passwd',
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
expect(result.isError).toBeUndefined();
|
|
342
|
-
expect(readFile).toHaveBeenCalledWith('/etc/passwd');
|
|
343
|
-
});
|
|
344
|
-
});
|