@insforge/mcp 1.2.6 → 1.2.8

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.
@@ -1,191 +1,2148 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  registerInsforgeTools
4
- } from "./chunk-3S2HFIGS.js";
4
+ } from "./chunk-DZ5W3BSP.js";
5
5
 
6
6
  // src/http/server.ts
7
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import "dotenv/config";
8
+ import express from "express";
9
+ import { randomUUID, createHash as createHash2 } from "crypto";
8
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
12
+
13
+ // src/http/session-manager.ts
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+
16
+ // src/http/redis.ts
17
+ import Redis from "ioredis";
18
+ function getRedisConfig() {
19
+ return {
20
+ host: process.env.REDIS_HOST || "localhost",
21
+ port: parseInt(process.env.REDIS_PORT || "6379", 10),
22
+ tls: process.env.REDIS_TLS === "true",
23
+ cluster: process.env.REDIS_CLUSTER === "true"
24
+ };
25
+ }
26
+ function createRedisClient(config) {
27
+ const redisConfig = config || getRedisConfig();
28
+ const { host, port, tls, cluster } = redisConfig;
29
+ const tlsOptions = tls ? { tls: {} } : {};
30
+ if (cluster) {
31
+ return new Redis.Cluster(
32
+ [{ host, port }],
33
+ {
34
+ redisOptions: {
35
+ ...tlsOptions
36
+ },
37
+ dnsLookup: (address, callback) => callback(null, address),
38
+ slotsRefreshTimeout: 2e3,
39
+ enableReadyCheck: true
40
+ }
41
+ );
42
+ } else {
43
+ return new Redis({
44
+ host,
45
+ port,
46
+ ...tlsOptions,
47
+ maxRetriesPerRequest: 3,
48
+ retryStrategy(times) {
49
+ const delay = Math.min(times * 100, 3e3);
50
+ return delay;
51
+ }
52
+ });
53
+ }
54
+ }
55
+ var redisClient = null;
56
+ function getRedisClient() {
57
+ if (!redisClient) {
58
+ redisClient = createRedisClient();
59
+ redisClient.on("error", (err) => {
60
+ console.error("[Redis] Connection error:", err.message);
61
+ });
62
+ redisClient.on("connect", () => {
63
+ console.log("[Redis] Connected successfully");
64
+ });
65
+ redisClient.on("ready", () => {
66
+ console.log("[Redis] Ready to accept commands");
67
+ });
68
+ }
69
+ return redisClient;
70
+ }
71
+ async function closeRedisClient() {
72
+ if (redisClient) {
73
+ await redisClient.quit();
74
+ redisClient = null;
75
+ console.log("[Redis] Connection closed");
76
+ }
77
+ }
78
+
79
+ // src/http/session-manager.ts
80
+ var SESSION_KEY_PREFIX = "mcp:session:";
81
+ var SESSION_TTL = 24 * 60 * 60;
82
+ var SessionManager = class {
83
+ // In-memory cache for runtime instances
84
+ runtimeSessions = /* @__PURE__ */ new Map();
85
+ /**
86
+ * Create a new session
87
+ *
88
+ * Uses connect-first strategy: establishes transport connection before
89
+ * persisting to Redis to avoid orphaned records if connection fails
90
+ */
91
+ async createSession(sessionId, sessionData, transport) {
92
+ const redis = getRedisClient();
93
+ const now = Date.now();
94
+ const server = new McpServer({
95
+ name: "insforge-mcp",
96
+ version: "1.0.0"
97
+ });
98
+ const toolsConfig = await registerInsforgeTools(server, {
99
+ apiKey: sessionData.apiKey,
100
+ apiBaseUrl: sessionData.apiBaseUrl,
101
+ mode: "remote"
102
+ });
103
+ await server.connect(transport);
104
+ const fullSessionData = {
105
+ ...sessionData,
106
+ createdAt: now,
107
+ lastAccessedAt: now,
108
+ backendVersion: toolsConfig.backendVersion
109
+ };
110
+ await redis.setex(
111
+ SESSION_KEY_PREFIX + sessionId,
112
+ SESSION_TTL,
113
+ JSON.stringify(fullSessionData)
114
+ );
115
+ this.runtimeSessions.set(sessionId, { server, transport, transportType: "streamable" });
116
+ console.log(`[SessionManager] Session created: ${sessionId}`);
117
+ return server;
118
+ }
119
+ /**
120
+ * Get session data from Redis
121
+ */
122
+ async getSessionData(sessionId) {
123
+ const redis = getRedisClient();
124
+ const data = await redis.get(SESSION_KEY_PREFIX + sessionId);
125
+ if (!data) {
126
+ return null;
127
+ }
128
+ return JSON.parse(data);
129
+ }
130
+ /**
131
+ * Get runtime session (transport + server) from memory
132
+ * If session exists in Redis but not in memory, it needs to be restored
133
+ */
134
+ getRuntimeSession(sessionId) {
135
+ return this.runtimeSessions.get(sessionId) || null;
136
+ }
137
+ /**
138
+ * Get runtime session with Streamable HTTP transport
139
+ * Returns null if session doesn't exist or uses SSE transport
140
+ */
141
+ getStreamableSession(sessionId) {
142
+ const session = this.runtimeSessions.get(sessionId);
143
+ if (!session || session.transportType !== "streamable") {
144
+ return null;
145
+ }
146
+ return { server: session.server, transport: session.transport };
147
+ }
148
+ /**
149
+ * Get runtime session with SSE transport
150
+ * Returns null if session doesn't exist or uses Streamable HTTP transport
151
+ */
152
+ getSSESession(sessionId) {
153
+ const session = this.runtimeSessions.get(sessionId);
154
+ if (!session || session.transportType !== "sse") {
155
+ return null;
156
+ }
157
+ return { server: session.server, transport: session.transport };
158
+ }
159
+ /**
160
+ * Check if session exists (either in memory or Redis)
161
+ */
162
+ async hasSession(sessionId) {
163
+ if (this.runtimeSessions.has(sessionId)) {
164
+ return true;
165
+ }
166
+ const redis = getRedisClient();
167
+ const exists = await redis.exists(SESSION_KEY_PREFIX + sessionId);
168
+ return exists === 1;
169
+ }
170
+ /**
171
+ * Restore a session from Redis into memory
172
+ * Called when request comes in with session ID but runtime is not in memory
173
+ * (e.g., after server restart or load balancer routing to different instance)
174
+ */
175
+ async restoreSession(sessionId, transport) {
176
+ const sessionData = await this.getSessionData(sessionId);
177
+ if (!sessionData) {
178
+ console.log(`[SessionManager] Session not found in Redis: ${sessionId}`);
179
+ return null;
180
+ }
181
+ console.log(`[SessionManager] Restoring session from Redis: ${sessionId}`);
182
+ const server = new McpServer({
183
+ name: "insforge-mcp",
184
+ version: "1.0.0"
185
+ });
186
+ await registerInsforgeTools(server, {
187
+ apiKey: sessionData.apiKey,
188
+ apiBaseUrl: sessionData.apiBaseUrl,
189
+ mode: "remote"
190
+ });
191
+ await server.connect(transport);
192
+ this.runtimeSessions.set(sessionId, { server, transport, transportType: "streamable" });
193
+ await this.touchSession(sessionId);
194
+ console.log(`[SessionManager] Session restored: ${sessionId}`);
195
+ return server;
196
+ }
197
+ /**
198
+ * Create a new SSE session (for legacy SSE transport)
199
+ *
200
+ * Uses connect-first strategy: establishes transport connection before
201
+ * persisting to Redis to avoid orphaned records if connection fails
202
+ */
203
+ async createSSESession(sessionId, sessionData, transport) {
204
+ const redis = getRedisClient();
205
+ const now = Date.now();
206
+ const server = new McpServer({
207
+ name: "insforge-mcp",
208
+ version: "1.0.0"
209
+ });
210
+ const toolsConfig = await registerInsforgeTools(server, {
211
+ apiKey: sessionData.apiKey,
212
+ apiBaseUrl: sessionData.apiBaseUrl,
213
+ mode: "remote"
214
+ });
215
+ await server.connect(transport);
216
+ const fullSessionData = {
217
+ ...sessionData,
218
+ createdAt: now,
219
+ lastAccessedAt: now,
220
+ backendVersion: toolsConfig.backendVersion
221
+ };
222
+ await redis.setex(
223
+ SESSION_KEY_PREFIX + sessionId,
224
+ SESSION_TTL,
225
+ JSON.stringify(fullSessionData)
226
+ );
227
+ this.runtimeSessions.set(sessionId, { server, transport, transportType: "sse" });
228
+ console.log(`[SessionManager] SSE session created: ${sessionId}`);
229
+ return server;
230
+ }
231
+ /**
232
+ * Update last accessed time and refresh TTL
233
+ */
234
+ async touchSession(sessionId) {
235
+ const redis = getRedisClient();
236
+ const sessionData = await this.getSessionData(sessionId);
237
+ if (sessionData) {
238
+ sessionData.lastAccessedAt = Date.now();
239
+ await redis.setex(
240
+ SESSION_KEY_PREFIX + sessionId,
241
+ SESSION_TTL,
242
+ JSON.stringify(sessionData)
243
+ );
244
+ }
245
+ }
246
+ /**
247
+ * Delete a session from both Redis and memory
248
+ */
249
+ async deleteSession(sessionId) {
250
+ const redis = getRedisClient();
251
+ const runtime = this.runtimeSessions.get(sessionId);
252
+ if (runtime) {
253
+ try {
254
+ await runtime.server.close();
255
+ await runtime.transport.close();
256
+ } catch (error) {
257
+ console.error(`[SessionManager] Error closing session ${sessionId}:`, error);
258
+ }
259
+ this.runtimeSessions.delete(sessionId);
260
+ }
261
+ await redis.del(SESSION_KEY_PREFIX + sessionId);
262
+ console.log(`[SessionManager] Session deleted: ${sessionId}`);
263
+ }
264
+ /**
265
+ * Get all session IDs (from memory - for graceful shutdown)
266
+ */
267
+ getActiveSessionIds() {
268
+ return Array.from(this.runtimeSessions.keys());
269
+ }
270
+ /**
271
+ * Close all sessions (for graceful shutdown)
272
+ */
273
+ async closeAllSessions() {
274
+ const sessionIds = this.getActiveSessionIds();
275
+ console.log(`[SessionManager] Closing ${sessionIds.length} sessions...`);
276
+ for (const sessionId of sessionIds) {
277
+ await this.deleteSession(sessionId);
278
+ }
279
+ console.log("[SessionManager] All sessions closed");
280
+ }
281
+ /**
282
+ * Get session statistics
283
+ */
284
+ async getStats() {
285
+ const redis = getRedisClient();
286
+ let cursor = "0";
287
+ let count = 0;
288
+ do {
289
+ const [newCursor, keys] = await redis.scan(
290
+ cursor,
291
+ "MATCH",
292
+ SESSION_KEY_PREFIX + "*",
293
+ "COUNT",
294
+ 100
295
+ );
296
+ cursor = newCursor;
297
+ count += keys.length;
298
+ } while (cursor !== "0");
299
+ return {
300
+ activeSessions: count,
301
+ memorySessionCount: this.runtimeSessions.size
302
+ };
303
+ }
304
+ };
305
+ var sessionManager = null;
306
+ function getSessionManager() {
307
+ if (!sessionManager) {
308
+ sessionManager = new SessionManager();
309
+ }
310
+ return sessionManager;
311
+ }
312
+
313
+ // src/http/oauth-manager.ts
314
+ import { createHash, randomBytes } from "crypto";
315
+
316
+ // src/http/insforge-api.ts
317
+ import fetch2 from "node-fetch";
318
+ var INSFORGE_API_BASE = process.env.INSFORGE_API_BASE || "https://api.insforge.dev";
319
+ var InsforgeApiError = class extends Error {
320
+ constructor(message, statusCode, code) {
321
+ super(message);
322
+ this.statusCode = statusCode;
323
+ this.code = code;
324
+ this.name = "InsforgeApiError";
325
+ }
326
+ };
327
+ async function validateToken(token) {
328
+ const response = await fetch2(`${INSFORGE_API_BASE}/auth/v1/profile`, {
329
+ method: "GET",
330
+ headers: {
331
+ "Authorization": `Bearer ${token}`,
332
+ "Content-Type": "application/json"
333
+ }
334
+ });
335
+ if (!response.ok) {
336
+ const errorText = await response.text();
337
+ throw new InsforgeApiError(
338
+ `Token validation failed: ${errorText}`,
339
+ response.status
340
+ );
341
+ }
342
+ const data = await response.json();
343
+ return data.user;
344
+ }
345
+ async function getOrganizations(token) {
346
+ const response = await fetch2(`${INSFORGE_API_BASE}/organizations/v1`, {
347
+ method: "GET",
348
+ headers: {
349
+ "Authorization": `Bearer ${token}`,
350
+ "Content-Type": "application/json"
351
+ }
352
+ });
353
+ if (!response.ok) {
354
+ const errorText = await response.text();
355
+ throw new InsforgeApiError(
356
+ `Failed to get organizations: ${errorText}`,
357
+ response.status
358
+ );
359
+ }
360
+ const data = await response.json();
361
+ return data.organizations;
362
+ }
363
+ async function getProjects(token, organizationId) {
364
+ const response = await fetch2(`${INSFORGE_API_BASE}/organizations/v1/${organizationId}/projects`, {
365
+ method: "GET",
366
+ headers: {
367
+ "Authorization": `Bearer ${token}`,
368
+ "Content-Type": "application/json"
369
+ }
370
+ });
371
+ if (!response.ok) {
372
+ const errorText = await response.text();
373
+ throw new InsforgeApiError(
374
+ `Failed to get projects: ${errorText}`,
375
+ response.status
376
+ );
377
+ }
378
+ const data = await response.json();
379
+ return data.projects;
380
+ }
381
+ async function getProject(token, projectId) {
382
+ const response = await fetch2(`${INSFORGE_API_BASE}/projects/v1/${projectId}`, {
383
+ method: "GET",
384
+ headers: {
385
+ "Authorization": `Bearer ${token}`,
386
+ "Content-Type": "application/json"
387
+ }
388
+ });
389
+ if (!response.ok) {
390
+ const errorText = await response.text();
391
+ throw new InsforgeApiError(
392
+ `Failed to get project: ${errorText}`,
393
+ response.status
394
+ );
395
+ }
396
+ const data = await response.json();
397
+ return data.project;
398
+ }
399
+ async function getProjectApiKey(token, projectId) {
400
+ const response = await fetch2(`${INSFORGE_API_BASE}/projects/v1/${projectId}/access-api-key`, {
401
+ method: "GET",
402
+ headers: {
403
+ "Authorization": `Bearer ${token}`,
404
+ "Content-Type": "application/json"
405
+ }
406
+ });
407
+ if (!response.ok) {
408
+ const errorText = await response.text();
409
+ throw new InsforgeApiError(
410
+ `Failed to get project API key: ${errorText}`,
411
+ response.status
412
+ );
413
+ }
414
+ const data = await response.json();
415
+ return data.access_api_key;
416
+ }
417
+ function buildAccessHost(project) {
418
+ if (project.customized_domain) {
419
+ return `https://${project.customized_domain}`;
420
+ }
421
+ return `https://${project.appkey}.${project.region}.insforge.app`;
422
+ }
423
+ async function getProjectAccess(token, projectId) {
424
+ const [project, apiKey] = await Promise.all([
425
+ getProject(token, projectId),
426
+ getProjectApiKey(token, projectId)
427
+ ]);
428
+ return {
429
+ projectId: project.id,
430
+ projectName: project.name,
431
+ organizationId: project.organization_id,
432
+ accessHost: buildAccessHost(project),
433
+ apiKey,
434
+ region: project.region,
435
+ status: project.status
436
+ };
437
+ }
438
+ async function getAllUserProjects(token) {
439
+ const organizations = await getOrganizations(token);
440
+ const results = await Promise.all(
441
+ organizations.map(async (org) => {
442
+ const projects = await getProjects(token, org.id);
443
+ return {
444
+ organization: org,
445
+ projects: projects.filter((p) => p.status === "active")
446
+ // Only active projects
447
+ };
448
+ })
449
+ );
450
+ return results.filter((r) => r.projects.length > 0);
451
+ }
452
+
453
+ // src/http/oauth-manager.ts
454
+ function generateCodeVerifier() {
455
+ return randomBytes(32).toString("base64url");
456
+ }
457
+ function generateCodeChallenge(verifier) {
458
+ return createHash("sha256").update(verifier).digest("base64url");
459
+ }
460
+ var AUTH_STATE_PREFIX = "mcp:auth:state:";
461
+ var TOKEN_BINDING_PREFIX = "mcp:auth:binding:";
462
+ var AUTH_CODE_PREFIX = "mcp:auth:code:";
463
+ var AUTH_STATE_TTL = 10 * 60;
464
+ var AUTH_CODE_TTL = 5 * 60;
465
+ var TOKEN_BINDING_TTL = 30 * 24 * 60 * 60;
466
+ function hashToken(token) {
467
+ return createHash("sha256").update(token).digest("hex");
468
+ }
469
+ function generateCode() {
470
+ return randomBytes(32).toString("base64url");
471
+ }
472
+ var OAuthManager = class {
473
+ /**
474
+ * Create a new authorization state (step 1 of OAuth flow)
475
+ * Returns a state ID and the PKCE code challenge for Insforge OAuth
476
+ */
477
+ async createAuthorizationState(params) {
478
+ if (params.codeChallenge && params.codeChallengeMethod && params.codeChallengeMethod !== "S256") {
479
+ throw new Error(`Unsupported code_challenge_method: ${params.codeChallengeMethod}. Only S256 is supported.`);
480
+ }
481
+ const redis = getRedisClient();
482
+ const stateId = generateCode();
483
+ const insforgeCodeVerifier = generateCodeVerifier();
484
+ const insforgeCodeChallenge = generateCodeChallenge(insforgeCodeVerifier);
485
+ const authState = {
486
+ ...params,
487
+ codeChallengeMethod: params.codeChallenge ? "S256" : void 0,
488
+ insforgeCodeVerifier,
489
+ createdAt: Date.now()
490
+ };
491
+ await redis.setex(
492
+ AUTH_STATE_PREFIX + stateId,
493
+ AUTH_STATE_TTL,
494
+ JSON.stringify(authState)
495
+ );
496
+ return { stateId, insforgeCodeChallenge };
497
+ }
498
+ /**
499
+ * Get authorization state
500
+ */
501
+ async getAuthorizationState(stateId) {
502
+ const redis = getRedisClient();
503
+ const data = await redis.get(AUTH_STATE_PREFIX + stateId);
504
+ if (!data) {
505
+ return null;
506
+ }
507
+ return JSON.parse(data);
508
+ }
509
+ /**
510
+ * Create an authorization code after user approves and selects a project
511
+ * Returns the code to be exchanged for a token
512
+ */
513
+ async createAuthorizationCode(stateId, token, projectId) {
514
+ const redis = getRedisClient();
515
+ const authState = await this.getAuthorizationState(stateId);
516
+ if (!authState) {
517
+ throw new Error("Invalid or expired authorization state");
518
+ }
519
+ const user = await validateToken(token);
520
+ const projectAccess = await getProjectAccess(token, projectId);
521
+ const tokenHash = hashToken(token);
522
+ const binding = {
523
+ tokenHash,
524
+ userId: user.id,
525
+ userEmail: user.email,
526
+ projectId: projectAccess.projectId,
527
+ projectName: projectAccess.projectName,
528
+ organizationId: projectAccess.organizationId,
529
+ accessHost: projectAccess.accessHost,
530
+ apiKey: projectAccess.apiKey,
531
+ createdAt: Date.now(),
532
+ lastUsedAt: Date.now()
533
+ };
534
+ await redis.setex(
535
+ TOKEN_BINDING_PREFIX + tokenHash,
536
+ TOKEN_BINDING_TTL,
537
+ JSON.stringify(binding)
538
+ );
539
+ const code = generateCode();
540
+ await redis.setex(
541
+ AUTH_CODE_PREFIX + code,
542
+ AUTH_CODE_TTL,
543
+ JSON.stringify({
544
+ tokenHash,
545
+ stateId,
546
+ redirectUri: authState.redirectUri,
547
+ codeChallenge: authState.codeChallenge,
548
+ codeChallengeMethod: authState.codeChallengeMethod
549
+ })
550
+ );
551
+ await redis.del(AUTH_STATE_PREFIX + stateId);
552
+ return code;
553
+ }
554
+ /**
555
+ * Exchange authorization code for token binding info
556
+ * This is called by the MCP client after OAuth callback
557
+ *
558
+ * Uses atomic GETDEL to prevent authorization code replay attacks
559
+ */
560
+ async exchangeCode(code, redirectUri, codeVerifier) {
561
+ const redis = getRedisClient();
562
+ const codeData = await redis.getdel(AUTH_CODE_PREFIX + code);
563
+ if (!codeData) {
564
+ throw new Error("Invalid or expired authorization code");
565
+ }
566
+ const { tokenHash, redirectUri: storedRedirectUri, codeChallenge, codeChallengeMethod } = JSON.parse(codeData);
567
+ if (redirectUri !== storedRedirectUri) {
568
+ throw new Error("Redirect URI mismatch");
569
+ }
570
+ if (codeChallenge) {
571
+ if (!codeVerifier) {
572
+ throw new Error("Code verifier required");
573
+ }
574
+ if (codeChallengeMethod && codeChallengeMethod !== "S256") {
575
+ throw new Error(`Unsupported code_challenge_method: ${codeChallengeMethod}. Only S256 is supported.`);
576
+ }
577
+ const computedChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
578
+ if (computedChallenge !== codeChallenge) {
579
+ throw new Error("Code verifier mismatch");
580
+ }
581
+ }
582
+ return { tokenHash };
583
+ }
584
+ /**
585
+ * Get token binding by token hash
586
+ */
587
+ async getTokenBinding(tokenHash) {
588
+ const redis = getRedisClient();
589
+ const data = await redis.get(TOKEN_BINDING_PREFIX + tokenHash);
590
+ if (!data) {
591
+ return null;
592
+ }
593
+ return JSON.parse(data);
594
+ }
595
+ /**
596
+ * Get token binding by raw token
597
+ */
598
+ async getBindingByToken(token) {
599
+ const tokenHash = hashToken(token);
600
+ return this.getTokenBinding(tokenHash);
601
+ }
602
+ /**
603
+ * Update last used time for a token binding
604
+ */
605
+ async touchBinding(tokenHash) {
606
+ const redis = getRedisClient();
607
+ const binding = await this.getTokenBinding(tokenHash);
608
+ if (binding) {
609
+ binding.lastUsedAt = Date.now();
610
+ await redis.setex(
611
+ TOKEN_BINDING_PREFIX + tokenHash,
612
+ TOKEN_BINDING_TTL,
613
+ JSON.stringify(binding)
614
+ );
615
+ }
616
+ }
617
+ /**
618
+ * Revoke a token binding
619
+ */
620
+ async revokeBinding(tokenHash) {
621
+ const redis = getRedisClient();
622
+ await redis.del(TOKEN_BINDING_PREFIX + tokenHash);
623
+ }
624
+ /**
625
+ * Resolve project info from OAuth token or tokenHash
626
+ * This is the main entry point used by the MCP server
627
+ *
628
+ * The token parameter can be either:
629
+ * 1. A tokenHash (returned by /oauth/token endpoint) - used by MCP clients after OAuth
630
+ * 2. A raw Insforge OAuth token - used for direct API access
631
+ *
632
+ * Flow:
633
+ * 1. Try to find binding using token directly as tokenHash
634
+ * 2. If not found, try hashing the token and look up again
635
+ * 3. If still not found, return null (client needs to go through OAuth flow)
636
+ */
637
+ async resolveProjectFromToken(token) {
638
+ let binding = await this.getTokenBinding(token);
639
+ let actualTokenHash = token;
640
+ if (!binding) {
641
+ actualTokenHash = hashToken(token);
642
+ binding = await this.getTokenBinding(actualTokenHash);
643
+ }
644
+ if (!binding) {
645
+ return null;
646
+ }
647
+ await this.touchBinding(actualTokenHash);
648
+ return {
649
+ apiKey: binding.apiKey,
650
+ apiBaseUrl: binding.accessHost,
651
+ projectId: binding.projectId,
652
+ projectName: binding.projectName,
653
+ userId: binding.userId,
654
+ organizationId: binding.organizationId,
655
+ oauthTokenHash: actualTokenHash
656
+ };
657
+ }
658
+ /**
659
+ * Get all available projects for a user (for project selection UI)
660
+ */
661
+ async getAvailableProjects(token) {
662
+ return getAllUserProjects(token);
663
+ }
664
+ /**
665
+ * Bind a token to a project directly (skip OAuth code flow)
666
+ * Used when user selects a project via API
667
+ */
668
+ async bindTokenToProject(token, projectId) {
669
+ const redis = getRedisClient();
670
+ const user = await validateToken(token);
671
+ const projectAccess = await getProjectAccess(token, projectId);
672
+ const tokenHash = hashToken(token);
673
+ const binding = {
674
+ tokenHash,
675
+ userId: user.id,
676
+ userEmail: user.email,
677
+ projectId: projectAccess.projectId,
678
+ projectName: projectAccess.projectName,
679
+ organizationId: projectAccess.organizationId,
680
+ accessHost: projectAccess.accessHost,
681
+ apiKey: projectAccess.apiKey,
682
+ createdAt: Date.now(),
683
+ lastUsedAt: Date.now()
684
+ };
685
+ await redis.setex(
686
+ TOKEN_BINDING_PREFIX + tokenHash,
687
+ TOKEN_BINDING_TTL,
688
+ JSON.stringify(binding)
689
+ );
690
+ console.log(`[OAuthManager] Token bound to project: ${projectAccess.projectName}`);
691
+ return binding;
692
+ }
693
+ };
694
+ var oauthManager = null;
695
+ function getOAuthManager() {
696
+ if (!oauthManager) {
697
+ oauthManager = new OAuthManager();
698
+ }
699
+ return oauthManager;
700
+ }
701
+
702
+ // src/http/config.ts
703
+ import "dotenv/config";
9
704
  import { program } from "commander";
