@dpesch/mantisbt-mcp-server 1.8.0 → 1.8.2
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 +14 -0
- package/README.de.md +1 -1
- package/README.md +1 -1
- package/dist/client.js +14 -2
- package/dist/config.js +2 -1
- package/dist/index.js +10 -3
- package/package.json +1 -1
- package/scripts/hooks/pre-push.mjs +30 -13
- package/server.json +2 -2
- package/tests/client.test.ts +22 -0
- package/tests/config.test.ts +23 -3
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,20 @@ This project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## [1.8.2] – 2026-03-27
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- HTTP transport (`TRANSPORT=http`): race condition when multiple sequential requests arrive quickly (e.g. `initialize` followed immediately by `ping`). The `res.on('close')` handler that calls `transport.close()` fires asynchronously, so the next request could arrive while the previous transport was still registered — causing `connect()` to throw "Already connected". Fixed by calling `await server.close()` before each `server.connect()` call (a no-op when not connected). Also added a `res.headersSent` guard in the error handler to prevent a secondary "Cannot set headers after they are sent" error from closing the socket without a response.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## [1.8.1] – 2026-03-27
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- `MANTIS_BASE_URL` values ending in `/api/rest` (as shown in README examples) caused a doubled path (`/api/rest/api/rest/...`) and broke all API calls. A new `normalizeBaseUrl()` helper strips the `/api/rest` suffix (and any trailing slash) on startup. Both formats are now accepted: `https://your-mantis.example.com` and `https://your-mantis.example.com/api/rest`.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
14
28
|
## [1.8.0] – 2026-03-27
|
|
15
29
|
|
|
16
30
|
### Added
|
package/README.de.md
CHANGED
|
@@ -65,7 +65,7 @@ npm run build
|
|
|
65
65
|
|
|
66
66
|
| Variable | Pflicht | Standard | Beschreibung |
|
|
67
67
|
|---|---|---|---|
|
|
68
|
-
| `MANTIS_BASE_URL` | ✅ | – | Basis-URL der MantisBT
|
|
68
|
+
| `MANTIS_BASE_URL` | ✅ | – | Basis-URL der MantisBT-Installation. Beide Formate werden akzeptiert: `https://deine-mantis-instanz.example.com` und `https://deine-mantis-instanz.example.com/api/rest` — das `/api/rest`-Suffix wird automatisch normalisiert. |
|
|
69
69
|
| `MANTIS_API_KEY` | ✅ | – | API-Token für die Authentifizierung |
|
|
70
70
|
| `MANTIS_CACHE_DIR` | – | `~/.cache/mantisbt-mcp` | Verzeichnis für den Metadaten-Cache |
|
|
71
71
|
| `MANTIS_CACHE_TTL` | – | `3600` | Cache-Lebensdauer in Sekunden |
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ npm run build
|
|
|
65
65
|
|
|
66
66
|
| Variable | Required | Default | Description |
|
|
67
67
|
|---|---|---|---|
|
|
68
|
-
| `MANTIS_BASE_URL` | ✅ | – | Base URL of
|
|
68
|
+
| `MANTIS_BASE_URL` | ✅ | – | Base URL of your MantisBT installation. Both `https://your-mantis.example.com` and `https://your-mantis.example.com/api/rest` are accepted — the `/api/rest` suffix is normalized automatically. |
|
|
69
69
|
| `MANTIS_API_KEY` | ✅ | – | API token for authentication |
|
|
70
70
|
| `MANTIS_CACHE_DIR` | – | `~/.cache/mantisbt-mcp` | Directory for the metadata cache |
|
|
71
71
|
| `MANTIS_CACHE_TTL` | – | `3600` | Cache lifetime in seconds |
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
+
// Helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/**
|
|
5
|
+
* Normalise a MANTIS_BASE_URL so that it never ends with "/api/rest" or a
|
|
6
|
+
* trailing slash. The client always appends "/api/rest/<path>" itself, so
|
|
7
|
+
* users who follow README examples that include "/api/rest" in the URL must
|
|
8
|
+
* not end up with a doubled prefix.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeBaseUrl(url) {
|
|
11
|
+
return url.replace(/\/api\/rest\/?$/, '').replace(/\/$/, '');
|
|
12
|
+
}
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
2
14
|
// MantisApiError
|
|
3
15
|
// ---------------------------------------------------------------------------
|
|
4
16
|
export class MantisApiError extends Error {
|
|
@@ -15,7 +27,7 @@ export class MantisClient {
|
|
|
15
27
|
resolvedCredentials;
|
|
16
28
|
constructor(baseUrlOrFactory, apiKeyOrObserver, responseObserver) {
|
|
17
29
|
if (typeof baseUrlOrFactory === 'string') {
|
|
18
|
-
this.resolvedCredentials = { baseUrl: baseUrlOrFactory
|
|
30
|
+
this.resolvedCredentials = { baseUrl: normalizeBaseUrl(baseUrlOrFactory), apiKey: apiKeyOrObserver };
|
|
19
31
|
this.responseObserver = responseObserver;
|
|
20
32
|
}
|
|
21
33
|
else {
|
|
@@ -29,7 +41,7 @@ export class MantisClient {
|
|
|
29
41
|
async getCredentials() {
|
|
30
42
|
if (!this.resolvedCredentials) {
|
|
31
43
|
const { baseUrl, apiKey } = await this.credentialFactory();
|
|
32
|
-
this.resolvedCredentials = { baseUrl: baseUrl
|
|
44
|
+
this.resolvedCredentials = { baseUrl: normalizeBaseUrl(baseUrl), apiKey };
|
|
33
45
|
}
|
|
34
46
|
return this.resolvedCredentials;
|
|
35
47
|
}
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { normalizeBaseUrl } from './client.js';
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// .env.local loader
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -85,7 +86,7 @@ export async function getConfig() {
|
|
|
85
86
|
`Set the environment variables MANTIS_BASE_URL and MANTIS_API_KEY.`);
|
|
86
87
|
}
|
|
87
88
|
cachedConfig = {
|
|
88
|
-
baseUrl: baseUrl
|
|
89
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
89
90
|
apiKey,
|
|
90
91
|
...readNonCredentialConfig(),
|
|
91
92
|
};
|
package/dist/index.js
CHANGED
|
@@ -107,13 +107,20 @@ async function runHttp() {
|
|
|
107
107
|
sessionIdGenerator: undefined,
|
|
108
108
|
enableJsonResponse: true,
|
|
109
109
|
});
|
|
110
|
-
res.on('close', () => transport.close());
|
|
110
|
+
res.on('close', () => { void transport.close(); });
|
|
111
|
+
// Disconnect from any previous transport before connecting the new one.
|
|
112
|
+
// The res.on('close') handler above closes the transport asynchronously,
|
|
113
|
+
// but the next request may arrive before it fires — causing connect() to
|
|
114
|
+
// throw "Already connected". Explicitly closing first avoids that race.
|
|
115
|
+
await server.close();
|
|
111
116
|
await server.connect(transport);
|
|
112
117
|
await transport.handleRequest(req, res, body);
|
|
113
118
|
}
|
|
114
119
|
catch {
|
|
115
|
-
res.
|
|
116
|
-
|
|
120
|
+
if (!res.headersSent) {
|
|
121
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
122
|
+
res.end(JSON.stringify({ error: 'Bad Request' }));
|
|
123
|
+
}
|
|
117
124
|
}
|
|
118
125
|
});
|
|
119
126
|
}
|
package/package.json
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// 3. Filters every commit between anchor and tip in order (oldest first)
|
|
11
11
|
// 4. Builds a proper filtered chain anchored to the actual remote tip
|
|
12
12
|
// 5. Pushes the filtered tip with --force
|
|
13
|
-
// 6. Exits
|
|
13
|
+
// 6. Exits 2 to block git's unfiltered push
|
|
14
|
+
// Exit codes: 0 = no Codeberg refs, 1 = error, 2 = success (filtered push done)
|
|
14
15
|
//
|
|
15
16
|
// Branches are processed before tags so the shaMap is available for tags
|
|
16
17
|
// that point to commits already processed as part of the branch.
|
|
@@ -110,6 +111,16 @@ rl.on('close', () => {
|
|
|
110
111
|
const branches = lines.filter(l => l.split(' ')[2]?.startsWith('refs/heads/'));
|
|
111
112
|
const others = lines.filter(l => !l.split(' ')[2]?.startsWith('refs/heads/'));
|
|
112
113
|
|
|
114
|
+
// Batch-fetch all tags from Codeberg (single ls-remote call, ref → sha).
|
|
115
|
+
const remoteTagsRaw = gitOptional(`ls-remote --tags "${remoteUrl}"`);
|
|
116
|
+
const remoteTagMap = new Map(
|
|
117
|
+
remoteTagsRaw
|
|
118
|
+
? remoteTagsRaw.split('\n').filter(Boolean)
|
|
119
|
+
.map(l => { const [sha, ref] = l.split(/\s+/); return [ref, sha]; })
|
|
120
|
+
.filter(([ref]) => ref)
|
|
121
|
+
: []
|
|
122
|
+
);
|
|
123
|
+
|
|
113
124
|
// localSha → filteredSha, accumulated across all refs in this push.
|
|
114
125
|
const shaMap = {};
|
|
115
126
|
let pushed = 0;
|
|
@@ -123,6 +134,17 @@ rl.on('close', () => {
|
|
|
123
134
|
|
|
124
135
|
const label = remoteRef.replace('refs/heads/', '').replace('refs/tags/', '');
|
|
125
136
|
|
|
137
|
+
if (remoteRef.startsWith('refs/tags/')) {
|
|
138
|
+
if (remoteTagMap.has(remoteRef)) {
|
|
139
|
+
console.log(` ↷ ${label} already on Codeberg — skipping`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!gitOptional(`rev-parse --verify "${localSha}"`)) {
|
|
143
|
+
console.warn(` ⚠ ${label} — commit ${localSha.slice(0, 8)} not found locally, skipping`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
// If this commit was already filtered as part of a branch push, reuse it.
|
|
127
149
|
if (localSha in shaMap) {
|
|
128
150
|
try {
|
|
@@ -136,9 +158,11 @@ rl.on('close', () => {
|
|
|
136
158
|
continue;
|
|
137
159
|
}
|
|
138
160
|
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
const actualRemoteSha =
|
|
161
|
+
// Tags: existing ones were skipped above, so only new tags reach here — no remote anchor.
|
|
162
|
+
// Branches: query individually to find the incremental anchor for filtered chain building.
|
|
163
|
+
const actualRemoteSha = remoteRef.startsWith('refs/tags/')
|
|
164
|
+
? ''
|
|
165
|
+
: (gitOptional(`ls-remote "${remoteUrl}" "${remoteRef}"`)?.split(/\s+/)[0] ?? '');
|
|
142
166
|
|
|
143
167
|
if (!actualRemoteSha) {
|
|
144
168
|
// New ref on Codeberg — filter the tip with no parent.
|
|
@@ -166,15 +190,8 @@ rl.on('close', () => {
|
|
|
166
190
|
shaMap[sha] = makeFilteredCommit(sha, filteredTree, mappedParents);
|
|
167
191
|
}
|
|
168
192
|
} else {
|
|
169
|
-
// Fallback: anchor not found.
|
|
170
|
-
// If the remote SHA doesn't exist locally (filtered commit from a prior push),
|
|
171
|
-
// tags can be safely skipped (immutable — already correct on Codeberg).
|
|
172
|
-
// Branches fall back to orphan filtering (best effort).
|
|
193
|
+
// Fallback: anchor not found — branch only (tags exit early above).
|
|
173
194
|
const remoteExists = !!gitOptional(`rev-parse --verify "${actualRemoteSha}"`);
|
|
174
|
-
if (!remoteExists && remoteRef.startsWith('refs/tags/')) {
|
|
175
|
-
console.log(` ↷ ${label} already on Codeberg — skipping`);
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
195
|
console.warn(` ⚠ Could not find local base for ${label}, filtering tip only`);
|
|
179
196
|
shaMap[localSha] = makeFilteredCommit(localSha, filterTree(localSha), remoteExists ? [actualRemoteSha] : []);
|
|
180
197
|
}
|
|
@@ -199,5 +216,5 @@ rl.on('close', () => {
|
|
|
199
216
|
|
|
200
217
|
if (pushed === 0) process.exit(0);
|
|
201
218
|
console.log('→ Filtered push complete. Blocking unfiltered push.');
|
|
202
|
-
process.exit(
|
|
219
|
+
process.exit(2); // 2 = success (filtered push done, unfiltered blocked); 1 = error
|
|
203
220
|
});
|
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.8.
|
|
6
|
+
"version": "1.8.2",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"identifier": "@dpesch/mantisbt-mcp-server",
|
|
11
|
-
"version": "1.8.
|
|
11
|
+
"version": "1.8.2",
|
|
12
12
|
"runtimeHint": "npx",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
package/tests/client.test.ts
CHANGED
|
@@ -56,6 +56,28 @@ describe('MantisClient – URL building', () => {
|
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it('strips /api/rest suffix when user includes it in the base URL', () => {
|
|
60
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
|
|
61
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
62
|
+
|
|
63
|
+
const client = new MantisClient('https://mantis.example.com/api/rest', 'token123');
|
|
64
|
+
return client.get('issues/42').then(() => {
|
|
65
|
+
const calledUrl: string = fetchMock.mock.calls[0][0] as string;
|
|
66
|
+
expect(calledUrl).toBe('https://mantis.example.com/api/rest/issues/42');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('strips /api/rest/ (with trailing slash) from base URL', () => {
|
|
71
|
+
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
|
|
72
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
73
|
+
|
|
74
|
+
const client = new MantisClient('https://mantis.example.com/api/rest/', 'token123');
|
|
75
|
+
return client.get('issues/42').then(() => {
|
|
76
|
+
const calledUrl: string = fetchMock.mock.calls[0][0] as string;
|
|
77
|
+
expect(calledUrl).toBe('https://mantis.example.com/api/rest/issues/42');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
59
81
|
it('appends defined query parameters', () => {
|
|
60
82
|
const fetchMock = vi.fn().mockResolvedValue(makeResponse(200, '{}'));
|
|
61
83
|
vi.stubGlobal('fetch', fetchMock);
|
package/tests/config.test.ts
CHANGED
|
@@ -40,24 +40,44 @@ beforeEach(() => {
|
|
|
40
40
|
|
|
41
41
|
describe('getConfig() – ENV variables', () => {
|
|
42
42
|
it('reads baseUrl and apiKey from environment variables', async () => {
|
|
43
|
-
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com
|
|
43
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com');
|
|
44
44
|
vi.stubEnv('MANTIS_API_KEY', 'env-api-key');
|
|
45
45
|
|
|
46
46
|
const getConfig = await freshGetConfig();
|
|
47
47
|
const config = await getConfig();
|
|
48
48
|
|
|
49
|
-
expect(config.baseUrl).toBe('https://mantis.example.com
|
|
49
|
+
expect(config.baseUrl).toBe('https://mantis.example.com');
|
|
50
50
|
expect(config.apiKey).toBe('env-api-key');
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it('strips trailing slash from baseUrl', async () => {
|
|
54
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com/');
|
|
55
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
56
|
+
|
|
57
|
+
const getConfig = await freshGetConfig();
|
|
58
|
+
const config = await getConfig();
|
|
59
|
+
|
|
60
|
+
expect(config.baseUrl).toBe('https://mantis.example.com');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('strips /api/rest suffix from baseUrl when user includes it', async () => {
|
|
64
|
+
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com/api/rest');
|
|
65
|
+
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
66
|
+
|
|
67
|
+
const getConfig = await freshGetConfig();
|
|
68
|
+
const config = await getConfig();
|
|
69
|
+
|
|
70
|
+
expect(config.baseUrl).toBe('https://mantis.example.com');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('strips /api/rest/ (with trailing slash) from baseUrl', async () => {
|
|
54
74
|
vi.stubEnv('MANTIS_BASE_URL', 'https://mantis.example.com/api/rest/');
|
|
55
75
|
vi.stubEnv('MANTIS_API_KEY', 'key');
|
|
56
76
|
|
|
57
77
|
const getConfig = await freshGetConfig();
|
|
58
78
|
const config = await getConfig();
|
|
59
79
|
|
|
60
|
-
expect(config.baseUrl).toBe('https://mantis.example.com
|
|
80
|
+
expect(config.baseUrl).toBe('https://mantis.example.com');
|
|
61
81
|
});
|
|
62
82
|
|
|
63
83
|
it('parses MANTIS_CACHE_TTL as a number', async () => {
|