@dpesch/mantisbt-mcp-server 1.0.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.
Files changed (49) hide show
  1. package/.env.local +2 -0
  2. package/CHANGELOG.md +68 -0
  3. package/LICENSE +21 -0
  4. package/README.de.md +177 -0
  5. package/README.md +177 -0
  6. package/dist/cache.js +52 -0
  7. package/dist/client.js +114 -0
  8. package/dist/config.js +54 -0
  9. package/dist/constants.js +23 -0
  10. package/dist/index.js +120 -0
  11. package/dist/tools/config.js +107 -0
  12. package/dist/tools/files.js +37 -0
  13. package/dist/tools/filters.js +35 -0
  14. package/dist/tools/issues.js +191 -0
  15. package/dist/tools/metadata.js +119 -0
  16. package/dist/tools/monitors.js +38 -0
  17. package/dist/tools/notes.js +96 -0
  18. package/dist/tools/projects.js +127 -0
  19. package/dist/tools/relationships.js +54 -0
  20. package/dist/tools/tags.js +78 -0
  21. package/dist/tools/users.js +34 -0
  22. package/dist/tools/version.js +58 -0
  23. package/dist/types.js +4 -0
  24. package/dist/version-hint.js +117 -0
  25. package/package.json +41 -0
  26. package/scripts/record-fixtures.ts +138 -0
  27. package/tests/cache.test.ts +149 -0
  28. package/tests/client.test.ts +241 -0
  29. package/tests/config.test.ts +164 -0
  30. package/tests/fixtures/get_current_user.json +39 -0
  31. package/tests/fixtures/get_issue.json +151 -0
  32. package/tests/fixtures/get_project_categories.json +60 -0
  33. package/tests/fixtures/get_project_versions.json +3 -0
  34. package/tests/fixtures/get_project_versions_with_data.json +28 -0
  35. package/tests/fixtures/list_issues.json +67 -0
  36. package/tests/fixtures/list_projects.json +65 -0
  37. package/tests/fixtures/recorded/get_current_user.json +108 -0
  38. package/tests/fixtures/recorded/get_issue.json +320 -0
  39. package/tests/fixtures/recorded/get_project_categories.json +241 -0
  40. package/tests/fixtures/recorded/get_project_versions.json +3 -0
  41. package/tests/fixtures/recorded/list_issues.json +824 -0
  42. package/tests/fixtures/recorded/list_projects.json +10641 -0
  43. package/tests/helpers/mock-server.ts +32 -0
  44. package/tests/tools/issues.test.ts +130 -0
  45. package/tests/tools/projects.test.ts +169 -0
  46. package/tests/tools/users.test.ts +76 -0
  47. package/tests/version-hint.test.ts +230 -0
  48. package/tsconfig.build.json +8 -0
  49. package/vitest.config.ts +8 -0
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
5
+ import { createServer } from 'node:http';
6
+ import { readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { join, dirname } from 'node:path';
9
+ import { getConfig } from './config.js';
10
+ import { MantisClient } from './client.js';
11
+ import { VersionHintService, setGlobalVersionHint } from './version-hint.js';
12
+ import { MetadataCache } from './cache.js';
13
+ import { registerIssueTools } from './tools/issues.js';
14
+ import { registerNoteTools } from './tools/notes.js';
15
+ import { registerFileTools } from './tools/files.js';
16
+ import { registerRelationshipTools } from './tools/relationships.js';
17
+ import { registerMonitorTools } from './tools/monitors.js';
18
+ import { registerProjectTools } from './tools/projects.js';
19
+ import { registerUserTools } from './tools/users.js';
20
+ import { registerFilterTools } from './tools/filters.js';
21
+ import { registerConfigTools } from './tools/config.js';
22
+ import { registerMetadataTools } from './tools/metadata.js';
23
+ import { registerTagTools } from './tools/tags.js';
24
+ import { registerVersionTools } from './tools/version.js';
25
+ // ---------------------------------------------------------------------------
26
+ // Read version from package.json
27
+ // ---------------------------------------------------------------------------
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = dirname(__filename);
30
+ const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
31
+ const version = packageJson.version;
32
+ // ---------------------------------------------------------------------------
33
+ // Bootstrap
34
+ // ---------------------------------------------------------------------------
35
+ async function createMcpServer() {
36
+ const config = await getConfig();
37
+ const versionHint = new VersionHintService();
38
+ setGlobalVersionHint(versionHint);
39
+ const client = new MantisClient(config.baseUrl, config.apiKey, (response) => versionHint.onSuccessfulResponse(response));
40
+ const cache = new MetadataCache(config.cacheDir, config.cacheTtl);
41
+ const server = new McpServer({
42
+ name: 'mantisbt-mcp-server',
43
+ version,
44
+ });
45
+ registerIssueTools(server, client);
46
+ registerNoteTools(server, client);
47
+ registerFileTools(server, client);
48
+ registerRelationshipTools(server, client);
49
+ registerMonitorTools(server, client);
50
+ registerProjectTools(server, client);
51
+ registerUserTools(server, client);
52
+ registerFilterTools(server, client);
53
+ registerConfigTools(server, client);
54
+ registerMetadataTools(server, client, cache);
55
+ registerTagTools(server, client);
56
+ registerVersionTools(server, client, versionHint);
57
+ return server;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Transport: stdio (default) or HTTP
61
+ // ---------------------------------------------------------------------------
62
+ async function runStdio() {
63
+ const server = await createMcpServer();
64
+ const transport = new StdioServerTransport();
65
+ await server.connect(transport);
66
+ console.error(`MantisBT MCP Server v${version} running (stdio)`);
67
+ }
68
+ async function runHttp() {
69
+ const server = await createMcpServer();
70
+ const port = parseInt(process.env.PORT ?? '3000', 10);
71
+ const httpServer = createServer(async (req, res) => {
72
+ if (req.method === 'POST' && req.url === '/mcp') {
73
+ const chunks = [];
74
+ req.on('data', (chunk) => chunks.push(chunk));
75
+ req.on('end', async () => {
76
+ try {
77
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
78
+ const transport = new StreamableHTTPServerTransport({
79
+ sessionIdGenerator: undefined,
80
+ enableJsonResponse: true,
81
+ });
82
+ res.on('close', () => transport.close());
83
+ await server.connect(transport);
84
+ await transport.handleRequest(req, res, body);
85
+ }
86
+ catch {
87
+ res.writeHead(400, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({ error: 'Bad Request' }));
89
+ }
90
+ });
91
+ }
92
+ else if (req.method === 'GET' && req.url === '/health') {
93
+ res.writeHead(200, { 'Content-Type': 'application/json' });
94
+ res.end(JSON.stringify({ status: 'ok', server: 'mantisbt-mcp-server', version }));
95
+ }
96
+ else {
97
+ res.writeHead(404);
98
+ res.end();
99
+ }
100
+ });
101
+ httpServer.listen(port, () => {
102
+ console.error(`MantisBT MCP Server v${version} running on http://localhost:${port}/mcp`);
103
+ });
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Start
107
+ // ---------------------------------------------------------------------------
108
+ const transport = process.env.TRANSPORT ?? 'stdio';
109
+ if (transport === 'http') {
110
+ runHttp().catch((err) => {
111
+ console.error('Server startup error:', err instanceof Error ? err.message : err);
112
+ process.exit(1);
113
+ });
114
+ }
115
+ else {
116
+ runStdio().catch((err) => {
117
+ console.error('Server startup error:', err instanceof Error ? err.message : err);
118
+ process.exit(1);
119
+ });
120
+ }
@@ -0,0 +1,107 @@
1
+ import { z } from 'zod';
2
+ import { getVersionHint } from '../version-hint.js';
3
+ function errorText(msg) {
4
+ const vh = getVersionHint();
5
+ vh?.triggerLatestVersionFetch();
6
+ const hint = vh?.getUpdateHint();
7
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
+ }
9
+ export function registerConfigTools(server, client) {
10
+ // ---------------------------------------------------------------------------
11
+ // get_config
12
+ // ---------------------------------------------------------------------------
13
+ server.registerTool('get_config', {
14
+ title: 'Get MantisBT Configuration',
15
+ description: `Retrieve one or more MantisBT configuration options.
16
+
17
+ Common option names:
18
+ - "status_enum_string" — issue status values and their IDs
19
+ - "priority_enum_string" — priority values
20
+ - "severity_enum_string" — severity values
21
+ - "resolution_enum_string" — resolution values
22
+ - "reproducibility_enum_string" — reproducibility values
23
+ - "view_state_enum_string" — view state values
24
+ - "access_levels_enum_string" — access level values`,
25
+ inputSchema: z.object({
26
+ options: z.array(z.string()).min(1).describe('Array of configuration option names to retrieve'),
27
+ }),
28
+ annotations: {
29
+ readOnlyHint: true,
30
+ destructiveHint: false,
31
+ idempotentHint: true,
32
+ },
33
+ }, async ({ options }) => {
34
+ try {
35
+ const params = {};
36
+ options.forEach((opt, i) => {
37
+ params[`option[${i}]`] = opt;
38
+ });
39
+ const result = await client.get('config', params);
40
+ return {
41
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
42
+ };
43
+ }
44
+ catch (error) {
45
+ const msg = error instanceof Error ? error.message : String(error);
46
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
47
+ }
48
+ });
49
+ // ---------------------------------------------------------------------------
50
+ // list_languages
51
+ // ---------------------------------------------------------------------------
52
+ server.registerTool('list_languages', {
53
+ title: 'List Supported Languages',
54
+ description: 'List all languages supported by the MantisBT installation.',
55
+ inputSchema: z.object({}),
56
+ annotations: {
57
+ readOnlyHint: true,
58
+ destructiveHint: false,
59
+ idempotentHint: true,
60
+ },
61
+ }, async () => {
62
+ try {
63
+ const result = await client.get('lang');
64
+ return {
65
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
66
+ };
67
+ }
68
+ catch (error) {
69
+ const msg = error instanceof Error ? error.message : String(error);
70
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
71
+ }
72
+ });
73
+ // ---------------------------------------------------------------------------
74
+ // list_tags
75
+ // ---------------------------------------------------------------------------
76
+ server.registerTool('list_tags', {
77
+ title: 'List Tags',
78
+ description: `List all tags defined in the MantisBT installation.
79
+
80
+ Note: The GET /tags endpoint is not available in MantisBT 2.25 and earlier.
81
+ If your MantisBT version does not support this endpoint, you will receive an error.
82
+ In that case, use get_issue to read the tags of a specific issue instead.`,
83
+ inputSchema: z.object({
84
+ page: z.number().int().positive().default(1).describe('Page number (default: 1)'),
85
+ page_size: z.number().int().min(1).max(200).default(50).describe('Tags per page (default: 50)'),
86
+ }),
87
+ annotations: {
88
+ readOnlyHint: true,
89
+ destructiveHint: false,
90
+ idempotentHint: true,
91
+ },
92
+ }, async ({ page, page_size }) => {
93
+ try {
94
+ const result = await client.get('tags', { page, page_size });
95
+ return {
96
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
97
+ };
98
+ }
99
+ catch (error) {
100
+ const msg = error instanceof Error ? error.message : String(error);
101
+ const hint = msg.includes('404')
102
+ ? `${msg}\n\nThe GET /tags endpoint is not supported by this MantisBT version. Use get_issue to read tags of a specific issue instead.`
103
+ : msg;
104
+ return { content: [{ type: 'text', text: `Error: ${hint}` }], isError: true };
105
+ }
106
+ });
107
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ import { getVersionHint } from '../version-hint.js';
3
+ function errorText(msg) {
4
+ const vh = getVersionHint();
5
+ vh?.triggerLatestVersionFetch();
6
+ const hint = vh?.getUpdateHint();
7
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
+ }
9
+ export function registerFileTools(server, client) {
10
+ // ---------------------------------------------------------------------------
11
+ // list_issue_files
12
+ // ---------------------------------------------------------------------------
13
+ server.registerTool('list_issue_files', {
14
+ title: 'List Issue File Attachments',
15
+ description: 'List all file attachments of a MantisBT issue.',
16
+ inputSchema: z.object({
17
+ issue_id: z.number().int().positive().describe('Numeric issue ID'),
18
+ }),
19
+ annotations: {
20
+ readOnlyHint: true,
21
+ destructiveHint: false,
22
+ idempotentHint: true,
23
+ },
24
+ }, async ({ issue_id }) => {
25
+ try {
26
+ const result = await client.get(`issues/${issue_id}`);
27
+ const attachments = result.issues?.[0]?.attachments ?? [];
28
+ return {
29
+ content: [{ type: 'text', text: JSON.stringify(attachments, null, 2) }],
30
+ };
31
+ }
32
+ catch (error) {
33
+ const msg = error instanceof Error ? error.message : String(error);
34
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
35
+ }
36
+ });
37
+ }
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { getVersionHint } from '../version-hint.js';
3
+ function errorText(msg) {
4
+ const vh = getVersionHint();
5
+ vh?.triggerLatestVersionFetch();
6
+ const hint = vh?.getUpdateHint();
7
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
+ }
9
+ export function registerFilterTools(server, client) {
10
+ // ---------------------------------------------------------------------------
11
+ // list_filters
12
+ // ---------------------------------------------------------------------------
13
+ server.registerTool('list_filters', {
14
+ title: 'List Saved Filters',
15
+ description: 'List all saved MantisBT issue filters accessible to the current user. Filter IDs can be used with list_issues.',
16
+ inputSchema: z.object({}),
17
+ annotations: {
18
+ readOnlyHint: true,
19
+ destructiveHint: false,
20
+ idempotentHint: true,
21
+ },
22
+ }, async () => {
23
+ try {
24
+ const result = await client.get('filters');
25
+ const filters = Array.isArray(result) ? result : result.filters ?? result;
26
+ return {
27
+ content: [{ type: 'text', text: JSON.stringify(filters, null, 2) }],
28
+ };
29
+ }
30
+ catch (error) {
31
+ const msg = error instanceof Error ? error.message : String(error);
32
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,191 @@
1
+ import { z } from 'zod';
2
+ import { getVersionHint } from '../version-hint.js';
3
+ function errorText(msg) {
4
+ const vh = getVersionHint();
5
+ vh?.triggerLatestVersionFetch();
6
+ const hint = vh?.getUpdateHint();
7
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
+ }
9
+ export function registerIssueTools(server, client) {
10
+ // ---------------------------------------------------------------------------
11
+ // get_issue
12
+ // ---------------------------------------------------------------------------
13
+ server.registerTool('get_issue', {
14
+ title: 'Get Issue',
15
+ description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships.',
16
+ inputSchema: z.object({
17
+ id: z.number().int().positive().describe('Numeric issue ID'),
18
+ }),
19
+ annotations: {
20
+ readOnlyHint: true,
21
+ destructiveHint: false,
22
+ idempotentHint: true,
23
+ },
24
+ }, async ({ id }) => {
25
+ try {
26
+ const result = await client.get(`issues/${id}`);
27
+ const issue = result.issues?.[0] ?? result;
28
+ return {
29
+ content: [{ type: 'text', text: JSON.stringify(issue, null, 2) }],
30
+ };
31
+ }
32
+ catch (error) {
33
+ const msg = error instanceof Error ? error.message : String(error);
34
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
35
+ }
36
+ });
37
+ // ---------------------------------------------------------------------------
38
+ // list_issues
39
+ // ---------------------------------------------------------------------------
40
+ server.registerTool('list_issues', {
41
+ title: 'List Issues',
42
+ description: 'List MantisBT issues with optional filtering. Returns a paginated list of issues.',
43
+ inputSchema: z.object({
44
+ project_id: z.number().int().positive().optional().describe('Filter by project ID'),
45
+ page: z.number().int().positive().default(1).describe('Page number (default: 1)'),
46
+ page_size: z.number().int().min(1).max(50).default(50).describe('Issues per page (default: 50, max: 50)'),
47
+ assigned_to: z.number().int().positive().optional().describe('Filter by handler/assignee user ID'),
48
+ reporter_id: z.number().int().positive().optional().describe('Filter by reporter user ID'),
49
+ filter_id: z.number().int().positive().optional().describe('Use a saved MantisBT filter ID'),
50
+ sort: z.string().optional().describe('Sort field (e.g. "last_updated", "id")'),
51
+ direction: z.enum(['ASC', 'DESC']).optional().describe('Sort direction'),
52
+ }),
53
+ annotations: {
54
+ readOnlyHint: true,
55
+ destructiveHint: false,
56
+ idempotentHint: true,
57
+ },
58
+ }, async ({ project_id, page, page_size, assigned_to, reporter_id, filter_id, sort, direction }) => {
59
+ try {
60
+ const params = {
61
+ page,
62
+ page_size,
63
+ project_id,
64
+ assigned_to,
65
+ reporter_id,
66
+ filter_id,
67
+ sort,
68
+ direction,
69
+ };
70
+ const result = await client.get('issues', params);
71
+ return {
72
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
73
+ };
74
+ }
75
+ catch (error) {
76
+ const msg = error instanceof Error ? error.message : String(error);
77
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
78
+ }
79
+ });
80
+ // ---------------------------------------------------------------------------
81
+ // create_issue
82
+ // ---------------------------------------------------------------------------
83
+ server.registerTool('create_issue', {
84
+ title: 'Create Issue',
85
+ description: 'Create a new MantisBT issue. Returns the created issue including its assigned ID.',
86
+ inputSchema: z.object({
87
+ summary: z.string().min(1).describe('Issue summary/title'),
88
+ description: z.string().default('').describe('Detailed issue description'),
89
+ project_id: z.number().int().positive().describe('Project ID the issue belongs to'),
90
+ category: z.string().min(1).describe('Category name (use get_project_categories to list available categories)'),
91
+ priority: z.string().optional().describe('Priority name (e.g. "normal", "high", "urgent", "immediate", "low", "none")'),
92
+ severity: z.string().optional().describe('Severity name (e.g. "minor", "major", "crash", "block", "feature", "trivial", "text")'),
93
+ handler_id: z.number().int().positive().optional().describe('User ID of the person to assign the issue to'),
94
+ }),
95
+ annotations: {
96
+ readOnlyHint: false,
97
+ destructiveHint: false,
98
+ idempotentHint: false,
99
+ },
100
+ }, async ({ summary, description, project_id, category, priority, severity, handler_id }) => {
101
+ try {
102
+ const body = {
103
+ summary,
104
+ description,
105
+ project: { id: project_id },
106
+ category: { name: category },
107
+ };
108
+ if (priority)
109
+ body.priority = { name: priority };
110
+ if (severity)
111
+ body.severity = { name: severity };
112
+ if (handler_id)
113
+ body.handler = { id: handler_id };
114
+ const result = await client.post('issues', body);
115
+ return {
116
+ content: [{ type: 'text', text: JSON.stringify(result.issue ?? result, null, 2) }],
117
+ };
118
+ }
119
+ catch (error) {
120
+ const msg = error instanceof Error ? error.message : String(error);
121
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
122
+ }
123
+ });
124
+ // ---------------------------------------------------------------------------
125
+ // update_issue
126
+ // ---------------------------------------------------------------------------
127
+ server.registerTool('update_issue', {
128
+ title: 'Update Issue',
129
+ description: `Update one or more fields of an existing MantisBT issue using a partial PATCH.
130
+
131
+ The "fields" object accepts any combination of:
132
+ - summary (string)
133
+ - description (string)
134
+ - status: { name: "new"|"feedback"|"acknowledged"|"confirmed"|"assigned"|"resolved"|"closed" }
135
+ - resolution: { id: 20 } (20 = fixed/resolved)
136
+ - handler: { id: <user_id> } or { name: "<username>" }
137
+ - priority: { name: "<priority_name>" }
138
+ - severity: { name: "<severity_name>" }
139
+ - category: { name: "<category_name>" }
140
+ - target_version: { name: "<version_name>" }
141
+ - fixed_in_version: { name: "<version_name>" }
142
+
143
+ Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".`,
144
+ inputSchema: z.object({
145
+ id: z.number().int().positive().describe('Numeric issue ID to update'),
146
+ fields: z.record(z.unknown()).describe('Object containing the fields to update (partial update — only provided fields are changed)'),
147
+ }),
148
+ annotations: {
149
+ readOnlyHint: false,
150
+ destructiveHint: false,
151
+ idempotentHint: false,
152
+ },
153
+ }, async ({ id, fields }) => {
154
+ try {
155
+ const result = await client.patch(`issues/${id}`, fields);
156
+ return {
157
+ content: [{ type: 'text', text: JSON.stringify(result.issue ?? result, null, 2) }],
158
+ };
159
+ }
160
+ catch (error) {
161
+ const msg = error instanceof Error ? error.message : String(error);
162
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
163
+ }
164
+ });
165
+ // ---------------------------------------------------------------------------
166
+ // delete_issue
167
+ // ---------------------------------------------------------------------------
168
+ server.registerTool('delete_issue', {
169
+ title: 'Delete Issue',
170
+ description: 'Permanently delete a MantisBT issue. This action is irreversible.',
171
+ inputSchema: z.object({
172
+ id: z.number().int().positive().describe('Numeric issue ID to delete'),
173
+ }),
174
+ annotations: {
175
+ readOnlyHint: false,
176
+ destructiveHint: true,
177
+ idempotentHint: true,
178
+ },
179
+ }, async ({ id }) => {
180
+ try {
181
+ await client.delete(`issues/${id}`);
182
+ return {
183
+ content: [{ type: 'text', text: `Issue #${id} deleted successfully.` }],
184
+ };
185
+ }
186
+ catch (error) {
187
+ const msg = error instanceof Error ? error.message : String(error);
188
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
189
+ }
190
+ });
191
+ }
@@ -0,0 +1,119 @@
1
+ import { z } from 'zod';
2
+ import { getVersionHint } from '../version-hint.js';
3
+ function errorText(msg) {
4
+ const vh = getVersionHint();
5
+ vh?.triggerLatestVersionFetch();
6
+ const hint = vh?.getUpdateHint();
7
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
+ }
9
+ async function fetchAndCacheMetadata(client, cache) {
10
+ // Fetch all projects
11
+ const projectResult = await client.get('projects');
12
+ const projects = projectResult.projects ?? [];
13
+ const byProject = {};
14
+ // For each project, fetch users, versions, categories in parallel
15
+ await Promise.all(projects.map(async (project) => {
16
+ const [usersResult, versionsResult, categoriesResult] = await Promise.allSettled([
17
+ client.get(`projects/${project.id}/users`),
18
+ client.get(`projects/${project.id}/versions`),
19
+ client.get(`projects/${project.id}/categories`),
20
+ ]);
21
+ const users = usersResult.status === 'fulfilled'
22
+ ? (usersResult.value.users ?? [])
23
+ : [];
24
+ const versions = versionsResult.status === 'fulfilled'
25
+ ? (versionsResult.value.versions ?? [])
26
+ : [];
27
+ const ALL_PROJECTS_PREFIX = '[All Projects] ';
28
+ const rawCategories = categoriesResult.status === 'fulfilled'
29
+ ? (categoriesResult.value.categories ?? categoriesResult.value)
30
+ : [];
31
+ const categories = Array.isArray(rawCategories)
32
+ ? rawCategories.map((cat) => ({
33
+ ...cat,
34
+ name: cat.name.startsWith(ALL_PROJECTS_PREFIX)
35
+ ? cat.name.slice(ALL_PROJECTS_PREFIX.length)
36
+ : cat.name,
37
+ }))
38
+ : rawCategories;
39
+ byProject[project.id] = { users, versions, categories };
40
+ }));
41
+ const data = {
42
+ timestamp: Date.now(),
43
+ projects,
44
+ byProject,
45
+ };
46
+ await cache.save(data);
47
+ return data;
48
+ }
49
+ export function registerMetadataTools(server, client, cache) {
50
+ // ---------------------------------------------------------------------------
51
+ // sync_metadata
52
+ // ---------------------------------------------------------------------------
53
+ server.registerTool('sync_metadata', {
54
+ title: 'Sync Metadata Cache',
55
+ description: `Fetch all projects and their associated users, versions, and categories from MantisBT and store them in the local metadata cache.
56
+
57
+ This is useful for getting a complete overview of your MantisBT installation.
58
+ The cache is valid for 1 hour by default (configurable via MANTIS_CACHE_TTL env var).
59
+ Use this tool to refresh stale data.`,
60
+ inputSchema: z.object({}),
61
+ annotations: {
62
+ readOnlyHint: false,
63
+ destructiveHint: false,
64
+ idempotentHint: false,
65
+ },
66
+ }, async () => {
67
+ try {
68
+ const data = await fetchAndCacheMetadata(client, cache);
69
+ const projectCount = data.projects.length;
70
+ const summary = data.projects.map((p) => {
71
+ const meta = data.byProject[p.id];
72
+ return ` - ${p.name} (ID ${p.id}): ${meta?.users.length ?? 0} users, ${meta?.versions.length ?? 0} versions, ${meta?.categories.length ?? 0} categories`;
73
+ }).join('\n');
74
+ return {
75
+ content: [{
76
+ type: 'text',
77
+ text: `Metadata synced successfully.\n\n${projectCount} project(s):\n${summary}`,
78
+ }],
79
+ };
80
+ }
81
+ catch (error) {
82
+ const msg = error instanceof Error ? error.message : String(error);
83
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
84
+ }
85
+ });
86
+ // ---------------------------------------------------------------------------
87
+ // get_metadata
88
+ // ---------------------------------------------------------------------------
89
+ server.registerTool('get_metadata', {
90
+ title: 'Get Cached Metadata',
91
+ description: `Return cached MantisBT metadata (projects, users, versions, categories).
92
+
93
+ If the cache does not exist or has expired (default TTL: 24 hours), it will automatically sync first.
94
+ Use sync_metadata to force a refresh.`,
95
+ inputSchema: z.object({}),
96
+ annotations: {
97
+ readOnlyHint: true,
98
+ destructiveHint: false,
99
+ idempotentHint: true,
100
+ },
101
+ }, async () => {
102
+ try {
103
+ let data = null;
104
+ if (await cache.isValid()) {
105
+ data = await cache.load();
106
+ }
107
+ if (!data) {
108
+ data = await fetchAndCacheMetadata(client, cache);
109
+ }
110
+ return {
111
+ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
112
+ };
113
+ }
114
+ catch (error) {
115
+ const msg = error instanceof Error ? error.message : String(error);
116
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
117
+ }
118
+ });
119
+ }
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import { getVersionHint } from '../version-hint.js';
3
+ function errorText(msg) {
4
+ const vh = getVersionHint();
5
+ vh?.triggerLatestVersionFetch();
6
+ const hint = vh?.getUpdateHint();
7
+ return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
8
+ }
9
+ export function registerMonitorTools(server, client) {
10
+ // ---------------------------------------------------------------------------
11
+ // add_monitor
12
+ // ---------------------------------------------------------------------------
13
+ server.registerTool('add_monitor', {
14
+ title: 'Add Issue Monitor',
15
+ description: 'Add a user as a monitor (watcher) of a MantisBT issue. Monitors receive email notifications for issue updates.',
16
+ inputSchema: z.object({
17
+ issue_id: z.number().int().positive().describe('Numeric issue ID'),
18
+ username: z.string().min(1).describe('Username of the user to add as monitor'),
19
+ }),
20
+ annotations: {
21
+ readOnlyHint: false,
22
+ destructiveHint: false,
23
+ idempotentHint: false,
24
+ },
25
+ }, async ({ issue_id, username }) => {
26
+ try {
27
+ const body = { name: username };
28
+ const result = await client.post(`issues/${issue_id}/monitors`, body);
29
+ return {
30
+ content: [{ type: 'text', text: JSON.stringify(result ?? { success: true }, null, 2) }],
31
+ };
32
+ }
33
+ catch (error) {
34
+ const msg = error instanceof Error ? error.message : String(error);
35
+ return { content: [{ type: 'text', text: errorText(msg) }], isError: true };
36
+ }
37
+ });
38
+ }