@insforge/mcp 1.2.6 → 1.2.7
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/README.md +21 -0
- package/dist/{chunk-3S2HFIGS.js → chunk-Z3FXBI3Z.js} +1324 -691
- package/dist/http-server.js +2087 -130
- package/dist/index.js +3 -2
- package/package.json +32 -7
- package/server.json +29 -6
package/dist/http-server.js
CHANGED
|
@@ -1,191 +1,2148 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
registerInsforgeTools
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-Z3FXBI3Z.js";
|
|
5
5
|
|
|
6
6
|
// src/http/server.ts
|
|
7
|
-
import
|
|
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
|
-
|
|
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
|
|
15
|
-
var
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
"&": "&",
|
|
1128
|
+
"<": "<",
|
|
1129
|
+
">": ">",
|
|
1130
|
+
'"': """,
|
|
1131
|
+
"'": "'"
|
|
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,
|
|
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
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
1317
|
+
return authHeader.substring(7);
|
|
62
1318
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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: "
|
|
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: () =>
|
|
82
|
-
onsessioninitialized: (
|
|
83
|
-
console.log(`Session
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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(
|
|
1886
|
+
app.get(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req, res) => {
|
|
111
1887
|
const sessionId = req.headers["mcp-session-id"];
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
await transport.handleRequest(req, res, req.body);
|
|
1907
|
+
await runtime.transport.handleRequest(req, res, req.body);
|
|
120
1908
|
});
|
|
121
|
-
app.delete(
|
|
1909
|
+
app.delete(STREAMABLE_HTTP_ENDPOINTS.mcp, async (req, res) => {
|
|
122
1910
|
const sessionId = req.headers["mcp-session-id"];
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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();
|