@midscene/shared 0.30.10 → 1.0.0

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.
Files changed (129) hide show
  1. package/dist/es/build/rspack-config.mjs +4 -0
  2. package/dist/es/constants/example-code.mjs +4 -4
  3. package/dist/es/env/constants.mjs +27 -82
  4. package/dist/es/env/global-config-manager.mjs +2 -3
  5. package/dist/es/env/helper.mjs +12 -17
  6. package/dist/es/env/init-debug.mjs +6 -6
  7. package/dist/es/env/model-config-manager.mjs +45 -65
  8. package/dist/es/env/parse-model-config.mjs +112 -0
  9. package/dist/es/env/types.mjs +70 -162
  10. package/dist/es/extractor/dom-util.mjs +10 -18
  11. package/dist/es/extractor/index.mjs +2 -3
  12. package/dist/es/extractor/locator.mjs +8 -15
  13. package/dist/es/extractor/tree.mjs +2 -5
  14. package/dist/es/extractor/util.mjs +4 -28
  15. package/dist/es/extractor/web-extractor.mjs +7 -14
  16. package/dist/es/index.mjs +2 -1
  17. package/dist/es/mcp/base-server.mjs +250 -0
  18. package/dist/es/mcp/base-tools.mjs +84 -0
  19. package/dist/es/mcp/index.mjs +5 -0
  20. package/dist/es/mcp/inject-report-html-plugin.mjs +53 -0
  21. package/dist/es/mcp/tool-generator.mjs +207 -0
  22. package/dist/es/mcp/types.mjs +3 -0
  23. package/dist/es/node/fs.mjs +2 -2
  24. package/dist/es/utils.mjs +2 -3
  25. package/dist/es/zod-schema-utils.mjs +54 -0
  26. package/dist/lib/baseDB.js +2 -2
  27. package/dist/lib/build/copy-static.js +4 -4
  28. package/dist/lib/build/rspack-config.js +38 -0
  29. package/dist/lib/common.js +4 -4
  30. package/dist/lib/constants/example-code.js +6 -6
  31. package/dist/lib/constants/index.js +13 -13
  32. package/dist/lib/env/basic.js +2 -2
  33. package/dist/lib/env/constants.js +32 -90
  34. package/dist/lib/env/global-config-manager.js +4 -5
  35. package/dist/lib/env/helper.js +13 -22
  36. package/dist/lib/env/index.js +24 -28
  37. package/dist/lib/env/init-debug.js +7 -7
  38. package/dist/lib/env/model-config-manager.js +47 -67
  39. package/dist/lib/env/parse-model-config.js +155 -0
  40. package/dist/lib/env/types.js +146 -379
  41. package/dist/lib/env/utils.js +4 -4
  42. package/dist/lib/extractor/constants.js +4 -4
  43. package/dist/lib/extractor/debug.js +1 -1
  44. package/dist/lib/extractor/dom-util.js +18 -26
  45. package/dist/lib/extractor/index.js +11 -21
  46. package/dist/lib/extractor/locator.js +10 -20
  47. package/dist/lib/extractor/tree.js +4 -7
  48. package/dist/lib/extractor/util.js +17 -50
  49. package/dist/lib/extractor/web-extractor.js +12 -19
  50. package/dist/lib/img/box-select.js +4 -4
  51. package/dist/lib/img/draw-box.js +2 -2
  52. package/dist/lib/img/get-jimp.js +16 -34
  53. package/dist/lib/img/get-photon.js +24 -47
  54. package/dist/lib/img/get-sharp.js +16 -34
  55. package/dist/lib/img/index.js +18 -18
  56. package/dist/lib/img/info.js +4 -4
  57. package/dist/lib/img/transform.js +10 -10
  58. package/dist/lib/index.js +8 -4
  59. package/dist/lib/logger.js +4 -4
  60. package/dist/lib/mcp/base-server.js +300 -0
  61. package/dist/lib/mcp/base-tools.js +118 -0
  62. package/dist/lib/mcp/index.js +86 -0
  63. package/dist/lib/mcp/inject-report-html-plugin.js +98 -0
  64. package/dist/lib/mcp/tool-generator.js +244 -0
  65. package/dist/lib/mcp/types.js +40 -0
  66. package/dist/lib/node/fs.js +6 -6
  67. package/dist/lib/node/index.js +6 -8
  68. package/dist/lib/polyfills/async-hooks.js +2 -2
  69. package/dist/lib/polyfills/index.js +6 -8
  70. package/dist/lib/types/index.js +2 -2
  71. package/dist/lib/us-keyboard-layout.js +2 -2
  72. package/dist/lib/utils.js +13 -14
  73. package/dist/lib/zod-schema-utils.js +97 -0
  74. package/dist/types/build/rspack-config.d.ts +8 -0
  75. package/dist/types/constants/example-code.d.ts +1 -1
  76. package/dist/types/env/constants.d.ts +5 -18
  77. package/dist/types/env/global-config-manager.d.ts +1 -2
  78. package/dist/types/env/helper.d.ts +2 -4
  79. package/dist/types/env/model-config-manager.d.ts +8 -7
  80. package/dist/types/env/parse-model-config.d.ts +28 -0
  81. package/dist/types/env/types.d.ts +152 -191
  82. package/dist/types/extractor/dom-util.d.ts +2 -15
  83. package/dist/types/extractor/index.d.ts +1 -2
  84. package/dist/types/extractor/locator.d.ts +0 -1
  85. package/dist/types/extractor/tree.d.ts +1 -4
  86. package/dist/types/extractor/util.d.ts +0 -3
  87. package/dist/types/index.d.ts +1 -0
  88. package/dist/types/mcp/base-server.d.ts +77 -0
  89. package/dist/types/mcp/base-tools.d.ts +55 -0
  90. package/dist/types/mcp/index.d.ts +5 -0
  91. package/dist/types/mcp/inject-report-html-plugin.d.ts +18 -0
  92. package/dist/types/mcp/tool-generator.d.ts +11 -0
  93. package/dist/types/mcp/types.d.ts +100 -0
  94. package/dist/types/types/index.d.ts +5 -2
  95. package/dist/types/zod-schema-utils.d.ts +23 -0
  96. package/package.json +19 -4
  97. package/src/build/rspack-config.ts +12 -0
  98. package/src/constants/example-code.ts +4 -4
  99. package/src/env/constants.ts +58 -203
  100. package/src/env/global-config-manager.ts +7 -7
  101. package/src/env/helper.ts +10 -31
  102. package/src/env/init-debug.ts +11 -6
  103. package/src/env/model-config-manager.ts +91 -87
  104. package/src/env/parse-model-config.ts +265 -0
  105. package/src/env/types.ts +212 -344
  106. package/src/extractor/dom-util.ts +15 -12
  107. package/src/extractor/index.ts +0 -3
  108. package/src/extractor/locator.ts +3 -12
  109. package/src/extractor/tree.ts +4 -4
  110. package/src/extractor/util.ts +0 -32
  111. package/src/index.ts +2 -0
  112. package/src/mcp/base-server.ts +435 -0
  113. package/src/mcp/base-tools.ts +196 -0
  114. package/src/mcp/index.ts +5 -0
  115. package/src/mcp/inject-report-html-plugin.ts +119 -0
  116. package/src/mcp/tool-generator.ts +330 -0
  117. package/src/mcp/types.ts +108 -0
  118. package/src/node/fs.ts +1 -1
  119. package/src/types/index.ts +8 -2
  120. package/src/utils.ts +1 -1
  121. package/src/zod-schema-utils.ts +133 -0
  122. package/dist/es/env/decide-model-config.mjs +0 -172
  123. package/dist/es/env/parse.mjs +0 -69
  124. package/dist/lib/env/decide-model-config.js +0 -212
  125. package/dist/lib/env/parse.js +0 -106
  126. package/dist/types/env/decide-model-config.d.ts +0 -14
  127. package/dist/types/env/parse.d.ts +0 -12
  128. package/src/env/decide-model-config.ts +0 -319
  129. package/src/env/parse.ts +0 -131
