gitlab-mcp 1.1.0 → 1.2.1
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/LICENSE +21 -0
- package/README.md +12 -1
- package/dist/config/dotenv.d.ts +2 -0
- package/dist/config/dotenv.js +40 -0
- package/dist/config/dotenv.js.map +1 -0
- package/dist/config/env.d.ts +55 -0
- package/dist/config/env.js +164 -0
- package/dist/config/env.js.map +1 -0
- package/dist/http-app.d.ts +45 -0
- package/dist/http-app.js +550 -0
- package/dist/http-app.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +65 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +65 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/auth-context.d.ts +9 -0
- package/dist/lib/auth-context.js +9 -0
- package/dist/lib/auth-context.js.map +1 -0
- package/dist/lib/gitlab-client.d.ts +331 -0
- package/dist/lib/gitlab-client.js +1025 -0
- package/dist/lib/gitlab-client.js.map +1 -0
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +13 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/network.d.ts +3 -0
- package/dist/lib/network.js +38 -0
- package/dist/lib/network.js.map +1 -0
- package/dist/lib/oauth.d.ts +29 -0
- package/dist/lib/oauth.js +220 -0
- package/dist/lib/oauth.js.map +1 -0
- package/dist/lib/output.d.ts +14 -0
- package/dist/lib/output.js +38 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/policy.d.ts +25 -0
- package/dist/lib/policy.js +48 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/request-runtime.d.ts +26 -0
- package/dist/lib/request-runtime.js +323 -0
- package/dist/lib/request-runtime.js.map +1 -0
- package/dist/lib/sanitize.d.ts +1 -0
- package/dist/lib/sanitize.js +21 -0
- package/dist/lib/sanitize.js.map +1 -0
- package/dist/lib/session-capacity.d.ts +8 -0
- package/dist/lib/session-capacity.js +7 -0
- package/dist/lib/session-capacity.js.map +1 -0
- package/dist/server/build-server.d.ts +3 -0
- package/dist/server/build-server.js +13 -0
- package/dist/server/build-server.js.map +1 -0
- package/dist/tools/gitlab.d.ts +9 -0
- package/dist/tools/gitlab.js +2576 -0
- package/dist/tools/gitlab.js.map +1 -0
- package/dist/tools/health.d.ts +2 -0
- package/dist/tools/health.js +21 -0
- package/dist/tools/health.js.map +1 -0
- package/dist/tools/mr-code-context.d.ts +38 -0
- package/dist/tools/mr-code-context.js +330 -0
- package/dist/tools/mr-code-context.js.map +1 -0
- package/{src/types/context.ts → dist/types/context.d.ts} +5 -6
- package/dist/types/context.js +2 -0
- package/dist/types/context.js.map +1 -0
- package/docs/architecture.md +10 -10
- package/docs/configuration.md +12 -7
- package/docs/mcp-integration-testing-best-practices.md +981 -0
- package/package.json +13 -1
- package/.dockerignore +0 -7
- package/.editorconfig +0 -9
- package/.env.example +0 -75
- package/.github/workflows/nodejs.yml +0 -31
- package/.github/workflows/npm-publish.yml +0 -31
- package/.husky/pre-commit +0 -1
- package/.nvmrc +0 -1
- package/.prettierrc.json +0 -6
- package/Dockerfile +0 -20
- package/docker-compose.yml +0 -10
- package/eslint.config.js +0 -23
- package/scripts/get-oauth-token.example.sh +0 -15
- package/src/config/env.ts +0 -171
- package/src/http.ts +0 -620
- package/src/index.ts +0 -77
- package/src/lib/auth-context.ts +0 -19
- package/src/lib/gitlab-client.ts +0 -1810
- package/src/lib/logger.ts +0 -17
- package/src/lib/network.ts +0 -45
- package/src/lib/oauth.ts +0 -287
- package/src/lib/output.ts +0 -51
- package/src/lib/policy.ts +0 -78
- package/src/lib/request-runtime.ts +0 -376
- package/src/lib/sanitize.ts +0 -25
- package/src/lib/session-capacity.ts +0 -14
- package/src/server/build-server.ts +0 -17
- package/src/tools/gitlab.ts +0 -3135
- package/src/tools/health.ts +0 -27
- package/src/tools/mr-code-context.ts +0 -473
- package/tests/auth-context.test.ts +0 -102
- package/tests/gitlab-client.test.ts +0 -672
- package/tests/graphql-guard.test.ts +0 -121
- package/tests/integration/agent-loop.integration.test.ts +0 -558
- package/tests/integration/server.integration.test.ts +0 -543
- package/tests/mr-code-context.test.ts +0 -600
- package/tests/oauth.test.ts +0 -43
- package/tests/output.test.ts +0 -186
- package/tests/policy.test.ts +0 -324
- package/tests/request-runtime.test.ts +0 -252
- package/tests/sanitize.test.ts +0 -123
- package/tests/session-capacity.test.ts +0 -49
- package/tests/upload-reference.test.ts +0 -88
- package/tsconfig.build.json +0 -11
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -12
package/src/http.ts
DELETED
|
@@ -1,620 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { createServer } from "node:http";
|
|
3
|
-
|
|
4
|
-
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
5
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
7
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
|
-
import express from "express";
|
|
9
|
-
|
|
10
|
-
import { env } from "./config/env.js";
|
|
11
|
-
import { runWithSessionAuth, type SessionAuth } from "./lib/auth-context.js";
|
|
12
|
-
import { GitLabClient } from "./lib/gitlab-client.js";
|
|
13
|
-
import { logger } from "./lib/logger.js";
|
|
14
|
-
import { configureNetworkRuntime } from "./lib/network.js";
|
|
15
|
-
import { OutputFormatter } from "./lib/output.js";
|
|
16
|
-
import { ToolPolicyEngine } from "./lib/policy.js";
|
|
17
|
-
import { GitLabRequestRuntime } from "./lib/request-runtime.js";
|
|
18
|
-
import { hasReachedSessionCapacity } from "./lib/session-capacity.js";
|
|
19
|
-
import { createMcpServer } from "./server/build-server.js";
|
|
20
|
-
import type { AppContext } from "./types/context.js";
|
|
21
|
-
|
|
22
|
-
interface SessionState {
|
|
23
|
-
sessionId?: string;
|
|
24
|
-
server: McpServer;
|
|
25
|
-
transport: StreamableHTTPServerTransport;
|
|
26
|
-
lastAccessAt: number;
|
|
27
|
-
queue: Promise<void>;
|
|
28
|
-
activeRequests: number;
|
|
29
|
-
closed: boolean;
|
|
30
|
-
auth?: SessionAuth;
|
|
31
|
-
rateLimit: {
|
|
32
|
-
windowStart: number;
|
|
33
|
-
count: number;
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface SseSessionState {
|
|
38
|
-
sessionId: string;
|
|
39
|
-
server: McpServer;
|
|
40
|
-
transport: SSEServerTransport;
|
|
41
|
-
lastAccessAt: number;
|
|
42
|
-
closed: boolean;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const requestRuntime = new GitLabRequestRuntime(env, logger);
|
|
46
|
-
configureNetworkRuntime(env, logger);
|
|
47
|
-
|
|
48
|
-
const context: AppContext = {
|
|
49
|
-
env,
|
|
50
|
-
logger,
|
|
51
|
-
gitlab: new GitLabClient(env.GITLAB_API_URL, env.GITLAB_PERSONAL_ACCESS_TOKEN, {
|
|
52
|
-
apiUrls: env.GITLAB_API_URLS,
|
|
53
|
-
timeoutMs: env.GITLAB_HTTP_TIMEOUT_MS,
|
|
54
|
-
beforeRequest: (requestContext) => requestRuntime.beforeRequest(requestContext)
|
|
55
|
-
}),
|
|
56
|
-
policy: new ToolPolicyEngine({
|
|
57
|
-
readOnlyMode: env.GITLAB_READ_ONLY_MODE,
|
|
58
|
-
allowedTools: env.GITLAB_ALLOWED_TOOLS,
|
|
59
|
-
deniedToolsRegex: env.GITLAB_DENIED_TOOLS_REGEX
|
|
60
|
-
? new RegExp(env.GITLAB_DENIED_TOOLS_REGEX)
|
|
61
|
-
: undefined,
|
|
62
|
-
enabledFeatures: {
|
|
63
|
-
wiki: env.USE_GITLAB_WIKI,
|
|
64
|
-
milestone: env.USE_MILESTONE,
|
|
65
|
-
pipeline: env.USE_PIPELINE,
|
|
66
|
-
release: env.USE_RELEASE
|
|
67
|
-
}
|
|
68
|
-
}),
|
|
69
|
-
formatter: new OutputFormatter({
|
|
70
|
-
responseMode: env.GITLAB_RESPONSE_MODE,
|
|
71
|
-
maxBytes: env.GITLAB_MAX_RESPONSE_BYTES
|
|
72
|
-
})
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const app = createMcpExpressApp({ host: env.HTTP_HOST });
|
|
76
|
-
app.use(express.json({ limit: "2mb" }));
|
|
77
|
-
|
|
78
|
-
const sessions = new Map<string, SessionState>();
|
|
79
|
-
const pendingSessions = new Set<SessionState>();
|
|
80
|
-
const sseSessions = new Map<string, SseSessionState>();
|
|
81
|
-
|
|
82
|
-
app.get("/healthz", (_req, res) => {
|
|
83
|
-
res.status(200).json({
|
|
84
|
-
status: sessions.size + sseSessions.size >= env.MAX_SESSIONS ? "degraded" : "ok",
|
|
85
|
-
server: env.MCP_SERVER_NAME,
|
|
86
|
-
activeSessions: sessions.size,
|
|
87
|
-
activeSseSessions: sseSessions.size,
|
|
88
|
-
pendingSessions: pendingSessions.size,
|
|
89
|
-
maxSessions: env.MAX_SESSIONS,
|
|
90
|
-
remoteAuthorization: env.REMOTE_AUTHORIZATION,
|
|
91
|
-
readOnlyMode: env.GITLAB_READ_ONLY_MODE,
|
|
92
|
-
sseEnabled: env.SSE
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
if (env.SSE) {
|
|
97
|
-
app.get("/sse", async (req, res) => {
|
|
98
|
-
let sessionId: string | undefined;
|
|
99
|
-
try {
|
|
100
|
-
const parsedAuth = parseRequestAuth(req);
|
|
101
|
-
const fallbackToken = env.REMOTE_AUTHORIZATION ? undefined : env.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
hasReachedSessionCapacity({
|
|
105
|
-
streamableSessions: sessions.size,
|
|
106
|
-
pendingSessions: pendingSessions.size,
|
|
107
|
-
sseSessions: sseSessions.size,
|
|
108
|
-
maxSessions: env.MAX_SESSIONS
|
|
109
|
-
})
|
|
110
|
-
) {
|
|
111
|
-
res.status(503).send(`Maximum ${env.MAX_SESSIONS} concurrent sessions reached`);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const server = createMcpServer(context);
|
|
116
|
-
const transport = new SSEServerTransport("/messages", res);
|
|
117
|
-
sessionId = transport.sessionId;
|
|
118
|
-
const state: SseSessionState = {
|
|
119
|
-
sessionId,
|
|
120
|
-
server,
|
|
121
|
-
transport,
|
|
122
|
-
lastAccessAt: Date.now(),
|
|
123
|
-
closed: false
|
|
124
|
-
};
|
|
125
|
-
sseSessions.set(sessionId, state);
|
|
126
|
-
const currentSessionId = sessionId;
|
|
127
|
-
|
|
128
|
-
res.on("close", () => {
|
|
129
|
-
void closeSseSession(currentSessionId, "client-close");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
await runWithSessionAuth(
|
|
133
|
-
{
|
|
134
|
-
sessionId,
|
|
135
|
-
token: parsedAuth?.token ?? fallbackToken,
|
|
136
|
-
apiUrl: parsedAuth?.apiUrl ?? env.GITLAB_API_URL,
|
|
137
|
-
header: parsedAuth?.header,
|
|
138
|
-
updatedAt: Date.now()
|
|
139
|
-
},
|
|
140
|
-
async () => {
|
|
141
|
-
await server.connect(transport);
|
|
142
|
-
}
|
|
143
|
-
);
|
|
144
|
-
logger.info({ sessionId }, "MCP SSE session initialized");
|
|
145
|
-
} catch (error) {
|
|
146
|
-
if (sessionId) {
|
|
147
|
-
await closeSseSession(sessionId, "connect-error");
|
|
148
|
-
}
|
|
149
|
-
logger.error({ err: error, sessionId }, "Failed to initialize SSE session");
|
|
150
|
-
if (!res.headersSent) {
|
|
151
|
-
res.status(500).send("Failed to initialize SSE session");
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
app.post("/messages", async (req, res) => {
|
|
157
|
-
let sessionId: string | undefined;
|
|
158
|
-
try {
|
|
159
|
-
sessionId = String(req.query.sessionId ?? "");
|
|
160
|
-
if (!sessionId) {
|
|
161
|
-
res.status(400).send("Missing sessionId");
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const session = sseSessions.get(sessionId);
|
|
166
|
-
if (!session || session.closed) {
|
|
167
|
-
res.status(400).send("No transport found for sessionId");
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const parsedAuth = parseRequestAuth(req);
|
|
172
|
-
const fallbackToken = env.REMOTE_AUTHORIZATION ? undefined : env.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
173
|
-
session.lastAccessAt = Date.now();
|
|
174
|
-
|
|
175
|
-
await runWithSessionAuth(
|
|
176
|
-
{
|
|
177
|
-
sessionId,
|
|
178
|
-
token: parsedAuth?.token ?? fallbackToken,
|
|
179
|
-
apiUrl: parsedAuth?.apiUrl ?? env.GITLAB_API_URL,
|
|
180
|
-
header: parsedAuth?.header,
|
|
181
|
-
updatedAt: Date.now()
|
|
182
|
-
},
|
|
183
|
-
async () => {
|
|
184
|
-
await session.transport.handlePostMessage(req, res);
|
|
185
|
-
}
|
|
186
|
-
);
|
|
187
|
-
} catch (error) {
|
|
188
|
-
logger.error({ err: error, sessionId }, "SSE post message failed");
|
|
189
|
-
if (!res.headersSent) {
|
|
190
|
-
res.status(500).send("SSE message processing failed");
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
app.all("/mcp", async (req, res) => {
|
|
197
|
-
const incomingSessionId = req.header("mcp-session-id") ?? undefined;
|
|
198
|
-
const parsedAuth = parseRequestAuth(req);
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
if (env.REMOTE_AUTHORIZATION && !parsedAuth?.token) {
|
|
202
|
-
res.status(401).json({
|
|
203
|
-
jsonrpc: "2.0",
|
|
204
|
-
error: {
|
|
205
|
-
code: -32010,
|
|
206
|
-
message:
|
|
207
|
-
"Missing remote authorization token. Provide 'Authorization: Bearer <token>' or 'Private-Token'."
|
|
208
|
-
},
|
|
209
|
-
id: null
|
|
210
|
-
});
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (env.REMOTE_AUTHORIZATION && env.ENABLE_DYNAMIC_API_URL && !parsedAuth?.apiUrl) {
|
|
215
|
-
res.status(400).json({
|
|
216
|
-
jsonrpc: "2.0",
|
|
217
|
-
error: {
|
|
218
|
-
code: -32011,
|
|
219
|
-
message:
|
|
220
|
-
"Missing 'X-GitLab-API-URL' while ENABLE_DYNAMIC_API_URL=true and REMOTE_AUTHORIZATION=true."
|
|
221
|
-
},
|
|
222
|
-
id: null
|
|
223
|
-
});
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
let session = incomingSessionId ? sessions.get(incomingSessionId) : undefined;
|
|
228
|
-
|
|
229
|
-
if (incomingSessionId && !session) {
|
|
230
|
-
res.status(404).json({
|
|
231
|
-
jsonrpc: "2.0",
|
|
232
|
-
error: {
|
|
233
|
-
code: -32001,
|
|
234
|
-
message: `Unknown session '${incomingSessionId}'`
|
|
235
|
-
},
|
|
236
|
-
id: null
|
|
237
|
-
});
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (!session) {
|
|
242
|
-
if (req.method !== "POST") {
|
|
243
|
-
res.status(400).json({
|
|
244
|
-
jsonrpc: "2.0",
|
|
245
|
-
error: {
|
|
246
|
-
code: -32000,
|
|
247
|
-
message: "Session not initialized. First call must be a POST initialize request."
|
|
248
|
-
},
|
|
249
|
-
id: null
|
|
250
|
-
});
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
hasReachedSessionCapacity({
|
|
256
|
-
streamableSessions: sessions.size,
|
|
257
|
-
pendingSessions: pendingSessions.size,
|
|
258
|
-
sseSessions: sseSessions.size,
|
|
259
|
-
maxSessions: env.MAX_SESSIONS
|
|
260
|
-
})
|
|
261
|
-
) {
|
|
262
|
-
res.status(503).json({
|
|
263
|
-
jsonrpc: "2.0",
|
|
264
|
-
error: {
|
|
265
|
-
code: -32002,
|
|
266
|
-
message: `Maximum ${env.MAX_SESSIONS} concurrent sessions reached`
|
|
267
|
-
},
|
|
268
|
-
id: null
|
|
269
|
-
});
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
session = await createSession(parsedAuth);
|
|
274
|
-
} else {
|
|
275
|
-
refreshSessionAuth(session, parsedAuth);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (!checkSessionRateLimit(session)) {
|
|
279
|
-
res.status(429).json({
|
|
280
|
-
jsonrpc: "2.0",
|
|
281
|
-
error: {
|
|
282
|
-
code: -32003,
|
|
283
|
-
message: `Rate limit exceeded: max ${env.MAX_REQUESTS_PER_MINUTE} requests/min per session`
|
|
284
|
-
},
|
|
285
|
-
id: null
|
|
286
|
-
});
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
await enqueueSessionRequest(session, async () => {
|
|
291
|
-
const runtimeAuth = buildRuntimeAuth(session);
|
|
292
|
-
await runWithSessionAuth(runtimeAuth, async () => {
|
|
293
|
-
await session.transport.handleRequest(req, res, req.body);
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
} catch (error) {
|
|
297
|
-
logger.error(
|
|
298
|
-
{
|
|
299
|
-
err: error,
|
|
300
|
-
method: req.method,
|
|
301
|
-
sessionId: incomingSessionId
|
|
302
|
-
},
|
|
303
|
-
"MCP HTTP request failed"
|
|
304
|
-
);
|
|
305
|
-
|
|
306
|
-
if (!res.headersSent) {
|
|
307
|
-
res.status(500).json({
|
|
308
|
-
jsonrpc: "2.0",
|
|
309
|
-
error: {
|
|
310
|
-
code: -32603,
|
|
311
|
-
message: "Internal server error"
|
|
312
|
-
},
|
|
313
|
-
id: null
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
const httpServer = createServer(app);
|
|
320
|
-
|
|
321
|
-
httpServer.listen(env.HTTP_PORT, env.HTTP_HOST, () => {
|
|
322
|
-
logger.info(
|
|
323
|
-
{
|
|
324
|
-
host: env.HTTP_HOST,
|
|
325
|
-
port: env.HTTP_PORT,
|
|
326
|
-
transport: env.SSE ? "streamable-http+sse" : "streamable-http",
|
|
327
|
-
jsonOnly: env.HTTP_JSON_ONLY,
|
|
328
|
-
maxSessions: env.MAX_SESSIONS,
|
|
329
|
-
sessionTimeoutSeconds: env.SESSION_TIMEOUT_SECONDS,
|
|
330
|
-
remoteAuthEnabled: env.REMOTE_AUTHORIZATION
|
|
331
|
-
},
|
|
332
|
-
"MCP HTTP server started"
|
|
333
|
-
);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
const gcInterval = setInterval(() => {
|
|
337
|
-
void garbageCollectSessions();
|
|
338
|
-
}, 30_000);
|
|
339
|
-
|
|
340
|
-
gcInterval.unref();
|
|
341
|
-
|
|
342
|
-
process.once("SIGINT", () => {
|
|
343
|
-
void shutdown("SIGINT");
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
process.once("SIGTERM", () => {
|
|
347
|
-
void shutdown("SIGTERM");
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
async function createSession(initialAuth?: SessionAuth): Promise<SessionState> {
|
|
351
|
-
const server = createMcpServer(context);
|
|
352
|
-
const state: SessionState = {
|
|
353
|
-
server,
|
|
354
|
-
transport: undefined as unknown as StreamableHTTPServerTransport,
|
|
355
|
-
lastAccessAt: Date.now(),
|
|
356
|
-
queue: Promise.resolve(),
|
|
357
|
-
activeRequests: 0,
|
|
358
|
-
closed: false,
|
|
359
|
-
auth: initialAuth,
|
|
360
|
-
rateLimit: {
|
|
361
|
-
windowStart: Date.now(),
|
|
362
|
-
count: 0
|
|
363
|
-
}
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
pendingSessions.add(state);
|
|
367
|
-
|
|
368
|
-
const transport = new StreamableHTTPServerTransport({
|
|
369
|
-
sessionIdGenerator: () => randomUUID(),
|
|
370
|
-
enableJsonResponse: env.HTTP_JSON_ONLY,
|
|
371
|
-
onsessioninitialized: (sessionId) => {
|
|
372
|
-
state.sessionId = sessionId;
|
|
373
|
-
state.lastAccessAt = Date.now();
|
|
374
|
-
sessions.set(sessionId, state);
|
|
375
|
-
pendingSessions.delete(state);
|
|
376
|
-
logger.info({ sessionId }, "MCP session initialized");
|
|
377
|
-
},
|
|
378
|
-
onsessionclosed: async (sessionId) => {
|
|
379
|
-
await closeSession(sessionId, "transport-close");
|
|
380
|
-
}
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
state.transport = transport;
|
|
384
|
-
|
|
385
|
-
transport.onerror = (error) => {
|
|
386
|
-
logger.error({ err: error, sessionId: state.sessionId }, "MCP transport error");
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
try {
|
|
390
|
-
await server.connect(transport);
|
|
391
|
-
return state;
|
|
392
|
-
} catch (error) {
|
|
393
|
-
pendingSessions.delete(state);
|
|
394
|
-
|
|
395
|
-
if (state.sessionId) {
|
|
396
|
-
await closeSession(state.sessionId, "transport-close");
|
|
397
|
-
throw error;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
state.closed = true;
|
|
401
|
-
try {
|
|
402
|
-
await transport.close();
|
|
403
|
-
} catch (closeError) {
|
|
404
|
-
logger.warn({ err: closeError }, "Failed to close transport after session init failure");
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
try {
|
|
408
|
-
await server.close();
|
|
409
|
-
} catch (closeError) {
|
|
410
|
-
logger.warn({ err: closeError }, "Failed to close MCP server after session init failure");
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
throw error;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function checkSessionRateLimit(session: SessionState): boolean {
|
|
418
|
-
const now = Date.now();
|
|
419
|
-
const oneMinute = 60_000;
|
|
420
|
-
|
|
421
|
-
if (now - session.rateLimit.windowStart >= oneMinute) {
|
|
422
|
-
session.rateLimit.windowStart = now;
|
|
423
|
-
session.rateLimit.count = 0;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (session.rateLimit.count >= env.MAX_REQUESTS_PER_MINUTE) {
|
|
427
|
-
return false;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
session.rateLimit.count += 1;
|
|
431
|
-
return true;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function refreshSessionAuth(session: SessionState, auth?: SessionAuth): void {
|
|
435
|
-
if (!auth) {
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
session.auth = auth;
|
|
440
|
-
session.lastAccessAt = Date.now();
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function buildRuntimeAuth(session: SessionState): SessionAuth | undefined {
|
|
444
|
-
const fallbackToken = env.REMOTE_AUTHORIZATION ? undefined : env.GITLAB_PERSONAL_ACCESS_TOKEN;
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
sessionId: session.sessionId,
|
|
448
|
-
token: session.auth?.token ?? fallbackToken,
|
|
449
|
-
apiUrl: session.auth?.apiUrl ?? env.GITLAB_API_URL,
|
|
450
|
-
header: session.auth?.header,
|
|
451
|
-
updatedAt: session.auth?.updatedAt ?? Date.now()
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function parseRequestAuth(req: express.Request): SessionAuth | undefined {
|
|
456
|
-
if (!env.REMOTE_AUTHORIZATION) {
|
|
457
|
-
return undefined;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const privateToken = req.header("private-token")?.trim();
|
|
461
|
-
const authorization = req.header("authorization")?.trim();
|
|
462
|
-
|
|
463
|
-
const bearerToken = authorization?.toLowerCase().startsWith("bearer ")
|
|
464
|
-
? authorization.slice(7).trim()
|
|
465
|
-
: undefined;
|
|
466
|
-
|
|
467
|
-
const token = privateToken || bearerToken;
|
|
468
|
-
|
|
469
|
-
let apiUrl: string | undefined;
|
|
470
|
-
|
|
471
|
-
if (env.ENABLE_DYNAMIC_API_URL) {
|
|
472
|
-
const dynamicApiUrl = req.header("x-gitlab-api-url")?.trim();
|
|
473
|
-
if (dynamicApiUrl) {
|
|
474
|
-
try {
|
|
475
|
-
apiUrl = new URL(dynamicApiUrl).toString();
|
|
476
|
-
} catch {
|
|
477
|
-
throw new Error(`Invalid x-gitlab-api-url header: '${dynamicApiUrl}'`);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
if (!token && !apiUrl) {
|
|
483
|
-
return undefined;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return {
|
|
487
|
-
token,
|
|
488
|
-
apiUrl,
|
|
489
|
-
header: privateToken ? "private-token" : bearerToken ? "authorization" : undefined,
|
|
490
|
-
updatedAt: Date.now()
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
async function enqueueSessionRequest(
|
|
495
|
-
session: SessionState,
|
|
496
|
-
task: () => Promise<void>
|
|
497
|
-
): Promise<void> {
|
|
498
|
-
const queued = session.queue.then(async () => {
|
|
499
|
-
session.activeRequests += 1;
|
|
500
|
-
session.lastAccessAt = Date.now();
|
|
501
|
-
|
|
502
|
-
try {
|
|
503
|
-
await task();
|
|
504
|
-
} finally {
|
|
505
|
-
session.activeRequests -= 1;
|
|
506
|
-
session.lastAccessAt = Date.now();
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
session.queue = queued.catch(() => undefined);
|
|
511
|
-
await queued;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
async function garbageCollectSessions(): Promise<void> {
|
|
515
|
-
const now = Date.now();
|
|
516
|
-
const timeoutMs = env.SESSION_TIMEOUT_SECONDS * 1000;
|
|
517
|
-
|
|
518
|
-
for (const [sessionId, session] of sessions) {
|
|
519
|
-
if (session.activeRequests > 0 || session.closed) {
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (now - session.lastAccessAt < timeoutMs) {
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
await closeSession(sessionId, "idle-timeout");
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
for (const [sessionId, session] of sseSessions) {
|
|
531
|
-
if (session.closed) {
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (now - session.lastAccessAt < timeoutMs) {
|
|
536
|
-
continue;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
await closeSseSession(sessionId, "idle-timeout");
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
async function closeSession(
|
|
544
|
-
sessionId: string,
|
|
545
|
-
reason: "transport-close" | "idle-timeout" | "shutdown"
|
|
546
|
-
): Promise<void> {
|
|
547
|
-
const session = sessions.get(sessionId);
|
|
548
|
-
if (!session || session.closed) {
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
session.closed = true;
|
|
553
|
-
sessions.delete(sessionId);
|
|
554
|
-
|
|
555
|
-
try {
|
|
556
|
-
await session.transport.close();
|
|
557
|
-
} catch (error) {
|
|
558
|
-
logger.warn({ err: error, sessionId, reason }, "Failed to close transport cleanly");
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
try {
|
|
562
|
-
await session.server.close();
|
|
563
|
-
} catch (error) {
|
|
564
|
-
logger.warn({ err: error, sessionId, reason }, "Failed to close MCP server cleanly");
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
logger.info({ sessionId, reason }, "MCP session closed");
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
async function closeSseSession(
|
|
571
|
-
sessionId: string,
|
|
572
|
-
reason: "client-close" | "connect-error" | "idle-timeout" | "shutdown"
|
|
573
|
-
): Promise<void> {
|
|
574
|
-
const session = sseSessions.get(sessionId);
|
|
575
|
-
if (!session || session.closed) {
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
session.closed = true;
|
|
580
|
-
sseSessions.delete(sessionId);
|
|
581
|
-
|
|
582
|
-
try {
|
|
583
|
-
await session.transport.close();
|
|
584
|
-
} catch (error) {
|
|
585
|
-
logger.warn({ err: error, sessionId, reason }, "Failed to close SSE transport cleanly");
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
try {
|
|
589
|
-
await session.server.close();
|
|
590
|
-
} catch (error) {
|
|
591
|
-
logger.warn({ err: error, sessionId, reason }, "Failed to close SSE MCP server cleanly");
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
logger.info({ sessionId, reason }, "MCP SSE session closed");
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
async function shutdown(signal: NodeJS.Signals): Promise<void> {
|
|
598
|
-
logger.info({ signal }, "Shutting down HTTP server");
|
|
599
|
-
|
|
600
|
-
clearInterval(gcInterval);
|
|
601
|
-
|
|
602
|
-
const pendingClose = [...sessions.keys()].map((sessionId) => closeSession(sessionId, "shutdown"));
|
|
603
|
-
const pendingSseClose = [...sseSessions.keys()].map((sessionId) =>
|
|
604
|
-
closeSseSession(sessionId, "shutdown")
|
|
605
|
-
);
|
|
606
|
-
await Promise.allSettled([...pendingClose, ...pendingSseClose]);
|
|
607
|
-
|
|
608
|
-
await new Promise<void>((resolve, reject) => {
|
|
609
|
-
httpServer.close((error) => {
|
|
610
|
-
if (error) {
|
|
611
|
-
reject(error);
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
resolve();
|
|
616
|
-
});
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
process.exit(0);
|
|
620
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
-
|
|
3
|
-
import { env } from "./config/env.js";
|
|
4
|
-
import { GitLabClient } from "./lib/gitlab-client.js";
|
|
5
|
-
import { logger } from "./lib/logger.js";
|
|
6
|
-
import { configureNetworkRuntime } from "./lib/network.js";
|
|
7
|
-
import { OutputFormatter } from "./lib/output.js";
|
|
8
|
-
import { ToolPolicyEngine } from "./lib/policy.js";
|
|
9
|
-
import { GitLabRequestRuntime } from "./lib/request-runtime.js";
|
|
10
|
-
import { createMcpServer } from "./server/build-server.js";
|
|
11
|
-
import type { AppContext } from "./types/context.js";
|
|
12
|
-
|
|
13
|
-
async function main(): Promise<void> {
|
|
14
|
-
const deniedToolsRegex = env.GITLAB_DENIED_TOOLS_REGEX
|
|
15
|
-
? new RegExp(env.GITLAB_DENIED_TOOLS_REGEX)
|
|
16
|
-
: undefined;
|
|
17
|
-
configureNetworkRuntime(env, logger);
|
|
18
|
-
const requestRuntime = new GitLabRequestRuntime(env, logger);
|
|
19
|
-
|
|
20
|
-
const context: AppContext = {
|
|
21
|
-
env,
|
|
22
|
-
logger,
|
|
23
|
-
gitlab: new GitLabClient(env.GITLAB_API_URL, env.GITLAB_PERSONAL_ACCESS_TOKEN, {
|
|
24
|
-
apiUrls: env.GITLAB_API_URLS,
|
|
25
|
-
timeoutMs: env.GITLAB_HTTP_TIMEOUT_MS,
|
|
26
|
-
beforeRequest: (requestContext) => requestRuntime.beforeRequest(requestContext)
|
|
27
|
-
}),
|
|
28
|
-
policy: new ToolPolicyEngine({
|
|
29
|
-
readOnlyMode: env.GITLAB_READ_ONLY_MODE,
|
|
30
|
-
allowedTools: env.GITLAB_ALLOWED_TOOLS,
|
|
31
|
-
deniedToolsRegex,
|
|
32
|
-
enabledFeatures: {
|
|
33
|
-
wiki: env.USE_GITLAB_WIKI,
|
|
34
|
-
milestone: env.USE_MILESTONE,
|
|
35
|
-
pipeline: env.USE_PIPELINE,
|
|
36
|
-
release: env.USE_RELEASE
|
|
37
|
-
}
|
|
38
|
-
}),
|
|
39
|
-
formatter: new OutputFormatter({
|
|
40
|
-
responseMode: env.GITLAB_RESPONSE_MODE,
|
|
41
|
-
maxBytes: env.GITLAB_MAX_RESPONSE_BYTES
|
|
42
|
-
})
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const server = createMcpServer(context);
|
|
46
|
-
const transport = new StdioServerTransport();
|
|
47
|
-
|
|
48
|
-
await server.connect(transport);
|
|
49
|
-
logger.info({ transport: "stdio" }, "MCP server started");
|
|
50
|
-
|
|
51
|
-
const handleSignal = (signal: NodeJS.Signals) => {
|
|
52
|
-
void shutdown(signal, server);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
process.once("SIGINT", () => handleSignal("SIGINT"));
|
|
56
|
-
process.once("SIGTERM", () => handleSignal("SIGTERM"));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function shutdown(
|
|
60
|
-
signal: NodeJS.Signals,
|
|
61
|
-
server: ReturnType<typeof createMcpServer>
|
|
62
|
-
): Promise<void> {
|
|
63
|
-
logger.info({ signal }, "Shutting down MCP server");
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
await server.close();
|
|
67
|
-
} catch (error) {
|
|
68
|
-
logger.error({ err: error }, "Server close failed");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
process.exit(0);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
void main().catch((error) => {
|
|
75
|
-
logger.error({ err: error }, "Failed to start MCP server");
|
|
76
|
-
process.exit(1);
|
|
77
|
-
});
|
package/src/lib/auth-context.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
|
|
3
|
-
export interface SessionAuth {
|
|
4
|
-
sessionId?: string;
|
|
5
|
-
token?: string;
|
|
6
|
-
apiUrl?: string;
|
|
7
|
-
header?: "authorization" | "private-token";
|
|
8
|
-
updatedAt: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const sessionAuthStore = new AsyncLocalStorage<SessionAuth | undefined>();
|
|
12
|
-
|
|
13
|
-
export function runWithSessionAuth<T>(auth: SessionAuth | undefined, callback: () => T): T {
|
|
14
|
-
return sessionAuthStore.run(auth, callback);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function getSessionAuth(): SessionAuth | undefined {
|
|
18
|
-
return sessionAuthStore.getStore();
|
|
19
|
-
}
|