vreko-mcp-server 3.1.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 +201 -0
- package/README.md +548 -0
- package/dist/index.js +843 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
// Vreko MCP Server - Bundled for Fly.io deployment
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { createServer } from "http";
|
|
6
|
+
import { URL } from "url";
|
|
7
|
+
|
|
8
|
+
// ../../packages/sentry-privacy/dist/index.js
|
|
9
|
+
import * as Sentry from "@sentry/node";
|
|
10
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
12
|
+
var EXTRA_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
13
|
+
"requestId",
|
|
14
|
+
"workspaceHash",
|
|
15
|
+
"sessionId",
|
|
16
|
+
"tier",
|
|
17
|
+
"cliVersion",
|
|
18
|
+
"daemonVersion",
|
|
19
|
+
"extensionVersion",
|
|
20
|
+
"platform",
|
|
21
|
+
"nodeVersion"
|
|
22
|
+
]);
|
|
23
|
+
var BLOCKED_BREADCRUMB_CATEGORIES = /* @__PURE__ */ new Set([
|
|
24
|
+
"fetch",
|
|
25
|
+
"xhr",
|
|
26
|
+
"console"
|
|
27
|
+
]);
|
|
28
|
+
var HOME_PATH_RE = /\/(Users|home)\/[^/]+\//g;
|
|
29
|
+
var GH_TOKEN_RE = /(ghp|gho|ghu|ghs)_[A-Za-z0-9]{36}/g;
|
|
30
|
+
var API_KEY_RE = /sk-[A-Za-z0-9]{32,}/g;
|
|
31
|
+
var MAX_STRING_LEN = 2048;
|
|
32
|
+
function scrubString(value) {
|
|
33
|
+
if (value.length > MAX_STRING_LEN) {
|
|
34
|
+
return `[TRUNCATED:${value.length}]`;
|
|
35
|
+
}
|
|
36
|
+
return value.replace(HOME_PATH_RE, "/~/").replace(GH_TOKEN_RE, "[REDACTED_GH_TOKEN]").replace(API_KEY_RE, "[REDACTED_API_KEY]");
|
|
37
|
+
}
|
|
38
|
+
__name(scrubString, "scrubString");
|
|
39
|
+
function createNextjsSentryConfig(options) {
|
|
40
|
+
const { dsn, release, environment, tracesSampleRate, ignoreErrors } = options;
|
|
41
|
+
const resolvedEnv = resolveDeploymentEnv(environment);
|
|
42
|
+
return {
|
|
43
|
+
dsn,
|
|
44
|
+
environment: resolvedEnv,
|
|
45
|
+
release: release ?? process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA ?? process.env.npm_package_version,
|
|
46
|
+
sendDefaultPii: false,
|
|
47
|
+
tracesSampleRate: tracesSampleRate ?? (resolvedEnv === "development" ? 1 : 0.1),
|
|
48
|
+
ignoreErrors: ignoreErrors ?? [
|
|
49
|
+
"NetworkError",
|
|
50
|
+
"Failed to fetch",
|
|
51
|
+
"Load failed",
|
|
52
|
+
"AbortError"
|
|
53
|
+
],
|
|
54
|
+
beforeSend(event) {
|
|
55
|
+
const req = event.request;
|
|
56
|
+
if (req?.headers) {
|
|
57
|
+
const headers = req.headers;
|
|
58
|
+
delete headers.authorization;
|
|
59
|
+
delete headers.cookie;
|
|
60
|
+
delete headers["x-api-key"];
|
|
61
|
+
}
|
|
62
|
+
const user = event.user;
|
|
63
|
+
if (user?.email) {
|
|
64
|
+
user.email = "[REDACTED]";
|
|
65
|
+
}
|
|
66
|
+
const exception = event.exception;
|
|
67
|
+
if (exception?.values) {
|
|
68
|
+
for (const exc of exception.values) {
|
|
69
|
+
if (exc.value && typeof exc.value === "string") {
|
|
70
|
+
exc.value = scrubString(exc.value);
|
|
71
|
+
}
|
|
72
|
+
const st = exc.stacktrace;
|
|
73
|
+
if (st?.frames) {
|
|
74
|
+
for (const frame of st.frames) {
|
|
75
|
+
if (typeof frame.filename === "string") {
|
|
76
|
+
frame.filename = scrubString(frame.filename);
|
|
77
|
+
}
|
|
78
|
+
if (typeof frame.abs_path === "string") {
|
|
79
|
+
frame.abs_path = scrubString(frame.abs_path);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return event;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
__name(createNextjsSentryConfig, "createNextjsSentryConfig");
|
|
90
|
+
function resolveDeploymentEnv(override) {
|
|
91
|
+
if (override) return override;
|
|
92
|
+
const explicit = process.env.DEPLOYMENT_ENV;
|
|
93
|
+
if (explicit === "development" || explicit === "staging" || explicit === "production") {
|
|
94
|
+
return explicit;
|
|
95
|
+
}
|
|
96
|
+
if (process.env.VERCEL_ENV === "preview") return "staging";
|
|
97
|
+
return process.env.NODE_ENV ?? "production";
|
|
98
|
+
}
|
|
99
|
+
__name(resolveDeploymentEnv, "resolveDeploymentEnv");
|
|
100
|
+
function createSentryConfig(options) {
|
|
101
|
+
const { dsn, surface, workspaceHash, tier, release, environment } = options;
|
|
102
|
+
const resolvedEnv = resolveDeploymentEnv(environment);
|
|
103
|
+
return {
|
|
104
|
+
dsn,
|
|
105
|
+
environment: resolvedEnv,
|
|
106
|
+
release: release ?? process.env.GIT_SHA ?? process.env.npm_package_version,
|
|
107
|
+
sendDefaultPii: false,
|
|
108
|
+
attachStacktrace: true,
|
|
109
|
+
// Full sampling in development; reduced in staging/production to limit overhead.
|
|
110
|
+
tracesSampleRate: resolvedEnv === "development" ? 1 : 0.1,
|
|
111
|
+
initialScope: {
|
|
112
|
+
tags: {
|
|
113
|
+
surface,
|
|
114
|
+
...tier ? {
|
|
115
|
+
tier
|
|
116
|
+
} : {}
|
|
117
|
+
},
|
|
118
|
+
...workspaceHash ? {
|
|
119
|
+
user: {
|
|
120
|
+
id: workspaceHash
|
|
121
|
+
}
|
|
122
|
+
} : {}
|
|
123
|
+
},
|
|
124
|
+
// Drop auto-capture integrations that can produce breadcrumbs with sensitive data
|
|
125
|
+
integrations(defaultIntegrations) {
|
|
126
|
+
return defaultIntegrations.filter((i) => ![
|
|
127
|
+
"Breadcrumbs",
|
|
128
|
+
"Console",
|
|
129
|
+
"Http"
|
|
130
|
+
].includes(i.name));
|
|
131
|
+
},
|
|
132
|
+
beforeSend(event) {
|
|
133
|
+
if (event.extra) {
|
|
134
|
+
const filtered = {};
|
|
135
|
+
for (const key of EXTRA_ALLOWLIST) {
|
|
136
|
+
if (key in event.extra) {
|
|
137
|
+
filtered[key] = event.extra[key];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
event.extra = filtered;
|
|
141
|
+
}
|
|
142
|
+
if (event.contexts?.runtime) {
|
|
143
|
+
event.contexts.runtime.args = void 0;
|
|
144
|
+
}
|
|
145
|
+
if (event.request) {
|
|
146
|
+
event.request.data = void 0;
|
|
147
|
+
}
|
|
148
|
+
if (event.breadcrumbs?.values) {
|
|
149
|
+
const breadcrumbs = event.breadcrumbs.values();
|
|
150
|
+
for (const crumb of breadcrumbs) {
|
|
151
|
+
crumb.data = void 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (event.exception?.values) {
|
|
155
|
+
for (const exc of event.exception.values) {
|
|
156
|
+
if (exc.value) {
|
|
157
|
+
exc.value = scrubString(exc.value);
|
|
158
|
+
}
|
|
159
|
+
if (exc.stacktrace?.frames) {
|
|
160
|
+
for (const frame of exc.stacktrace.frames) {
|
|
161
|
+
if (frame.filename) {
|
|
162
|
+
frame.filename = scrubString(frame.filename);
|
|
163
|
+
}
|
|
164
|
+
if (frame.abs_path) {
|
|
165
|
+
frame.abs_path = scrubString(frame.abs_path);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (event.message) {
|
|
172
|
+
event.message = scrubString(event.message);
|
|
173
|
+
}
|
|
174
|
+
return event;
|
|
175
|
+
},
|
|
176
|
+
beforeBreadcrumb(breadcrumb) {
|
|
177
|
+
const cat = breadcrumb.category ?? "";
|
|
178
|
+
if (BLOCKED_BREADCRUMB_CATEGORIES.has(cat)) {
|
|
179
|
+
const data = breadcrumb.data;
|
|
180
|
+
if (data?._allow === true) {
|
|
181
|
+
const { _allow: _, ...rest } = data;
|
|
182
|
+
breadcrumb.data = rest;
|
|
183
|
+
return breadcrumb;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return breadcrumb;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
__name(createSentryConfig, "createSentryConfig");
|
|
192
|
+
|
|
193
|
+
// src/http-client.ts
|
|
194
|
+
import { Agent, setGlobalDispatcher } from "undici";
|
|
195
|
+
var globalAgent = new Agent({
|
|
196
|
+
connections: 50,
|
|
197
|
+
keepAliveTimeout: 3e4,
|
|
198
|
+
keepAliveMaxTimeout: 6e4,
|
|
199
|
+
pipelining: 1
|
|
200
|
+
});
|
|
201
|
+
setGlobalDispatcher(globalAgent);
|
|
202
|
+
async function fetchWithPooling(input, init) {
|
|
203
|
+
return fetch(input, init);
|
|
204
|
+
}
|
|
205
|
+
function destroyPools() {
|
|
206
|
+
void globalAgent.destroy();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/utils/logger.ts
|
|
210
|
+
function fmt(level, msg, ctx) {
|
|
211
|
+
return `[${level}] ${msg}${ctx ? ` ${JSON.stringify(ctx)}` : ""}
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
var logger = {
|
|
215
|
+
info(msg, ctx) {
|
|
216
|
+
if (process.env.LOG_LEVEL !== "silent") {
|
|
217
|
+
process.stdout.write(fmt("INFO", msg, ctx));
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
warn(msg, ctx) {
|
|
221
|
+
process.stderr.write(fmt("WARN", msg, ctx));
|
|
222
|
+
},
|
|
223
|
+
error(msg, ctx) {
|
|
224
|
+
process.stderr.write(fmt("ERROR", msg, ctx));
|
|
225
|
+
},
|
|
226
|
+
debug(msg, ctx) {
|
|
227
|
+
if (process.env.LOG_LEVEL === "debug") {
|
|
228
|
+
process.stdout.write(fmt("DEBUG", msg, ctx));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/validation.ts
|
|
234
|
+
var API_URL = process.env.VREKO_API_URL || "https://api.vreko.dev";
|
|
235
|
+
var MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
236
|
+
var API_KEY_PATTERN = /^sk_(live|test)_[a-zA-Z0-9_-]{32,}$/;
|
|
237
|
+
var WORKSPACE_ID_PATTERN = /^([a-f0-9]{12}|ws_[a-f0-9]{32})$/;
|
|
238
|
+
var WORKSPACE_ID_LENGTHS = [12, 35];
|
|
239
|
+
var DANGEROUS_CHARS = /[;<>|&$`\\]/;
|
|
240
|
+
var PATH_DANGEROUS_CHARS = /[<>|&;$`\\]/;
|
|
241
|
+
function validateApiKey(apiKey) {
|
|
242
|
+
if (!apiKey || apiKey.trim() === "") {
|
|
243
|
+
return {
|
|
244
|
+
valid: false,
|
|
245
|
+
error: "Missing API key"
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (!API_KEY_PATTERN.test(apiKey)) {
|
|
249
|
+
return {
|
|
250
|
+
valid: false,
|
|
251
|
+
error: "Invalid API key format. Must start with sk_live_ or sk_test_ followed by at least 32 alphanumeric characters"
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (DANGEROUS_CHARS.test(apiKey)) {
|
|
255
|
+
return {
|
|
256
|
+
valid: false,
|
|
257
|
+
error: "Invalid API key format. Contains illegal characters"
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return { valid: true };
|
|
261
|
+
}
|
|
262
|
+
async function validateApiKeyWithDatabase(apiKey) {
|
|
263
|
+
const formatResult = validateApiKey(apiKey);
|
|
264
|
+
if (!formatResult.valid) {
|
|
265
|
+
return { valid: false, error: formatResult.error };
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const response = await fetchWithPooling(`${API_URL}/orpc/auth.verifyApiKey`, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: {
|
|
271
|
+
"Content-Type": "application/json"
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify({
|
|
274
|
+
apiKey,
|
|
275
|
+
// Request MCP tools permission
|
|
276
|
+
requiredPermissions: {
|
|
277
|
+
mcp: ["tools"]
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
});
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
if (response.status === 401) {
|
|
283
|
+
return { valid: false, error: "Invalid or expired API key" };
|
|
284
|
+
}
|
|
285
|
+
return { valid: false, error: `API verification failed: ${response.status}` };
|
|
286
|
+
}
|
|
287
|
+
const result = await response.json();
|
|
288
|
+
if (!result.valid) {
|
|
289
|
+
return { valid: false, error: "Invalid API key" };
|
|
290
|
+
}
|
|
291
|
+
const permissions = result.permissions || {};
|
|
292
|
+
const hasPro = permissions.api?.includes("write") || permissions["vreko:snapshot"]?.includes("write");
|
|
293
|
+
return {
|
|
294
|
+
valid: true,
|
|
295
|
+
userId: result.userId,
|
|
296
|
+
tier: hasPro ? "pro" : "free"
|
|
297
|
+
};
|
|
298
|
+
} catch (_error) {
|
|
299
|
+
return {
|
|
300
|
+
valid: false,
|
|
301
|
+
transient: true,
|
|
302
|
+
error: "auth_service_unavailable"
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function validateWorkspaceId(workspaceId) {
|
|
307
|
+
if (!workspaceId || workspaceId.trim() === "") {
|
|
308
|
+
return {
|
|
309
|
+
valid: false,
|
|
310
|
+
error: "Missing workspace ID"
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (!WORKSPACE_ID_PATTERN.test(workspaceId)) {
|
|
314
|
+
return {
|
|
315
|
+
valid: false,
|
|
316
|
+
error: "Invalid workspace ID format. Must be 12 lowercase hex characters (unified) or ws_ followed by 32 lowercase hex characters (legacy)"
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (!WORKSPACE_ID_LENGTHS.includes(workspaceId.length)) {
|
|
320
|
+
return {
|
|
321
|
+
valid: false,
|
|
322
|
+
error: "Invalid workspace ID length"
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (DANGEROUS_CHARS.test(workspaceId)) {
|
|
326
|
+
return {
|
|
327
|
+
valid: false,
|
|
328
|
+
error: "Invalid workspace ID format. Contains illegal characters"
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return { valid: true };
|
|
332
|
+
}
|
|
333
|
+
function validateWorkspace(workspace) {
|
|
334
|
+
if (workspace === "default") {
|
|
335
|
+
return { valid: true };
|
|
336
|
+
}
|
|
337
|
+
if (!workspace || workspace.trim() === "") {
|
|
338
|
+
return {
|
|
339
|
+
valid: false,
|
|
340
|
+
error: "Missing workspace parameter"
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (workspace.includes("..")) {
|
|
344
|
+
return {
|
|
345
|
+
valid: false,
|
|
346
|
+
error: "Invalid workspace path. Path traversal detected"
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (!workspace.startsWith("/")) {
|
|
350
|
+
return {
|
|
351
|
+
valid: false,
|
|
352
|
+
error: "Invalid workspace path. Must be an absolute path or 'default'"
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (workspace.includes("\0")) {
|
|
356
|
+
return {
|
|
357
|
+
valid: false,
|
|
358
|
+
error: "Invalid workspace path. Contains null bytes"
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (PATH_DANGEROUS_CHARS.test(workspace)) {
|
|
362
|
+
return {
|
|
363
|
+
valid: false,
|
|
364
|
+
error: "Invalid workspace path. Contains illegal characters"
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return { valid: true };
|
|
368
|
+
}
|
|
369
|
+
function getAllowedCorsOrigin(requestOrigin, allowedOrigins) {
|
|
370
|
+
if (allowedOrigins === "*") {
|
|
371
|
+
return "*";
|
|
372
|
+
}
|
|
373
|
+
if (!requestOrigin) {
|
|
374
|
+
return allowedOrigins.split(",")[0] || null;
|
|
375
|
+
}
|
|
376
|
+
const origins = allowedOrigins.split(",").map((o) => o.trim());
|
|
377
|
+
if (origins.includes(requestOrigin)) {
|
|
378
|
+
return requestOrigin;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
function getMaxBodySize() {
|
|
383
|
+
return MAX_BODY_SIZE;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/index.ts
|
|
387
|
+
Sentry.init(
|
|
388
|
+
createSentryConfig({
|
|
389
|
+
dsn: process.env.SENTRY_DSN_MCP || process.env.SENTRY_DSN || "",
|
|
390
|
+
surface: "mcp-proxy"
|
|
391
|
+
})
|
|
392
|
+
);
|
|
393
|
+
process.once("uncaughtException", (error) => {
|
|
394
|
+
Sentry.captureException(error, { tags: { component: "uncaughtException" } });
|
|
395
|
+
logger.error("Uncaught exception", { message: error.message, stack: error.stack });
|
|
396
|
+
process.exit(1);
|
|
397
|
+
});
|
|
398
|
+
process.once("unhandledRejection", (reason) => {
|
|
399
|
+
Sentry.captureException(reason instanceof Error ? reason : new Error(String(reason)), {
|
|
400
|
+
tags: { component: "unhandledRejection" },
|
|
401
|
+
level: "warning"
|
|
402
|
+
});
|
|
403
|
+
logger.warn("Unhandled rejection", { reason: String(reason) });
|
|
404
|
+
});
|
|
405
|
+
var PORT = Number.parseInt(process.env.PORT || "8080", 10);
|
|
406
|
+
var _NODE_ENV = process.env.NODE_ENV || "development";
|
|
407
|
+
var MCP_VERSION = process.env.MCP_VERSION || "2025-03-26";
|
|
408
|
+
var API_URL2 = process.env.VREKO_API_URL || "https://api.vreko.dev";
|
|
409
|
+
var isShuttingDown = false;
|
|
410
|
+
var isReady = false;
|
|
411
|
+
var startTime = Date.now();
|
|
412
|
+
async function proxyToApi(endpoint, method, body, headers) {
|
|
413
|
+
try {
|
|
414
|
+
const res = await fetchWithPooling(`${API_URL2}/api/v1/mcp${endpoint}`, {
|
|
415
|
+
method,
|
|
416
|
+
headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...headers },
|
|
417
|
+
body: body ? JSON.stringify(body) : void 0
|
|
418
|
+
});
|
|
419
|
+
const resBody = await res.text();
|
|
420
|
+
const resHeaders = {
|
|
421
|
+
/* intentionally empty */
|
|
422
|
+
};
|
|
423
|
+
res.headers.forEach((v, k) => {
|
|
424
|
+
resHeaders[k] = v;
|
|
425
|
+
});
|
|
426
|
+
return { status: res.status, body: resBody, headers: resHeaders };
|
|
427
|
+
} catch (_e) {
|
|
428
|
+
return {
|
|
429
|
+
status: 503,
|
|
430
|
+
body: JSON.stringify({ error: "SERVICE_UNAVAILABLE" }),
|
|
431
|
+
headers: { "Content-Type": "application/json" }
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function setCors(req, res) {
|
|
436
|
+
const origin = getAllowedCorsOrigin(req.headers.origin, process.env.CORS_ORIGIN || "*");
|
|
437
|
+
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
|
438
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
439
|
+
res.setHeader(
|
|
440
|
+
"Access-Control-Allow-Headers",
|
|
441
|
+
"Content-Type, Authorization, mcp-session-id, x-api-key, x-workspace-id"
|
|
442
|
+
);
|
|
443
|
+
res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
|
|
444
|
+
}
|
|
445
|
+
var server = createServer(async (req, res) => {
|
|
446
|
+
setCors(req, res);
|
|
447
|
+
if (req.method === "OPTIONS") {
|
|
448
|
+
res.writeHead(200);
|
|
449
|
+
res.end();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const nodeEnv = process.env.NODE_ENV || "development";
|
|
453
|
+
const requestOrigin = req.headers.origin;
|
|
454
|
+
if (nodeEnv === "production" && requestOrigin) {
|
|
455
|
+
const allowedOrigins = (process.env.CORS_ORIGIN || "").split(",").map((o) => o.trim()).filter(Boolean);
|
|
456
|
+
if (allowedOrigins.length > 0 && !allowedOrigins.includes(requestOrigin) && allowedOrigins[0] !== "*") {
|
|
457
|
+
logger.warn("Blocked request from unauthorized origin", { origin: requestOrigin });
|
|
458
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
459
|
+
res.end(JSON.stringify({ error: "FORBIDDEN", message: "Origin not allowed" }));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const requestId = randomUUID();
|
|
464
|
+
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
|
465
|
+
logger.info("Request", { requestId, method: req.method, path: url.pathname });
|
|
466
|
+
const maxSize = getMaxBodySize();
|
|
467
|
+
const chunks = [];
|
|
468
|
+
let size = 0;
|
|
469
|
+
let aborted = false;
|
|
470
|
+
req.on("data", (chunk) => {
|
|
471
|
+
if (aborted) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
size += chunk.length;
|
|
475
|
+
if (size > maxSize) {
|
|
476
|
+
aborted = true;
|
|
477
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
478
|
+
res.end(JSON.stringify({ error: "PAYLOAD_TOO_LARGE" }));
|
|
479
|
+
req.destroy();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
chunks.push(chunk);
|
|
483
|
+
});
|
|
484
|
+
req.on("end", async () => {
|
|
485
|
+
if (aborted) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const body = chunks.length === 0 ? "" : Buffer.concat(chunks, size).toString("utf8");
|
|
489
|
+
try {
|
|
490
|
+
let result;
|
|
491
|
+
const path = url.pathname;
|
|
492
|
+
if (path === "/mcp" && req.method === "GET") {
|
|
493
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
494
|
+
if (!sessionId) {
|
|
495
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
496
|
+
res.end(JSON.stringify({ error: "mcp-session-id header required" }));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const upstreamHeaders = {
|
|
500
|
+
"mcp-session-id": sessionId,
|
|
501
|
+
Accept: "text/event-stream"
|
|
502
|
+
};
|
|
503
|
+
if (req.headers["x-api-key"]) {
|
|
504
|
+
upstreamHeaders["x-api-key"] = req.headers["x-api-key"];
|
|
505
|
+
}
|
|
506
|
+
if (req.headers.authorization?.startsWith("Bearer ")) {
|
|
507
|
+
upstreamHeaders.Authorization = req.headers.authorization;
|
|
508
|
+
}
|
|
509
|
+
if (req.headers["x-workspace-path"]) {
|
|
510
|
+
upstreamHeaders["x-workspace-path"] = req.headers["x-workspace-path"];
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const upstream = await fetchWithPooling(`${API_URL2}/api/v1/mcp`, {
|
|
514
|
+
method: "GET",
|
|
515
|
+
headers: upstreamHeaders
|
|
516
|
+
});
|
|
517
|
+
const responseHeaders2 = {};
|
|
518
|
+
upstream.headers.forEach((v, k) => {
|
|
519
|
+
responseHeaders2[k] = v;
|
|
520
|
+
});
|
|
521
|
+
res.writeHead(upstream.status, responseHeaders2);
|
|
522
|
+
if (upstream.body) {
|
|
523
|
+
const reader = upstream.body.getReader();
|
|
524
|
+
req.on(
|
|
525
|
+
"close",
|
|
526
|
+
() => reader.cancel().catch(() => {
|
|
527
|
+
})
|
|
528
|
+
);
|
|
529
|
+
(async () => {
|
|
530
|
+
try {
|
|
531
|
+
while (true) {
|
|
532
|
+
const { done, value } = await reader.read();
|
|
533
|
+
if (done) {
|
|
534
|
+
res.end();
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
res.write(value);
|
|
538
|
+
}
|
|
539
|
+
} catch (pipeErr) {
|
|
540
|
+
logger.warn("SSE pipe closed unexpectedly", {
|
|
541
|
+
sessionId,
|
|
542
|
+
error: pipeErr instanceof Error ? pipeErr.message : String(pipeErr)
|
|
543
|
+
});
|
|
544
|
+
Sentry.captureException(
|
|
545
|
+
pipeErr instanceof Error ? pipeErr : new Error(String(pipeErr)),
|
|
546
|
+
{ tags: { component: "sse-pipe" }, extra: { sessionId } }
|
|
547
|
+
);
|
|
548
|
+
res.end();
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
} else {
|
|
552
|
+
res.end();
|
|
553
|
+
}
|
|
554
|
+
} catch (sseErr) {
|
|
555
|
+
logger.warn("SSE upstream connect failed", {
|
|
556
|
+
sessionId,
|
|
557
|
+
error: sseErr instanceof Error ? sseErr.message : String(sseErr)
|
|
558
|
+
});
|
|
559
|
+
Sentry.captureException(sseErr instanceof Error ? sseErr : new Error(String(sseErr)), {
|
|
560
|
+
tags: { component: "sse-upstream" },
|
|
561
|
+
extra: { sessionId }
|
|
562
|
+
});
|
|
563
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
564
|
+
res.end(JSON.stringify({ error: "SERVICE_UNAVAILABLE" }));
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (path === "/health" || path === "/health/live") {
|
|
569
|
+
result = {
|
|
570
|
+
status: 200,
|
|
571
|
+
body: JSON.stringify({
|
|
572
|
+
status: isShuttingDown ? "shutting_down" : "alive",
|
|
573
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
574
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
575
|
+
version: MCP_VERSION
|
|
576
|
+
}),
|
|
577
|
+
headers: { "Content-Type": "application/json" }
|
|
578
|
+
};
|
|
579
|
+
} else if (path === "/health/ready") {
|
|
580
|
+
if (isShuttingDown) {
|
|
581
|
+
result = {
|
|
582
|
+
status: 503,
|
|
583
|
+
body: JSON.stringify({
|
|
584
|
+
status: "not_ready",
|
|
585
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
586
|
+
message: "Server is shutting down"
|
|
587
|
+
}),
|
|
588
|
+
headers: { "Content-Type": "application/json" }
|
|
589
|
+
};
|
|
590
|
+
} else {
|
|
591
|
+
const apiCheck = await proxyToApi("/health", "GET", null, {});
|
|
592
|
+
const isApiReady = apiCheck.status === 200;
|
|
593
|
+
if (isApiReady && !isReady) {
|
|
594
|
+
isReady = true;
|
|
595
|
+
logger.info("MCP Server recovered - API reachable on readiness probe");
|
|
596
|
+
}
|
|
597
|
+
result = {
|
|
598
|
+
status: isApiReady ? 200 : 503,
|
|
599
|
+
body: JSON.stringify({
|
|
600
|
+
status: isApiReady ? "ready" : "not_ready",
|
|
601
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
602
|
+
api: isApiReady ? "connected" : "disconnected",
|
|
603
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
604
|
+
version: MCP_VERSION
|
|
605
|
+
}),
|
|
606
|
+
headers: { "Content-Type": "application/json" }
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
} else if (path === "/health/startup") {
|
|
610
|
+
result = {
|
|
611
|
+
status: isReady ? 200 : 503,
|
|
612
|
+
body: JSON.stringify({
|
|
613
|
+
status: isReady ? "started" : "starting",
|
|
614
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
615
|
+
}),
|
|
616
|
+
headers: { "Content-Type": "application/json" }
|
|
617
|
+
};
|
|
618
|
+
} else if (path === "/mcp") {
|
|
619
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
620
|
+
let parsed = {};
|
|
621
|
+
try {
|
|
622
|
+
parsed = JSON.parse(body || "{}");
|
|
623
|
+
} catch {
|
|
624
|
+
res.writeHead(400);
|
|
625
|
+
res.end(JSON.stringify({ error: "BAD_REQUEST" }));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const workspace = parsed.workspace || url.searchParams.get("workspace") || req.headers["x-workspace-path"] || "default";
|
|
629
|
+
if (!validateWorkspace(workspace).valid) {
|
|
630
|
+
res.writeHead(400);
|
|
631
|
+
res.end(JSON.stringify({ error: "BAD_REQUEST" }));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const workspaceId = parsed.workspaceId || req.headers["x-workspace-id"];
|
|
635
|
+
if (workspaceId && !validateWorkspaceId(workspaceId).valid) {
|
|
636
|
+
res.writeHead(401);
|
|
637
|
+
res.end(JSON.stringify({ error: "UNAUTHORIZED" }));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const apiKey = req.headers["x-api-key"];
|
|
641
|
+
if (apiKey) {
|
|
642
|
+
const validation = await validateApiKeyWithDatabase(apiKey);
|
|
643
|
+
if (!validation.valid) {
|
|
644
|
+
if (validation.transient) {
|
|
645
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
646
|
+
res.end(
|
|
647
|
+
JSON.stringify({
|
|
648
|
+
jsonrpc: "2.0",
|
|
649
|
+
error: {
|
|
650
|
+
code: -32003,
|
|
651
|
+
message: "Auth service temporarily unavailable",
|
|
652
|
+
data: { reason: "auth_service_unavailable", retryable: true }
|
|
653
|
+
},
|
|
654
|
+
id: parsed?.id ?? null
|
|
655
|
+
})
|
|
656
|
+
);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const errorResponse = {
|
|
660
|
+
jsonrpc: "2.0",
|
|
661
|
+
error: {
|
|
662
|
+
code: -32001,
|
|
663
|
+
message: "Unauthorized",
|
|
664
|
+
data: { reason: validation.error || "key_not_found_or_revoked" }
|
|
665
|
+
},
|
|
666
|
+
id: parsed?.id ?? null
|
|
667
|
+
};
|
|
668
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
669
|
+
res.end(JSON.stringify(errorResponse));
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (validation.userId) {
|
|
673
|
+
logger.debug("API key validated", { userId: validation.userId, tier: validation.tier });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const headers = { "x-workspace-path": workspace };
|
|
677
|
+
if (sessionId) {
|
|
678
|
+
headers["mcp-session-id"] = sessionId;
|
|
679
|
+
}
|
|
680
|
+
if (workspaceId) {
|
|
681
|
+
headers["x-workspace-id"] = workspaceId;
|
|
682
|
+
}
|
|
683
|
+
if (apiKey) {
|
|
684
|
+
headers["x-api-key"] = apiKey;
|
|
685
|
+
}
|
|
686
|
+
if (req.headers.authorization?.startsWith("Bearer ")) {
|
|
687
|
+
headers.Authorization = req.headers.authorization;
|
|
688
|
+
}
|
|
689
|
+
result = await proxyToApi("", req.method || "POST", parsed, headers);
|
|
690
|
+
} else if (path === "/auth/link-workspace") {
|
|
691
|
+
if (req.method !== "POST") {
|
|
692
|
+
res.writeHead(405);
|
|
693
|
+
res.end();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
let parsed = {};
|
|
697
|
+
try {
|
|
698
|
+
parsed = JSON.parse(body);
|
|
699
|
+
} catch {
|
|
700
|
+
res.writeHead(400);
|
|
701
|
+
res.end();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
result = await proxyToApi(
|
|
705
|
+
"/auth/link-workspace",
|
|
706
|
+
"POST",
|
|
707
|
+
parsed,
|
|
708
|
+
req.headers.authorization ? { Authorization: req.headers.authorization } : {}
|
|
709
|
+
);
|
|
710
|
+
} else if (path === "/bridge/push") {
|
|
711
|
+
if (req.method !== "POST") {
|
|
712
|
+
res.writeHead(405);
|
|
713
|
+
res.end();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
let parsed = {};
|
|
717
|
+
try {
|
|
718
|
+
parsed = JSON.parse(body);
|
|
719
|
+
} catch {
|
|
720
|
+
res.writeHead(400);
|
|
721
|
+
res.end();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
result = await proxyToApi("/bridge/push", "POST", parsed, {});
|
|
725
|
+
} else if (path === "/bridge/status") {
|
|
726
|
+
if (req.method !== "GET") {
|
|
727
|
+
res.writeHead(405);
|
|
728
|
+
res.end();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const wsId = url.searchParams.get("workspaceId");
|
|
732
|
+
result = await proxyToApi(
|
|
733
|
+
wsId ? `/bridge/status?workspaceId=${encodeURIComponent(wsId)}` : "/bridge/status",
|
|
734
|
+
"GET",
|
|
735
|
+
null,
|
|
736
|
+
{}
|
|
737
|
+
);
|
|
738
|
+
} else if (path === "/capabilities/false-positive") {
|
|
739
|
+
if (req.method !== "POST") {
|
|
740
|
+
res.writeHead(405);
|
|
741
|
+
res.end();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
let parsed = {};
|
|
745
|
+
try {
|
|
746
|
+
parsed = JSON.parse(body);
|
|
747
|
+
} catch {
|
|
748
|
+
res.writeHead(400);
|
|
749
|
+
res.end();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
result = await proxyToApi("/capabilities/false-positive", "POST", parsed, {});
|
|
753
|
+
} else if (path === "/capabilities") {
|
|
754
|
+
if (req.method !== "GET") {
|
|
755
|
+
res.writeHead(405);
|
|
756
|
+
res.end();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const userId = url.searchParams.get("userId");
|
|
760
|
+
if (!userId) {
|
|
761
|
+
res.writeHead(400);
|
|
762
|
+
res.end(JSON.stringify({ error: "BAD_REQUEST" }));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
result = await proxyToApi(`/capabilities?userId=${encodeURIComponent(userId)}`, "GET", null, {});
|
|
766
|
+
} else {
|
|
767
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
768
|
+
res.end(JSON.stringify({ error: "NOT_FOUND" }));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const responseHeaders = {
|
|
772
|
+
"Content-Type": result.headers["content-type"] || "application/json"
|
|
773
|
+
};
|
|
774
|
+
if (result.headers["mcp-session-id"]) {
|
|
775
|
+
responseHeaders["mcp-session-id"] = result.headers["mcp-session-id"];
|
|
776
|
+
}
|
|
777
|
+
res.writeHead(result.status, responseHeaders);
|
|
778
|
+
res.end(result.body);
|
|
779
|
+
} catch (e) {
|
|
780
|
+
logger.error("Error", { requestId, error: String(e) });
|
|
781
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
782
|
+
res.end(JSON.stringify({ error: "INTERNAL_ERROR" }));
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
var SHUTDOWN_TIMEOUT_MS = 25e3;
|
|
787
|
+
async function gracefulShutdown(signal) {
|
|
788
|
+
if (isShuttingDown) {
|
|
789
|
+
logger.warn("Shutdown already in progress");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
isShuttingDown = true;
|
|
793
|
+
logger.info("Shutdown initiated", { signal, pid: process.pid });
|
|
794
|
+
isReady = false;
|
|
795
|
+
logger.info("Service marked as not ready");
|
|
796
|
+
server.closeAllConnections();
|
|
797
|
+
logger.info("SSE connections closed");
|
|
798
|
+
logger.info("Waiting for in-flight requests to complete...");
|
|
799
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
800
|
+
destroyPools();
|
|
801
|
+
logger.info("Connection pools destroyed");
|
|
802
|
+
try {
|
|
803
|
+
await Sentry.close(2e3);
|
|
804
|
+
} catch {
|
|
805
|
+
}
|
|
806
|
+
logger.info("Closing server...");
|
|
807
|
+
server.close(() => {
|
|
808
|
+
logger.info("Server closed, exiting");
|
|
809
|
+
process.exit(0);
|
|
810
|
+
});
|
|
811
|
+
setTimeout(() => {
|
|
812
|
+
logger.error("Forced exit due to timeout");
|
|
813
|
+
process.exit(1);
|
|
814
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
815
|
+
}
|
|
816
|
+
process.once("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
|
817
|
+
process.once("SIGINT", () => gracefulShutdown("SIGINT"));
|
|
818
|
+
server.listen(PORT, "0.0.0.0", () => {
|
|
819
|
+
logger.info("MCP Server started", { port: PORT, host: "0.0.0.0", version: MCP_VERSION, apiUrl: API_URL2 });
|
|
820
|
+
(async () => {
|
|
821
|
+
try {
|
|
822
|
+
const check = await fetchWithPooling(`${API_URL2}/health`, {
|
|
823
|
+
method: "GET",
|
|
824
|
+
headers: { Accept: "application/json" }
|
|
825
|
+
});
|
|
826
|
+
if (check.ok) {
|
|
827
|
+
isReady = true;
|
|
828
|
+
logger.info("MCP Server ready - API reachable", { status: check.status });
|
|
829
|
+
} else {
|
|
830
|
+
logger.warn("MCP Server started but API not reachable", { status: check.status });
|
|
831
|
+
}
|
|
832
|
+
} catch (err) {
|
|
833
|
+
logger.warn("MCP Server started but API health check failed", {
|
|
834
|
+
error: err instanceof Error ? err.message : String(err)
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
})();
|
|
838
|
+
});
|
|
839
|
+
var index_default = server;
|
|
840
|
+
export {
|
|
841
|
+
index_default as default
|
|
842
|
+
};
|
|
843
|
+
//# sourceMappingURL=index.js.map
|