10
- import express from "express";
11
- import { randomUUID } from "crypto";
12
- program.option("--port <number>", "Port to run HTTP server on", "3000");
705
+ program.option("--port <number>", "Port to run HTTP server on", "3000").option("--host <string>", "Host to bind to", "127.0.0.1");
13
706
  program.parse(process.argv);
14
- var options = program.opts();
15
- var { port } = options;
16
- var PORT = parseInt(port) || 3e3;
17
- var transports = /* @__PURE__ */ new Map();
18
- var servers = /* @__PURE__ */ new Map();
707
+ var cliOptions = program.opts();
708
+ var SERVER_CONFIG = {
709
+ /** Port to run HTTP server on */
710
+ port: parseInt(cliOptions.port) || 3e3,
711
+ /** Host to bind to */
712
+ host: cliOptions.host || "127.0.0.1",
713
+ /** Public URL of this MCP server */
714
+ publicUrl: process.env.MCP_SERVER_URL || "http://localhost:3000"
715
+ };
716
+ var INSFORGE_CONFIG = {
717
+ /** Insforge API base URL */
718
+ apiBase: process.env.INSFORGE_API_BASE || "https://api.insforge.dev",
719
+ /** Insforge frontend URL */
720
+ frontendUrl: process.env.INSFORGE_FRONTEND_URL || "https://insforge.dev",
721
+ /** OAuth client ID (registered with Insforge platform) */
722
+ clientId: process.env.INSFORGE_CLIENT_ID || "",
723
+ /** OAuth client secret (registered with Insforge platform) */
724
+ clientSecret: process.env.INSFORGE_CLIENT_SECRET || "",
725
+ /** OAuth scopes to request from Insforge */
726
+ oauthScopes: "user:read organizations:read projects:read projects:write"
727
+ };
728
+ var OAUTH_CONFIG = {
729
+ /** OAuth callback URL for Insforge OAuth */
730
+ callbackUrl: `${SERVER_CONFIG.publicUrl}/oauth/callback`,
731
+ /** Scopes supported by this MCP server */
732
+ supportedScopes: ["mcp:read", "mcp:write", "project:select"],
733
+ /** Grant types supported */
734
+ grantTypes: ["authorization_code"],
735
+ /** Response types supported */
736
+ responseTypes: ["code"],
737
+ /** Code challenge methods supported (only S256 for security) */
738
+ codeChallengesMethods: ["S256"]
739
+ };
740
+ var REDIS_CONFIG = {
741
+ /** Redis host */
742
+ host: process.env.REDIS_HOST || "localhost",
743
+ /** Redis port */
744
+ port: parseInt(process.env.REDIS_PORT || "6379"),
745
+ /** Redis password */
746
+ password: process.env.REDIS_PASSWORD || void 0,
747
+ /** Use TLS for Redis connection */
748
+ tls: process.env.REDIS_TLS === "true",
749
+ /** Use Redis cluster mode */
750
+ cluster: process.env.REDIS_CLUSTER === "true"
751
+ };
752
+ var SESSION_CONFIG = {
753
+ /** Session TTL in seconds (24 hours) */
754
+ ttl: 24 * 60 * 60,
755
+ /** Redis key prefix for sessions */
756
+ keyPrefix: "mcp:session:"
757
+ };
758
+ var ANALYTICS_CONFIG = {
759
+ /** Mixpanel project token */
760
+ mixpanelToken: process.env.MIXPANEL_TOKEN || ""
761
+ };
762
+ function isAnalyticsConfigured() {
763
+ return !!ANALYTICS_CONFIG.mixpanelToken && process.env.ENABLE_ANALYTICS !== "false";
764
+ }
765
+ var STREAMABLE_HTTP_ENDPOINTS = {
766
+ /** Main MCP endpoint - handles POST (messages), GET (SSE stream), DELETE (close) */
767
+ mcp: "/mcp"
768
+ };
769
+ var SSE_ENDPOINTS = {
770
+ /** SSE stream endpoint - GET to establish Server-Sent Events connection */
771
+ sse: "/sse",
772
+ /** Messages endpoint - POST to send messages to server */
773
+ messages: "/messages"
774
+ };
775
+ var OAUTH_ENDPOINTS = {
776
+ /** OAuth authorization server metadata (RFC 8414) */
777
+ metadata: "/.well-known/oauth-authorization-server",
778
+ /** OAuth protected resource metadata */
779
+ protectedResource: "/.well-known/oauth-protected-resource",
780
+ /** Dynamic client registration (RFC 7591) */
781
+ register: "/oauth/register",
782
+ /** Authorization endpoint */
783
+ authorize: "/oauth/authorize",
784
+ /** OAuth callback from Insforge */
785
+ callback: "/oauth/callback",
786
+ /** Project selection page */
787
+ selectProject: "/oauth/select-project",
788
+ /** Token endpoint */
789
+ token: "/oauth/token",
790
+ /** Token revocation endpoint */
791
+ revoke: "/oauth/revoke"
792
+ };
793
+ var API_ENDPOINTS = {
794
+ /** Health check */
795
+ health: "/health",
796
+ /** List projects */
797
+ projects: "/api/projects",
798
+ /** Bind token to project */
799
+ bindProject: "/api/projects/:projectId/bind"
800
+ };
801
+ function isOAuthConfigured() {
802
+ return !!(INSFORGE_CONFIG.clientId && INSFORGE_CONFIG.clientSecret);
803
+ }
804
+ function validateConfig() {
805
+ if (!isOAuthConfigured()) {
806
+ console.warn("[Config] WARNING: OAuth client credentials not configured.");
807
+ console.warn("[Config] Set INSFORGE_CLIENT_ID and INSFORGE_CLIENT_SECRET environment variables.");
808
+ }
809
+ if (!isAnalyticsConfigured()) {
810
+ console.log("[Config] Analytics not configured. Set MIXPANEL_TOKEN to enable Mixpanel tracking.");
811
+ }
812
+ }
813
+
814
+ // src/http/templates/project-selection.ts
815
+ function renderProjectSelectionPage(options) {
816
+ const { stateId, projectGroups, selectProjectEndpoint } = options;
817
+ const projectsHtml = projectGroups.length > 0 ? projectGroups.map((group) => `
818
+ <div class="org-section">
819
+ <div class="org-name">${escapeHtml(group.organization.name)}</div>
820
+ ${group.projects.map((project) => `
821
+ <form method="POST" action="${selectProjectEndpoint}">
822
+ <input type="hidden" name="state_id" value="${escapeHtml(stateId)}">
823
+ <input type="hidden" name="project_id" value="${escapeHtml(project.id)}">
824
+ <button type="submit" class="project-card">
825
+ <div class="project-info">
826
+ <div class="project-name">${escapeHtml(project.name)}</div>
827
+ <div class="project-url">https://${escapeHtml(project.appkey)}.${escapeHtml(project.region)}.insforge.app</div>
828
+ </div>
829
+ <span class="project-region">${escapeHtml(project.region)}</span>
830
+ <span class="project-arrow">
831
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
832
+ <path d="M7 4L13 10L7 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
833
+ </svg>
834
+ </span>
835
+ </button>
836
+ </form>
837
+ `).join("")}
838
+ </div>
839
+ `).join("") : `
840
+ <div class="no-projects">
841
+ <p>No active projects found.</p>
842
+ <p>Please create a project in the InsForge dashboard first.</p>
843
+ </div>
844
+ `;
845
+ return `<!DOCTYPE html>
846
+ <html>
847
+ <head>
848
+ <title>Select Project - InsForge MCP</title>
849
+ <meta charset="utf-8">
850
+ <meta name="viewport" content="width=device-width, initial-scale=1">
851
+ <link rel="preconnect" href="https://fonts.googleapis.com">
852
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
853
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
854
+ <style>
855
+ * { box-sizing: border-box; margin: 0; padding: 0; }
856
+
857
+ body {
858
+ font-family: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
859
+ background: #0F0F0F;
860
+ color: #e5e5e5;
861
+ min-height: 100vh;
862
+ padding: 60px 20px;
863
+ position: relative;
864
+ }
865
+
866
+ /* Dot grid background pattern */
867
+ body::before {
868
+ content: '';
869
+ position: fixed;
870
+ top: 0;
871
+ left: 0;
872
+ right: 0;
873
+ bottom: 0;
874
+ background-image: radial-gradient(circle, rgba(255,255,255,0.08) 1px, transparent 1px);
875
+ background-size: 24px 24px;
876
+ pointer-events: none;
877
+ z-index: 0;
878
+ }
879
+
880
+ /* Gradient overlay */
881
+ body::after {
882
+ content: '';
883
+ position: fixed;
884
+ top: 0;
885
+ left: 0;
886
+ right: 0;
887
+ height: 400px;
888
+ background: radial-gradient(ellipse at top, rgba(110, 231, 183, 0.08) 0%, transparent 70%);
889
+ pointer-events: none;
890
+ z-index: 0;
891
+ }
892
+
893
+ .container {
894
+ max-width: 560px;
895
+ margin: 0 auto;
896
+ position: relative;
897
+ z-index: 1;
898
+ }
899
+
900
+ .logo {
901
+ text-align: left;
902
+ margin-bottom: 20px;
903
+ }
904
+
905
+ h1 {
906
+ font-size: 32px;
907
+ font-weight: 700;
908
+ color: #fff;
909
+ margin-bottom: 12px;
910
+ text-align: center;
911
+ letter-spacing: -0.02em;
912
+ }
913
+
914
+ .subtitle {
915
+ color: #a0a0a0;
916
+ text-align: center;
917
+ margin-bottom: 48px;
918
+ font-size: 16px;
919
+ font-weight: 400;
920
+ }
921
+
922
+ .org-section {
923
+ margin-bottom: 32px;
924
+ }
925
+
926
+ .org-name {
927
+ font-size: 12px;
928
+ font-weight: 600;
929
+ color: #6EE7B7;
930
+ text-transform: uppercase;
931
+ letter-spacing: 0.1em;
932
+ margin-bottom: 16px;
933
+ padding-left: 4px;
934
+ display: flex;
935
+ align-items: center;
936
+ gap: 8px;
937
+ }
938
+
939
+ .org-name::before {
940
+ content: '';
941
+ width: 6px;
942
+ height: 6px;
943
+ background: #6EE7B7;
944
+ border-radius: 50%;
945
+ }
946
+
947
+ .project-card {
948
+ background: linear-gradient(135deg, #1C1C1C 0%, #171717 100%);
949
+ border: 1px solid #262626;
950
+ border-radius: 8px;
951
+ padding: 20px 24px;
952
+ margin-bottom: 12px;
953
+ cursor: pointer;
954
+ transition: all 0.2s ease;
955
+ display: flex;
956
+ justify-content: space-between;
957
+ align-items: center;
958
+ position: relative;
959
+ overflow: hidden;
960
+ }
961
+
962
+ .project-card::before {
963
+ content: '';
964
+ position: absolute;
965
+ top: 0;
966
+ left: 0;
967
+ right: 0;
968
+ bottom: 0;
969
+ background: linear-gradient(135deg, rgba(110, 231, 183, 0.05) 0%, transparent 50%);
970
+ opacity: 0;
971
+ transition: opacity 0.2s ease;
972
+ }
973
+
974
+ .project-card:hover {
975
+ border-color: #6EE7B7;
976
+ transform: translateY(-2px);
977
+ box-shadow: 0 8px 32px rgba(110, 231, 183, 0.1);
978
+ }
979
+
980
+ .project-card:hover::before {
981
+ opacity: 1;
982
+ }
983
+
984
+ .project-card:active {
985
+ transform: translateY(0);
986
+ }
987
+
988
+ .project-info {
989
+ flex: 1;
990
+ position: relative;
991
+ z-index: 1;
992
+ }
993
+
994
+ .project-name {
995
+ font-weight: 600;
996
+ font-size: 16px;
997
+ color: #fff;
998
+ margin-bottom: 6px;
999
+ letter-spacing: -0.01em;
1000
+ }
1001
+
1002
+ .project-url {
1003
+ font-size: 13px;
1004
+ color: #6EE7B7;
1005
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1006
+ opacity: 0.8;
1007
+ }
1008
+
1009
+ .project-region {
1010
+ font-size: 11px;
1011
+ font-weight: 600;
1012
+ color: #6EE7B7;
1013
+ background: rgba(110, 231, 183, 0.1);
1014
+ border: 1px solid rgba(110, 231, 183, 0.2);
1015
+ padding: 6px 12px;
1016
+ border-radius: 20px;
1017
+ margin-left: 16px;
1018
+ text-transform: uppercase;
1019
+ letter-spacing: 0.05em;
1020
+ position: relative;
1021
+ z-index: 1;
1022
+ }
1023
+
1024
+ .project-arrow {
1025
+ color: #6EE7B7;
1026
+ opacity: 0;
1027
+ transform: translateX(-8px);
1028
+ transition: all 0.2s ease;
1029
+ margin-left: 12px;
1030
+ position: relative;
1031
+ z-index: 1;
1032
+ }
1033
+
1034
+ .project-card:hover .project-arrow {
1035
+ opacity: 1;
1036
+ transform: translateX(0);
1037
+ }
1038
+
1039
+ .no-projects {
1040
+ text-align: center;
1041
+ color: #525252;
1042
+ padding: 60px 40px;
1043
+ background: linear-gradient(135deg, #1C1C1C 0%, #171717 100%);
1044
+ border: 1px solid #262626;
1045
+ border-radius: 8px;
1046
+ }
1047
+
1048
+ .no-projects p {
1049
+ margin-bottom: 8px;
1050
+ }
1051
+
1052
+ .no-projects p:last-child {
1053
+ margin-bottom: 0;
1054
+ }
1055
+
1056
+ .cancel-link {
1057
+ display: inline-flex;
1058
+ align-items: center;
1059
+ justify-content: center;
1060
+ width: 100%;
1061
+ margin-top: 32px;
1062
+ color: #737373;
1063
+ text-decoration: none;
1064
+ font-size: 14px;
1065
+ font-weight: 500;
1066
+ padding: 12px;
1067
+ border-radius: 6px;
1068
+ transition: all 0.2s ease;
1069
+ }
1070
+
1071
+ .cancel-link:hover {
1072
+ color: #a3a3a3;
1073
+ background: rgba(255, 255, 255, 0.03);
1074
+ }
1075
+
1076
+ form { display: contents; }
1077
+
1078
+ button.project-card {
1079
+ width: 100%;
1080
+ text-align: left;
1081
+ font: inherit;
1082
+ }
1083
+
1084
+ /* Footer branding */
1085
+ .footer {
1086
+ text-align: center;
1087
+ margin-top: 48px;
1088
+ padding-top: 24px;
1089
+ border-top: 1px solid #262626;
1090
+ }
1091
+
1092
+ .footer-text {
1093
+ font-size: 12px;
1094
+ color: #525252;
1095
+ }
1096
+
1097
+ .footer-text a {
1098
+ color: #6EE7B7;
1099
+ text-decoration: none;
1100
+ transition: opacity 0.2s;
1101
+ }
1102
+
1103
+ .footer-text a:hover {
1104
+ opacity: 0.8;
1105
+ }
1106
+ </style>
1107
+ </head>
1108
+ <body>
1109
+ <div class="container">
1110
+ <div class="logo">
1111
+ <img alt="InsForge Logo" width="100" height="24" decoding="async" data-nimg="1" style="color:transparent" src="https://insforge.dev/assets/logos/logo_text.svg">
1112
+ </div>
1113
+
1114
+ <h1>Select a Project</h1>
1115
+ <p class="subtitle">Choose the project to connect with your coding agent</p>
1116
+
1117
+ ${projectsHtml}
1118
+
1119
+ <a href="javascript:history.back()" class="cancel-link">Cancel</a>
1120
+
1121
+ </div>
1122
+ </body>
1123
+ </html>`;
1124
+ }
1125
+ function escapeHtml(text) {
1126
+ const htmlEscapes = {
1127
+ "&": "&amp;",
1128
+ "<": "&lt;",
1129
+ ">": "&gt;",
1130
+ '"': "&quot;",
1131
+ "'": "&#39;"
1132
+ };
1133
+ return text.replace(/[&<>"']/g, (char) => htmlEscapes[char]);
1134
+ }
1135
+
1136
+ // src/http/analytics.ts
1137
+ import Mixpanel from "mixpanel";
1138
+ function extractClientInfo(body) {
1139
+ if (!body || typeof body !== "object") return null;
1140
+ const single = body;
1141
+ if (single.method === "initialize" && single.params?.clientInfo?.name) {
1142
+ return {
1143
+ name: single.params.clientInfo.name,
1144
+ version: single.params.clientInfo.version || "unknown"
1145
+ };
1146
+ }
1147
+ if (Array.isArray(body)) {
1148
+ for (const req of body) {
1149
+ const result = extractClientInfo(req);
1150
+ if (result) return result;
1151
+ }
1152
+ }
1153
+ return null;
1154
+ }
1155
+ var CLIENT_PATTERNS = [
1156
+ // Anthropic
1157
+ [["claude-code", "claudecode", "claude code"], "claude_code"],
1158
+ [["claude-desktop", "claudedesktop", "claude desktop"], "claude_desktop"],
1159
+ // Editors / IDEs
1160
+ [["cursor"], "cursor"],
1161
+ [["cline"], "cline"],
1162
+ [["windsurf"], "windsurf"],
1163
+ [["roocode", "roo-code", "roo code"], "roocode"],
1164
+ [["trae"], "trae"],
1165
+ [["kiro"], "kiro"],
1166
+ [["github copilot", "github-copilot"], "github_copilot"],
1167
+ [["vscode", "visual studio code"], "vscode"],
1168
+ // CLI agents
1169
+ [["codex"], "codex"],
1170
+ [["opencode", "open-code"], "opencode"],
1171
+ [["gemini-cli", "gemini cli", "gemini_cli"], "gemini_cli"],
1172
+ [["antigravity", "google-antigravity"], "google_antigravity"],
1173
+ [["qoder"], "qoder"],
1174
+ [["goose"], "goose"]
1175
+ ];
1176
+ function wordBoundaryMatch(input, pattern) {
1177
+ const idx = input.indexOf(pattern);
1178
+ if (idx === -1) return false;
1179
+ const before = idx === 0 || !/[a-z]/.test(input[idx - 1]);
1180
+ const after = idx + pattern.length >= input.length || !/[a-z]/.test(input[idx + pattern.length]);
1181
+ return before && after;
1182
+ }
1183
+ function matchClientType(input) {
1184
+ const lower = input.toLowerCase();
1185
+ for (const [patterns, clientType] of CLIENT_PATTERNS) {
1186
+ if (patterns.some((p) => wordBoundaryMatch(lower, p))) return clientType;
1187
+ }
1188
+ return "unknown";
1189
+ }
1190
+ function normalizeClientName(name) {
1191
+ return matchClientType(name);
1192
+ }
1193
+ function parseUserAgent(userAgent) {
1194
+ if (!userAgent) return "unknown";
1195
+ return matchClientType(userAgent);
1196
+ }
1197
+ var AnalyticsService = class {
1198
+ mixpanel = null;
1199
+ enabled;
1200
+ constructor() {
1201
+ const mixpanelToken = ANALYTICS_CONFIG.mixpanelToken;
1202
+ this.enabled = isAnalyticsConfigured();
1203
+ if (this.enabled && mixpanelToken) {
1204
+ this.mixpanel = Mixpanel.init(mixpanelToken, {
1205
+ protocol: "https",
1206
+ keepAlive: false
1207
+ });
1208
+ console.log("[Analytics] Mixpanel initialized");
1209
+ } else {
1210
+ console.log("[Analytics] Disabled (no MIXPANEL_TOKEN or ENABLE_ANALYTICS=false)");
1211
+ }
1212
+ }
1213
+ /**
1214
+ * Track when a new MCP session is created.
1215
+ * Uses clientInfo (from MCP initialize) as primary source, User-Agent as fallback.
1216
+ */
1217
+ trackSessionCreated(params) {
1218
+ if (!this.enabled || !this.mixpanel) return;
1219
+ const clientType = params.clientName ? normalizeClientName(params.clientName) : parseUserAgent(params.userAgent);
1220
+ const distinctId = params.userId !== "legacy" && params.userId !== "unknown" ? params.userId : `anon_${params.projectId}`;
1221
+ try {
1222
+ this.mixpanel.track("mcp_session_created", {
1223
+ distinct_id: distinctId,
1224
+ client_type: clientType,
1225
+ client_name: params.clientName || "not_provided",
1226
+ client_version: params.clientVersion || "not_provided",
1227
+ user_agent: params.userAgent || "not_provided",
1228
+ transport_type: params.transportType,
1229
+ project_id: params.projectId,
1230
+ user_id: params.userId,
1231
+ organization_id: params.organizationId,
1232
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1233
+ });
1234
+ } catch (error) {
1235
+ console.warn("[Analytics] Failed to track mcp_session_created:", error instanceof Error ? error.message : error);
1236
+ }
1237
+ }
1238
+ /**
1239
+ * Track a successful OAuth token exchange.
1240
+ */
1241
+ trackOAuthSuccess(params) {
1242
+ if (!this.enabled || !this.mixpanel) return;
1243
+ try {
1244
+ this.mixpanel.track("mcp_oauth_success", {
1245
+ distinct_id: params.clientId,
1246
+ client_id: params.clientId,
1247
+ scope: params.scope,
1248
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1249
+ });
1250
+ } catch (error) {
1251
+ console.warn("[Analytics] Failed to track mcp_oauth_success:", error instanceof Error ? error.message : error);
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Track an OAuth failure.
1256
+ */
1257
+ trackOAuthFailure(params) {
1258
+ if (!this.enabled || !this.mixpanel) return;
1259
+ try {
1260
+ this.mixpanel.track("mcp_oauth_failure", {
1261
+ distinct_id: "system",
1262
+ error_type: params.errorType,
1263
+ error_description: params.errorDescription,
1264
+ endpoint: params.endpoint,
1265
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1266
+ });
1267
+ } catch (error) {
1268
+ console.warn("[Analytics] Failed to track mcp_oauth_failure:", error instanceof Error ? error.message : error);
1269
+ }
1270
+ }
1271
+ };
1272
+ var _instance = null;
1273
+ function getAnalyticsService() {
1274
+ if (!_instance) _instance = new AnalyticsService();
1275
+ return _instance;
1276
+ }
1277
+
1278
+ // src/http/server.ts
19
1279
  var app = express();
1280
+ app.set("trust proxy", true);
20
1281
  app.use(express.json({ limit: "10mb" }));
1282
+ app.use(express.urlencoded({ extended: true }));
21
1283
  app.use((req, res, next) => {
22
1284
  res.setHeader("Access-Control-Allow-Origin", "*");
23
1285
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
24
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, X-Base-URL, Mcp-Session-Id, Last-Event-ID");
1286
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, Mcp-Session-Id, Last-Event-ID");
25
1287
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
26
1288
  if (req.method === "OPTIONS") {
27
1289
  return res.sendStatus(200);
28
1290
  }
29
1291
  next();
30
1292
  });
31
- app.get("/health", (_req, res) => {
32
- res.json({
33
- status: "ok",
34
- server: "insforge-mcp-streamable",
35
- version: "1.0.0",
36
- protocol: "Streamable HTTP",
37
- sessions: transports.size,
38
- authentication: "per-request via headers",
39
- requiredHeaders: {
40
- "Authorization": "Bearer <API_KEY>",
41
- "X-Base-URL": "<BACKEND_URL> (e.g. http://localhost:7130)"
42
- }
43
- });
44
- });
45
1293
  function isInitializeRequest(body) {
46
1294
  if (!body) return false;
47
- if (body.method === "initialize") {
48
- return true;
1295
+ if (typeof body === "object" && body !== null && "method" in body) {
1296
+ if (body.method === "initialize") {
1297
+ return true;
1298
+ }
49
1299
  }
50
1300
  if (Array.isArray(body)) {
51
- return body.some((req) => req.method === "initialize");
1301
+ return body.some(
1302
+ (req) => typeof req === "object" && req !== null && "method" in req && req.method === "initialize"
1303
+ );
52
1304
  }
53
1305
  return false;
54
1306
  }
55
- app.post("/mcp", async (req, res) => {
56
- const sessionId = req.headers["mcp-session-id"];
57
- console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] POST /mcp - Session: ${sessionId || "none"}`);
1307
+ function tokenFingerprint(token) {
1308
+ return createHash2("sha256").update(token).digest("hex").substring(0, 8);
1309
+ }
1310
+ async function resolveProjectFromToken(token) {
1311
+ const oauthManager2 = getOAuthManager();
1312
+ return oauthManager2.resolveProjectFromToken(token);
1313
+ }
1314
+ function extractOAuthToken(req) {
58
1315
  const authHeader = req.headers["authorization"];
59
- let apiKey;
60
1316
  if (authHeader?.startsWith("Bearer ")) {
61
- apiKey = authHeader.substring(7);
1317
+ return authHeader.substring(7);
62
1318
  }
63
- const apiBaseUrl = req.headers["x-base-url"];
64
- let transport;
65
- let mcpServer;
66
- if (sessionId && transports.has(sessionId)) {
67
- transport = transports.get(sessionId);
68
- console.log("Using existing transport for session:", sessionId);
69
- } else if (isInitializeRequest(req.body)) {
70
- if (!apiKey) {
71
- return res.status(401).json({
72
- error: "Missing required Authorization header. Expected: Authorization: Bearer <API_KEY>"
1319
+ return void 0;
1320
+ }
1321
+ function extractLegacyHeaders(req) {
1322
+ return {
1323
+ apiKey: req.headers["x-api-key"],
1324
+ apiBaseUrl: req.headers["x-base-url"]
1325
+ };
1326
+ }
1327
+ app.get(API_ENDPOINTS.health, async (_req, res) => {
1328
+ const sessionManager2 = getSessionManager();
1329
+ const stats = await sessionManager2.getStats();
1330
+ res.json({
1331
+ status: "ok",
1332
+ server: "insforge-mcp",
1333
+ version: "1.0.0",
1334
+ protocols: {
1335
+ streamableHttp: "2025-03-26",
1336
+ sse: "2024-11-05 (deprecated)"
1337
+ },
1338
+ sessions: stats,
1339
+ authentication: "OAuth Bearer Token"
1340
+ });
1341
+ });
1342
+ app.get(OAUTH_ENDPOINTS.metadata, (_req, res) => {
1343
+ const baseUrl = SERVER_CONFIG.publicUrl;
1344
+ res.json({
1345
+ issuer: baseUrl,
1346
+ authorization_endpoint: `${baseUrl}${OAUTH_ENDPOINTS.authorize}`,
1347
+ token_endpoint: `${baseUrl}${OAUTH_ENDPOINTS.token}`,
1348
+ revocation_endpoint: `${baseUrl}${OAUTH_ENDPOINTS.revoke}`,
1349
+ registration_endpoint: `${baseUrl}${OAUTH_ENDPOINTS.register}`,
1350
+ response_types_supported: OAUTH_CONFIG.responseTypes,
1351
+ grant_types_supported: OAUTH_CONFIG.grantTypes,
1352
+ code_challenge_methods_supported: OAUTH_CONFIG.codeChallengesMethods,
1353
+ scopes_supported: OAUTH_CONFIG.supportedScopes
1354
+ });
1355
+ });
1356
+ app.get(OAUTH_ENDPOINTS.protectedResource, (_req, res) => {
1357
+ const baseUrl = SERVER_CONFIG.publicUrl;
1358
+ res.json({
1359
+ resource: `${baseUrl}/mcp`,
1360
+ authorization_servers: [baseUrl],
1361
+ scopes_supported: ["mcp:read", "mcp:write"]
1362
+ });
1363
+ });
1364
+ app.post(OAUTH_ENDPOINTS.register, async (req, res) => {
1365
+ const {
1366
+ client_name,
1367
+ redirect_uris,
1368
+ grant_types,
1369
+ response_types,
1370
+ token_endpoint_auth_method,
1371
+ scope
1372
+ } = req.body;
1373
+ if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
1374
+ return res.status(400).json({
1375
+ error: "invalid_client_metadata",
1376
+ error_description: "redirect_uris is required and must be a non-empty array"
1377
+ });
1378
+ }
1379
+ const clientId = `mcp_${randomUUID().replace(/-/g, "")}`;
1380
+ const redis = getRedisClient();
1381
+ const clientData = {
1382
+ client_id: clientId,
1383
+ client_name: client_name || "MCP Client",
1384
+ redirect_uris,
1385
+ grant_types: grant_types || OAUTH_CONFIG.grantTypes,
1386
+ response_types: response_types || ["code"],
1387
+ token_endpoint_auth_method: token_endpoint_auth_method || "none",
1388
+ scope: scope || "mcp:read mcp:write",
1389
+ created_at: Date.now()
1390
+ };
1391
+ await redis.setex(
1392
+ `mcp:oauth:client:${clientId}`,
1393
+ 30 * 24 * 60 * 60,
1394
+ JSON.stringify(clientData)
1395
+ );
1396
+ console.log(`[OAuth] Registered new client: ${clientId} (${clientData.client_name})`);
1397
+ res.status(201).json({
1398
+ client_id: clientId,
1399
+ client_name: clientData.client_name,
1400
+ redirect_uris: clientData.redirect_uris,
1401
+ grant_types: clientData.grant_types,
1402
+ response_types: clientData.response_types,
1403
+ token_endpoint_auth_method: clientData.token_endpoint_auth_method,
1404
+ scope: clientData.scope
1405
+ });
1406
+ });
1407
+ app.get(OAUTH_ENDPOINTS.authorize, async (req, res) => {
1408
+ const { client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method } = req.query;
1409
+ if (!isOAuthConfigured()) {
1410
+ return res.status(500).json({
1411
+ error: "server_error",
1412
+ error_description: "OAuth client credentials not configured. Set INSFORGE_CLIENT_ID and INSFORGE_CLIENT_SECRET."
1413
+ });
1414
+ }
1415
+ if (!client_id || !redirect_uri || !response_type) {
1416
+ return res.status(400).json({
1417
+ error: "invalid_request",
1418
+ error_description: "Missing required parameters: client_id, redirect_uri, response_type"
1419
+ });
1420
+ }
1421
+ const resolvedScope = scope || OAUTH_CONFIG.supportedScopes.join(" ");
1422
+ if (response_type !== "code") {
1423
+ return res.status(400).json({
1424
+ error: "unsupported_response_type",
1425
+ error_description: "Only response_type=code is supported"
1426
+ });
1427
+ }
1428
+ const redis = getRedisClient();
1429
+ const clientDataStr = await redis.get(`mcp:oauth:client:${client_id}`);
1430
+ if (!clientDataStr) {
1431
+ return res.status(400).json({
1432
+ error: "invalid_client",
1433
+ error_description: "Unknown client_id. Register client first via /oauth/register."
1434
+ });
1435
+ }
1436
+ let clientData;
1437
+ try {
1438
+ clientData = JSON.parse(clientDataStr);
1439
+ } catch (parseError) {
1440
+ console.error(`[OAuth] Failed to parse client data for client_id ${client_id}:`, parseError);
1441
+ return res.status(500).json({
1442
+ error: "server_error",
1443
+ error_description: "Failed to read client registration data."
1444
+ });
1445
+ }
1446
+ if (!clientData.client_id || typeof clientData.client_id !== "string") {
1447
+ console.error(`[OAuth] Invalid client data: missing or invalid client_id for ${client_id}`);
1448
+ return res.status(500).json({
1449
+ error: "server_error",
1450
+ error_description: "Client registration data is corrupted."
1451
+ });
1452
+ }
1453
+ if (!Array.isArray(clientData.redirect_uris)) {
1454
+ console.error(`[OAuth] Invalid client data: missing or invalid redirect_uris for ${client_id}`);
1455
+ return res.status(500).json({
1456
+ error: "server_error",
1457
+ error_description: "Client registration data is corrupted."
1458
+ });
1459
+ }
1460
+ if (!clientData.redirect_uris.includes(redirect_uri)) {
1461
+ return res.status(400).json({
1462
+ error: "invalid_request",
1463
+ error_description: "redirect_uri does not match any registered redirect URIs for this client."
1464
+ });
1465
+ }
1466
+ try {
1467
+ const oauthManager2 = getOAuthManager();
1468
+ const { stateId, insforgeCodeChallenge } = await oauthManager2.createAuthorizationState({
1469
+ clientId: client_id,
1470
+ redirectUri: redirect_uri,
1471
+ scope: resolvedScope,
1472
+ state,
1473
+ codeChallenge: code_challenge,
1474
+ codeChallengeMethod: code_challenge_method
1475
+ });
1476
+ const authUrl = new URL(`${INSFORGE_CONFIG.apiBase}/api/oauth/v1/authorize`);
1477
+ authUrl.searchParams.set("client_id", INSFORGE_CONFIG.clientId);
1478
+ authUrl.searchParams.set("redirect_uri", OAUTH_CONFIG.callbackUrl);
1479
+ authUrl.searchParams.set("response_type", "code");
1480
+ authUrl.searchParams.set("scope", INSFORGE_CONFIG.oauthScopes);
1481
+ authUrl.searchParams.set("state", stateId);
1482
+ authUrl.searchParams.set("code_challenge", insforgeCodeChallenge);
1483
+ authUrl.searchParams.set("code_challenge_method", "S256");
1484
+ console.log(`[OAuth] Redirecting to Insforge OAuth: ${authUrl.toString()}`);
1485
+ res.redirect(authUrl.toString());
1486
+ } catch (error) {
1487
+ console.error("OAuth authorize error:", error);
1488
+ res.status(500).json({
1489
+ error: "server_error",
1490
+ error_description: "Failed to initiate authorization"
1491
+ });
1492
+ }
1493
+ });
1494
+ app.get(OAUTH_ENDPOINTS.callback, async (req, res) => {
1495
+ const { code, state, error, error_description } = req.query;
1496
+ const oauthManager2 = getOAuthManager();
1497
+ if (error) {
1498
+ console.error("[OAuth] Insforge returned error:", error, error_description);
1499
+ getAnalyticsService().trackOAuthFailure({
1500
+ errorType: "insforge_error",
1501
+ errorDescription: error_description || error || "Unknown Insforge error",
1502
+ endpoint: "/oauth/callback"
1503
+ });
1504
+ const authState = state ? await oauthManager2.getAuthorizationState(state) : null;
1505
+ if (authState?.redirectUri) {
1506
+ const redirectUrl = new URL(authState.redirectUri);
1507
+ redirectUrl.searchParams.set("error", error);
1508
+ if (error_description) {
1509
+ redirectUrl.searchParams.set("error_description", error_description);
1510
+ }
1511
+ if (authState.state) {
1512
+ redirectUrl.searchParams.set("state", authState.state);
1513
+ }
1514
+ return res.redirect(redirectUrl.toString());
1515
+ }
1516
+ return res.status(400).json({ error, error_description });
1517
+ }
1518
+ if (!code || !state) {
1519
+ getAnalyticsService().trackOAuthFailure({
1520
+ errorType: "invalid_request",
1521
+ errorDescription: "Missing required parameters: code, state",
1522
+ endpoint: "/oauth/callback"
1523
+ });
1524
+ return res.status(400).json({
1525
+ error: "invalid_request",
1526
+ error_description: "Missing required parameters: code, state"
1527
+ });
1528
+ }
1529
+ try {
1530
+ const authState = await oauthManager2.getAuthorizationState(state);
1531
+ if (!authState) {
1532
+ getAnalyticsService().trackOAuthFailure({
1533
+ errorType: "invalid_request",
1534
+ errorDescription: "Invalid or expired state",
1535
+ endpoint: "/oauth/callback"
1536
+ });
1537
+ return res.status(400).json({
1538
+ error: "invalid_request",
1539
+ error_description: "Invalid or expired state"
73
1540
  });
74
1541
  }
75
- if (!apiBaseUrl) {
1542
+ console.log("[OAuth] Exchanging code for tokens...");
1543
+ const tokenResponse = await fetch(`${INSFORGE_CONFIG.apiBase}/api/oauth/v1/token`, {
1544
+ method: "POST",
1545
+ headers: { "Content-Type": "application/json" },
1546
+ body: JSON.stringify({
1547
+ grant_type: "authorization_code",
1548
+ code,
1549
+ redirect_uri: OAUTH_CONFIG.callbackUrl,
1550
+ client_id: INSFORGE_CONFIG.clientId,
1551
+ client_secret: INSFORGE_CONFIG.clientSecret,
1552
+ code_verifier: authState.insforgeCodeVerifier
1553
+ })
1554
+ });
1555
+ const tokens = await tokenResponse.json();
1556
+ if (tokens.error || !tokens.access_token) {
1557
+ console.error("[OAuth] Token exchange error:", tokens);
1558
+ getAnalyticsService().trackOAuthFailure({
1559
+ errorType: "token_exchange_failed",
1560
+ errorDescription: "Token exchange failed",
1561
+ endpoint: "/oauth/callback"
1562
+ });
76
1563
  return res.status(400).json({
77
- error: "Missing required X-Base-URL header. Expected: X-Base-URL: <BACKEND_URL>"
1564
+ error: "token_exchange_failed",
1565
+ error_description: tokens.error_description || tokens.error || "No access token returned"
78
1566
  });
79
1567
  }
1568
+ console.log("[OAuth] Token received, redirecting to project selection...");
1569
+ const redis = getRedisClient();
1570
+ await redis.setex(
1571
+ `mcp:oauth:token:${state}`,
1572
+ 10 * 60,
1573
+ tokens.access_token
1574
+ );
1575
+ res.redirect(`${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.selectProject}?state_id=${state}`);
1576
+ } catch (error2) {
1577
+ console.error("OAuth callback error:", error2);
1578
+ getAnalyticsService().trackOAuthFailure({
1579
+ errorType: "server_error",
1580
+ errorDescription: "Failed to process callback",
1581
+ endpoint: "/oauth/callback"
1582
+ });
1583
+ res.status(500).json({
1584
+ error: "server_error",
1585
+ error_description: error2 instanceof Error ? error2.message : "Failed to process callback"
1586
+ });
1587
+ }
1588
+ });
1589
+ app.get(OAUTH_ENDPOINTS.selectProject, async (req, res) => {
1590
+ const { state_id } = req.query;
1591
+ if (!state_id) {
1592
+ return res.status(400).send("Missing state_id parameter");
1593
+ }
1594
+ try {
1595
+ const oauthManager2 = getOAuthManager();
1596
+ const redis = getRedisClient();
1597
+ const token = await redis.get(`mcp:oauth:token:${state_id}`);
1598
+ if (!token) {
1599
+ return res.status(400).send("Session expired. Please start the authorization process again.");
1600
+ }
1601
+ const authState = await oauthManager2.getAuthorizationState(state_id);
1602
+ if (!authState) {
1603
+ return res.status(400).send("Invalid or expired state");
1604
+ }
1605
+ const projectGroups = await oauthManager2.getAvailableProjects(token);
1606
+ res.send(renderProjectSelectionPage({
1607
+ stateId: state_id,
1608
+ projectGroups,
1609
+ selectProjectEndpoint: OAUTH_ENDPOINTS.selectProject
1610
+ }));
1611
+ } catch (error) {
1612
+ console.error("Project selection page error:", error);
1613
+ res.status(500).send("Failed to load projects. Please try again.");
1614
+ }
1615
+ });
1616
+ app.post(OAUTH_ENDPOINTS.selectProject, async (req, res) => {
1617
+ const { state_id, project_id } = req.body;
1618
+ if (!state_id || !project_id) {
1619
+ return res.status(400).json({
1620
+ error: "invalid_request",
1621
+ error_description: "Missing required parameters: state_id, project_id"
1622
+ });
1623
+ }
1624
+ try {
1625
+ const oauthManager2 = getOAuthManager();
1626
+ const redis = getRedisClient();
1627
+ const token = await redis.get(`mcp:oauth:token:${state_id}`);
1628
+ if (!token) {
1629
+ return res.status(400).send("Session expired. Please start the authorization process again.");
1630
+ }
1631
+ const authState = await oauthManager2.getAuthorizationState(state_id);
1632
+ if (!authState) {
1633
+ return res.status(400).json({
1634
+ error: "invalid_request",
1635
+ error_description: "Invalid or expired state"
1636
+ });
1637
+ }
1638
+ const code = await oauthManager2.createAuthorizationCode(
1639
+ state_id,
1640
+ token,
1641
+ project_id
1642
+ );
1643
+ await redis.del(`mcp:oauth:token:${state_id}`);
1644
+ const redirectUrl = new URL(authState.redirectUri);
1645
+ redirectUrl.searchParams.set("code", code);
1646
+ if (authState.state) {
1647
+ redirectUrl.searchParams.set("state", authState.state);
1648
+ }
1649
+ console.log(`[OAuth] Authorization complete, redirecting to client: ${redirectUrl.toString()}`);
1650
+ res.redirect(redirectUrl.toString());
1651
+ } catch (error) {
1652
+ console.error("Project selection error:", error);
1653
+ res.status(500).json({
1654
+ error: "server_error",
1655
+ error_description: error instanceof Error ? error.message : "Failed to process project selection"
1656
+ });
1657
+ }
1658
+ });
1659
+ app.post(OAUTH_ENDPOINTS.token, async (req, res) => {
1660
+ const { grant_type, code, redirect_uri, code_verifier } = req.body;
1661
+ if (grant_type === "authorization_code") {
1662
+ if (!code || !redirect_uri) {
1663
+ getAnalyticsService().trackOAuthFailure({
1664
+ errorType: "invalid_request",
1665
+ errorDescription: "Missing required parameters: code, redirect_uri",
1666
+ endpoint: "/oauth/token"
1667
+ });
1668
+ return res.status(400).json({
1669
+ error: "invalid_request",
1670
+ error_description: "Missing required parameters: code, redirect_uri"
1671
+ });
1672
+ }
1673
+ try {
1674
+ const oauthManager2 = getOAuthManager();
1675
+ const { tokenHash } = await oauthManager2.exchangeCode(
1676
+ code,
1677
+ redirect_uri,
1678
+ code_verifier
1679
+ );
1680
+ getAnalyticsService().trackOAuthSuccess({
1681
+ clientId: req.body.client_id || "unknown",
1682
+ scope: "mcp:read mcp:write"
1683
+ });
1684
+ res.json({
1685
+ access_token: tokenHash,
1686
+ token_type: "Bearer",
1687
+ expires_in: 30 * 24 * 60 * 60,
1688
+ scope: "mcp:read mcp:write"
1689
+ });
1690
+ } catch (error) {
1691
+ console.error("OAuth token error:", error);
1692
+ getAnalyticsService().trackOAuthFailure({
1693
+ errorType: "invalid_grant",
1694
+ errorDescription: "Invalid authorization code",
1695
+ endpoint: "/oauth/token"
1696
+ });
1697
+ res.status(400).json({
1698
+ error: "invalid_grant",
1699
+ error_description: error instanceof Error ? error.message : "Invalid authorization code"
1700
+ });
1701
+ }
1702
+ } else if (grant_type === "refresh_token") {
1703
+ getAnalyticsService().trackOAuthFailure({
1704
+ errorType: "unsupported_grant_type",
1705
+ errorDescription: "Refresh tokens are not supported",
1706
+ endpoint: "/oauth/token"
1707
+ });
1708
+ return res.status(400).json({
1709
+ error: "unsupported_grant_type",
1710
+ error_description: "Refresh tokens are not supported"
1711
+ });
1712
+ } else {
1713
+ getAnalyticsService().trackOAuthFailure({
1714
+ errorType: "unsupported_grant_type",
1715
+ errorDescription: "Only authorization_code grant type is supported",
1716
+ endpoint: "/oauth/token"
1717
+ });
1718
+ return res.status(400).json({
1719
+ error: "unsupported_grant_type",
1720
+ error_description: "Only authorization_code grant type is supported"
1721
+ });
1722
+ }
1723
+ });
1724
+ app.post(OAUTH_ENDPOINTS.revoke, async (req, res) => {
1725
+ const { token } = req.body;
1726
+ if (!token) {
1727
+ return res.status(400).json({
1728
+ error: "invalid_request",
1729
+ error_description: "Missing token parameter"
1730
+ });
1731
+ }
1732
+ try {
1733
+ const oauthManager2 = getOAuthManager();
1734
+ await oauthManager2.revokeBinding(token);
1735
+ res.status(200).send();
1736
+ } catch {
1737
+ res.status(200).send();
1738
+ }
1739
+ });
1740
+ app.get(API_ENDPOINTS.projects, async (req, res) => {
1741
+ const authHeader = req.headers["authorization"];
1742
+ if (!authHeader?.startsWith("Bearer ")) {
1743
+ return res.status(401).json({ error: "Missing or invalid Authorization header" });
1744
+ }
1745
+ const token = authHeader.substring(7);
1746
+ try {
1747
+ const oauthManager2 = getOAuthManager();
1748
+ const projects = await oauthManager2.getAvailableProjects(token);
1749
+ res.json({ organizations: projects });
1750
+ } catch (error) {
1751
+ console.error("Get projects error:", error);
1752
+ res.status(500).json({
1753
+ error: "Failed to get projects",
1754
+ details: error instanceof Error ? error.message : "Unknown error"
1755
+ });
1756
+ }
1757
+ });
1758
+ app.post(API_ENDPOINTS.bindProject, async (req, res) => {
1759
+ const authHeader = req.headers["authorization"];
1760
+ if (!authHeader?.startsWith("Bearer ")) {
1761
+ return res.status(401).json({ error: "Missing or invalid Authorization header" });
1762
+ }
1763
+ const token = authHeader.substring(7);
1764
+ const projectId = req.params.projectId;
1765
+ try {
1766
+ const oauthManager2 = getOAuthManager();
1767
+ const binding = await oauthManager2.bindTokenToProject(token, projectId);
1768
+ res.json({
1769
+ success: true,
1770
+ project: {
1771
+ id: binding.projectId,
1772
+ name: binding.projectName,
1773
+ organizationId: binding.organizationId
1774
+ },
1775
+ message: "Token successfully bound to project. You can now use this token with the MCP endpoint."
1776
+ });
1777
+ } catch (error) {
1778
+ console.error("Bind project error:", error);
1779
+ res.status(500).json({
1780
+ error: "Failed to bind project",
1781
+ details: error instanceof Error ? error.message : "Unknown error"
1782
+ });
1783
+ }
1784
+ });
1785
+ app.post(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req, res) => {
1786
+ const sessionId = req.headers["mcp-session-id"];
1787
+ const sessionManager2 = getSessionManager();
1788
+ const oauthToken = extractOAuthToken(req);
1789
+ const { apiKey: legacyApiKey, apiBaseUrl: legacyApiBaseUrl } = extractLegacyHeaders(req);
1790
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] POST ${STREAMABLE_HTTP_ENDPOINTS.mcp} - Session: ${sessionId || "none"}, Token: ${oauthToken ? tokenFingerprint(oauthToken) : "none"}`);
1791
+ let transport;
1792
+ const existingRuntime = sessionId ? sessionManager2.getStreamableSession(sessionId) : null;
1793
+ if (existingRuntime) {
1794
+ transport = existingRuntime.transport;
1795
+ console.log("[Streamable HTTP] Using existing transport for session:", sessionId);
1796
+ await sessionManager2.touchSession(sessionId);
1797
+ } else if (sessionId && await sessionManager2.hasSession(sessionId)) {
1798
+ console.log("[Streamable HTTP] Session found in Redis, restoring:", sessionId);
80
1799
  transport = new StreamableHTTPServerTransport({
81
- sessionIdGenerator: () => randomUUID(),
82
- onsessioninitialized: (sessionId2) => {
83
- console.log(`Session initialized: ${sessionId2}`);
84
- transports.set(sessionId2, transport);
85
- if (mcpServer) {
86
- servers.set(sessionId2, mcpServer);
87
- }
1800
+ sessionIdGenerator: () => sessionId,
1801
+ onsessioninitialized: () => {
1802
+ console.log(`[Streamable HTTP] Session restored: ${sessionId}`);
88
1803
  }
89
1804
  });
90
- mcpServer = new McpServer({
91
- name: "insforge-mcp",
92
- version: "1.0.0"
93
- });
94
- await registerInsforgeTools(mcpServer, {
95
- apiKey,
96
- apiBaseUrl
1805
+ const server = await sessionManager2.restoreSession(sessionId, transport);
1806
+ if (!server) {
1807
+ return res.status(500).json({
1808
+ error: "Failed to restore session from Redis"
1809
+ });
1810
+ }
1811
+ } else if (isInitializeRequest(req.body)) {
1812
+ let projectInfo = oauthToken ? await resolveProjectFromToken(oauthToken) : null;
1813
+ if (!projectInfo) {
1814
+ if (!legacyApiKey && !oauthToken) {
1815
+ return res.status(401).json({
1816
+ error: "authentication_required",
1817
+ error_description: "Missing authentication. Provide Authorization: Bearer <OAUTH_TOKEN> or X-Api-Key header.",
1818
+ oauth_authorize_url: `${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.authorize}`
1819
+ });
1820
+ }
1821
+ if (oauthToken && !legacyApiBaseUrl) {
1822
+ return res.status(401).json({
1823
+ error: "project_binding_required",
1824
+ error_description: "OAuth token is valid but not bound to a project. Complete the OAuth flow or call POST /api/projects/{projectId}/bind",
1825
+ oauth_authorize_url: `${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.authorize}`,
1826
+ projects_url: `${SERVER_CONFIG.publicUrl}${API_ENDPOINTS.projects}`
1827
+ });
1828
+ }
1829
+ if (!legacyApiBaseUrl) {
1830
+ return res.status(400).json({
1831
+ error: "Missing X-Base-URL header (required for legacy authentication)."
1832
+ });
1833
+ }
1834
+ if (!legacyApiKey) {
1835
+ return res.status(401).json({
1836
+ error: "invalid_credentials",
1837
+ error_description: "Legacy authentication requires X-Api-Key header. OAuth tokens cannot be used as API keys."
1838
+ });
1839
+ }
1840
+ projectInfo = {
1841
+ apiKey: legacyApiKey,
1842
+ apiBaseUrl: legacyApiBaseUrl,
1843
+ projectId: "legacy",
1844
+ projectName: "Legacy Session",
1845
+ userId: "legacy",
1846
+ organizationId: "legacy",
1847
+ oauthTokenHash: "legacy"
1848
+ };
1849
+ }
1850
+ const newSessionId = randomUUID();
1851
+ transport = new StreamableHTTPServerTransport({
1852
+ sessionIdGenerator: () => newSessionId,
1853
+ onsessioninitialized: async (initializedSessionId) => {
1854
+ console.log(`[Streamable HTTP] Session initialized: ${initializedSessionId}`);
1855
+ }
97
1856
  });
98
- console.log("Connecting server to transport...");
99
- await mcpServer.connect(transport);
100
- console.log("Server connected successfully");
1857
+ try {
1858
+ await sessionManager2.createSession(newSessionId, projectInfo, transport);
1859
+ console.log("[Streamable HTTP] New session created:", newSessionId);
1860
+ const clientInfo = extractClientInfo(req.body);
1861
+ getAnalyticsService().trackSessionCreated({
1862
+ clientName: clientInfo?.name,
1863
+ clientVersion: clientInfo?.version,
1864
+ userAgent: req.headers["user-agent"],
1865
+ transportType: "streamable_http",
1866
+ projectId: projectInfo.projectId,
1867
+ userId: projectInfo.userId,
1868
+ organizationId: projectInfo.organizationId
1869
+ });
1870
+ } catch (error) {
1871
+ console.error("[Streamable HTTP] Failed to create session:", error);
1872
+ return res.status(500).json({
1873
+ error: "Failed to create session",
1874
+ details: error instanceof Error ? error.message : "Unknown error"
1875
+ });
1876
+ }
101
1877
  } else {
102
1878
  return res.status(400).json({
103
- error: "Session required. Send initialize request first or provide Mcp-Session-Id header."
1879
+ error: "Session required. Send initialize request first or provide valid Mcp-Session-Id header."
104
1880
  });
105
1881
  }
106
- console.log("Handling request with transport...");
1882
+ console.log("[Streamable HTTP] Handling request...");
107
1883
  await transport.handleRequest(req, res, req.body);
108
- console.log("Request handled");
1884
+ console.log("[Streamable HTTP] Request handled");
109
1885
  });
110
- app.get("/mcp", async (req, res) => {
1886
+ app.get(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req, res) => {
111
1887
  const sessionId = req.headers["mcp-session-id"];
112
- console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] GET /mcp - Session: ${sessionId || "none"}`);
113
- if (!sessionId || !transports.has(sessionId)) {
1888
+ const authHeader = req.headers["authorization"];
1889
+ const sessionManager2 = getSessionManager();
1890
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] GET ${STREAMABLE_HTTP_ENDPOINTS.mcp} - Session: ${sessionId || "none"}, Auth: ${authHeader ? "present" : "missing"}`);
1891
+ if (!sessionId) {
1892
+ return res.status(400).json({
1893
+ error: "Missing Mcp-Session-Id header."
1894
+ });
1895
+ }
1896
+ const runtime = sessionManager2.getStreamableSession(sessionId);
1897
+ if (!runtime) {
1898
+ if (await sessionManager2.hasSession(sessionId)) {
1899
+ return res.status(400).json({
1900
+ error: "Session exists but not active. Send a POST request to restore the session first."
1901
+ });
1902
+ }
114
1903
  return res.status(404).json({
115
1904
  error: "Session not found. Initialize first with POST request."
116
1905
  });
117
1906
  }
118
- const transport = transports.get(sessionId);
119
- await transport.handleRequest(req, res, req.body);
1907
+ await runtime.transport.handleRequest(req, res, req.body);
120
1908
  });
121
- app.delete("/mcp", async (req, res) => {
1909
+ app.delete(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req, res) => {
122
1910
  const sessionId = req.headers["mcp-session-id"];
123
- console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] DELETE /mcp - Session: ${sessionId || "none"}`);
124
- if (!sessionId || !transports.has(sessionId)) {
1911
+ const sessionManager2 = getSessionManager();
1912
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] DELETE ${STREAMABLE_HTTP_ENDPOINTS.mcp} - Session: ${sessionId || "none"}`);
1913
+ if (!sessionId) {
1914
+ return res.status(400).json({
1915
+ error: "Missing Mcp-Session-Id header."
1916
+ });
1917
+ }
1918
+ const runtime = sessionManager2.getStreamableSession(sessionId);
1919
+ if (!runtime) {
1920
+ if (await sessionManager2.hasSession(sessionId)) {
1921
+ await sessionManager2.deleteSession(sessionId);
1922
+ return res.status(200).json({
1923
+ message: "Session deleted from storage."
1924
+ });
1925
+ }
125
1926
  return res.status(404).json({
126
1927
  error: "Session not found."
127
1928
  });
128
1929
  }
129
- const transport = transports.get(sessionId);
130
- const server2 = servers.get(sessionId);
131
- await transport.handleRequest(req, res, req.body);
132
- if (server2) {
133
- await server2.close();
134
- servers.delete(sessionId);
135
- }
136
- transports.delete(sessionId);
137
- console.log(`Session ${sessionId} closed`);
138
- });
139
- var server = app.listen(PORT, "127.0.0.1", () => {
140
- console.log(`
141
- \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
142
- \u2551 Insforge MCP Streamable HTTP Server \u2551
143
- \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
144
-
145
- \u{1F680} Server: http://127.0.0.1:${PORT}
146
- \u{1F517} Endpoint: http://127.0.0.1:${PORT}/mcp
147
- \u{1F49A} Health: http://127.0.0.1:${PORT}/health
148
-
149
- \u{1F4CB} Protocol: Streamable HTTP (2024-11-05+ spec)
150
- \u{1F510} Required Headers (per-request):
151
- \u2022 Authorization: Bearer <API_KEY>
152
- \u2022 X-Base-URL: <BACKEND_URL>
153
-
154
- \u{1F4DD} Client Configuration Example:
155
- {
156
- "mcpServers": {
157
- "insforge": {
158
- "url": "http://127.0.0.1:${PORT}/mcp",
159
- "headers": {
160
- "Authorization": "Bearer YOUR_API_KEY",
161
- "X-Base-URL": "http://localhost:7130"
162
- }
163
- }
1930
+ try {
1931
+ await runtime.transport.handleRequest(req, res, req.body);
1932
+ } finally {
1933
+ await sessionManager2.deleteSession(sessionId);
1934
+ console.log(`[Streamable HTTP] Session ${sessionId} closed`);
164
1935
  }
165
- }
166
-
167
- \u{1F504} Session Management: Automatic (stateful)
168
- \u{1F6E1}\uFE0F Security: Binding to localhost only (127.0.0.1)
169
- `);
170
1936
  });
171
- process.on("SIGINT", async () => {
172
- console.log("\n\u{1F6D1} Shutting down server...");
173
- for (const [sessionId, server2] of servers.entries()) {
174
- try {
175
- console.log(`Closing session: ${sessionId}`);
176
- await server2.close();
177
- const transport = transports.get(sessionId);
178
- if (transport) {
179
- await transport.close();
180
- }
181
- } catch (error) {
182
- console.error(`Error closing session ${sessionId}:`, error);
1937
+ var sseTransports = /* @__PURE__ */ new Map();
1938
+ app.get(SSE_ENDPOINTS.sse, async (req, res) => {
1939
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] GET ${SSE_ENDPOINTS.sse} - Establishing SSE connection (DEPRECATED protocol)`);
1940
+ const oauthToken = extractOAuthToken(req);
1941
+ const { apiKey: legacyApiKey, apiBaseUrl: legacyApiBaseUrl } = extractLegacyHeaders(req);
1942
+ let projectInfo = oauthToken ? await resolveProjectFromToken(oauthToken) : null;
1943
+ if (!projectInfo) {
1944
+ if (!legacyApiKey && !oauthToken) {
1945
+ return res.status(401).json({
1946
+ error: "authentication_required",
1947
+ error_description: "Missing authentication. Provide Authorization: Bearer <OAUTH_TOKEN> or X-Api-Key header."
1948
+ });
1949
+ }
1950
+ if (oauthToken && !legacyApiBaseUrl) {
1951
+ return res.status(401).json({
1952
+ error: "project_binding_required",
1953
+ error_description: "OAuth token is valid but not bound to a project. Complete the OAuth flow."
1954
+ });
183
1955
  }
1956
+ if (!legacyApiBaseUrl || !legacyApiKey) {
1957
+ return res.status(400).json({
1958
+ error: "Missing X-Api-Key or X-Base-URL header (required for legacy authentication)."
1959
+ });
1960
+ }
1961
+ projectInfo = {
1962
+ apiKey: legacyApiKey,
1963
+ apiBaseUrl: legacyApiBaseUrl,
1964
+ projectId: "legacy",
1965
+ projectName: "Legacy Project",
1966
+ userId: "unknown",
1967
+ organizationId: "unknown",
1968
+ oauthTokenHash: ""
1969
+ };
184
1970
  }
185
- servers.clear();
186
- transports.clear();
187
- server.close(() => {
188
- console.log("\u2705 Server shutdown complete");
189
- process.exit(0);
1971
+ const validProjectInfo = projectInfo;
1972
+ const transport = new SSEServerTransport(SSE_ENDPOINTS.messages, res);
1973
+ sseTransports.set(transport.sessionId, transport);
1974
+ console.log(`[SSE] Session created: ${transport.sessionId}, Project: ${validProjectInfo.projectName}`);
1975
+ res.on("close", () => {
1976
+ console.log(`[SSE] Session closed: ${transport.sessionId}`);
1977
+ sseTransports.delete(transport.sessionId);
1978
+ const sessionManager3 = getSessionManager();
1979
+ sessionManager3.deleteSession(transport.sessionId).catch((error) => {
1980
+ console.error(`[SSE] Failed to cleanup session ${transport.sessionId}:`, error);
1981
+ });
190
1982
  });
1983
+ const sessionManager2 = getSessionManager();
1984
+ try {
1985
+ await sessionManager2.createSSESession(transport.sessionId, {
1986
+ apiKey: validProjectInfo.apiKey,
1987
+ apiBaseUrl: validProjectInfo.apiBaseUrl,
1988
+ projectId: validProjectInfo.projectId,
1989
+ projectName: validProjectInfo.projectName,
1990
+ userId: validProjectInfo.userId,
1991
+ organizationId: validProjectInfo.organizationId,
1992
+ oauthTokenHash: validProjectInfo.oauthTokenHash
1993
+ }, transport);
1994
+ console.log(`[SSE] MCP server connected for session: ${transport.sessionId}`);
1995
+ getAnalyticsService().trackSessionCreated({
1996
+ clientName: void 0,
1997
+ // SSE: clientInfo not available until initialize message
1998
+ clientVersion: void 0,
1999
+ userAgent: req.headers["user-agent"],
2000
+ transportType: "sse",
2001
+ projectId: validProjectInfo.projectId,
2002
+ userId: validProjectInfo.userId,
2003
+ organizationId: validProjectInfo.organizationId
2004
+ });
2005
+ } catch (error) {
2006
+ console.error(`[SSE] Failed to create session ${transport.sessionId}:`, error);
2007
+ sseTransports.delete(transport.sessionId);
2008
+ try {
2009
+ await transport.close();
2010
+ } catch (closeError) {
2011
+ console.error(`[SSE] Error closing transport ${transport.sessionId}:`, closeError);
2012
+ }
2013
+ sessionManager2.deleteSession(transport.sessionId).catch(() => {
2014
+ });
2015
+ if (!res.headersSent) {
2016
+ res.status(500).json({
2017
+ error: "session_creation_failed",
2018
+ error_description: error instanceof Error ? error.message : "Failed to create MCP session"
2019
+ });
2020
+ } else {
2021
+ res.end();
2022
+ }
2023
+ }
191
2024
  });
2025
+ app.post(SSE_ENDPOINTS.messages, async (req, res) => {
2026
+ const sessionId = req.query.sessionId;
2027
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] POST ${SSE_ENDPOINTS.messages} - Session: ${sessionId || "none"}`);
2028
+ if (!sessionId) {
2029
+ return res.status(400).json({
2030
+ error: "Missing sessionId query parameter"
2031
+ });
2032
+ }
2033
+ const transport = sseTransports.get(sessionId);
2034
+ if (!transport) {
2035
+ return res.status(404).json({
2036
+ error: `Session not found. Establish SSE connection first via GET ${SSE_ENDPOINTS.sse}`
2037
+ });
2038
+ }
2039
+ await transport.handlePostMessage(req, res, req.body);
2040
+ });
2041
+ async function startServer() {
2042
+ try {
2043
+ validateConfig();
2044
+ const redis = getRedisClient();
2045
+ await redis.ping();
2046
+ console.log("[Redis] Connection verified");
2047
+ const server = app.listen(SERVER_CONFIG.port, SERVER_CONFIG.host, () => {
2048
+ const redisConfig = getRedisConfig();
2049
+ console.log(`
2050
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
2051
+ \u2551 Insforge MCP Remote Server (OAuth + Redis) \u2551
2052
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
2053
+
2054
+ \u{1F680} Server: http://${SERVER_CONFIG.host}:${SERVER_CONFIG.port}
2055
+
2056
+ \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2057
+ \u2502 \u{1F4CB} Streamable HTTP Transport (Protocol 2025-03-26) - RECOMMENDED
2058
+ \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2059
+ \u2502 POST/GET/DELETE ${SERVER_CONFIG.publicUrl}${STREAMABLE_HTTP_ENDPOINTS.mcp}
2060
+ \u2502
2061
+ \u2502 Client config:
2062
+ \u2502 {
2063
+ \u2502 "mcpServers": {
2064
+ \u2502 "insforge": {
2065
+ \u2502 "url": "${SERVER_CONFIG.publicUrl}${STREAMABLE_HTTP_ENDPOINTS.mcp}"
2066
+ \u2502 }
2067
+ \u2502 }
2068
+ \u2502 }
2069
+ \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2070
+
2071
+ \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2072
+ \u2502 \u{1F4CB} Legacy SSE Transport (Protocol 2024-11-05) - DEPRECATED
2073
+ \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2074
+ \u2502 GET ${SERVER_CONFIG.publicUrl}${SSE_ENDPOINTS.sse} (establish SSE stream)
2075
+ \u2502 POST ${SERVER_CONFIG.publicUrl}${SSE_ENDPOINTS.messages} (send messages)
2076
+ \u2502
2077
+ \u2502 Client config:
2078
+ \u2502 {
2079
+ \u2502 "mcpServers": {
2080
+ \u2502 "insforge": {
2081
+ \u2502 "type": "sse",
2082
+ \u2502 "url": "${SERVER_CONFIG.publicUrl}${SSE_ENDPOINTS.sse}"
2083
+ \u2502 }
2084
+ \u2502 }
2085
+ \u2502 }
2086
+ \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2087
+
2088
+ \u{1F510} OAuth 2.0 Endpoints:
2089
+ \u2022 Discovery: ${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.metadata}
2090
+ \u2022 Authorize: ${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.authorize}
2091
+ \u2022 Token: ${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.token}
2092
+ \u2022 Revoke: ${SERVER_CONFIG.publicUrl}${OAUTH_ENDPOINTS.revoke}
2093
+
2094
+ \u{1F3AF} Project API:
2095
+ \u2022 List: GET ${SERVER_CONFIG.publicUrl}${API_ENDPOINTS.projects}
2096
+ \u2022 Bind: POST ${SERVER_CONFIG.publicUrl}${API_ENDPOINTS.bindProject}
2097
+
2098
+ \u{1F4BE} Configuration:
2099
+ \u2022 Redis: ${redisConfig.host}:${redisConfig.port} (TLS: ${redisConfig.tls}, Cluster: ${redisConfig.cluster})
2100
+ \u2022 Insforge: ${INSFORGE_CONFIG.apiBase}
2101
+ \u2022 Frontend: ${INSFORGE_CONFIG.frontendUrl}
2102
+ \u2022 Analytics: ${isAnalyticsConfigured() ? "Mixpanel enabled" : "Disabled (set MIXPANEL_TOKEN)"}
2103
+ `);
2104
+ });
2105
+ const shutdown = async (signal) => {
2106
+ console.log(`
2107
+ \u{1F6D1} Received ${signal}, shutting down...`);
2108
+ const forceExitTimer = setTimeout(() => {
2109
+ console.error("\u26A0\uFE0F Forced shutdown after timeout");
2110
+ process.exit(1);
2111
+ }, 1e4);
2112
+ try {
2113
+ console.log(`[Shutdown] Closing ${sseTransports.size} SSE connections...`);
2114
+ for (const [sessionId, transport] of sseTransports) {
2115
+ try {
2116
+ await transport.close();
2117
+ } catch (error) {
2118
+ console.error(`[Shutdown] Error closing SSE transport ${sessionId}:`, error);
2119
+ }
2120
+ }
2121
+ sseTransports.clear();
2122
+ try {
2123
+ const sessionManager2 = getSessionManager();
2124
+ await sessionManager2.closeAllSessions();
2125
+ } catch (error) {
2126
+ console.error("[Shutdown] Error closing sessions:", error);
2127
+ }
2128
+ try {
2129
+ await closeRedisClient();
2130
+ } catch (error) {
2131
+ console.error("[Shutdown] Error closing Redis client:", error);
2132
+ }
2133
+ } finally {
2134
+ server.close(() => {
2135
+ clearTimeout(forceExitTimer);
2136
+ console.log("\u2705 Server shutdown complete");
2137
+ process.exit(0);
2138
+ });
2139
+ }
2140
+ };
2141
+ process.on("SIGINT", () => shutdown("SIGINT"));
2142
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
2143
+ } catch (error) {
2144
+ console.error("Failed to start server:", error);
2145
+ process.exit(1);
2146
+ }
2147
+ }
2148
+ startServer();