@@ -1,5 +1,4 @@
1
- import { NodeType } from '../constants';
2
- import { generateHashId } from '../utils';
1
+ import type { LocateResultElement } from '../types';
3
2
 
4
3
  export function isFormElement(node: globalThis.Node) {
5
4
  return (
@@ -132,20 +131,24 @@ function includeBaseElement(node: globalThis.Node) {
132
131
  return false;
133
132
  }
134
133
 
135
- export function generateElementByPosition(position: { x: number; y: number }) {
134
+ export function generateElementByPosition(
135
+ position: {
136
+ x: number;
137
+ y: number;
138
+ },
139
+ description: string,
140
+ ): LocateResultElement {
141
+ const edgeSize = 8;
136
142
  const rect = {
137
- left: Math.max(position.x - 4, 0),
138
- top: Math.max(position.y - 4, 0),
139
- width: 8,
140
- height: 8,
143
+ left: Math.round(Math.max(position.x - edgeSize / 2, 0)),
144
+ top: Math.round(Math.max(position.y - edgeSize / 2, 0)),
145
+ width: edgeSize,
146
+ height: edgeSize,
141
147
  };
142
- const id = generateHashId(rect);
143
148
  const element = {
144
- id,
145
- attributes: { nodeType: NodeType.POSITION },
146
149
  rect,
147
- content: '',
148
- center: [position.x, position.y],
150
+ center: [position.x, position.y] as [number, number],
151
+ description: description || '',
149
152
  };
150
153
 
151
154
  return element;
@@ -35,10 +35,7 @@ export { extractTreeNode as webExtractNodeTree } from './web-extractor';
35
35
 
36
36
  export { extractTreeNodeAsString as webExtractNodeTreeAsString } from './web-extractor';
37
37
 
38
- export { setNodeHashCacheListOnWindow, getNodeFromCacheList } from './util';
39
-
40
38
  export {
41
- getXpathsById,
42
39
  getXpathsByPoint,
43
40
  getNodeInfoByXpath,
44
41
  getElementInfoByXpath,
@@ -1,7 +1,6 @@
1
1
  import type { ElementInfo } from '.';
2
2
  import type { Point } from '../types';
3
3
  import { isSvgElement } from './dom-util';
4
- import { getNodeFromCacheList } from './util';
5
4
  import { getRect, isElementPartiallyInViewport } from './util';
6
5
  import { collectElementInfo } from './web-extractor';
7
6
 
@@ -105,17 +104,6 @@ export const getElementXpath = (
105
104
  return buildCurrentElementXpath(el, isOrderSensitive, isLeafElement);
106
105
  };
107
106
 
108
- export function getXpathsById(id: string): string[] | null {
109
- const node = getNodeFromCacheList(id);
110
-
111
- if (!node) {
112
- return null;
113
- }
114
-
115
- const fullXPath = getElementXpath(node, false, true);
116
- return [fullXPath];
117
- }
118
-
119
107
  export function getXpathsByPoint(
120
108
  point: Point,
121
109
  isOrderSensitive: boolean,
@@ -140,6 +128,9 @@ export function getNodeInfoByXpath(xpath: string): Node | null {
140
128
  );
141
129
 
142
130
  if (xpathResult.snapshotLength !== 1) {
131
+ console.warn(
132
+ `[midscene:warning] Received XPath "${xpath}" but it matched ${xpathResult.snapshotLength} elements. Discarding this result.`,
133
+ );
143
134
  return null;
144
135
  }
145
136
 
@@ -45,7 +45,7 @@ export function trimAttributes(
45
45
  res[currentKey] = truncateText(attributeVal, truncateTextLength);
46
46
  return res;
47
47
  },
48
- {} as BaseElement['attributes'],
48
+ {} as Record<string, string>,
49
49
  );
50
50
  return tailorAttributes;
51
51
  }
@@ -106,8 +106,8 @@ export function descriptionOfTree<
106
106
  .replace(/\sNode$/, '')
107
107
  .toLowerCase();
108
108
  }
109
- const markerId = node.node.indexId;
110
- const markerIdString = markerId ? `markerId="${markerId}"` : '';
109
+ // const markerId = node.node.indexId;
110
+ // const markerIdString = markerId ? `markerId="${markerId}"` : '';
111
111
  const rectAttribute = node.node.rect
112
112
  ? {
113
113
  left: node.node.rect.left,
@@ -116,7 +116,7 @@ export function descriptionOfTree<
116
116
  height: node.node.rect.height,
117
117
  }
118
118
  : {};
119
- before = `<${nodeTypeString} id="${node.node.id}" ${markerIdString} ${attributesString(trimAttributes(node.node.attributes || {}, truncateTextLength))} ${attributesString(rectAttribute)}>`;
119
+ before = `<${nodeTypeString} id="${node.node.id}" ${attributesString(trimAttributes(node.node.attributes || {}, truncateTextLength))} ${attributesString(rectAttribute)}>`;
120
120
  const content = truncateText(node.node.content, truncateTextLength);
121
121
  contentWithIndent = content ? `\n${indentStr} ${content}` : '';
122
122
  after = `</${nodeTypeString}>`;
@@ -399,42 +399,10 @@ export function midsceneGenerateHash(
399
399
  ): string {
400
400
  const slicedHash = generateHashId(rect, content);
401
401
 
402
- if (node) {
403
- if (!(window as any).midsceneNodeHashCacheList) {
404
- setNodeHashCacheListOnWindow();
405
- }
406
-
407
- setNodeToCacheList(node, slicedHash);
408
- }
409
-
410
402
  // Returns the first 10 characters as a short hash
411
403
  return slicedHash;
412
404
  }
413
405
 
414
- export function setNodeHashCacheListOnWindow() {
415
- if (typeof window !== 'undefined') {
416
- (window as any).midsceneNodeHashCacheList = [];
417
- }
418
- }
419
-
420
- export function setNodeToCacheList(node: globalThis.Node, id: string) {
421
- if (typeof window !== 'undefined') {
422
- if (getNodeFromCacheList(id)) {
423
- return;
424
- }
425
- (window as any).midsceneNodeHashCacheList?.push({ node, id });
426
- }
427
- }
428
-
429
- export function getNodeFromCacheList(id: string) {
430
- if (typeof window !== 'undefined') {
431
- return (window as any).midsceneNodeHashCacheList?.find(
432
- (item: { node: Node; id: string }) => item.id === id,
433
- )?.node;
434
- }
435
- return null;
436
- }
437
-
438
406
  export function generateId(numberId: number) {
439
407
  // const letters = 'ABCDEFGHIJKLMNPRSTUVXYZ';
440
408
  // const numbers = '0123456789';
package/src/index.ts CHANGED
@@ -3,4 +3,6 @@ export {
3
3
  createPlaygroundCopyPlugin,
4
4
  } from './build/copy-static';
5
5
 
6
+ export { commonIgnoreWarnings } from './build/rspack-config';
7
+
6
8
  export default {};
@@ -0,0 +1,435 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { ParseArgsConfig } from 'node:util';
3
+ import { setIsMcp } from '@midscene/shared/utils';
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import express, {
8
+ type Application,
9
+ type Request,
10
+ type Response,
11
+ } from 'express';
12
+ import type { IMidsceneTools } from './types';
13
+
14
+ export interface BaseMCPServerConfig {
15
+ name: string;
16
+ version: string;
17
+ description: string;
18
+ }
19
+
20
+ export interface HttpLaunchOptions {
21
+ port: number;
22
+ host?: string;
23
+ }
24
+
25
+ interface SessionData {
26
+ transport: StreamableHTTPServerTransport;
27
+ createdAt: Date;
28
+ lastAccessedAt: Date;
29
+ }
30
+
31
+ /**
32
+ * CLI argument configuration for MCP servers
33
+ */
34
+ export const CLI_ARGS_CONFIG: ParseArgsConfig['options'] = {
35
+ mode: { type: 'string', default: 'stdio' },
36
+ port: { type: 'string', default: '3000' },
37
+ host: { type: 'string', default: 'localhost' },
38
+ };
39
+
40
+ export interface CLIArgs {
41
+ mode?: string;
42
+ port?: string;
43
+ host?: string;
44
+ }
45
+
46
+ /**
47
+ * Launch an MCP server based on CLI arguments
48
+ * Shared helper to reduce duplication across platform CLI entry points
49
+ */
50
+ export function launchMCPServer(
51
+ server: BaseMCPServer,
52
+ args: CLIArgs,
53
+ ): Promise<void> {
54
+ if (args.mode === 'http') {
55
+ return server.launchHttp({
56
+ port: Number.parseInt(args.port || '3000', 10),
57
+ host: args.host || 'localhost',
58
+ });
59
+ }
60
+ return server.launch();
61
+ }
62
+
63
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
64
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
65
+ const MAX_SESSIONS = 100; // Maximum concurrent sessions to prevent DoS
66
+
67
+ /**
68
+ * Base MCP Server class with programmatic launch() API
69
+ * Each platform extends this to provide their own tools manager
70
+ */
71
+ export abstract class BaseMCPServer {
72
+ protected mcpServer: McpServer;
73
+ protected toolsManager?: IMidsceneTools;
74
+ protected config: BaseMCPServerConfig;
75
+
76
+ constructor(config: BaseMCPServerConfig) {
77
+ this.config = config;
78
+ this.mcpServer = new McpServer({
79
+ name: config.name,
80
+ version: config.version,
81
+ description: config.description,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Platform-specific: create tools manager instance
87
+ */
88
+ protected abstract createToolsManager(): IMidsceneTools;
89
+
90
+ /**
91
+ * Initialize tools manager and attach to MCP server
92
+ */
93
+ private async initializeToolsManager(): Promise<void> {
94
+ setIsMcp(true);
95
+ this.toolsManager = this.createToolsManager();
96
+
97
+ try {
98
+ await this.toolsManager.initTools();
99
+ } catch (error: unknown) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ console.error(`Failed to initialize tools: ${message}`);
102
+ console.error('Tools will be initialized on first use');
103
+ }
104
+
105
+ this.toolsManager.attachToServer(this.mcpServer);
106
+ }
107
+
108
+ /**
109
+ * Perform cleanup on shutdown
110
+ */
111
+ private performCleanup(): void {
112
+ console.error(`${this.config.name} closing...`);
113
+ this.mcpServer.close();
114
+ this.toolsManager?.closeBrowser?.().catch(console.error);
115
+ }
116
+
117
+ /**
118
+ * Initialize and launch the MCP server with stdio transport
119
+ */
120
+ public async launch(): Promise<void> {
121
+ // Hijack stdout-based console methods to stderr for stdio mode
122
+ // This prevents them from breaking MCP JSON-RPC protocol on stdout
123
+ // Note: console.warn and console.error already output to stderr
124
+ console.log = (...args: unknown[]) => {
125
+ console.error('[LOG]', ...args);
126
+ };
127
+ console.info = (...args: unknown[]) => {
128
+ console.error('[INFO]', ...args);
129
+ };
130
+ console.debug = (...args: unknown[]) => {
131
+ console.error('[DEBUG]', ...args);
132
+ };
133
+
134
+ await this.initializeToolsManager();
135
+
136
+ const transport = new StdioServerTransport();
137
+
138
+ try {
139
+ await this.mcpServer.connect(transport);
140
+ } catch (error: unknown) {
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ console.error(`Failed to connect MCP stdio transport: ${message}`);
143
+ throw new Error(`Failed to initialize MCP stdio transport: ${message}`);
144
+ }
145
+
146
+ // Setup process-level error handlers to prevent crashes
147
+ process.on('uncaughtException', (error: Error) => {
148
+ console.error(`[${this.config.name}] Uncaught Exception:`, error);
149
+ console.error('Stack:', error.stack);
150
+ // Don't exit - try to recover
151
+ });
152
+
153
+ process.on('unhandledRejection', (reason: unknown) => {
154
+ console.error(`[${this.config.name}] Unhandled Rejection:`, reason);
155
+ if (reason instanceof Error) {
156
+ console.error('Stack:', reason.stack);
157
+ }
158
+ // Don't exit - try to recover
159
+ });
160
+
161
+ // Setup cleanup handlers
162
+ process.stdin.on('close', () => this.performCleanup());
163
+
164
+ // Setup signal handlers for graceful shutdown
165
+ const cleanup = () => {
166
+ console.error(`${this.config.name} shutting down...`);
167
+ this.performCleanup();
168
+ process.exit(0);
169
+ };
170
+
171
+ process.once('SIGINT', cleanup);
172
+ process.once('SIGTERM', cleanup);
173
+ }
174
+
175
+ /**
176
+ * Launch MCP server with HTTP transport
177
+ * Supports stateful sessions for web applications and service integration
178
+ */
179
+ public async launchHttp(options: HttpLaunchOptions): Promise<void> {
180
+ // Validate port number
181
+ if (
182
+ !Number.isInteger(options.port) ||
183
+ options.port < 1 ||
184
+ options.port > 65535
185
+ ) {
186
+ throw new Error(
187
+ `Invalid port number: ${options.port}. Port must be between 1 and 65535.`,
188
+ );
189
+ }
190
+
191
+ await this.initializeToolsManager();
192
+
193
+ const app: Application = express();
194
+
195
+ // Add JSON body parser with size limit
196
+ app.use(express.json({ limit: '10mb' }));
197
+
198
+ const sessions = new Map<string, SessionData>();
199
+
200
+ app.all('/mcp', async (req: Request, res: Response) => {
201
+ const startTime = Date.now();
202
+ const requestId = randomUUID().substring(0, 8);
203
+
204
+ try {
205
+ const rawSessionId = req.headers['mcp-session-id'];
206
+ const sessionId = Array.isArray(rawSessionId)
207
+ ? rawSessionId[0]
208
+ : rawSessionId;
209
+ let session = sessionId ? sessions.get(sessionId) : undefined;
210
+
211
+ if (!session && req.method === 'POST') {
212
+ // Check session limit to prevent DoS
213
+ if (sessions.size >= MAX_SESSIONS) {
214
+ console.error(
215
+ `[${new Date().toISOString()}] [${requestId}] Session limit reached: ${sessions.size}/${MAX_SESSIONS}`,
216
+ );
217
+ res.status(503).json({
218
+ error: 'Too many active sessions',
219
+ message: 'Server is at maximum capacity. Please try again later.',
220
+ });
221
+ return;
222
+ }
223
+ session = await this.createHttpSession(sessions);
224
+ console.log(
225
+ `[${new Date().toISOString()}] [${requestId}] New session created: ${session.transport.sessionId}`,
226
+ );
227
+ }
228
+
229
+ if (session) {
230
+ session.lastAccessedAt = new Date();
231
+ await session.transport.handleRequest(req, res, req.body);
232
+ const duration = Date.now() - startTime;
233
+ console.log(
234
+ `[${new Date().toISOString()}] [${requestId}] Request completed in ${duration}ms`,
235
+ );
236
+ } else {
237
+ console.error(
238
+ `[${new Date().toISOString()}] [${requestId}] Invalid session or GET without session`,
239
+ );
240
+ res
241
+ .status(400)
242
+ .json({ error: 'Invalid session or GET without session' });
243
+ }
244
+ } catch (error: unknown) {
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ const duration = Date.now() - startTime;
247
+ console.error(
248
+ `[${new Date().toISOString()}] [${requestId}] MCP request error after ${duration}ms: ${message}`,
249
+ );
250
+ if (!res.headersSent) {
251
+ res.status(500).json({
252
+ error: 'Internal server error',
253
+ message: 'Failed to process MCP request',
254
+ });
255
+ }
256
+ }
257
+ });
258
+
259
+ const host = options.host || 'localhost';
260
+
261
+ // Create server with error handling
262
+ const server = app
263
+ .listen(options.port, host, () => {
264
+ console.log(
265
+ `${this.config.name} HTTP server listening on http://${host}:${options.port}/mcp`,
266
+ );
267
+ })
268
+ .on('error', (error: NodeJS.ErrnoException) => {
269
+ if (error.code === 'EADDRINUSE') {
270
+ console.error(
271
+ `ERROR: Port ${options.port} is already in use.\nPlease try a different port: --port=<number>\nExample: --mode=http --port=${options.port + 1}`,
272
+ );
273
+ } else if (error.code === 'EACCES') {
274
+ console.error(
275
+ `ERROR: Permission denied to bind to port ${options.port}.\nPorts below 1024 require root/admin privileges.\nPlease use a port above 1024 or run with elevated privileges.`,
276
+ );
277
+ } else {
278
+ console.error(
279
+ `ERROR: Failed to start HTTP server on ${host}:${options.port}\n` +
280
+ `Reason: ${error.message}\n` +
281
+ `Code: ${error.code || 'unknown'}`,
282
+ );
283
+ }
284
+ process.exit(1);
285
+ });
286
+
287
+ const cleanupInterval = this.startSessionCleanup(sessions);
288
+ this.setupHttpShutdownHandlers(server, sessions, cleanupInterval);
289
+ }
290
+
291
+ /**
292
+ * Create a new HTTP session with transport
293
+ */
294
+ private async createHttpSession(
295
+ sessions: Map<string, SessionData>,
296
+ ): Promise<SessionData> {
297
+ const transport = new StreamableHTTPServerTransport({
298
+ sessionIdGenerator: () => randomUUID(),
299
+ onsessioninitialized: (sid: string) => {
300
+ sessions.set(sid, {
301
+ transport,
302
+ createdAt: new Date(),
303
+ lastAccessedAt: new Date(),
304
+ });
305
+ console.log(
306
+ `[${new Date().toISOString()}] Session ${sid} initialized (total: ${sessions.size})`,
307
+ );
308
+ },
309
+ });
310
+
311
+ transport.onclose = () => {
312
+ if (transport.sessionId) {
313
+ sessions.delete(transport.sessionId);
314
+ console.log(
315
+ `[${new Date().toISOString()}] Session ${transport.sessionId} closed (remaining: ${sessions.size})`,
316
+ );
317
+ }
318
+ };
319
+
320
+ try {
321
+ await this.mcpServer.connect(transport);
322
+ } catch (error: unknown) {
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ console.error(
325
+ `[${new Date().toISOString()}] Failed to connect MCP transport: ${message}`,
326
+ );
327
+ // Clean up the failed transport
328
+ if (transport.sessionId) {
329
+ sessions.delete(transport.sessionId);
330
+ }
331
+ throw new Error(`Failed to initialize MCP session: ${message}`);
332
+ }
333
+
334
+ return {
335
+ transport,
336
+ createdAt: new Date(),
337
+ lastAccessedAt: new Date(),
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Start periodic session cleanup for inactive sessions
343
+ */
344
+ private startSessionCleanup(
345
+ sessions: Map<string, SessionData>,
346
+ ): ReturnType<typeof setInterval> {
347
+ return setInterval(() => {
348
+ const now = Date.now();
349
+ for (const [sid, session] of sessions) {
350
+ if (now - session.lastAccessedAt.getTime() > SESSION_TIMEOUT_MS) {
351
+ try {
352
+ session.transport.close();
353
+ sessions.delete(sid);
354
+ console.log(
355
+ `[${new Date().toISOString()}] Session ${sid} cleaned up due to inactivity (remaining: ${sessions.size})`,
356
+ );
357
+ } catch (error: unknown) {
358
+ const message =
359
+ error instanceof Error ? error.message : String(error);
360
+ console.error(
361
+ `[${new Date().toISOString()}] Failed to close session ${sid} during cleanup: ${message}`,
362
+ );
363
+ // Still delete from map to prevent retry loops
364
+ sessions.delete(sid);
365
+ }
366
+ }
367
+ }
368
+ }, CLEANUP_INTERVAL_MS);
369
+ }
370
+
371
+ /**
372
+ * Setup shutdown handlers for HTTP server
373
+ */
374
+ private setupHttpShutdownHandlers(
375
+ server: ReturnType<Application['listen']>,
376
+ sessions: Map<string, SessionData>,
377
+ cleanupInterval: ReturnType<typeof setInterval>,
378
+ ): void {
379
+ const cleanup = () => {
380
+ console.error(`${this.config.name} shutting down...`);
381
+ clearInterval(cleanupInterval);
382
+
383
+ // Close all sessions with error handling
384
+ for (const session of sessions.values()) {
385
+ try {
386
+ session.transport.close();
387
+ } catch (error: unknown) {
388
+ const message =
389
+ error instanceof Error ? error.message : String(error);
390
+ console.error(`Error closing session during shutdown: ${message}`);
391
+ }
392
+ }
393
+ sessions.clear();
394
+
395
+ // Close HTTP server gracefully
396
+ try {
397
+ server.close(() => {
398
+ // Server closed callback - all connections finished
399
+ this.performCleanup();
400
+ process.exit(0);
401
+ });
402
+
403
+ // Set a timeout in case server.close() hangs
404
+ setTimeout(() => {
405
+ console.error('Forcefully shutting down after timeout');
406
+ this.performCleanup();
407
+ process.exit(1);
408
+ }, 5000);
409
+ } catch (error: unknown) {
410
+ const message = error instanceof Error ? error.message : String(error);
411
+ console.error(`Error closing HTTP server: ${message}`);
412
+ this.performCleanup();
413
+ process.exit(1);
414
+ }
415
+ };
416
+
417
+ // Use once() to prevent multiple registrations
418
+ process.once('SIGINT', cleanup);
419
+ process.once('SIGTERM', cleanup);
420
+ }
421
+
422
+ /**
423
+ * Get the underlying MCP server instance
424
+ */
425
+ public getServer(): McpServer {
426
+ return this.mcpServer;
427
+ }
428
+
429
+ /**
430
+ * Get the tools manager instance
431
+ */
432
+ public getToolsManager(): IMidsceneTools | undefined {
433
+ return this.toolsManager;
434
+ }
435
+ }