@dpesch/mantisbt-mcp-server 1.10.6 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,23 @@ This project adheres to [Semantic Versioning](https://semver.org/).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.11.0] – 2026-07-03
15
+
16
+ ### Added
17
+
18
+ - `create_issue` and `update_issue` now accept `custom_fields` (`[{field: {id|name}, value}]`) to write custom field values — reading was already supported via `select`.
19
+ - `update_issue` gained an optional `note` parameter (plus `note_view_state`) that appends a note after a successful update — e.g. the reason for a status change in a single call. If the note fails after a successful PATCH, the response contains `note_error` instead of failing the whole call. `dry_run` previews the note as `would_add_note`.
20
+ - `get_issue` now supports the `select` parameter for server-side field projection (same as `list_issues`), significantly reducing response size for large issues.
21
+
22
+ Feature ideas inspired by [kfnzero/mantis-mcp-server#8](https://github.com/kfnzero/mantis-mcp-server/pull/8).
23
+
24
+ ### Security
25
+
26
+ - **HTTP transport now requires `MCP_HTTP_TOKEN`.** Starting with `TRANSPORT=http` and no token set previously exposed all tools (including writes like `create_issue`, `delete_issue`, `upload_file`) unauthenticated to any process that could reach the port. The server now refuses to start in HTTP mode without a token. **Behaviour change for HTTP users:** set `MCP_HTTP_TOKEN` to a secret value. The stdio transport (the default) is unaffected.
27
+ - **`upload_file`'s `file_path` is now disabled over the HTTP transport** unless `MANTIS_UPLOAD_DIR` is configured. Over HTTP, `file_path` reads from the *server's* filesystem — a remote client could otherwise exfiltrate arbitrary server-readable files (e.g. SSH keys) as issue attachments. HTTP clients should upload via the `content` (Base64) parameter. The stdio transport keeps unrestricted `file_path` access (the caller is the trusted local agent); set `MANTIS_UPLOAD_DIR` there too if you want to sandbox it.
28
+
29
+ ---
30
+
14
31
  ## [1.10.6] – 2026-06-29
15
32
 
16
33
  ### Changed
package/README.de.md CHANGED
@@ -73,13 +73,13 @@ npm run build
73
73
  | `TRANSPORT` | – | `stdio` | Transport-Modus: `stdio` oder `http` |
74
74
  | `PORT` | – | `3000` | Port für HTTP-Modus |
75
75
  | `MCP_HTTP_HOST` | – | `127.0.0.1` | Bind-Adresse für HTTP-Modus. **Geändert von `0.0.0.0` auf `127.0.0.1`** — der Server horcht standardmäßig nur auf localhost. Für Docker oder Remote-Zugriff `0.0.0.0` setzen. |
76
- | `MCP_HTTP_TOKEN` | | – | Wenn gesetzt, muss jede `/mcp`-Anfrage den Header `Authorization: Bearer <token>` enthalten. `/health` ist immer öffentlich. |
76
+ | `MCP_HTTP_TOKEN` | (HTTP-Modus) | – | Bearer-Token für den `/mcp`-Endpunkt (`Authorization: Bearer <token>`). **Pflicht bei `TRANSPORT=http`** — der Server startet im HTTP-Modus ohne diesen Wert nicht, damit Tools nie unauthentifiziert erreichbar sind. Im stdio-Modus ohne Bedeutung. `/health` ist immer öffentlich. |
77
77
  | `MANTIS_SEARCH_ENABLED` | – | `false` | Auf `true` setzen, um die semantische Suche zu aktivieren |
78
78
  | `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vektorspeicher: `vectra` (reines JS) oder `sqlite-vec` (manuelle Installation erforderlich) |
79
79
  | `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Verzeichnis für den Suchindex |
80
80
  | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding-Modell (wird beim ersten Start einmalig heruntergeladen, ~80 MB) |
81
81
  | `MANTIS_SEARCH_THREADS` | – | `1` | Anzahl der ONNX-Intra-Op-Threads für das Embedding-Modell. Standard ist 1, um CPU-Sättigung auf Mehrkernsystemen und in WSL zu verhindern. Nur erhöhen, wenn die Indexierungsgeschwindigkeit kritisch ist und der Host ausschließlich für diese Last vorgesehen ist. |
82
- | `MANTIS_UPLOAD_DIR` | – | – | Schränkt `upload_file` auf Dateien in diesem Verzeichnis ein. Wenn gesetzt, wird jeder `file_path` außerhalb des Verzeichnisses abgelehnt (Pfad-Traversal-Versuche via `../` werden blockiert). Ohne diese Variable gilt keine Einschränkung. |
82
+ | `MANTIS_UPLOAD_DIR` | – | – | Schränkt den `file_path` von `upload_file` auf dieses Verzeichnis ein (Pfad-Traversal via `../` wird blockiert). Im **stdio-Modus** ist `file_path` ohne diese Variable unbeschränkt. Im **HTTP-Modus** liest `file_path` aus dem Dateisystem des Servers und ist deshalb **deaktiviert, sofern diese Variable nicht gesetzt ist** — HTTP-Clients sollten stattdessen den Parameter `content` (Base64) verwenden. |
83
83
 
84
84
  ## Verfügbare Tools
85
85
 
@@ -87,11 +87,11 @@ npm run build
87
87
 
88
88
  | Tool | Beschreibung |
89
89
  |---|---|
90
- | `get_issue` | Ein Issue anhand seiner ID abrufen |
90
+ | `get_issue` | Ein Issue anhand seiner ID abrufen; optionales `select` zur Feldauswahl reduziert die Antwortgröße |
91
91
  | `get_issues` | Mehrere Issues per ID in einem Aufruf abrufen (1–50 IDs); nicht zugängliche IDs liefern `null` an ihrer Position, statt den gesamten Aufruf abzubrechen |
92
92
  | `list_issues` | Issues nach Projekt, Status, Autor u.v.m. filtern; optionales `select` für Feldprojektion und `status` für clientseitige Statusfilterung — kanonische englische Statusnamen (z.B. `"new"`, `"resolved"`) werden per ID abgeglichen und funktionieren damit sprachunabhängig auf lokalisierten Installationen |
93
- | `create_issue` | Neues Issue anlegen; `severity` und `priority` müssen kanonische englische Namen sein (z.B. `minor`, `major`, `normal`, `high`) — `get_issue_enums` aufrufen, um alle gültigen Werte und deren lokalisierte Bezeichnungen zu sehen; optionaler `handler`-Parameter akzeptiert einen Benutzernamen als Alternative zu `handler_id` (wird gegen die Projektmitglieder aufgelöst) |
94
- | `update_issue` | Bestehendes Issue bearbeiten; Enum-Felder (`status`, `priority`, `severity`, `resolution`, `reproducibility`) akzeptieren kanonische englische Namen, lokalisierte Namen oder numerische IDs — der Server löst Namen automatisch zu IDs auf |
93
+ | `create_issue` | Neues Issue anlegen; `severity` und `priority` müssen kanonische englische Namen sein (z.B. `minor`, `major`, `normal`, `high`) — `get_issue_enums` aufrufen, um alle gültigen Werte und deren lokalisierte Bezeichnungen zu sehen; optionaler `handler`-Parameter akzeptiert einen Benutzernamen als Alternative zu `handler_id` (wird gegen die Projektmitglieder aufgelöst); optionales `custom_fields` zum Setzen benutzerdefinierter Felder |
94
+ | `update_issue` | Bestehendes Issue bearbeiten; Enum-Felder (`status`, `priority`, `severity`, `resolution`, `reproducibility`) akzeptieren kanonische englische Namen, lokalisierte Namen oder numerische IDs — der Server löst Namen automatisch zu IDs auf; unterstützt `custom_fields` sowie einen optionalen `note`-Parameter, der im selben Aufruf eine Notiz anhängt (z.B. die Begründung eines Status-Wechsels) |
95
95
  | `delete_issue` | Issue löschen |
96
96
 
97
97
  ### Notizen
@@ -215,16 +215,19 @@ MCP-Prompt-Templates sind Gesprächseinstiege, die den LLM anweisen, strukturier
215
215
 
216
216
  ## HTTP-Modus
217
217
 
218
- Für den Einsatz als eigenständiger Server (z.B. in Remote-Setups):
218
+ Für den Einsatz als eigenständiger Server (z.B. in Remote-Setups). `MCP_HTTP_TOKEN` ist im HTTP-Modus **Pflicht** — der Server startet ohne diesen Wert nicht:
219
219
 
220
220
  ```bash
221
- MANTIS_BASE_URL=... MANTIS_API_KEY=... TRANSPORT=http PORT=3456 node dist/index.js
221
+ MCP_HTTP_TOKEN=secret MANTIS_BASE_URL=... MANTIS_API_KEY=... \
222
+ TRANSPORT=http PORT=3456 node dist/index.js
222
223
 
223
- # Mit Token-Authentifizierung und expliziter Bind-Adresse (erforderlich für Docker/Remote):
224
+ # Mit expliziter Bind-Adresse (erforderlich für Docker/Remote):
224
225
  # MCP_HTTP_TOKEN=secret MANTIS_BASE_URL=... MANTIS_API_KEY=... \
225
226
  # TRANSPORT=http PORT=3456 MCP_HTTP_HOST=0.0.0.0 node dist/index.js
226
227
  ```
227
228
 
229
+ Jede `/mcp`-Anfrage muss `Authorization: Bearer <token>` senden. Im HTTP-Modus ist der `file_path` von `upload_file` deaktiviert, sofern `MANTIS_UPLOAD_DIR` nicht gesetzt ist — stattdessen den Parameter `content` (Base64) verwenden.
230
+
228
231
  Healthcheck: `GET http://localhost:3456/health` (immer öffentlich, kein Token erforderlich)
229
232
 
230
233
  ## Dokumentation
package/README.md CHANGED
@@ -73,13 +73,13 @@ npm run build
73
73
  | `TRANSPORT` | – | `stdio` | Transport mode: `stdio` or `http` |
74
74
  | `PORT` | – | `3000` | Port for HTTP mode |
75
75
  | `MCP_HTTP_HOST` | – | `127.0.0.1` | Bind address for HTTP mode. **Changed from `0.0.0.0` to `127.0.0.1`** — the server now listens on localhost only by default. Set to `0.0.0.0` for Docker or remote access. |
76
- | `MCP_HTTP_TOKEN` | | – | When set, the `/mcp` endpoint requires `Authorization: Bearer <token>`. The `/health` endpoint is always public. |
76
+ | `MCP_HTTP_TOKEN` | (HTTP mode) | – | Bearer token for the `/mcp` endpoint (`Authorization: Bearer <token>`). **Required when `TRANSPORT=http`** — the server refuses to start in HTTP mode without it, so tools are never exposed unauthenticated. Ignored in stdio mode. The `/health` endpoint is always public. |
77
77
  | `MANTIS_SEARCH_ENABLED` | – | `false` | Set to `true` to enable semantic search |
78
78
  | `MANTIS_SEARCH_BACKEND` | – | `vectra` | Vector store backend: `vectra` (pure JS) or `sqlite-vec` (requires manual install) |
79
79
  | `MANTIS_SEARCH_DIR` | – | `{MANTIS_CACHE_DIR}/search` | Directory for the search index |
80
80
  | `MANTIS_SEARCH_MODEL` | – | `Xenova/paraphrase-multilingual-MiniLM-L12-v2` | Embedding model name (downloaded once on first use, ~80 MB) |
81
81
  | `MANTIS_SEARCH_THREADS` | – | `1` | Number of ONNX intra-op threads for the embedding model. Default is 1 to prevent CPU saturation on multi-core machines and WSL. Increase only if index rebuild speed matters and the host is dedicated to this workload. |
82
- | `MANTIS_UPLOAD_DIR` | – | – | Restrict `upload_file` to files within this directory. When set, any `file_path` outside the directory is rejected (path traversal attempts via `../` are blocked). Without this variable there is no restriction. |
82
+ | `MANTIS_UPLOAD_DIR` | – | – | Restrict `upload_file`'s `file_path` to files within this directory (path traversal via `../` is blocked). In **stdio mode** `file_path` is unrestricted unless this is set. In **HTTP mode** `file_path` reads from the server's filesystem, so it is **disabled unless this variable is set** — HTTP clients should upload via the `content` (Base64) parameter instead. |
83
83
 
84
84
  ## Available tools
85
85
 
@@ -87,11 +87,11 @@ npm run build
87
87
 
88
88
  | Tool | Description |
89
89
  |---|---|
90
- | `get_issue` | Retrieve an issue by its numeric ID |
90
+ | `get_issue` | Retrieve an issue by its numeric ID; optional `select` for field projection to reduce response size |
91
91
  | `get_issues` | Retrieve multiple issues by ID in one call (1–50 IDs); missing or inaccessible IDs return `null` at their position instead of failing the call |
92
92
  | `list_issues` | Filter issues by project, status, author, and more; optional `select` for field projection and `status` for client-side status filtering — canonical English status names (e.g. `"new"`, `"resolved"`) are matched by ID, making the filter language-independent on localized installations |
93
- | `create_issue` | Create a new issue; `severity` and `priority` must be canonical English names (e.g. `minor`, `major`, `normal`, `high`) — call `get_issue_enums` to see all valid values and their localized labels; optional `handler` parameter accepts a username as alternative to `handler_id` (resolved against project members) |
94
- | `update_issue` | Update an existing issue; enum fields (`status`, `priority`, `severity`, `resolution`, `reproducibility`) accept canonical English names, localized names, or numeric IDs — the server resolves names to IDs automatically |
93
+ | `create_issue` | Create a new issue; `severity` and `priority` must be canonical English names (e.g. `minor`, `major`, `normal`, `high`) — call `get_issue_enums` to see all valid values and their localized labels; optional `handler` parameter accepts a username as alternative to `handler_id` (resolved against project members); optional `custom_fields` to set custom field values |
94
+ | `update_issue` | Update an existing issue; enum fields (`status`, `priority`, `severity`, `resolution`, `reproducibility`) accept canonical English names, localized names, or numeric IDs — the server resolves names to IDs automatically; supports `custom_fields` and an optional `note` parameter that appends a note in the same call (e.g. the reason for a status change) |
95
95
  | `delete_issue` | Delete an issue |
96
96
 
97
97
  ### Notes
@@ -215,16 +215,19 @@ MCP prompt templates are conversation starters that instruct the LLM to collect
215
215
 
216
216
  ## HTTP mode
217
217
 
218
- For use as a standalone server (e.g. in remote setups):
218
+ For use as a standalone server (e.g. in remote setups). `MCP_HTTP_TOKEN` is **required** in HTTP mode — the server refuses to start without it:
219
219
 
220
220
  ```bash
221
- MANTIS_BASE_URL=... MANTIS_API_KEY=... TRANSPORT=http PORT=3456 node dist/index.js
221
+ MCP_HTTP_TOKEN=secret MANTIS_BASE_URL=... MANTIS_API_KEY=... \
222
+ TRANSPORT=http PORT=3456 node dist/index.js
222
223
 
223
- # With token authentication and explicit bind address (required for Docker/remote):
224
+ # With explicit bind address (required for Docker/remote):
224
225
  # MCP_HTTP_TOKEN=secret MANTIS_BASE_URL=... MANTIS_API_KEY=... \
225
226
  # TRANSPORT=http PORT=3456 MCP_HTTP_HOST=0.0.0.0 node dist/index.js
226
227
  ```
227
228
 
229
+ Every `/mcp` request must send `Authorization: Bearer <token>`. Over HTTP, `upload_file`'s `file_path` is disabled unless `MANTIS_UPLOAD_DIR` is set — use the `content` (Base64) parameter instead.
230
+
228
231
  Health check: `GET http://localhost:3456/health` (always public, no token required)
229
232
 
230
233
  ## Documentation
package/dist/config.js CHANGED
@@ -67,6 +67,19 @@ export async function getStartupConfig() {
67
67
  await loadDotEnvLocal();
68
68
  return readNonCredentialConfig();
69
69
  }
70
+ /**
71
+ * Enforces authentication for the HTTP transport. Without a token, every tool
72
+ * (including writes like create_issue, delete_issue and upload_file) would be
73
+ * reachable unauthenticated by any process that can reach the port. Aborts
74
+ * startup when the token is missing or blank so HTTP mode is never exposed open.
75
+ */
76
+ export function assertHttpAuthConfigured(httpToken) {
77
+ if (httpToken === undefined || httpToken.trim() === '') {
78
+ throw new Error('HTTP transport requires authentication: set MCP_HTTP_TOKEN to a secret value.\n' +
79
+ 'Without it, all tools would be exposed unauthenticated. ' +
80
+ 'Use the stdio transport (the default) if you do not need HTTP.');
81
+ }
82
+ }
70
83
  // ---------------------------------------------------------------------------
71
84
  // Full config (credentials required)
72
85
  // ---------------------------------------------------------------------------
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { createServer } from 'node:http';
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { join, dirname } from 'node:path';
9
- import { getConfig, getStartupConfig } from './config.js';
9
+ import { getConfig, getStartupConfig, assertHttpAuthConfigured } from './config.js';
10
10
  import { MantisClient } from './client.js';
11
11
  import { VersionHintService, setGlobalVersionHint } from './version-hint.js';
12
12
  import { MetadataCache } from './cache.js';
@@ -34,7 +34,7 @@ const version = packageJson.version;
34
34
  // ---------------------------------------------------------------------------
35
35
  // Bootstrap
36
36
  // ---------------------------------------------------------------------------
37
- async function createMcpServer() {
37
+ async function createMcpServer(transport) {
38
38
  // Use startup config (no credentials required) so the server can connect
39
39
  // and respond to tools/list even without MANTIS_BASE_URL / MANTIS_API_KEY.
40
40
  // Credentials are resolved lazily on the first actual tool invocation.
@@ -52,7 +52,7 @@ async function createMcpServer() {
52
52
  });
53
53
  registerIssueTools(server, client, cache);
54
54
  registerNoteTools(server, client);
55
- registerFileTools(server, client, startupConfig.uploadDir);
55
+ registerFileTools(server, client, startupConfig.uploadDir, transport);
56
56
  registerRelationshipTools(server, client);
57
57
  registerMonitorTools(server, client);
58
58
  registerProjectTools(server, client, cache);
@@ -75,7 +75,7 @@ async function createMcpServer() {
75
75
  // Transport: stdio (default) or HTTP
76
76
  // ---------------------------------------------------------------------------
77
77
  async function runStdio() {
78
- const server = await createMcpServer();
78
+ const server = await createMcpServer('stdio');
79
79
  const transport = new StdioServerTransport();
80
80
  await server.connect(transport);
81
81
  console.error(`MantisBT MCP Server v${version} running (stdio)`);
@@ -86,7 +86,8 @@ async function runStdio() {
86
86
  }
87
87
  async function runHttp() {
88
88
  const startupConfig = await getStartupConfig();
89
- const server = await createMcpServer();
89
+ assertHttpAuthConfigured(startupConfig.httpToken);
90
+ const server = await createMcpServer('http');
90
91
  const port = startupConfig.httpPort;
91
92
  // Serialize stateless requests: each request waits for the previous transport
92
93
  // to be fully closed before connecting a new one. Without this, concurrent
@@ -96,13 +97,12 @@ async function runHttp() {
96
97
  let requestQueue = Promise.resolve();
97
98
  const httpServer = createServer(async (req, res) => {
98
99
  if (req.method === 'POST' && req.url === '/mcp') {
99
- if (startupConfig.httpToken) {
100
- const auth = req.headers['authorization'];
101
- if (auth !== `Bearer ${startupConfig.httpToken}`) {
102
- res.writeHead(401, { 'Content-Type': 'application/json' });
103
- res.end(JSON.stringify({ error: 'Unauthorized' }));
104
- return;
105
- }
100
+ // Token is guaranteed present by assertHttpAuthConfigured() at startup.
101
+ const auth = req.headers['authorization'];
102
+ if (auth !== `Bearer ${startupConfig.httpToken}`) {
103
+ res.writeHead(401, { 'Content-Type': 'application/json' });
104
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
105
+ return;
106
106
  }
107
107
  const chunks = [];
108
108
  req.on('data', (chunk) => chunks.push(chunk));
@@ -8,8 +8,14 @@ function errorText(msg) {
8
8
  const hint = vh?.getUpdateHint();
9
9
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
10
10
  }
11
- export function registerFileTools(server, client, uploadDir) {
11
+ export function registerFileTools(server, client, uploadDir, transport = 'stdio') {
12
12
  const normalizedUploadDir = uploadDir ? resolve(uploadDir) + sep : undefined;
13
+ // file_path reads from the server's own filesystem. Over stdio the caller is
14
+ // the trusted local agent (same machine, same user), so any path is fine. Over
15
+ // HTTP a remote client neither controls nor should reach server-local paths —
16
+ // it must send Base64 via `content`. Only allow file_path in HTTP mode when an
17
+ // explicit MANTIS_UPLOAD_DIR sandboxes it.
18
+ const filePathAllowed = transport === 'stdio' || normalizedUploadDir !== undefined;
13
19
  // ---------------------------------------------------------------------------
14
20
  // list_issue_files
15
21
  // ---------------------------------------------------------------------------
@@ -47,7 +53,7 @@ Use this tool when you need to inspect or enumerate files attached to an issue.
47
53
  description: `Upload a file as an attachment to a MantisBT issue. Adds the file to the issue without modifying any issue fields or status. Returns the created attachment metadata on success.
48
54
 
49
55
  Provide exactly one of the two input modes:
50
- - file_path (preferred): absolute path to a local file — use this whenever the file exists on disk; the server reads and encodes it automatically; filename is derived from the path
56
+ - file_path (preferred): absolute path to a local file — use this whenever the file exists on disk; the server reads and encodes it automatically; filename is derived from the path. Note: file_path reads from the server's filesystem and is disabled over the HTTP transport unless MANTIS_UPLOAD_DIR is configured — HTTP clients should use content instead.
51
57
  - content: Base64-encoded file content — only use this when the file is not accessible via a path (e.g. in-memory data); filename must be supplied explicitly via the filename parameter
52
58
 
53
59
  The optional content_type sets the MIME type (e.g. "image/png"); defaults to "application/octet-stream". Use the optional description to annotate the attachment.
@@ -77,6 +83,9 @@ Use this tool to attach files such as logs, screenshots, or patches to an existi
77
83
  let base64Content;
78
84
  let fileName;
79
85
  if (file_path) {
86
+ if (!filePathAllowed) {
87
+ return { content: [{ type: 'text', text: errorText('file_path is disabled over the HTTP transport. Send the file via the "content" parameter (Base64), or set MANTIS_UPLOAD_DIR to permit server-local paths within that directory.') }], isError: true };
88
+ }
80
89
  if (normalizedUploadDir) {
81
90
  const normalizedPath = resolve(file_path);
82
91
  if (!normalizedPath.startsWith(normalizedUploadDir)) {
@@ -4,22 +4,35 @@ import { getVersionHint } from '../version-hint.js';
4
4
  import { MANTIS_CANONICAL_ENUM_NAMES, MANTIS_RESOLVED_STATUS_ID, resolveEnumId } from '../constants.js';
5
5
  import { dateFilterSchema, matchesDateFilter, hasDateFilter } from '../date-filter.js';
6
6
  import { fetchIssueEnumsWithCache } from './config.js';
7
+ import { postNote } from './notes.js';
7
8
  function errorText(msg) {
8
9
  const vh = getVersionHint();
9
10
  vh?.triggerLatestVersionFetch();
10
11
  const hint = vh?.getUpdateHint();
11
12
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
12
13
  }
13
- function enrichIssue(issue, baseUrl) {
14
+ // issueId: fallback when a select projection excluded the id field from the response
15
+ function enrichIssue(issue, baseUrl, issueId) {
16
+ const id = issue.id ?? issueId;
17
+ if (id === undefined)
18
+ return issue;
14
19
  return {
15
20
  ...issue,
16
- view_url: buildIssueViewUrl(baseUrl, issue.id),
21
+ view_url: buildIssueViewUrl(baseUrl, id),
17
22
  notes: issue.notes?.map(note => ({
18
23
  ...note,
19
- view_url: buildNoteViewUrl(baseUrl, issue.id, note.id),
24
+ view_url: buildNoteViewUrl(baseUrl, id, note.id),
20
25
  })),
21
26
  };
22
27
  }
28
+ // MantisBT reference shape: at least one of id or name must be provided
29
+ const ref = z.object({ id: z.number().optional(), name: z.string().optional() })
30
+ .refine(o => o.id !== undefined || o.name !== undefined, { message: "At least one of 'id' or 'name' must be provided" });
31
+ // Custom field entry in MantisBT REST format: { field: {id|name}, value: "<string>" }
32
+ const customFieldEntry = z.object({
33
+ field: ref.describe('Custom field reference: { id } or { name }'),
34
+ value: z.coerce.string().describe('Field value as string'),
35
+ });
23
36
  const GET_ISSUES_CONCURRENCY = 5;
24
37
  // Worker-pool: runs `fn` over all `items` with at most `concurrency` in-flight at once.
25
38
  // nextIndex is only incremented inside microtasks, so the ++ is safe without a lock.
@@ -61,22 +74,23 @@ export function registerIssueTools(server, client, cache) {
61
74
  // ---------------------------------------------------------------------------
62
75
  server.registerTool('get_issue', {
63
76
  title: 'Get Issue',
64
- description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships. Notes are always included — no separate list_notes call needed.',
77
+ description: 'Retrieve a single MantisBT issue by its numeric ID. Returns all issue fields including notes, attachments, and relationships (unless "select" is given). Notes are always included — no separate list_notes call needed.',
65
78
  inputSchema: z.object({
66
79
  id: z.coerce.number().int().positive().describe('Numeric issue ID'),
80
+ select: z.string().optional().describe('Comma-separated list of fields to include in the response (server-side projection, same as list_issues). Significantly reduces response size. Example: "id,summary,status,notes"'),
67
81
  }),
68
82
  annotations: {
69
83
  readOnlyHint: true,
70
84
  destructiveHint: false,
71
85
  idempotentHint: true,
72
86
  },
73
- }, async ({ id }) => {
87
+ }, async ({ id, select }) => {
74
88
  try {
75
- const result = await client.get(`issues/${id}`);
89
+ const result = await client.get(`issues/${id}`, { select });
76
90
  const issue = result.issues?.[0] ?? result;
77
91
  const baseUrl = await client.getBaseUrl();
78
92
  return {
79
- content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl), null, 2) }],
93
+ content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl, id), null, 2) }],
80
94
  };
81
95
  }
82
96
  catch (error) {
@@ -278,13 +292,14 @@ For the handler, prefer the username field (resolved server-side) over handler_i
278
292
  additional_information: z.string().optional().describe('Additional context or notes about the issue. Plain text or Markdown.'),
279
293
  reproducibility: z.string().optional().describe('How reliably the issue reproduces. Canonical English names: always, sometimes, random, have not tried, unable to reproduce, N/A. Use get_issue_enums to see localized labels.'),
280
294
  view_state: z.enum(['public', 'private']).optional().describe('Visibility of the issue: "public" (visible to all, default) or "private" (restricted to higher-access users).'),
295
+ custom_fields: z.array(customFieldEntry).optional().describe('Custom field values: [{field: {id|name}, value: "<string>"}]. Use get_issue_fields or get_metadata to discover available custom fields per project.'),
281
296
  }),
282
297
  annotations: {
283
298
  readOnlyHint: false,
284
299
  destructiveHint: false,
285
300
  idempotentHint: false,
286
301
  },
287
- }, async ({ summary, description, project_id, category, priority, severity, handler_id, handler, version, target_version, fixed_in_version, steps_to_reproduce, additional_information, reproducibility, view_state }) => {
302
+ }, async ({ summary, description, project_id, category, priority, severity, handler_id, handler, version, target_version, fixed_in_version, steps_to_reproduce, additional_information, reproducibility, view_state, custom_fields }) => {
288
303
  // Resolve handler username to handler_id when only a name is given
289
304
  let resolvedHandlerId = handler_id;
290
305
  if (resolvedHandlerId === undefined && handler !== undefined) {
@@ -344,6 +359,8 @@ For the handler, prefer the username field (resolved server-side) over handler_i
344
359
  }
345
360
  if (view_state !== undefined)
346
361
  body.view_state = { name: view_state };
362
+ if (custom_fields !== undefined)
363
+ body.custom_fields = custom_fields;
347
364
  const raw = await client.post('issues', body);
348
365
  const partial = ('issue' in raw && typeof raw['issue'] === 'object' && raw['issue'] !== null)
349
366
  ? raw['issue']
@@ -374,9 +391,6 @@ For the handler, prefer the username field (resolved server-side) over handler_i
374
391
  // update_issue
375
392
  // ---------------------------------------------------------------------------
376
393
  const coerceBool = (val) => val === 'true' ? true : val === 'false' ? false : val;
377
- // MantisBT reference shape: at least one of id or name must be provided
378
- const ref = z.object({ id: z.number().optional(), name: z.string().optional() })
379
- .refine(o => o.id !== undefined || o.name !== undefined, { message: "At least one of 'id' or 'name' must be provided" });
380
394
  server.registerTool('update_issue', {
381
395
  title: 'Update Issue',
382
396
  description: `Update one or more fields of an existing MantisBT issue using a partial PATCH.
@@ -397,11 +411,16 @@ The "fields" object accepts any combination of:
397
411
  - target_version: { name: "<version_name>" }
398
412
  - fixed_in_version: { name: "<version_name>" }
399
413
  - view_state: { name: "public"|"private" }
414
+ - custom_fields: [{field: {id|name}, value: "<string>"}] (only the listed custom fields are changed, others stay untouched; use get_issue_fields to discover fields)
400
415
 
401
- Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".`,
416
+ Important: when resolving an issue, always set BOTH status and resolution to avoid leaving resolution as "open".
417
+
418
+ Use the optional "note" parameter to append a note in the same call (e.g. the reason for a status change) — no separate add_note call needed. For a note without field changes use add_note.`,
402
419
  inputSchema: z.object({
403
420
  id: z.coerce.number().int().positive().describe('Numeric issue ID to update'),
404
421
  dry_run: z.preprocess(coerceBool, z.boolean().optional()).describe('If true, return the patch payload that would be sent without actually updating the issue. Useful for previewing changes before committing them.'),
422
+ note: z.string().min(1).optional().describe('Optional note text appended after a successful update (e.g. reason for a status change). Replaces a separate add_note call.'),
423
+ note_view_state: z.enum(['public', 'private']).default('public').describe('Visibility of the appended note: "public" (default) or "private". Only used when "note" is set.'),
405
424
  fields: z.preprocess((v) => {
406
425
  if (typeof v !== 'string')
407
426
  return v;
@@ -427,6 +446,7 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
427
446
  target_version: ref.optional(),
428
447
  fixed_in_version: ref.optional(),
429
448
  view_state: ref.optional(),
449
+ custom_fields: z.array(customFieldEntry).optional(),
430
450
  }).strict().describe('Fields to update (partial update — only provided fields are changed; unknown keys are rejected)')),
431
451
  }),
432
452
  annotations: {
@@ -434,10 +454,10 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
434
454
  destructiveHint: false,
435
455
  idempotentHint: false,
436
456
  },
437
- }, async ({ id, fields, dry_run }) => {
457
+ }, async ({ id, fields, dry_run, note, note_view_state }) => {
438
458
  if (dry_run) {
439
459
  return {
440
- content: [{ type: 'text', text: JSON.stringify({ dry_run: true, id, would_patch: fields }, null, 2) }],
460
+ content: [{ type: 'text', text: JSON.stringify({ dry_run: true, id, would_patch: fields, would_add_note: note ?? null }, null, 2) }],
441
461
  };
442
462
  }
443
463
  try {
@@ -455,8 +475,20 @@ Important: when resolving an issue, always set BOTH status and resolution to avo
455
475
  client.getBaseUrl(),
456
476
  ]);
457
477
  const issue = result.issue ?? result;
478
+ const response = { ...enrichIssue(issue, baseUrl, id) };
479
+ if (note !== undefined) {
480
+ // The PATCH already succeeded — a note failure must not look like a failed
481
+ // update, or the caller would retry the whole update. Report it inline instead.
482
+ try {
483
+ response['note'] = await postNote(client, id, note, note_view_state);
484
+ }
485
+ catch (noteError) {
486
+ const noteMsg = noteError instanceof Error ? noteError.message : String(noteError);
487
+ response['note_error'] = `Issue #${id} was updated successfully, but adding the note failed: ${noteMsg}. Retry with add_note.`;
488
+ }
489
+ }
458
490
  return {
459
- content: [{ type: 'text', text: JSON.stringify(enrichIssue(issue, baseUrl), null, 2) }],
491
+ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
460
492
  };
461
493
  }
462
494
  catch (error) {
@@ -7,6 +7,19 @@ function errorText(msg) {
7
7
  const hint = vh?.getUpdateHint();
8
8
  return hint ? `Error: ${msg}\n\n${hint}` : `Error: ${msg}`;
9
9
  }
10
+ // Shared with update_issue (note parameter) — posts a note and returns it enriched with view_url.
11
+ export async function postNote(client, issueId, text, viewState) {
12
+ const body = {
13
+ text,
14
+ view_state: { name: viewState },
15
+ };
16
+ const [result, baseUrl] = await Promise.all([
17
+ client.post(`issues/${issueId}/notes`, body),
18
+ client.getBaseUrl(),
19
+ ]);
20
+ const note = result.note ?? result;
21
+ return { ...note, view_url: buildNoteViewUrl(baseUrl, issueId, note.id) };
22
+ }
10
23
  export function registerNoteTools(server, client) {
11
24
  // ---------------------------------------------------------------------------
12
25
  // list_notes
@@ -65,17 +78,9 @@ Prerequisites: obtain issue_id from list_issues, get_issue, or search_issues.`,
65
78
  },
66
79
  }, async ({ issue_id, text, view_state }) => {
67
80
  try {
68
- const body = {
69
- text,
70
- view_state: { name: view_state },
71
- };
72
- const [result, baseUrl] = await Promise.all([
73
- client.post(`issues/${issue_id}/notes`, body),
74
- client.getBaseUrl(),
75
- ]);
76
- const note = result.note ?? result;
81
+ const note = await postNote(client, issue_id, text, view_state);
77
82
  return {
78
- content: [{ type: 'text', text: JSON.stringify({ ...note, view_url: buildNoteViewUrl(baseUrl, issue_id, note.id) }, null, 2) }],
83
+ content: [{ type: 'text', text: JSON.stringify(note, null, 2) }],
79
84
  };
80
85
  }
81
86
  catch (error) {
@@ -18,6 +18,8 @@ Tool-orientierte Rezepte für den MantisBT MCP Server — jedes Rezept zeigt gen
18
18
  - [Gespeicherten Filter anwenden](#gespeicherten-filter-anwenden)
19
19
  - [Issue erstellen](#issue-erstellen)
20
20
  - [Issue schließen (Status + Auflösung)](#issue-schließen-status--auflösung)
21
+ - [Issue auflösen mit Begründungs-Notiz (ein Aufruf)](#issue-auflösen-mit-begründungs-notiz-ein-aufruf)
22
+ - [Benutzerdefinierte Felder setzen](#benutzerdefinierte-felder-setzen)
21
23
  - [Issue neu zuweisen](#issue-neu-zuweisen)
22
24
  - [Fix-Version setzen](#fix-version-setzen)
23
25
  - [Notizen](#notizen)
@@ -194,6 +196,7 @@ Ruft ein einzelnes Issue anhand seiner numerischen ID ab, inklusive Notizen, Anh
194
196
 
195
197
  **Parameter:**
196
198
  - `id` — numerische Issue-ID
199
+ - `select` — _(optional)_ kommagetrennte Feldliste (server-seitige Projektion); reduziert die Antwortgröße bei großen Issues deutlich, z.B. `"id,summary,status,notes"`
197
200
 
198
201
  **Request:**
199
202
 
@@ -203,6 +206,15 @@ Ruft ein einzelnes Issue anhand seiner numerischen ID ab, inklusive Notizen, Anh
203
206
  }
204
207
  ```
205
208
 
209
+ Kompakte Variante (nur die benötigten Felder):
210
+
211
+ ```json
212
+ {
213
+ "id": 1042,
214
+ "select": "id,summary,status,handler"
215
+ }
216
+ ```
217
+
206
218
  **Response:**
207
219
 
208
220
  ```json
@@ -606,6 +618,61 @@ Löst ein Issue auf und schließt es. **Immer beide Felder** `status` und `resol
606
618
 
607
619
  ---
608
620
 
621
+ ### Issue auflösen mit Begründungs-Notiz (ein Aufruf)
622
+
623
+ Ändert den Status und hängt eine Notiz mit der Begründung an — in einem einzigen `update_issue`-Aufruf, kein separates `add_note` nötig.
624
+
625
+ **Tool:** `update_issue`
626
+
627
+ **Parameter:**
628
+ - `id` — numerische Issue-ID
629
+ - `fields` — zu ändernde Felder
630
+ - `note` — Notiztext, der nach erfolgreichem Update angehängt wird
631
+ - `note_view_state` — _(optional)_ `"public"` (Standard) oder `"private"`
632
+
633
+ **Request:**
634
+
635
+ ```json
636
+ {
637
+ "id": 1042,
638
+ "fields": {
639
+ "status": { "name": "resolved" },
640
+ "resolution": { "id": 20 }
641
+ },
642
+ "note": "Behoben in Commit abc123 — der Touch-Event-Handler fehlte unter iOS."
643
+ }
644
+ ```
645
+
646
+ **Response:** das aktualisierte Issue plus ein `note`-Objekt mit `view_url`.
647
+
648
+ > **Hinweis:** Schlägt die Notiz nach erfolgreichem Update fehl, enthält die Antwort `note_error` statt eines Fehlers für den Gesamtaufruf — dann mit `add_note` nachholen. Für eine Notiz ohne Feldänderungen direkt `add_note` verwenden.
649
+
650
+ ---
651
+
652
+ ### Benutzerdefinierte Felder setzen
653
+
654
+ Schreibt Custom-Field-Werte beim Anlegen oder Aktualisieren. Dasselbe `custom_fields`-Format funktioniert bei `create_issue` und `update_issue` (dort innerhalb von `fields`).
655
+
656
+ **Tool:** `update_issue`
657
+
658
+ **Request:**
659
+
660
+ ```json
661
+ {
662
+ "id": 1042,
663
+ "fields": {
664
+ "custom_fields": [
665
+ { "field": { "name": "Kunde" }, "value": "ACME Corp" },
666
+ { "field": { "id": 3 }, "value": "2026-07-01" }
667
+ ]
668
+ }
669
+ }
670
+ ```
671
+
672
+ > **Hinweis:** Nur die aufgeführten Custom Fields werden geändert — alle anderen bleiben unangetastet. Verfügbare Felder pro Projekt mit `get_issue_fields()` oder `get_metadata()` ermitteln. Werte sind immer Strings.
673
+
674
+ ---
675
+
609
676
  ### Issue neu zuweisen
610
677
 
611
678
  Ändert den Bearbeiter (Handler) eines bestehenden Issues.
package/docs/cookbook.md CHANGED
@@ -18,6 +18,8 @@ Tool-oriented recipes for the MantisBT MCP server — each recipe shows exactly
18
18
  - [Apply a saved filter](#apply-a-saved-filter)
19
19
  - [Create an issue](#create-an-issue)
20
20
  - [Close an issue (status + resolution)](#close-an-issue-status--resolution)
21
+ - [Resolve an issue with a reason note (single call)](#resolve-an-issue-with-a-reason-note-single-call)
22
+ - [Set custom fields](#set-custom-fields)
21
23
  - [Reassign an issue](#reassign-an-issue)
22
24
  - [Set fix version](#set-fix-version)
23
25
  - [Notes](#notes)
@@ -194,6 +196,7 @@ Retrieves a single issue by its numeric ID including notes, attachments, tags, a
194
196
 
195
197
  **Parameters:**
196
198
  - `id` — numeric issue ID
199
+ - `select` — _(optional)_ comma-separated field list (server-side projection); significantly reduces response size for large issues, e.g. `"id,summary,status,notes"`
197
200
 
198
201
  **Request:**
199
202
 
@@ -203,6 +206,15 @@ Retrieves a single issue by its numeric ID including notes, attachments, tags, a
203
206
  }
204
207
  ```
205
208
 
209
+ Compact variant (only the fields you need):
210
+
211
+ ```json
212
+ {
213
+ "id": 1042,
214
+ "select": "id,summary,status,handler"
215
+ }
216
+ ```
217
+
206
218
  **Response:**
207
219
 
208
220
  ```json
@@ -606,6 +618,61 @@ Resolves and closes an issue. Always set **both** `status` and `resolution` —
606
618
 
607
619
  ---
608
620
 
621
+ ### Resolve an issue with a reason note (single call)
622
+
623
+ Changes the status and appends a note explaining the change — in one `update_issue` call, no separate `add_note` needed.
624
+
625
+ **Tool:** `update_issue`
626
+
627
+ **Parameters:**
628
+ - `id` — numeric issue ID
629
+ - `fields` — fields to change
630
+ - `note` — note text appended after a successful update
631
+ - `note_view_state` — _(optional)_ `"public"` (default) or `"private"`
632
+
633
+ **Request:**
634
+
635
+ ```json
636
+ {
637
+ "id": 1042,
638
+ "fields": {
639
+ "status": { "name": "resolved" },
640
+ "resolution": { "id": 20 }
641
+ },
642
+ "note": "Fixed in commit abc123 — the touch event handler was missing on iOS."
643
+ }
644
+ ```
645
+
646
+ **Response:** the updated issue plus a `note` object with its `view_url`.
647
+
648
+ > **Note:** If the update succeeds but the note fails, the response contains `note_error` instead of failing the whole call — retry with `add_note`. For a note without field changes use `add_note` directly.
649
+
650
+ ---
651
+
652
+ ### Set custom fields
653
+
654
+ Writes custom field values on create or update. The same `custom_fields` format works for both `create_issue` and `update_issue` (inside `fields`).
655
+
656
+ **Tool:** `update_issue`
657
+
658
+ **Request:**
659
+
660
+ ```json
661
+ {
662
+ "id": 1042,
663
+ "fields": {
664
+ "custom_fields": [
665
+ { "field": { "name": "Customer" }, "value": "ACME Corp" },
666
+ { "field": { "id": 3 }, "value": "2026-07-01" }
667
+ ]
668
+ }
669
+ }
670
+ ```
671
+
672
+ > **Note:** Only the listed custom fields are changed — others stay untouched. Use `get_issue_fields()` or `get_metadata()` to discover available custom fields per project. Values are always strings.
673
+
674
+ ---
675
+
609
676
  ### Reassign an issue
610
677
 
611
678
  Changes the handler (assignee) of an existing issue.
@@ -16,6 +16,8 @@ Praktische Beispiele für die Interaktion mit MantisBT über Claude, sobald der
16
16
 
17
17
  > »Was ist der Status von Issue #1042?«
18
18
 
19
+ > »Zeige mir von Issue #1042 nur Titel, Status und Bearbeiter — ohne die vollständige Beschreibung und Notizen.«
20
+
19
21
  > »Zeige mir alle Issues, die jsmith diesen Monat gemeldet hat.«
20
22
 
21
23
  > »Welche Issues im Webshop-Projekt blockieren das Release 2.4.0?«
@@ -30,6 +32,8 @@ Praktische Beispiele für die Interaktion mit MantisBT über Claude, sobald der
30
32
 
31
33
  > »Erstelle einen Feature-Request im Frontend-Projekt für einen Dunkelmodus in den Benutzereinstellungen. Niedrige Priorität, kein Fälligkeitsdatum.«
32
34
 
35
+ > »Erstelle einen Bug im Backend-Projekt, Kategorie API, Titel 'Token-Refresh gibt 500 zurück bei abgelaufenem Token', und setze das Custom Field 'Kunde' auf 'ACME Corp'.«
36
+
33
37
  ---
34
38
 
35
39
  ### Issues aktualisieren
@@ -40,6 +44,10 @@ Praktische Beispiele für die Interaktion mit MantisBT über Claude, sobald der
40
44
 
41
45
  > »Ändere den Schweregrad von #1099 auf 'schwerwiegend' und füge eine Notiz hinzu: auf Produktion reproduziert.«
42
46
 
47
+ > »Löse Issue #1042 als behoben auf und füge im selben Schritt eine Notiz hinzu: in Release 2.4.1 auf Produktion ausgerollt.«
48
+
49
+ > »Setze das Custom Field 'Ticket-Referenz' an Issue #887 auf 'EXT-4421'.«
50
+
43
51
  > »Setze die Fix-Version von Issues #901 und #902 auf 2.4.1.«
44
52
 
45
53
  ---
package/docs/examples.md CHANGED
@@ -16,6 +16,8 @@ Practical examples of how to interact with MantisBT through Claude once the MCP
16
16
 
17
17
  > "What's the status of issue #1042?"
18
18
 
19
+ > "Show me only the summary, status, and handler of issue #1042 — skip the full description and notes."
20
+
19
21
  > "Show me all issues reported by jsmith this month."
20
22
 
21
23
  > "Which issues in the Webshop project are blocking the 2.4.0 release?"
@@ -30,6 +32,8 @@ Practical examples of how to interact with MantisBT through Claude once the MCP
30
32
 
31
33
  > "Create a feature request in the Frontend project for a dark mode in the user settings. Low priority, no due date."
32
34
 
35
+ > "Create a bug in the Backend project, category API, summary 'Token refresh returns 500 on expired token', and set the custom field 'Customer' to 'ACME Corp'."
36
+
33
37
  ---
34
38
 
35
39
  ### Updating issues
@@ -40,6 +44,10 @@ Practical examples of how to interact with MantisBT through Claude once the MCP
40
44
 
41
45
  > "Change the severity of #1099 to 'major' and add a note: reproduced on production."
42
46
 
47
+ > "Resolve issue #1042 as fixed and add a note in the same step: deployed to production in release 2.4.1."
48
+
49
+ > "Set the custom field 'Ticket Reference' on issue #887 to 'EXT-4421'."
50
+
43
51
  > "Set the fix version of issues #901 and #902 to 2.4.1."
44
52
 
45
53
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dpesch/mantisbt-mcp-server",
3
- "version": "1.10.6",
3
+ "version": "1.11.0",
4
4
  "mcpName": "io.github.dpesch/mantisbt-mcp-server",
5
5
  "description": "MCP server for MantisBT REST API – read and manage bug tracker issues",
6
6
  "author": "Dominik Pesch",
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.10.6",
6
+ "version": "1.11.0",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "@dpesch/mantisbt-mcp-server",
11
- "version": "1.10.6",
11
+ "version": "1.11.0",
12
12
  "runtimeHint": "npx",
13
13
  "transport": {
14
14
  "type": "stdio"