@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.
- package/dist/es/build/rspack-config.mjs +4 -0
- package/dist/es/constants/example-code.mjs +4 -4
- package/dist/es/env/constants.mjs +27 -82
- package/dist/es/env/global-config-manager.mjs +2 -3
- package/dist/es/env/helper.mjs +12 -17
- package/dist/es/env/init-debug.mjs +6 -6
- package/dist/es/env/model-config-manager.mjs +45 -65
- package/dist/es/env/parse-model-config.mjs +112 -0
- package/dist/es/env/types.mjs +70 -162
- package/dist/es/extractor/dom-util.mjs +10 -18
- package/dist/es/extractor/index.mjs +2 -3
- package/dist/es/extractor/locator.mjs +8 -15
- package/dist/es/extractor/tree.mjs +2 -5
- package/dist/es/extractor/util.mjs +4 -28
- package/dist/es/extractor/web-extractor.mjs +7 -14
- package/dist/es/index.mjs +2 -1
- package/dist/es/mcp/base-server.mjs +250 -0
- package/dist/es/mcp/base-tools.mjs +84 -0
- package/dist/es/mcp/index.mjs +5 -0
- package/dist/es/mcp/inject-report-html-plugin.mjs +53 -0
- package/dist/es/mcp/tool-generator.mjs +207 -0
- package/dist/es/mcp/types.mjs +3 -0
- package/dist/es/node/fs.mjs +2 -2
- package/dist/es/utils.mjs +2 -3
- package/dist/es/zod-schema-utils.mjs +54 -0
- package/dist/lib/baseDB.js +2 -2
- package/dist/lib/build/copy-static.js +4 -4
- package/dist/lib/build/rspack-config.js +38 -0
- package/dist/lib/common.js +4 -4
- package/dist/lib/constants/example-code.js +6 -6
- package/dist/lib/constants/index.js +13 -13
- package/dist/lib/env/basic.js +2 -2
- package/dist/lib/env/constants.js +32 -90
- package/dist/lib/env/global-config-manager.js +4 -5
- package/dist/lib/env/helper.js +13 -22
- package/dist/lib/env/index.js +24 -28
- package/dist/lib/env/init-debug.js +7 -7
- package/dist/lib/env/model-config-manager.js +47 -67
- package/dist/lib/env/parse-model-config.js +155 -0
- package/dist/lib/env/types.js +146 -379
- package/dist/lib/env/utils.js +4 -4
- package/dist/lib/extractor/constants.js +4 -4
- package/dist/lib/extractor/debug.js +1 -1
- package/dist/lib/extractor/dom-util.js +18 -26
- package/dist/lib/extractor/index.js +11 -21
- package/dist/lib/extractor/locator.js +10 -20
- package/dist/lib/extractor/tree.js +4 -7
- package/dist/lib/extractor/util.js +17 -50
- package/dist/lib/extractor/web-extractor.js +12 -19
- package/dist/lib/img/box-select.js +4 -4
- package/dist/lib/img/draw-box.js +2 -2
- package/dist/lib/img/get-jimp.js +16 -34
- package/dist/lib/img/get-photon.js +24 -47
- package/dist/lib/img/get-sharp.js +16 -34
- package/dist/lib/img/index.js +18 -18
- package/dist/lib/img/info.js +4 -4
- package/dist/lib/img/transform.js +10 -10
- package/dist/lib/index.js +8 -4
- package/dist/lib/logger.js +4 -4
- package/dist/lib/mcp/base-server.js +300 -0
- package/dist/lib/mcp/base-tools.js +118 -0
- package/dist/lib/mcp/index.js +86 -0
- package/dist/lib/mcp/inject-report-html-plugin.js +98 -0
- package/dist/lib/mcp/tool-generator.js +244 -0
- package/dist/lib/mcp/types.js +40 -0
- package/dist/lib/node/fs.js +6 -6
- package/dist/lib/node/index.js +6 -8
- package/dist/lib/polyfills/async-hooks.js +2 -2
- package/dist/lib/polyfills/index.js +6 -8
- package/dist/lib/types/index.js +2 -2
- package/dist/lib/us-keyboard-layout.js +2 -2
- package/dist/lib/utils.js +13 -14
- package/dist/lib/zod-schema-utils.js +97 -0
- package/dist/types/build/rspack-config.d.ts +8 -0
- package/dist/types/constants/example-code.d.ts +1 -1
- package/dist/types/env/constants.d.ts +5 -18
- package/dist/types/env/global-config-manager.d.ts +1 -2
- package/dist/types/env/helper.d.ts +2 -4
- package/dist/types/env/model-config-manager.d.ts +8 -7
- package/dist/types/env/parse-model-config.d.ts +28 -0
- package/dist/types/env/types.d.ts +152 -191
- package/dist/types/extractor/dom-util.d.ts +2 -15
- package/dist/types/extractor/index.d.ts +1 -2
- package/dist/types/extractor/locator.d.ts +0 -1
- package/dist/types/extractor/tree.d.ts +1 -4
- package/dist/types/extractor/util.d.ts +0 -3
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mcp/base-server.d.ts +77 -0
- package/dist/types/mcp/base-tools.d.ts +55 -0
- package/dist/types/mcp/index.d.ts +5 -0
- package/dist/types/mcp/inject-report-html-plugin.d.ts +18 -0
- package/dist/types/mcp/tool-generator.d.ts +11 -0
- package/dist/types/mcp/types.d.ts +100 -0
- package/dist/types/types/index.d.ts +5 -2
- package/dist/types/zod-schema-utils.d.ts +23 -0
- package/package.json +19 -4
- package/src/build/rspack-config.ts +12 -0
- package/src/constants/example-code.ts +4 -4
- package/src/env/constants.ts +58 -203
- package/src/env/global-config-manager.ts +7 -7
- package/src/env/helper.ts +10 -31
- package/src/env/init-debug.ts +11 -6
- package/src/env/model-config-manager.ts +91 -87
- package/src/env/parse-model-config.ts +265 -0
- package/src/env/types.ts +212 -344
- package/src/extractor/dom-util.ts +15 -12
- package/src/extractor/index.ts +0 -3
- package/src/extractor/locator.ts +3 -12
- package/src/extractor/tree.ts +4 -4
- package/src/extractor/util.ts +0 -32
- package/src/index.ts +2 -0
- package/src/mcp/base-server.ts +435 -0
- package/src/mcp/base-tools.ts +196 -0
- package/src/mcp/index.ts +5 -0
- package/src/mcp/inject-report-html-plugin.ts +119 -0
- package/src/mcp/tool-generator.ts +330 -0
- package/src/mcp/types.ts +108 -0
- package/src/node/fs.ts +1 -1
- package/src/types/index.ts +8 -2
- package/src/utils.ts +1 -1
- package/src/zod-schema-utils.ts +133 -0
- package/dist/es/env/decide-model-config.mjs +0 -172
- package/dist/es/env/parse.mjs +0 -69
- package/dist/lib/env/decide-model-config.js +0 -212
- package/dist/lib/env/parse.js +0 -106
- package/dist/types/env/decide-model-config.d.ts +0 -14
- package/dist/types/env/parse.d.ts +0 -12
- package/src/env/decide-model-config.ts +0 -319
- package/src/env/parse.ts +0 -131
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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 -
|
|
138
|
-
top: Math.max(position.y -
|
|
139
|
-
width:
|
|
140
|
-
height:
|
|
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
|
-
|
|
148
|
-
|
|
150
|
+
center: [position.x, position.y] as [number, number],
|
|
151
|
+
description: description || '',
|
|
149
152
|
};
|
|
150
153
|
|
|
151
154
|
return element;
|
package/src/extractor/index.ts
CHANGED
|
@@ -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,
|
package/src/extractor/locator.ts
CHANGED
|
@@ -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
|
|
package/src/extractor/tree.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function trimAttributes(
|
|
|
45
45
|
res[currentKey] = truncateText(attributeVal, truncateTextLength);
|
|
46
46
|
return res;
|
|
47
47
|
},
|
|
48
|
-
{} as
|
|
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}" ${
|
|
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}>`;
|
package/src/extractor/util.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|