@onemrvapublic/design-system-mcp-server 2.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +420 -0
- package/dist/component-parser.d.ts +79 -0
- package/dist/component-parser.js +646 -0
- package/dist/component-parser.js.map +1 -0
- package/dist/component-scanner.d.ts +81 -0
- package/dist/component-scanner.js +444 -0
- package/dist/component-scanner.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +999 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-shared.d.ts +141 -0
- package/dist/mcp-shared.js +519 -0
- package/dist/mcp-shared.js.map +1 -0
- package/dist/metrics.d.ts +64 -0
- package/dist/metrics.js +271 -0
- package/dist/metrics.js.map +1 -0
- package/dist/migration-rules.d.ts +29 -0
- package/dist/migration-rules.js +862 -0
- package/dist/migration-rules.js.map +1 -0
- package/dist/tool-components.d.ts +23 -0
- package/dist/tool-components.js +816 -0
- package/dist/tool-components.js.map +1 -0
- package/dist/tool-design.d.ts +41 -0
- package/dist/tool-design.js +618 -0
- package/dist/tool-design.js.map +1 -0
- package/dist/tool-migration.d.ts +5 -0
- package/dist/tool-migration.js +976 -0
- package/dist/tool-migration.js.map +1 -0
- package/dist/tool-registry.d.ts +3 -0
- package/dist/tool-registry.js +458 -0
- package/dist/tool-registry.js.map +1 -0
- package/dist/tools.d.ts +13 -0
- package/dist/tools.js +349 -0
- package/dist/tools.js.map +1 -0
- package/mcp-data/codebase.json +4809 -0
- package/mcp-data/components.json +129424 -0
- package/mcp-data/icons.json +470 -0
- package/mcp-data/icons_sorted.txt +2 -0
- package/mcp-data/mapping.json +309 -0
- package/mcp-data/styles.json +331 -0
- package/mcp-data/sync-status.json +83 -0
- package/mcp-data/tokens.json +1586 -0
- package/package.json +77 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID, timingSafeEqual } from 'crypto';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
5
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { ComponentScanner } from './component-scanner.js';
|
|
9
|
+
import { handleToolCall } from './tools.js';
|
|
10
|
+
import { getTools } from './tool-registry.js';
|
|
11
|
+
import { recordToolCall, recordQuestion, getMetricsSnapshot, renderPrometheusMetrics, serializeMetrics, loadMetrics, } from './metrics.js';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import express from 'express';
|
|
16
|
+
const PACKAGE_NAME = '@onemrvapublic/design-system-mcp-server';
|
|
17
|
+
const PACKAGE_VERSION = '2.0.0';
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
const MCP_DATA_DIR = path.resolve(__dirname, '../mcp-data');
|
|
21
|
+
const MCP_DATA_FILES = [
|
|
22
|
+
'codebase.json',
|
|
23
|
+
'components.json',
|
|
24
|
+
'tokens.json',
|
|
25
|
+
'styles.json',
|
|
26
|
+
'icons.json',
|
|
27
|
+
'sync-status.json',
|
|
28
|
+
];
|
|
29
|
+
// Parse a numeric env var, falling back to `fallback` (with a stderr warning)
|
|
30
|
+
// when it is unset/blank or does not resolve to a finite number.
|
|
31
|
+
function readNumericEnv(name, fallback) {
|
|
32
|
+
const raw = process.env[name];
|
|
33
|
+
if (raw === undefined || raw.trim() === '') {
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
const parsed = Number(raw);
|
|
37
|
+
if (!Number.isFinite(parsed)) {
|
|
38
|
+
logInfo(`[MCP] Warning: invalid ${name}="${raw}", falling back to ${fallback}.`);
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
const PORT = Math.trunc(readNumericEnv('PORT', 3001));
|
|
44
|
+
const DS_PATH = process.env.DESIGN_SYSTEM_PATH || '';
|
|
45
|
+
const API_KEY = (process.env.MCP_API_KEY || '').trim();
|
|
46
|
+
// Separate key for admin endpoints (/metrics, /health, /rescan). Falls back to
|
|
47
|
+
// MCP_API_KEY when unset.
|
|
48
|
+
const ADMIN_KEY = (process.env.MCP_ADMIN_KEY || '').trim();
|
|
49
|
+
const METRICS_FILE = (process.env.MCP_METRICS_FILE || '').trim();
|
|
50
|
+
const METRICS_FLUSH_MS = Math.max(1000, readNumericEnv('MCP_METRICS_FLUSH_MS', 60000));
|
|
51
|
+
const ALLOW_UNAUTHENTICATED_HTTP = (process.env.MCP_ALLOW_UNAUTHENTICATED_HTTP || 'false').toLowerCase() ===
|
|
52
|
+
'true';
|
|
53
|
+
const MAX_HTTP_SESSIONS = Math.max(1, readNumericEnv('MCP_MAX_HTTP_SESSIONS', 100));
|
|
54
|
+
const RATE_LIMIT_WINDOW_MS = Math.max(1000, readNumericEnv('MCP_RATE_LIMIT_WINDOW_MS', 60000));
|
|
55
|
+
const RATE_LIMIT_MAX_REQUESTS = Math.max(1, readNumericEnv('MCP_RATE_LIMIT_MAX_REQUESTS', 300));
|
|
56
|
+
const CLI_TRANSPORT_ARG = getCliOption('transport');
|
|
57
|
+
const CLI_DATA_SOURCE_ARG = getCliOption('data-source');
|
|
58
|
+
const CLI_WATCH_ENABLED = getCliFlag('watch');
|
|
59
|
+
const TRANSPORT_MODE = parseTransportMode(CLI_TRANSPORT_ARG || process.env.MCP_TRANSPORT || 'stdio');
|
|
60
|
+
// `let`: demoted to mcp-data below if `live` is set but the DS source is absent.
|
|
61
|
+
let DATA_SOURCE_MODE = parseDataSourceMode(CLI_DATA_SOURCE_ARG || process.env.MCP_DATA_SOURCE || 'auto');
|
|
62
|
+
const AUTO_RESCAN_ENABLED = CLI_WATCH_ENABLED ||
|
|
63
|
+
(process.env.MCP_AUTO_RESCAN || 'false').toLowerCase() === 'true';
|
|
64
|
+
const ENABLE_LEGACY_SSE = (process.env.MCP_ENABLE_LEGACY_SSE || 'false').toLowerCase() === 'true';
|
|
65
|
+
process.env.MCP_DATA_SOURCE = DATA_SOURCE_MODE;
|
|
66
|
+
function logInfo(message) {
|
|
67
|
+
process.stderr.write(`${message}\n`);
|
|
68
|
+
}
|
|
69
|
+
function parseTransportMode(rawMode) {
|
|
70
|
+
const value = rawMode.trim().toLowerCase();
|
|
71
|
+
if (value === 'stdio' || value === 'http' || value === 'both') {
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
if (value === 'sse' || value === 'streamable' || value === 'server') {
|
|
75
|
+
return 'http';
|
|
76
|
+
}
|
|
77
|
+
logInfo(`[MCP] Unknown transport mode "${rawMode}", falling back to "stdio".`);
|
|
78
|
+
return 'stdio';
|
|
79
|
+
}
|
|
80
|
+
function parseDataSourceMode(rawMode) {
|
|
81
|
+
const value = rawMode.trim().toLowerCase();
|
|
82
|
+
if (value === 'auto' || value === 'mcp-data' || value === 'live') {
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
logInfo(`[MCP] Unknown data source mode "${rawMode}", falling back to "auto".`);
|
|
86
|
+
return 'auto';
|
|
87
|
+
}
|
|
88
|
+
function getCliOption(name) {
|
|
89
|
+
const direct = `--${name}`;
|
|
90
|
+
const withValue = `${direct}=`;
|
|
91
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
92
|
+
const arg = process.argv[i];
|
|
93
|
+
if (arg === direct && process.argv[i + 1]) {
|
|
94
|
+
return process.argv[i + 1];
|
|
95
|
+
}
|
|
96
|
+
if (arg.startsWith(withValue)) {
|
|
97
|
+
return arg.slice(withValue.length);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
function getCliFlag(name) {
|
|
103
|
+
const direct = `--${name}`;
|
|
104
|
+
const withValue = `${direct}=`;
|
|
105
|
+
for (const arg of process.argv) {
|
|
106
|
+
if (arg === direct) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (arg.startsWith(withValue)) {
|
|
110
|
+
const value = arg.slice(withValue.length).trim().toLowerCase();
|
|
111
|
+
return value !== 'false' && value !== '0' && value !== 'no';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
function findDesignSystemPath() {
|
|
117
|
+
if (DS_PATH && fs.existsSync(DS_PATH)) {
|
|
118
|
+
return DS_PATH;
|
|
119
|
+
}
|
|
120
|
+
const possiblePaths = [
|
|
121
|
+
path.resolve(__dirname, '../../../@onemrvapublic/design-system'),
|
|
122
|
+
path.resolve(process.cwd(), 'node_modules/@onemrvapublic/design-system'),
|
|
123
|
+
path.resolve(__dirname, '../../../projects/onemrva/design-system'),
|
|
124
|
+
path.resolve(__dirname, '../../../../design-system'),
|
|
125
|
+
'/app/design-system',
|
|
126
|
+
];
|
|
127
|
+
for (const testPath of possiblePaths) {
|
|
128
|
+
if (fs.existsSync(testPath)) {
|
|
129
|
+
return testPath;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return path.resolve(__dirname, '../../../projects/onemrva/design-system');
|
|
133
|
+
}
|
|
134
|
+
const DESIGN_SYSTEM_PATH = findDesignSystemPath();
|
|
135
|
+
const sseSessions = new Map();
|
|
136
|
+
const streamableSessions = new Map();
|
|
137
|
+
let stdioSession = null;
|
|
138
|
+
let httpServer;
|
|
139
|
+
let shutdownInProgress = false;
|
|
140
|
+
const liveWatchers = [];
|
|
141
|
+
let watchedPaths = [];
|
|
142
|
+
let rescanDebounceTimer = null;
|
|
143
|
+
let liveRescanInProgress = false;
|
|
144
|
+
let liveRescanQueued = false;
|
|
145
|
+
let rateTrackerSweepTimer = null;
|
|
146
|
+
let sessionSweepTimer = null;
|
|
147
|
+
let metricsFlushTimer = null;
|
|
148
|
+
const connectionStats = {
|
|
149
|
+
accepted: 0,
|
|
150
|
+
closed: 0,
|
|
151
|
+
badMessageRequests: 0,
|
|
152
|
+
messageErrors: 0,
|
|
153
|
+
rateLimitedRequests: 0,
|
|
154
|
+
};
|
|
155
|
+
const requestRateTracker = new Map();
|
|
156
|
+
const DESIGN_SYSTEM_PATH_EXISTS = fs.existsSync(DESIGN_SYSTEM_PATH);
|
|
157
|
+
const AVAILABLE_MCP_DATA_FILES = MCP_DATA_FILES.filter(fileName => fs.existsSync(path.join(MCP_DATA_DIR, fileName)));
|
|
158
|
+
const HAS_MCP_DATA = AVAILABLE_MCP_DATA_FILES.length > 0;
|
|
159
|
+
const HAS_MCP_CODEBASE = fs.existsSync(path.join(MCP_DATA_DIR, 'codebase.json'));
|
|
160
|
+
// Demote `live` to `mcp-data` when the DS source is absent but a snapshot is
|
|
161
|
+
// bundled — keeps the server usable detached from the DS repo, no FATAL exit.
|
|
162
|
+
if (DATA_SOURCE_MODE === 'live' &&
|
|
163
|
+
!DESIGN_SYSTEM_PATH_EXISTS &&
|
|
164
|
+
HAS_MCP_CODEBASE) {
|
|
165
|
+
logInfo(`[MCP] "live" requested but DESIGN_SYSTEM_PATH is absent (${DESIGN_SYSTEM_PATH}). ` +
|
|
166
|
+
'Demoting to "mcp-data" — DS knowledge stays available from the synced snapshot; live rescan is disabled.');
|
|
167
|
+
DATA_SOURCE_MODE = 'mcp-data';
|
|
168
|
+
process.env.MCP_DATA_SOURCE = 'mcp-data';
|
|
169
|
+
}
|
|
170
|
+
const SHOULD_USE_MCP_DATA = DATA_SOURCE_MODE === 'mcp-data' ||
|
|
171
|
+
(DATA_SOURCE_MODE === 'auto' && HAS_MCP_CODEBASE);
|
|
172
|
+
const LIVE_SCAN_ENABLED = DATA_SOURCE_MODE === 'live' ||
|
|
173
|
+
(DATA_SOURCE_MODE === 'auto' && !HAS_MCP_CODEBASE);
|
|
174
|
+
async function closeServerSession(session) {
|
|
175
|
+
await Promise.allSettled([session.transport.close(), session.server.close()]);
|
|
176
|
+
}
|
|
177
|
+
function collectSessionCloseOps(sessions, closeOps) {
|
|
178
|
+
for (const [sessionId, session] of sessions) {
|
|
179
|
+
sessions.delete(sessionId);
|
|
180
|
+
closeOps.push(session.transport.close());
|
|
181
|
+
closeOps.push(session.server.close());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function readJsonIfExists(filePath) {
|
|
185
|
+
if (!fs.existsSync(filePath)) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
logInfo(`[MCP] Warning: could not parse ${path.basename(filePath)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Persist usage KPIs to MCP_METRICS_FILE so they survive restarts.
|
|
197
|
+
function writeMetricsFile() {
|
|
198
|
+
if (!METRICS_FILE) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
fs.mkdirSync(path.dirname(METRICS_FILE), { recursive: true });
|
|
203
|
+
const tmp = `${METRICS_FILE}.${process.pid}.tmp`;
|
|
204
|
+
fs.writeFileSync(tmp, JSON.stringify(serializeMetrics()), 'utf-8');
|
|
205
|
+
fs.renameSync(tmp, METRICS_FILE);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
console.error('[MCP] Failed to persist metrics:', error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function startMetricsPersistence() {
|
|
212
|
+
if (!METRICS_FILE) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const data = readJsonIfExists(METRICS_FILE);
|
|
216
|
+
if (data && loadMetrics(data)) {
|
|
217
|
+
logInfo(`[MCP] Restored usage metrics from ${METRICS_FILE}`);
|
|
218
|
+
}
|
|
219
|
+
metricsFlushTimer = setInterval(writeMetricsFile, METRICS_FLUSH_MS);
|
|
220
|
+
metricsFlushTimer.unref();
|
|
221
|
+
}
|
|
222
|
+
function getRuntimeSummary() {
|
|
223
|
+
const syncedCodebase = readJsonIfExists(path.join(MCP_DATA_DIR, 'codebase.json'));
|
|
224
|
+
const syncStatus = readJsonIfExists(path.join(MCP_DATA_DIR, 'sync-status.json'));
|
|
225
|
+
const summarizeSyncedCodebase = (codebase) => ({
|
|
226
|
+
components: Array.isArray(codebase.components)
|
|
227
|
+
? codebase.components.length
|
|
228
|
+
: 0,
|
|
229
|
+
jsonForms: Array.isArray(codebase.jsonforms)
|
|
230
|
+
? codebase.jsonforms.length
|
|
231
|
+
: 0,
|
|
232
|
+
themeUtilities: Array.isArray(codebase.theme) ? codebase.theme.length : 0,
|
|
233
|
+
source: 'mcp-data/codebase.json',
|
|
234
|
+
mcpDataFiles: AVAILABLE_MCP_DATA_FILES,
|
|
235
|
+
lastFigmaSync: syncStatus?.lastFigmaSync ?? null,
|
|
236
|
+
figmaStats: syncStatus?.stats ?? null,
|
|
237
|
+
});
|
|
238
|
+
if (SHOULD_USE_MCP_DATA && syncedCodebase) {
|
|
239
|
+
return summarizeSyncedCodebase(syncedCodebase);
|
|
240
|
+
}
|
|
241
|
+
if (LIVE_SCAN_ENABLED && DESIGN_SYSTEM_PATH_EXISTS) {
|
|
242
|
+
const summary = scanner.getSummary();
|
|
243
|
+
return {
|
|
244
|
+
components: summary.components,
|
|
245
|
+
jsonForms: summary.jsonForms,
|
|
246
|
+
themeUtilities: summary.themeUtilities,
|
|
247
|
+
source: 'live-scan',
|
|
248
|
+
mcpDataFiles: AVAILABLE_MCP_DATA_FILES,
|
|
249
|
+
lastFigmaSync: syncStatus?.lastFigmaSync ?? null,
|
|
250
|
+
figmaStats: syncStatus?.stats ?? null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (syncedCodebase) {
|
|
254
|
+
return summarizeSyncedCodebase(syncedCodebase);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
components: 0,
|
|
258
|
+
jsonForms: 0,
|
|
259
|
+
themeUtilities: 0,
|
|
260
|
+
source: 'none',
|
|
261
|
+
mcpDataFiles: AVAILABLE_MCP_DATA_FILES,
|
|
262
|
+
lastFigmaSync: syncStatus?.lastFigmaSync ?? null,
|
|
263
|
+
figmaStats: syncStatus?.stats ?? null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function createMcpServer() {
|
|
267
|
+
const server = new Server({
|
|
268
|
+
name: PACKAGE_NAME,
|
|
269
|
+
version: PACKAGE_VERSION,
|
|
270
|
+
}, {
|
|
271
|
+
capabilities: {
|
|
272
|
+
tools: {},
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
276
|
+
tools: getTools(),
|
|
277
|
+
}));
|
|
278
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
279
|
+
const startedAt = Date.now();
|
|
280
|
+
// IDE/agent from the initialize handshake.
|
|
281
|
+
const client = server.getClientVersion()?.name ?? 'unknown';
|
|
282
|
+
try {
|
|
283
|
+
const result = await handleToolCall(request, scanner);
|
|
284
|
+
const ok = !result.isError;
|
|
285
|
+
recordToolCall(request.params.name, Date.now() - startedAt, ok, client);
|
|
286
|
+
if (ok) {
|
|
287
|
+
recordQuestion(request.params.name, request.params.arguments);
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
recordToolCall(request.params.name, Date.now() - startedAt, false, client);
|
|
293
|
+
console.error(`[MCP] Tool "${request.params.name}" failed:`, err);
|
|
294
|
+
return {
|
|
295
|
+
content: [
|
|
296
|
+
{
|
|
297
|
+
type: 'text',
|
|
298
|
+
text: `Internal error in tool "${request.params.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
isError: true,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
server.onerror = error => {
|
|
306
|
+
console.error('[MCP Error]', error);
|
|
307
|
+
};
|
|
308
|
+
return server;
|
|
309
|
+
}
|
|
310
|
+
const scanner = new ComponentScanner(DESIGN_SYSTEM_PATH);
|
|
311
|
+
const HTTP_MODE_ENABLED = TRANSPORT_MODE === 'http' || TRANSPORT_MODE === 'both';
|
|
312
|
+
const STDIO_MODE_ENABLED = TRANSPORT_MODE === 'stdio' || TRANSPORT_MODE === 'both';
|
|
313
|
+
async function rescan() {
|
|
314
|
+
if (!LIVE_SCAN_ENABLED) {
|
|
315
|
+
throw new Error('Live rescan is disabled while using synced mcp-data as the active source. Set MCP_DATA_SOURCE=live and restart if you need runtime scanning.');
|
|
316
|
+
}
|
|
317
|
+
if (!DESIGN_SYSTEM_PATH_EXISTS) {
|
|
318
|
+
throw new Error('Live rescan unavailable: DESIGN_SYSTEM_PATH is not mounted. The server is currently running from synced mcp-data only.');
|
|
319
|
+
}
|
|
320
|
+
logInfo('[MCP] Rescan triggered...');
|
|
321
|
+
const result = await scanner.rescan();
|
|
322
|
+
logInfo(`[MCP] Rescan complete: ${result.components} components, ${result.renderers} renderers, ${result.theme} theme utilities`);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
function getLiveWatchPaths() {
|
|
326
|
+
const candidates = [
|
|
327
|
+
DESIGN_SYSTEM_PATH,
|
|
328
|
+
path.resolve(DESIGN_SYSTEM_PATH, 'theme'),
|
|
329
|
+
path.resolve(DESIGN_SYSTEM_PATH, '../jsonforms'),
|
|
330
|
+
path.resolve(DESIGN_SYSTEM_PATH, '../theme'),
|
|
331
|
+
path.resolve(DESIGN_SYSTEM_PATH, '../demos/src/app/demos'),
|
|
332
|
+
];
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
const available = [];
|
|
335
|
+
for (const candidate of candidates) {
|
|
336
|
+
const resolved = path.resolve(candidate);
|
|
337
|
+
if (seen.has(resolved)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
seen.add(resolved);
|
|
341
|
+
if (fs.existsSync(resolved)) {
|
|
342
|
+
available.push(resolved);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return available;
|
|
346
|
+
}
|
|
347
|
+
async function runQueuedLiveRescan(reason) {
|
|
348
|
+
if (liveRescanInProgress) {
|
|
349
|
+
liveRescanQueued = true;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
liveRescanInProgress = true;
|
|
353
|
+
try {
|
|
354
|
+
logInfo(`[MCP] Auto rescan triggered (${reason})...`);
|
|
355
|
+
const result = await scanner.rescan();
|
|
356
|
+
logInfo(`[MCP] Auto rescan complete: ${result.components} components, ${result.renderers} renderers, ${result.theme} theme utilities`);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
console.error('[MCP] Auto rescan failed:', error);
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
liveRescanInProgress = false;
|
|
363
|
+
if (liveRescanQueued) {
|
|
364
|
+
liveRescanQueued = false;
|
|
365
|
+
queueLiveRescan('queued file changes');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function queueLiveRescan(reason) {
|
|
370
|
+
if (rescanDebounceTimer) {
|
|
371
|
+
clearTimeout(rescanDebounceTimer);
|
|
372
|
+
}
|
|
373
|
+
rescanDebounceTimer = setTimeout(() => {
|
|
374
|
+
rescanDebounceTimer = null;
|
|
375
|
+
void runQueuedLiveRescan(reason);
|
|
376
|
+
}, 700);
|
|
377
|
+
}
|
|
378
|
+
function startLiveRescanWatchers() {
|
|
379
|
+
watchedPaths = getLiveWatchPaths();
|
|
380
|
+
if (watchedPaths.length === 0) {
|
|
381
|
+
logInfo('[MCP] Auto rescan enabled, but no source paths were found to watch.');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
for (const watchPath of watchedPaths) {
|
|
385
|
+
try {
|
|
386
|
+
const watcher = fs.watch(watchPath, { recursive: true }, (_eventType, fileName) => {
|
|
387
|
+
const suffix = fileName ? `/${fileName}` : '';
|
|
388
|
+
queueLiveRescan(`${watchPath}${suffix}`);
|
|
389
|
+
});
|
|
390
|
+
watcher.on('error', error => {
|
|
391
|
+
console.error(`[MCP] Watcher error on ${watchPath}:`, error);
|
|
392
|
+
});
|
|
393
|
+
liveWatchers.push(watcher);
|
|
394
|
+
logInfo(`[MCP] Watching: ${watchPath}`);
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
console.error(`[MCP] Failed to watch ${watchPath}:`, error);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (liveWatchers.length > 0) {
|
|
401
|
+
logInfo(`[MCP] Live auto rescan enabled (${liveWatchers.length} watcher${liveWatchers.length === 1 ? '' : 's'}).`);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
logInfo('[MCP] Auto rescan requested but no watchers could be started.');
|
|
405
|
+
}
|
|
406
|
+
// Keys the request presents (x-mcp-api-key and/or Authorization: Bearer).
|
|
407
|
+
function candidateKeys(req) {
|
|
408
|
+
const keys = [];
|
|
409
|
+
const headerKey = req.header('x-mcp-api-key');
|
|
410
|
+
if (headerKey) {
|
|
411
|
+
keys.push(headerKey);
|
|
412
|
+
}
|
|
413
|
+
const authHeader = req.header('authorization');
|
|
414
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
415
|
+
keys.push(authHeader.slice('Bearer '.length).trim());
|
|
416
|
+
}
|
|
417
|
+
return keys;
|
|
418
|
+
}
|
|
419
|
+
// Constant-time compare (length check leaks length only).
|
|
420
|
+
function keyEquals(candidate, key) {
|
|
421
|
+
if (!key) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
const a = Buffer.from(candidate);
|
|
425
|
+
const b = Buffer.from(key);
|
|
426
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
427
|
+
}
|
|
428
|
+
// Valid if the request presents any of `accepted`. Empty set -> open.
|
|
429
|
+
function accepts(req, accepted) {
|
|
430
|
+
const keys = accepted.filter(Boolean);
|
|
431
|
+
if (keys.length === 0) {
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
return candidateKeys(req).some(c => keys.some(k => keyEquals(c, k)));
|
|
435
|
+
}
|
|
436
|
+
function deny(res) {
|
|
437
|
+
connectionStats.badMessageRequests += 1;
|
|
438
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
439
|
+
}
|
|
440
|
+
// /mcp: client key only. Unset MCP_API_KEY -> /mcp open; admin stays protected.
|
|
441
|
+
function requireClientKey(req, res, next) {
|
|
442
|
+
if (accepts(req, [API_KEY])) {
|
|
443
|
+
next();
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
deny(res);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Admin endpoints: the admin key only (falls back to the shared key if no
|
|
450
|
+
// separate admin key is set), so a teammate's client key cannot reach them.
|
|
451
|
+
function requireAdminKey(req, res, next) {
|
|
452
|
+
if (accepts(req, [ADMIN_KEY || API_KEY])) {
|
|
453
|
+
next();
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
deny(res);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function getRequestClientKey(req) {
|
|
460
|
+
return req.ip || req.socket.remoteAddress || 'unknown';
|
|
461
|
+
}
|
|
462
|
+
function requireRateLimit(req, res, next) {
|
|
463
|
+
const now = Date.now();
|
|
464
|
+
const clientKey = getRequestClientKey(req);
|
|
465
|
+
const existing = requestRateTracker.get(clientKey);
|
|
466
|
+
if (!existing || now - existing.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
467
|
+
requestRateTracker.set(clientKey, { count: 1, windowStart: now });
|
|
468
|
+
next();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (existing.count >= RATE_LIMIT_MAX_REQUESTS) {
|
|
472
|
+
connectionStats.rateLimitedRequests += 1;
|
|
473
|
+
res.status(429).json({ error: 'rate limit exceeded' });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
existing.count += 1;
|
|
477
|
+
next();
|
|
478
|
+
}
|
|
479
|
+
// Drop rate-tracker entries whose window has fully expired so the Map cannot
|
|
480
|
+
// grow unbounded from one-off client IPs.
|
|
481
|
+
function sweepRateTracker() {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
for (const [clientKey, entry] of requestRateTracker) {
|
|
484
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
485
|
+
requestRateTracker.delete(clientKey);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function startRateTrackerSweep() {
|
|
490
|
+
if (rateTrackerSweepTimer) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const sweepInterval = Math.max(RATE_LIMIT_WINDOW_MS, 30000);
|
|
494
|
+
rateTrackerSweepTimer = setInterval(sweepRateTracker, sweepInterval);
|
|
495
|
+
// Never keep the event loop alive just for the sweep.
|
|
496
|
+
rateTrackerSweepTimer.unref();
|
|
497
|
+
}
|
|
498
|
+
function enforceSessionCapacity(res) {
|
|
499
|
+
const activeSessions = sseSessions.size + streamableSessions.size;
|
|
500
|
+
if (activeSessions < MAX_HTTP_SESSIONS) {
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
connectionStats.badMessageRequests += 1;
|
|
504
|
+
res.status(503).json({ error: 'maximum MCP session capacity reached' });
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
function readSessionIdFromQuery(req) {
|
|
508
|
+
const value = req.query.sessionId;
|
|
509
|
+
if (typeof value === 'string') {
|
|
510
|
+
return value;
|
|
511
|
+
}
|
|
512
|
+
if (Array.isArray(value) && typeof value[0] === 'string') {
|
|
513
|
+
return value[0];
|
|
514
|
+
}
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
function readSessionIdFromHeader(req) {
|
|
518
|
+
const value = req.header('mcp-session-id');
|
|
519
|
+
return value?.trim() || undefined;
|
|
520
|
+
}
|
|
521
|
+
async function cleanupSseSession(sessionId, reason) {
|
|
522
|
+
const session = sseSessions.get(sessionId);
|
|
523
|
+
if (!session) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
sseSessions.delete(sessionId);
|
|
527
|
+
connectionStats.closed += 1;
|
|
528
|
+
await closeServerSession(session);
|
|
529
|
+
logInfo(`[MCP] SSE session closed (${reason}, ${sseSessions.size} remaining)`);
|
|
530
|
+
}
|
|
531
|
+
async function cleanupStreamableSession(sessionId, reason) {
|
|
532
|
+
if (!sessionId) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const session = streamableSessions.get(sessionId);
|
|
536
|
+
if (!session) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
streamableSessions.delete(sessionId);
|
|
540
|
+
connectionStats.closed += 1;
|
|
541
|
+
await closeServerSession(session);
|
|
542
|
+
logInfo(`[MCP] Streamable HTTP session closed (${reason}, ${streamableSessions.size} remaining)`);
|
|
543
|
+
}
|
|
544
|
+
// Reap sessions idle past the TTL so abandoned/half-closed clients cannot
|
|
545
|
+
// accumulate and eventually exhaust MAX_HTTP_SESSIONS (503 for everyone).
|
|
546
|
+
const SESSION_IDLE_MS = Math.max(60000, readNumericEnv('MCP_SESSION_IDLE_MS', 1800000));
|
|
547
|
+
function sweepIdleSessions() {
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
for (const [sessionId, session] of streamableSessions) {
|
|
550
|
+
if (now - session.lastActivity >= SESSION_IDLE_MS) {
|
|
551
|
+
void cleanupStreamableSession(sessionId, 'idle timeout');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for (const [sessionId, session] of sseSessions) {
|
|
555
|
+
if (now - session.lastActivity >= SESSION_IDLE_MS) {
|
|
556
|
+
void cleanupSseSession(sessionId, 'idle timeout');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function startSessionSweep() {
|
|
561
|
+
if (sessionSweepTimer) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
sessionSweepTimer = setInterval(sweepIdleSessions, Math.min(SESSION_IDLE_MS, 60000));
|
|
565
|
+
sessionSweepTimer.unref();
|
|
566
|
+
}
|
|
567
|
+
const app = express();
|
|
568
|
+
// Behind the OpenShift router: trust its X-Forwarded-For so the rate limiter
|
|
569
|
+
// keys on the real client IP, not one shared router-IP bucket.
|
|
570
|
+
app.set('trust proxy', readNumericEnv('MCP_TRUST_PROXY_HOPS', 1));
|
|
571
|
+
app.disable('x-powered-by');
|
|
572
|
+
app.use('/mcp', express.json({ limit: '2mb' }));
|
|
573
|
+
app.use(['/health', '/rescan', '/mcp', '/sse', '/messages'], requireRateLimit);
|
|
574
|
+
// Liveness probe, no auth (no runtime detail exposed).
|
|
575
|
+
app.get('/livez', (_req, res) => {
|
|
576
|
+
res.json({ status: 'ok', uptime: process.uptime() });
|
|
577
|
+
});
|
|
578
|
+
app.get('/health', requireAdminKey, (_req, res) => {
|
|
579
|
+
const summary = getRuntimeSummary();
|
|
580
|
+
const autoRescanActive = AUTO_RESCAN_ENABLED && LIVE_SCAN_ENABLED && liveWatchers.length > 0;
|
|
581
|
+
res.json({
|
|
582
|
+
status: 'ok',
|
|
583
|
+
uptime: process.uptime(),
|
|
584
|
+
mode: TRANSPORT_MODE,
|
|
585
|
+
dataSourceMode: DATA_SOURCE_MODE,
|
|
586
|
+
liveScanEnabled: LIVE_SCAN_ENABLED,
|
|
587
|
+
autoRescanEnabled: AUTO_RESCAN_ENABLED,
|
|
588
|
+
autoRescanActive,
|
|
589
|
+
watchPaths: watchedPaths,
|
|
590
|
+
liveScanAvailable: DESIGN_SYSTEM_PATH_EXISTS,
|
|
591
|
+
mcpDataAvailable: HAS_MCP_DATA,
|
|
592
|
+
dataSource: summary.source,
|
|
593
|
+
mcpDataFiles: summary.mcpDataFiles,
|
|
594
|
+
connections: {
|
|
595
|
+
sse: sseSessions.size,
|
|
596
|
+
streamableHttp: streamableSessions.size,
|
|
597
|
+
stdio: stdioSession ? 1 : 0,
|
|
598
|
+
},
|
|
599
|
+
connectionStats,
|
|
600
|
+
usage: getMetricsSnapshot(),
|
|
601
|
+
components: summary.components,
|
|
602
|
+
jsonForms: summary.jsonForms,
|
|
603
|
+
themeUtilities: summary.themeUtilities,
|
|
604
|
+
figmaSync: {
|
|
605
|
+
lastSync: summary.lastFigmaSync,
|
|
606
|
+
stats: summary.figmaStats,
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
// Usage KPIs. JSON, or Prometheus with ?format=prometheus.
|
|
611
|
+
app.get('/metrics', requireAdminKey, (req, res) => {
|
|
612
|
+
const wantsPrometheus = req.query.format === 'prometheus' ||
|
|
613
|
+
(req.header('accept') || '').includes('text/plain');
|
|
614
|
+
if (wantsPrometheus) {
|
|
615
|
+
res.type('text/plain; version=0.0.4').send(renderPrometheusMetrics());
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
res.json(getMetricsSnapshot());
|
|
619
|
+
});
|
|
620
|
+
app.post('/rescan', requireAdminKey, async (_req, res) => {
|
|
621
|
+
try {
|
|
622
|
+
const result = await rescan();
|
|
623
|
+
res.json({ status: 'ok', ...result });
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
res.status(500).json({ error: String(err) });
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
app.all('/mcp', requireClientKey, async (req, res) => {
|
|
630
|
+
if (!HTTP_MODE_ENABLED) {
|
|
631
|
+
res.status(404).json({ error: 'http transport is disabled' });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
let createdSession;
|
|
635
|
+
try {
|
|
636
|
+
const sessionId = readSessionIdFromHeader(req);
|
|
637
|
+
if (sessionId && sseSessions.has(sessionId)) {
|
|
638
|
+
connectionStats.badMessageRequests += 1;
|
|
639
|
+
res.status(400).json({
|
|
640
|
+
jsonrpc: '2.0',
|
|
641
|
+
error: {
|
|
642
|
+
code: -32000,
|
|
643
|
+
message: 'session exists but uses legacy SSE transport',
|
|
644
|
+
},
|
|
645
|
+
id: null,
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
let session = sessionId ? streamableSessions.get(sessionId) : undefined;
|
|
650
|
+
if (session) {
|
|
651
|
+
session.lastActivity = Date.now();
|
|
652
|
+
}
|
|
653
|
+
if (!session) {
|
|
654
|
+
if (sessionId) {
|
|
655
|
+
connectionStats.badMessageRequests += 1;
|
|
656
|
+
res.status(404).json({
|
|
657
|
+
jsonrpc: '2.0',
|
|
658
|
+
error: {
|
|
659
|
+
code: -32000,
|
|
660
|
+
message: 'session not found',
|
|
661
|
+
},
|
|
662
|
+
id: null,
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (req.method !== 'POST' || !isInitializeRequest(req.body)) {
|
|
667
|
+
connectionStats.badMessageRequests += 1;
|
|
668
|
+
res.status(400).json({
|
|
669
|
+
jsonrpc: '2.0',
|
|
670
|
+
error: {
|
|
671
|
+
code: -32000,
|
|
672
|
+
message: 'no valid session and no initialize request',
|
|
673
|
+
},
|
|
674
|
+
id: null,
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (!enforceSessionCapacity(res)) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const server = createMcpServer();
|
|
682
|
+
const transport = new StreamableHTTPServerTransport({
|
|
683
|
+
sessionIdGenerator: () => randomUUID(),
|
|
684
|
+
onsessioninitialized: initializedSessionId => {
|
|
685
|
+
streamableSessions.set(initializedSessionId, {
|
|
686
|
+
transport,
|
|
687
|
+
server,
|
|
688
|
+
lastActivity: Date.now(),
|
|
689
|
+
});
|
|
690
|
+
connectionStats.accepted += 1;
|
|
691
|
+
logInfo(`[MCP] Streamable HTTP session opened (${initializedSessionId})`);
|
|
692
|
+
},
|
|
693
|
+
});
|
|
694
|
+
transport.onerror = error => {
|
|
695
|
+
console.error('[MCP] Streamable HTTP transport error:', error);
|
|
696
|
+
};
|
|
697
|
+
transport.onclose = () => {
|
|
698
|
+
void cleanupStreamableSession(transport.sessionId, 'client disconnected');
|
|
699
|
+
};
|
|
700
|
+
await server.connect(transport);
|
|
701
|
+
session = { transport, server, lastActivity: Date.now() };
|
|
702
|
+
createdSession = session;
|
|
703
|
+
}
|
|
704
|
+
await session.transport.handleRequest(req, res, req.body);
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
connectionStats.messageErrors += 1;
|
|
708
|
+
console.error('[MCP] Error handling /mcp request:', err);
|
|
709
|
+
if (createdSession) {
|
|
710
|
+
await closeServerSession(createdSession);
|
|
711
|
+
}
|
|
712
|
+
if (!res.headersSent) {
|
|
713
|
+
res.status(500).json({
|
|
714
|
+
jsonrpc: '2.0',
|
|
715
|
+
error: {
|
|
716
|
+
code: -32603,
|
|
717
|
+
message: 'internal error',
|
|
718
|
+
},
|
|
719
|
+
id: null,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
if (ENABLE_LEGACY_SSE) {
|
|
725
|
+
app.get('/sse', requireClientKey, async (req, res) => {
|
|
726
|
+
if (!HTTP_MODE_ENABLED) {
|
|
727
|
+
res.status(404).json({ error: 'http transport is disabled' });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (!enforceSessionCapacity(res)) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
logInfo(`[MCP] New legacy SSE connection from ${req.ip}`);
|
|
734
|
+
const transport = new SSEServerTransport('/messages', res);
|
|
735
|
+
const server = createMcpServer();
|
|
736
|
+
const sessionId = transport.sessionId;
|
|
737
|
+
sseSessions.set(sessionId, { transport, server, lastActivity: Date.now() });
|
|
738
|
+
connectionStats.accepted += 1;
|
|
739
|
+
res.once('close', () => {
|
|
740
|
+
void cleanupSseSession(sessionId, 'client disconnected');
|
|
741
|
+
});
|
|
742
|
+
try {
|
|
743
|
+
await server.connect(transport);
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
await cleanupSseSession(sessionId, 'connect failure');
|
|
747
|
+
console.error('[MCP] Failed to establish SSE connection:', err);
|
|
748
|
+
if (!res.headersSent) {
|
|
749
|
+
res.status(500).json({ error: 'failed to establish SSE connection' });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
app.post('/messages', requireClientKey, async (req, res) => {
|
|
754
|
+
if (!HTTP_MODE_ENABLED) {
|
|
755
|
+
res.status(404).json({ error: 'http transport is disabled' });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const sessionId = readSessionIdFromQuery(req);
|
|
759
|
+
if (!sessionId) {
|
|
760
|
+
connectionStats.badMessageRequests += 1;
|
|
761
|
+
res.status(400).json({ error: 'sessionId is required' });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (streamableSessions.has(sessionId)) {
|
|
765
|
+
connectionStats.badMessageRequests += 1;
|
|
766
|
+
res.status(400).json({ error: 'session uses streamable HTTP transport' });
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const session = sseSessions.get(sessionId);
|
|
770
|
+
if (!session) {
|
|
771
|
+
connectionStats.badMessageRequests += 1;
|
|
772
|
+
res.status(404).json({ error: 'session not found' });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
session.lastActivity = Date.now();
|
|
776
|
+
try {
|
|
777
|
+
await session.transport.handlePostMessage(req, res);
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
connectionStats.messageErrors += 1;
|
|
781
|
+
console.error('[MCP] Error handling legacy SSE message:', err);
|
|
782
|
+
if (!res.headersSent) {
|
|
783
|
+
res.status(500).json({ error: 'internal error' });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
// Final error handler — maps thrown/forwarded errors (incl. body-parser
|
|
789
|
+
// SyntaxError / PayloadTooLarge) to a clean JSON-RPC-shaped response with no
|
|
790
|
+
// stack trace. Must stay AFTER all routes so Express routes errors here.
|
|
791
|
+
app.use((err, req, res, _next) => {
|
|
792
|
+
connectionStats.messageErrors += 1;
|
|
793
|
+
const errRecord = err && typeof err === 'object'
|
|
794
|
+
? err
|
|
795
|
+
: {};
|
|
796
|
+
const rawStatus = typeof errRecord.status === 'number'
|
|
797
|
+
? errRecord.status
|
|
798
|
+
: typeof errRecord.statusCode === 'number'
|
|
799
|
+
? errRecord.statusCode
|
|
800
|
+
: undefined;
|
|
801
|
+
const isPayloadTooLarge = rawStatus === 413 || errRecord.type === 'entity.too.large';
|
|
802
|
+
const isBadJson = err instanceof SyntaxError || errRecord.type === 'entity.parse.failed';
|
|
803
|
+
const status = isPayloadTooLarge
|
|
804
|
+
? 413
|
|
805
|
+
: isBadJson
|
|
806
|
+
? 400
|
|
807
|
+
: rawStatus && rawStatus >= 400 && rawStatus < 600
|
|
808
|
+
? rawStatus
|
|
809
|
+
: 500;
|
|
810
|
+
const code = isBadJson ? -32700 : isPayloadTooLarge ? -32600 : -32603;
|
|
811
|
+
const message = isPayloadTooLarge
|
|
812
|
+
? 'request payload too large'
|
|
813
|
+
: isBadJson
|
|
814
|
+
? 'invalid JSON payload'
|
|
815
|
+
: 'internal error';
|
|
816
|
+
logInfo(`[MCP] Request error on ${req.method} ${req.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
817
|
+
if (res.headersSent) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
res.status(status).json({
|
|
821
|
+
jsonrpc: '2.0',
|
|
822
|
+
error: { code, message },
|
|
823
|
+
id: null,
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
async function startStdioTransport() {
|
|
827
|
+
if (!STDIO_MODE_ENABLED) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const transport = new StdioServerTransport();
|
|
831
|
+
const server = createMcpServer();
|
|
832
|
+
stdioSession = { transport, server };
|
|
833
|
+
transport.onclose = () => {
|
|
834
|
+
connectionStats.closed += 1;
|
|
835
|
+
stdioSession = null;
|
|
836
|
+
logInfo('[MCP] stdio transport closed');
|
|
837
|
+
if (!HTTP_MODE_ENABLED) {
|
|
838
|
+
void shutdown('STDIO_CLOSED');
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
transport.onerror = error => {
|
|
842
|
+
connectionStats.messageErrors += 1;
|
|
843
|
+
console.error('[MCP] stdio transport error:', error);
|
|
844
|
+
};
|
|
845
|
+
await server.connect(transport);
|
|
846
|
+
connectionStats.accepted += 1;
|
|
847
|
+
logInfo('[MCP] stdio transport ready');
|
|
848
|
+
}
|
|
849
|
+
function startHttpTransport() {
|
|
850
|
+
if (!HTTP_MODE_ENABLED) {
|
|
851
|
+
return Promise.resolve();
|
|
852
|
+
}
|
|
853
|
+
startRateTrackerSweep();
|
|
854
|
+
startSessionSweep();
|
|
855
|
+
return new Promise((resolve, reject) => {
|
|
856
|
+
const server = app.listen(PORT, '0.0.0.0', () => {
|
|
857
|
+
httpServer = server;
|
|
858
|
+
logInfo(`[MCP] HTTP server listening on http://0.0.0.0:${PORT}`);
|
|
859
|
+
logInfo('[MCP] Health: GET /health');
|
|
860
|
+
logInfo('[MCP] Streamable HTTP: ALL /mcp');
|
|
861
|
+
if (ENABLE_LEGACY_SSE) {
|
|
862
|
+
logInfo('[MCP] Legacy SSE: GET /sse + POST /messages');
|
|
863
|
+
}
|
|
864
|
+
if (API_KEY) {
|
|
865
|
+
logInfo(`[MCP] Auth: /mcp requires the client key${ADMIN_KEY ? '; admin endpoints require the admin key' : ''}`);
|
|
866
|
+
}
|
|
867
|
+
else if (ADMIN_KEY) {
|
|
868
|
+
logInfo('[MCP] Auth: /mcp is open; admin endpoints (/metrics, /health, /rescan) require the admin key');
|
|
869
|
+
}
|
|
870
|
+
else if (ALLOW_UNAUTHENTICATED_HTTP) {
|
|
871
|
+
logInfo('[MCP] WARNING: all HTTP endpoints are open (no keys set)');
|
|
872
|
+
}
|
|
873
|
+
resolve();
|
|
874
|
+
});
|
|
875
|
+
server.once('error', reject);
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
async function main() {
|
|
879
|
+
logInfo(`Design System Path: ${DESIGN_SYSTEM_PATH}${DESIGN_SYSTEM_PATH_EXISTS ? '' : ' (not found)'}`);
|
|
880
|
+
logInfo(`[MCP] Starting with transport mode "${TRANSPORT_MODE}"${CLI_TRANSPORT_ARG ? ' (from CLI)' : ''}`);
|
|
881
|
+
logInfo(`[MCP] Data source mode "${DATA_SOURCE_MODE}"${CLI_DATA_SOURCE_ARG ? ' (from CLI)' : ''}`);
|
|
882
|
+
if (HTTP_MODE_ENABLED &&
|
|
883
|
+
!API_KEY &&
|
|
884
|
+
!ADMIN_KEY &&
|
|
885
|
+
!ALLOW_UNAUTHENTICATED_HTTP) {
|
|
886
|
+
console.error('[MCP] FATAL: HTTP transport has no keys. Set MCP_ADMIN_KEY (leaves /mcp open, protects /metrics|/health|/rescan), or MCP_API_KEY (keys /mcp too), or MCP_ALLOW_UNAUTHENTICATED_HTTP=true.');
|
|
887
|
+
process.exit(1);
|
|
888
|
+
}
|
|
889
|
+
if (DATA_SOURCE_MODE === 'mcp-data' && !HAS_MCP_CODEBASE) {
|
|
890
|
+
console.error('[MCP] FATAL: MCP_DATA_SOURCE is set to "mcp-data" but mcp-data/codebase.json is missing. Run `npm run sync:mcp` first.');
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
if (DATA_SOURCE_MODE === 'live' && !DESIGN_SYSTEM_PATH_EXISTS) {
|
|
894
|
+
console.error(`[MCP] FATAL: MCP_DATA_SOURCE is set to "live" but DESIGN_SYSTEM_PATH does not exist: ${DESIGN_SYSTEM_PATH}`);
|
|
895
|
+
process.exit(1);
|
|
896
|
+
}
|
|
897
|
+
if (!DESIGN_SYSTEM_PATH_EXISTS && !HAS_MCP_DATA) {
|
|
898
|
+
console.error(`[MCP] FATAL: Design system path does not exist and no synced mcp-data is available. Checked path: ${DESIGN_SYSTEM_PATH}`);
|
|
899
|
+
process.exit(1);
|
|
900
|
+
}
|
|
901
|
+
if (LIVE_SCAN_ENABLED && DESIGN_SYSTEM_PATH_EXISTS) {
|
|
902
|
+
logInfo('Scanning design system components...');
|
|
903
|
+
await scanner.scan();
|
|
904
|
+
const summary = scanner.getSummary();
|
|
905
|
+
logInfo(`Found ${summary.components} components, ${summary.jsonForms} JSON Forms renderers, ${summary.themeUtilities} theme utilities.`);
|
|
906
|
+
if (summary.components === 0) {
|
|
907
|
+
console.warn('[MCP] WARNING: No components found — check DESIGN_SYSTEM_PATH');
|
|
908
|
+
}
|
|
909
|
+
if (AUTO_RESCAN_ENABLED) {
|
|
910
|
+
startLiveRescanWatchers();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
logInfo(`[MCP] Using synced mcp-data as source of truth (${AVAILABLE_MCP_DATA_FILES.join(', ') || 'none found'}).`);
|
|
915
|
+
if (AUTO_RESCAN_ENABLED) {
|
|
916
|
+
logInfo('[MCP] Auto rescan was requested, but it only works in live mode. Start with --data-source=live --watch to enable automatic detection.');
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
startMetricsPersistence();
|
|
920
|
+
await startHttpTransport();
|
|
921
|
+
await startStdioTransport();
|
|
922
|
+
if (!HTTP_MODE_ENABLED && !STDIO_MODE_ENABLED) {
|
|
923
|
+
throw new Error(`No valid transport enabled for mode "${TRANSPORT_MODE}".`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
async function shutdown(signal) {
|
|
927
|
+
if (shutdownInProgress) {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
shutdownInProgress = true;
|
|
931
|
+
logInfo(`[MCP] ${signal} received, shutting down...`);
|
|
932
|
+
// Failsafe: if a transport/server close hangs, force the process out anyway.
|
|
933
|
+
const forceExitTimer = setTimeout(() => {
|
|
934
|
+
console.error('[MCP] Forced exit: graceful shutdown timed out.');
|
|
935
|
+
process.exit(1);
|
|
936
|
+
}, 5000);
|
|
937
|
+
forceExitTimer.unref();
|
|
938
|
+
const closeOps = [];
|
|
939
|
+
if (httpServer) {
|
|
940
|
+
closeOps.push(new Promise(resolve => {
|
|
941
|
+
httpServer?.close(() => resolve(undefined));
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
collectSessionCloseOps(sseSessions, closeOps);
|
|
945
|
+
collectSessionCloseOps(streamableSessions, closeOps);
|
|
946
|
+
if (stdioSession) {
|
|
947
|
+
closeOps.push(stdioSession.transport.close());
|
|
948
|
+
closeOps.push(stdioSession.server.close());
|
|
949
|
+
stdioSession = null;
|
|
950
|
+
}
|
|
951
|
+
if (rescanDebounceTimer) {
|
|
952
|
+
clearTimeout(rescanDebounceTimer);
|
|
953
|
+
rescanDebounceTimer = null;
|
|
954
|
+
}
|
|
955
|
+
if (rateTrackerSweepTimer) {
|
|
956
|
+
clearInterval(rateTrackerSweepTimer);
|
|
957
|
+
rateTrackerSweepTimer = null;
|
|
958
|
+
}
|
|
959
|
+
if (sessionSweepTimer) {
|
|
960
|
+
clearInterval(sessionSweepTimer);
|
|
961
|
+
sessionSweepTimer = null;
|
|
962
|
+
}
|
|
963
|
+
if (metricsFlushTimer) {
|
|
964
|
+
clearInterval(metricsFlushTimer);
|
|
965
|
+
metricsFlushTimer = null;
|
|
966
|
+
}
|
|
967
|
+
writeMetricsFile(); // final flush
|
|
968
|
+
for (const watcher of liveWatchers.splice(0)) {
|
|
969
|
+
try {
|
|
970
|
+
watcher.close();
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
void error;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
await Promise.allSettled(closeOps);
|
|
977
|
+
clearTimeout(forceExitTimer);
|
|
978
|
+
process.exit(0);
|
|
979
|
+
}
|
|
980
|
+
process.on('SIGINT', () => {
|
|
981
|
+
void shutdown('SIGINT');
|
|
982
|
+
});
|
|
983
|
+
process.on('SIGTERM', () => {
|
|
984
|
+
void shutdown('SIGTERM');
|
|
985
|
+
});
|
|
986
|
+
// Keep the process alive on a stray rejection — just surface it on stderr.
|
|
987
|
+
process.on('unhandledRejection', reason => {
|
|
988
|
+
console.error('[MCP] Unhandled promise rejection:', reason);
|
|
989
|
+
});
|
|
990
|
+
// A truly uncaught exception leaves us in an unknown state — log then drain.
|
|
991
|
+
process.on('uncaughtException', error => {
|
|
992
|
+
console.error('[MCP] Uncaught exception:', error);
|
|
993
|
+
void shutdown('UNCAUGHT_EXCEPTION');
|
|
994
|
+
});
|
|
995
|
+
main().catch(error => {
|
|
996
|
+
console.error('Fatal error starting server:', error);
|
|
997
|
+
process.exit(1);
|
|
998
|
+
});
|
|
999
|
+
//# sourceMappingURL=index.js.map
|