@midscene/shared 1.9.8-beta-20260618014851.0 → 1.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/es/cli/cli-runner.mjs +1 -1
- package/dist/es/env/parse-model-config.mjs +1 -1
- package/dist/es/env/types.mjs +5 -3
- package/dist/es/mcp/base-server.mjs +295 -0
- package/dist/es/{agent-tools → mcp}/base-tools.mjs +8 -1
- package/dist/es/{agent-tools → mcp}/chrome-path.mjs +3 -14
- package/dist/es/{agent-tools → mcp}/index.mjs +3 -0
- package/dist/es/mcp/inject-report-html-plugin.mjs +53 -0
- package/dist/es/mcp/launcher-helper.mjs +52 -0
- package/dist/es/{agent-tools → mcp}/tool-generator.mjs +3 -3
- package/dist/es/utils.mjs +6 -2
- package/dist/lib/cli/cli-runner.js +1 -1
- package/dist/lib/env/parse-model-config.js +1 -1
- package/dist/lib/env/types.js +10 -5
- package/dist/lib/mcp/base-server.js +345 -0
- package/dist/lib/{agent-tools → mcp}/base-tools.js +8 -1
- package/dist/lib/{agent-tools → mcp}/chrome-path.js +2 -13
- package/dist/lib/{agent-tools → mcp}/index.js +37 -16
- package/dist/lib/mcp/inject-report-html-plugin.js +98 -0
- package/dist/lib/mcp/launcher-helper.js +86 -0
- package/dist/lib/{agent-tools → mcp}/tool-generator.js +3 -3
- package/dist/lib/utils.js +15 -8
- package/dist/types/cli/cli-args.d.ts +1 -1
- package/dist/types/cli/cli-runner.d.ts +2 -2
- package/dist/types/env/types.d.ts +6 -8
- package/dist/types/key-alias-utils.d.ts +2 -2
- package/dist/types/mcp/base-server.d.ts +106 -0
- package/dist/types/{agent-tools → mcp}/base-tools.d.ts +13 -7
- package/dist/types/{agent-tools → mcp}/index.d.ts +3 -0
- package/dist/types/{agent-tools → mcp}/init-arg-utils.d.ts +3 -3
- package/dist/types/mcp/inject-report-html-plugin.d.ts +18 -0
- package/dist/types/mcp/launcher-helper.d.ts +94 -0
- package/dist/types/{agent-tools → mcp}/tool-defaults.d.ts +6 -5
- package/dist/types/{agent-tools → mcp}/tool-generator.d.ts +1 -1
- package/dist/types/{agent-tools → mcp}/types.d.ts +9 -4
- package/dist/types/utils.d.ts +1 -0
- package/package.json +8 -15
- package/src/cli/cli-args.ts +1 -1
- package/src/cli/cli-runner.ts +4 -4
- package/src/env/types.ts +5 -5
- package/src/key-alias-utils.ts +2 -2
- package/src/mcp/base-server.ts +529 -0
- package/src/{agent-tools → mcp}/base-tools.ts +33 -8
- package/src/{agent-tools → mcp}/chrome-path.ts +3 -20
- package/src/{agent-tools → mcp}/index.ts +3 -0
- package/src/{agent-tools → mcp}/init-arg-utils.ts +3 -3
- package/src/mcp/inject-report-html-plugin.ts +119 -0
- package/src/mcp/launcher-helper.ts +200 -0
- package/src/{agent-tools → mcp}/tool-defaults.ts +6 -5
- package/src/{agent-tools → mcp}/tool-generator.ts +6 -6
- package/src/{agent-tools → mcp}/types.ts +9 -4
- package/src/utils.ts +10 -1
- /package/dist/es/{agent-tools → mcp}/agent-behavior-init-args.mjs +0 -0
- /package/dist/es/{agent-tools → mcp}/cli-report-session.mjs +0 -0
- /package/dist/es/{agent-tools → mcp}/error-formatter.mjs +0 -0
- /package/dist/es/{agent-tools → mcp}/init-arg-utils.mjs +0 -0
- /package/dist/es/{agent-tools → mcp}/tool-defaults.mjs +0 -0
- /package/dist/es/{agent-tools → mcp}/types.mjs +0 -0
- /package/dist/es/{agent-tools → mcp}/user-prompt.mjs +0 -0
- /package/dist/lib/{agent-tools → mcp}/agent-behavior-init-args.js +0 -0
- /package/dist/lib/{agent-tools → mcp}/cli-report-session.js +0 -0
- /package/dist/lib/{agent-tools → mcp}/error-formatter.js +0 -0
- /package/dist/lib/{agent-tools → mcp}/init-arg-utils.js +0 -0
- /package/dist/lib/{agent-tools → mcp}/tool-defaults.js +0 -0
- /package/dist/lib/{agent-tools → mcp}/types.js +0 -0
- /package/dist/lib/{agent-tools → mcp}/user-prompt.js +0 -0
- /package/dist/types/{agent-tools → mcp}/agent-behavior-init-args.d.ts +0 -0
- /package/dist/types/{agent-tools → mcp}/chrome-path.d.ts +0 -0
- /package/dist/types/{agent-tools → mcp}/cli-report-session.d.ts +0 -0
- /package/dist/types/{agent-tools → mcp}/error-formatter.d.ts +0 -0
- /package/dist/types/{agent-tools → mcp}/user-prompt.d.ts +0 -0
- /package/src/{agent-tools → mcp}/agent-behavior-init-args.ts +0 -0
- /package/src/{agent-tools → mcp}/cli-report-session.ts +0 -0
- /package/src/{agent-tools → mcp}/error-formatter.ts +0 -0
- /package/src/{agent-tools → mcp}/user-prompt.ts +0 -0
|
@@ -0,0 +1,529 @@
|
|
|
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 { getErrorMessage } from './error-formatter';
|
|
13
|
+
import {
|
|
14
|
+
TOOL_BEHAVIOR_FLAGS,
|
|
15
|
+
type ToolDefaults,
|
|
16
|
+
mergeToolDefaults,
|
|
17
|
+
resolveToolDefaults,
|
|
18
|
+
} from './tool-defaults';
|
|
19
|
+
import type { IMidsceneTools } from './types';
|
|
20
|
+
|
|
21
|
+
export interface BaseMCPServerConfig {
|
|
22
|
+
name: string;
|
|
23
|
+
version: string;
|
|
24
|
+
description: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HttpLaunchOptions {
|
|
28
|
+
port: number;
|
|
29
|
+
host?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LaunchMCPServerResult {
|
|
33
|
+
/**
|
|
34
|
+
* The MCP server port (for HTTP mode)
|
|
35
|
+
*/
|
|
36
|
+
port?: number;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The server host (for HTTP mode)
|
|
40
|
+
*/
|
|
41
|
+
host?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Function to gracefully shutdown the MCP server
|
|
45
|
+
*/
|
|
46
|
+
close: () => Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SessionData {
|
|
50
|
+
transport: StreamableHTTPServerTransport;
|
|
51
|
+
createdAt: Date;
|
|
52
|
+
lastAccessedAt: Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* CLI argument configuration for MCP servers. Behavior flags (e.g.
|
|
57
|
+
* `--deep-locate`) are generated from {@link TOOL_BEHAVIOR_FLAGS}, so adding a
|
|
58
|
+
* new flag there exposes it here automatically.
|
|
59
|
+
*/
|
|
60
|
+
export const CLI_ARGS_CONFIG: ParseArgsConfig['options'] = {
|
|
61
|
+
mode: { type: 'string', default: 'stdio' },
|
|
62
|
+
port: { type: 'string', default: '3000' },
|
|
63
|
+
host: { type: 'string', default: 'localhost' },
|
|
64
|
+
...Object.fromEntries(
|
|
65
|
+
TOOL_BEHAVIOR_FLAGS.map((flag) => [flag.cli, { type: 'boolean' as const }]),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export interface CLIArgs {
|
|
70
|
+
mode?: string;
|
|
71
|
+
port?: string;
|
|
72
|
+
host?: string;
|
|
73
|
+
/** Behavior flags such as `deep-locate` / `deep-think` (see TOOL_BEHAVIOR_FLAGS). */
|
|
74
|
+
[flag: string]: string | boolean | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Launch an MCP server based on CLI arguments
|
|
79
|
+
* Shared helper to reduce duplication across platform CLI entry points
|
|
80
|
+
*/
|
|
81
|
+
export function launchMCPServer(
|
|
82
|
+
server: BaseMCPServer,
|
|
83
|
+
args: CLIArgs,
|
|
84
|
+
): Promise<LaunchMCPServerResult> {
|
|
85
|
+
server.setToolDefaults(resolveToolDefaults((cli) => args[cli] === true));
|
|
86
|
+
if (args.mode === 'http') {
|
|
87
|
+
return server.launchHttp({
|
|
88
|
+
port: Number.parseInt(args.port || '3000', 10),
|
|
89
|
+
host: args.host || 'localhost',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return server.launch();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
96
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
97
|
+
const MAX_SESSIONS = 100; // Maximum concurrent sessions to prevent DoS
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Base MCP Server class with programmatic launch() API
|
|
101
|
+
* Each platform extends this to provide their own tools manager
|
|
102
|
+
*/
|
|
103
|
+
export abstract class BaseMCPServer {
|
|
104
|
+
protected mcpServer: McpServer;
|
|
105
|
+
protected toolsManager?: IMidsceneTools;
|
|
106
|
+
protected config: BaseMCPServerConfig;
|
|
107
|
+
protected providedToolsManager?: IMidsceneTools;
|
|
108
|
+
protected toolDefaults: ToolDefaults = {};
|
|
109
|
+
|
|
110
|
+
constructor(config: BaseMCPServerConfig, toolsManager?: IMidsceneTools) {
|
|
111
|
+
this.config = config;
|
|
112
|
+
this.mcpServer = new McpServer({
|
|
113
|
+
name: config.name,
|
|
114
|
+
version: config.version,
|
|
115
|
+
description: config.description,
|
|
116
|
+
});
|
|
117
|
+
this.providedToolsManager = toolsManager;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Set the default options injected into generated tool calls (e.g. forced
|
|
122
|
+
* deep locate / deep think). Must be called before `launch()` /
|
|
123
|
+
* `launchHttp()` so they are applied to the tools manager before its tools
|
|
124
|
+
* are generated. Merges with any previously set defaults.
|
|
125
|
+
*/
|
|
126
|
+
public setToolDefaults(toolDefaults: ToolDefaults): void {
|
|
127
|
+
this.toolDefaults = mergeToolDefaults(this.toolDefaults, toolDefaults);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Platform-specific: create tools manager instance
|
|
132
|
+
* This is only called if no tools manager was provided in constructor
|
|
133
|
+
*/
|
|
134
|
+
protected abstract createToolsManager(): IMidsceneTools;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Initialize tools manager and attach to MCP server
|
|
138
|
+
*/
|
|
139
|
+
private async initializeToolsManager(): Promise<void> {
|
|
140
|
+
setIsMcp(true);
|
|
141
|
+
|
|
142
|
+
// Use provided tools manager if available, otherwise create new one
|
|
143
|
+
this.toolsManager = this.providedToolsManager || this.createToolsManager();
|
|
144
|
+
|
|
145
|
+
// Apply the tool defaults before tools are generated so they are baked
|
|
146
|
+
// into the generated tool handlers.
|
|
147
|
+
this.toolsManager.setToolDefaults?.(this.toolDefaults);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await this.toolsManager.initTools();
|
|
151
|
+
} catch (error: unknown) {
|
|
152
|
+
const message = getErrorMessage(error);
|
|
153
|
+
console.error(`Failed to initialize tools: ${message}`);
|
|
154
|
+
console.error('Tools will be initialized on first use');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.toolsManager.attachToServer(this.mcpServer);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Perform cleanup on shutdown
|
|
162
|
+
*/
|
|
163
|
+
private async performCleanup(): Promise<void> {
|
|
164
|
+
console.error(`${this.config.name} closing...`);
|
|
165
|
+
this.mcpServer.close();
|
|
166
|
+
await this.toolsManager?.destroy?.().catch(console.error);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Initialize and launch the MCP server with stdio transport
|
|
171
|
+
*/
|
|
172
|
+
public async launch(): Promise<LaunchMCPServerResult> {
|
|
173
|
+
// Hijack stdout-based console methods to stderr for stdio mode
|
|
174
|
+
// This prevents them from breaking MCP JSON-RPC protocol on stdout
|
|
175
|
+
// Note: console.warn and console.error already output to stderr
|
|
176
|
+
console.log = (...args: unknown[]) => {
|
|
177
|
+
console.error('[LOG]', ...args);
|
|
178
|
+
};
|
|
179
|
+
console.info = (...args: unknown[]) => {
|
|
180
|
+
console.error('[INFO]', ...args);
|
|
181
|
+
};
|
|
182
|
+
console.debug = (...args: unknown[]) => {
|
|
183
|
+
console.error('[DEBUG]', ...args);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
await this.initializeToolsManager();
|
|
187
|
+
|
|
188
|
+
const transport = new StdioServerTransport();
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await this.mcpServer.connect(transport);
|
|
192
|
+
} catch (error: unknown) {
|
|
193
|
+
const message = getErrorMessage(error);
|
|
194
|
+
console.error(`Failed to connect MCP stdio transport: ${message}`);
|
|
195
|
+
throw new Error(`Failed to initialize MCP stdio transport: ${message}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Setup signal handlers for graceful shutdown
|
|
199
|
+
let isShuttingDown = false;
|
|
200
|
+
const cleanup = () => {
|
|
201
|
+
if (isShuttingDown) return;
|
|
202
|
+
isShuttingDown = true;
|
|
203
|
+
console.error(`${this.config.name} shutting down...`);
|
|
204
|
+
this.performCleanup().finally(() => process.exit(0));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Setup process-level error handlers to prevent crashes
|
|
208
|
+
process.on('uncaughtException', (error: Error & { code?: string }) => {
|
|
209
|
+
// Exit on pipe errors — parent process is gone
|
|
210
|
+
if (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') {
|
|
211
|
+
cleanup();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
console.error(`[${this.config.name}] Uncaught Exception:`, error);
|
|
215
|
+
console.error('Stack:', error.stack);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
process.on('unhandledRejection', (reason: unknown) => {
|
|
219
|
+
console.error(`[${this.config.name}] Unhandled Rejection:`, reason);
|
|
220
|
+
if (reason instanceof Error) {
|
|
221
|
+
console.error('Stack:', reason.stack);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Exit when stdin closes (parent process gone) or reaches EOF
|
|
226
|
+
process.stdin.on('close', cleanup);
|
|
227
|
+
process.stdin.on('end', cleanup);
|
|
228
|
+
|
|
229
|
+
// Exit when stdout/stderr pipe breaks (parent process gone)
|
|
230
|
+
process.stdout.on('error', cleanup);
|
|
231
|
+
|
|
232
|
+
process.once('SIGINT', cleanup);
|
|
233
|
+
process.once('SIGTERM', cleanup);
|
|
234
|
+
process.once('SIGHUP', cleanup);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
close: async () => {
|
|
238
|
+
this.performCleanup();
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Launch MCP server with HTTP transport
|
|
245
|
+
* Supports stateful sessions for web applications and service integration
|
|
246
|
+
*/
|
|
247
|
+
public async launchHttp(
|
|
248
|
+
options: HttpLaunchOptions,
|
|
249
|
+
): Promise<LaunchMCPServerResult> {
|
|
250
|
+
// Validate port number
|
|
251
|
+
if (
|
|
252
|
+
!Number.isInteger(options.port) ||
|
|
253
|
+
options.port < 1 ||
|
|
254
|
+
options.port > 65535
|
|
255
|
+
) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Invalid port number: ${options.port}. Port must be between 1 and 65535.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await this.initializeToolsManager();
|
|
262
|
+
|
|
263
|
+
const app: Application = express();
|
|
264
|
+
|
|
265
|
+
// Add JSON body parser with size limit
|
|
266
|
+
app.use(express.json({ limit: '10mb' }));
|
|
267
|
+
|
|
268
|
+
const sessions = new Map<string, SessionData>();
|
|
269
|
+
|
|
270
|
+
app.all('/mcp', async (req: Request, res: Response) => {
|
|
271
|
+
const startTime = Date.now();
|
|
272
|
+
const requestId = randomUUID().substring(0, 8);
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const rawSessionId = req.headers['mcp-session-id'];
|
|
276
|
+
const sessionId = Array.isArray(rawSessionId)
|
|
277
|
+
? rawSessionId[0]
|
|
278
|
+
: rawSessionId;
|
|
279
|
+
let session = sessionId ? sessions.get(sessionId) : undefined;
|
|
280
|
+
|
|
281
|
+
if (!session && req.method === 'POST') {
|
|
282
|
+
// Check session limit to prevent DoS
|
|
283
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
284
|
+
console.error(
|
|
285
|
+
`[${new Date().toISOString()}] [${requestId}] Session limit reached: ${sessions.size}/${MAX_SESSIONS}`,
|
|
286
|
+
);
|
|
287
|
+
res.status(503).json({
|
|
288
|
+
error: 'Too many active sessions',
|
|
289
|
+
message: 'Server is at maximum capacity. Please try again later.',
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
session = await this.createHttpSession(sessions);
|
|
294
|
+
console.log(
|
|
295
|
+
`[${new Date().toISOString()}] [${requestId}] New session created: ${session.transport.sessionId}`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (session) {
|
|
300
|
+
session.lastAccessedAt = new Date();
|
|
301
|
+
await session.transport.handleRequest(req, res, req.body);
|
|
302
|
+
const duration = Date.now() - startTime;
|
|
303
|
+
console.log(
|
|
304
|
+
`[${new Date().toISOString()}] [${requestId}] Request completed in ${duration}ms`,
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
console.error(
|
|
308
|
+
`[${new Date().toISOString()}] [${requestId}] Invalid session or GET without session`,
|
|
309
|
+
);
|
|
310
|
+
res
|
|
311
|
+
.status(400)
|
|
312
|
+
.json({ error: 'Invalid session or GET without session' });
|
|
313
|
+
}
|
|
314
|
+
} catch (error: unknown) {
|
|
315
|
+
const message = getErrorMessage(error);
|
|
316
|
+
const duration = Date.now() - startTime;
|
|
317
|
+
console.error(
|
|
318
|
+
`[${new Date().toISOString()}] [${requestId}] MCP request error after ${duration}ms: ${message}`,
|
|
319
|
+
);
|
|
320
|
+
if (!res.headersSent) {
|
|
321
|
+
res.status(500).json({
|
|
322
|
+
error: 'Internal server error',
|
|
323
|
+
message: 'Failed to process MCP request',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const host = options.host || 'localhost';
|
|
330
|
+
|
|
331
|
+
// Create server with error handling
|
|
332
|
+
const server = app
|
|
333
|
+
.listen(options.port, host, () => {
|
|
334
|
+
console.log(
|
|
335
|
+
`${this.config.name} HTTP server listening on http://${host}:${options.port}/mcp`,
|
|
336
|
+
);
|
|
337
|
+
})
|
|
338
|
+
.on('error', (error: NodeJS.ErrnoException) => {
|
|
339
|
+
if (error.code === 'EADDRINUSE') {
|
|
340
|
+
console.error(
|
|
341
|
+
`ERROR: Port ${options.port} is already in use.\nPlease try a different port: --port=<number>\nExample: --mode=http --port=${options.port + 1}`,
|
|
342
|
+
);
|
|
343
|
+
} else if (error.code === 'EACCES') {
|
|
344
|
+
console.error(
|
|
345
|
+
`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.`,
|
|
346
|
+
);
|
|
347
|
+
} else {
|
|
348
|
+
console.error(
|
|
349
|
+
`ERROR: Failed to start HTTP server on ${host}:${options.port}\n` +
|
|
350
|
+
`Reason: ${error.message}\n` +
|
|
351
|
+
`Code: ${error.code || 'unknown'}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
process.exit(1);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const cleanupInterval = this.startSessionCleanup(sessions);
|
|
358
|
+
this.setupHttpShutdownHandlers(server, sessions, cleanupInterval);
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
port: options.port,
|
|
362
|
+
host,
|
|
363
|
+
close: async () => {
|
|
364
|
+
clearInterval(cleanupInterval);
|
|
365
|
+
for (const session of sessions.values()) {
|
|
366
|
+
try {
|
|
367
|
+
await session.transport.close();
|
|
368
|
+
} catch (error: unknown) {
|
|
369
|
+
const message = getErrorMessage(error);
|
|
370
|
+
console.error(
|
|
371
|
+
`Failed to close session ${session.transport.sessionId}: ${message}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
sessions.clear();
|
|
376
|
+
|
|
377
|
+
return new Promise<void>((resolve) => {
|
|
378
|
+
server.close(async (err) => {
|
|
379
|
+
if (err) {
|
|
380
|
+
console.error('Error closing HTTP server:', err);
|
|
381
|
+
}
|
|
382
|
+
await this.performCleanup();
|
|
383
|
+
resolve();
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Create a new HTTP session with transport
|
|
392
|
+
*/
|
|
393
|
+
private async createHttpSession(
|
|
394
|
+
sessions: Map<string, SessionData>,
|
|
395
|
+
): Promise<SessionData> {
|
|
396
|
+
const transport = new StreamableHTTPServerTransport({
|
|
397
|
+
sessionIdGenerator: () => randomUUID(),
|
|
398
|
+
onsessioninitialized: (sid: string) => {
|
|
399
|
+
sessions.set(sid, {
|
|
400
|
+
transport,
|
|
401
|
+
createdAt: new Date(),
|
|
402
|
+
lastAccessedAt: new Date(),
|
|
403
|
+
});
|
|
404
|
+
console.log(
|
|
405
|
+
`[${new Date().toISOString()}] Session ${sid} initialized (total: ${sessions.size})`,
|
|
406
|
+
);
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
transport.onclose = () => {
|
|
411
|
+
if (transport.sessionId) {
|
|
412
|
+
sessions.delete(transport.sessionId);
|
|
413
|
+
console.log(
|
|
414
|
+
`[${new Date().toISOString()}] Session ${transport.sessionId} closed (remaining: ${sessions.size})`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
await this.mcpServer.connect(transport);
|
|
421
|
+
} catch (error: unknown) {
|
|
422
|
+
const message = getErrorMessage(error);
|
|
423
|
+
console.error(
|
|
424
|
+
`[${new Date().toISOString()}] Failed to connect MCP transport: ${message}`,
|
|
425
|
+
);
|
|
426
|
+
// Clean up the failed transport
|
|
427
|
+
if (transport.sessionId) {
|
|
428
|
+
sessions.delete(transport.sessionId);
|
|
429
|
+
}
|
|
430
|
+
throw new Error(`Failed to initialize MCP session: ${message}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
transport,
|
|
435
|
+
createdAt: new Date(),
|
|
436
|
+
lastAccessedAt: new Date(),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Start periodic session cleanup for inactive sessions
|
|
442
|
+
*/
|
|
443
|
+
private startSessionCleanup(
|
|
444
|
+
sessions: Map<string, SessionData>,
|
|
445
|
+
): ReturnType<typeof setInterval> {
|
|
446
|
+
return setInterval(() => {
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
for (const [sid, session] of sessions) {
|
|
449
|
+
if (now - session.lastAccessedAt.getTime() > SESSION_TIMEOUT_MS) {
|
|
450
|
+
try {
|
|
451
|
+
session.transport.close();
|
|
452
|
+
sessions.delete(sid);
|
|
453
|
+
console.log(
|
|
454
|
+
`[${new Date().toISOString()}] Session ${sid} cleaned up due to inactivity (remaining: ${sessions.size})`,
|
|
455
|
+
);
|
|
456
|
+
} catch (error: unknown) {
|
|
457
|
+
const message = getErrorMessage(error);
|
|
458
|
+
console.error(
|
|
459
|
+
`[${new Date().toISOString()}] Failed to close session ${sid} during cleanup: ${message}`,
|
|
460
|
+
);
|
|
461
|
+
// Still delete from map to prevent retry loops
|
|
462
|
+
sessions.delete(sid);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}, CLEANUP_INTERVAL_MS);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Setup shutdown handlers for HTTP server
|
|
471
|
+
*/
|
|
472
|
+
private setupHttpShutdownHandlers(
|
|
473
|
+
server: ReturnType<Application['listen']>,
|
|
474
|
+
sessions: Map<string, SessionData>,
|
|
475
|
+
cleanupInterval: ReturnType<typeof setInterval>,
|
|
476
|
+
): void {
|
|
477
|
+
const cleanup = () => {
|
|
478
|
+
console.error(`${this.config.name} shutting down...`);
|
|
479
|
+
clearInterval(cleanupInterval);
|
|
480
|
+
|
|
481
|
+
// Close all sessions with error handling
|
|
482
|
+
for (const session of sessions.values()) {
|
|
483
|
+
try {
|
|
484
|
+
session.transport.close();
|
|
485
|
+
} catch (error: unknown) {
|
|
486
|
+
const message = getErrorMessage(error);
|
|
487
|
+
console.error(`Error closing session during shutdown: ${message}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
sessions.clear();
|
|
491
|
+
|
|
492
|
+
// Close HTTP server gracefully
|
|
493
|
+
try {
|
|
494
|
+
server.close(() => {
|
|
495
|
+
// Server closed callback - all connections finished
|
|
496
|
+
this.performCleanup().finally(() => process.exit(0));
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Set a timeout in case server.close() hangs
|
|
500
|
+
setTimeout(() => {
|
|
501
|
+
console.error('Forcefully shutting down after timeout');
|
|
502
|
+
this.performCleanup().finally(() => process.exit(1));
|
|
503
|
+
}, 5000);
|
|
504
|
+
} catch (error: unknown) {
|
|
505
|
+
const message = getErrorMessage(error);
|
|
506
|
+
console.error(`Error closing HTTP server: ${message}`);
|
|
507
|
+
this.performCleanup().finally(() => process.exit(1));
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Use once() to prevent multiple registrations
|
|
512
|
+
process.once('SIGINT', cleanup);
|
|
513
|
+
process.once('SIGTERM', cleanup);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Get the underlying MCP server instance
|
|
518
|
+
*/
|
|
519
|
+
public getServer(): McpServer {
|
|
520
|
+
return this.mcpServer;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Get the tools manager instance
|
|
525
|
+
*/
|
|
526
|
+
public getToolsManager(): IMidsceneTools | undefined {
|
|
527
|
+
return this.toolsManager;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseBase64 } from '@midscene/shared/img';
|
|
2
2
|
import { getDebug } from '@midscene/shared/logger';
|
|
3
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import type { z } from 'zod';
|
|
4
5
|
import { camelToKebab, getKeyAliases } from '../key-alias-utils';
|
|
5
6
|
import {
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
ToolSchema,
|
|
29
30
|
} from './types';
|
|
30
31
|
|
|
31
|
-
const debug = getDebug('
|
|
32
|
+
const debug = getDebug('mcp:base-tools');
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Declarative description of a platform's agent init args.
|
|
@@ -38,11 +39,11 @@ const debug = getDebug('agent-tools:base-tools');
|
|
|
38
39
|
export interface InitArgSpec<TInitParam> {
|
|
39
40
|
/** Arg namespace, e.g. `android`, `ios`. */
|
|
40
41
|
namespace: string;
|
|
41
|
-
/** Zod shape describing the init args. Field names drive the
|
|
42
|
+
/** Zod shape describing the init args. Field names drive the MCP schema. */
|
|
42
43
|
shape: Record<string, z.ZodTypeAny>;
|
|
43
44
|
/**
|
|
44
45
|
* Optional CLI presentation hints. These affect `--help` output for
|
|
45
|
-
* single-platform CLIs but do not alter YAML protocol keys.
|
|
46
|
+
* single-platform CLIs but do not alter MCP/YAML protocol keys.
|
|
46
47
|
*/
|
|
47
48
|
cli?: {
|
|
48
49
|
/** Prefer bare `--device-id`-style options in platform CLI help output. */
|
|
@@ -60,7 +61,7 @@ export interface InitArgSpec<TInitParam> {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
* Base class for platform-specific
|
|
64
|
+
* Base class for platform-specific MCP tools.
|
|
64
65
|
* @typeParam TAgent - Platform-specific agent type.
|
|
65
66
|
* @typeParam TInitParam - Platform-specific init parameter consumed by
|
|
66
67
|
* `ensureAgent`. Defaults to `undefined` for platforms that take no args.
|
|
@@ -70,19 +71,20 @@ export abstract class BaseMidsceneTools<
|
|
|
70
71
|
TInitParam = unknown,
|
|
71
72
|
> implements IMidsceneTools
|
|
72
73
|
{
|
|
74
|
+
protected mcpServer?: McpServer;
|
|
73
75
|
protected agent?: TAgent;
|
|
74
76
|
protected toolDefinitions: ToolDefinition[] = [];
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* Default options injected into every generated tool call (e.g. forced deep
|
|
78
|
-
* locate / deep think). Set from
|
|
80
|
+
* locate / deep think). Set from server/CLI behavior flags before
|
|
79
81
|
* `initTools()` so they are baked into the generated tool handlers.
|
|
80
82
|
* See https://github.com/web-infra-dev/midscene/issues/2446.
|
|
81
83
|
*/
|
|
82
84
|
protected toolDefaults: ToolDefaults = {};
|
|
83
85
|
|
|
84
86
|
/**
|
|
85
|
-
* Declarative init-arg spec. Subclasses that accept CLI init args should
|
|
87
|
+
* Declarative init-arg spec. Subclasses that accept CLI/MCP init args should
|
|
86
88
|
* set this once and get `extractAgentInitParam` / `sanitizeToolArgs` /
|
|
87
89
|
* `getAgentInitArgSchema` auto-implemented.
|
|
88
90
|
*
|
|
@@ -106,7 +108,7 @@ export abstract class BaseMidsceneTools<
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
|
-
* Extract a platform-specific agent init parameter from CLI tool args.
|
|
111
|
+
* Extract a platform-specific agent init parameter from CLI/MCP tool args.
|
|
110
112
|
*/
|
|
111
113
|
protected extractAgentInitParam(
|
|
112
114
|
args: Record<string, unknown>,
|
|
@@ -159,7 +161,7 @@ export abstract class BaseMidsceneTools<
|
|
|
159
161
|
* show ergonomic bare flags while the underlying schema stays namespaced.
|
|
160
162
|
* When `preferBareKeys` is enabled, single-platform CLIs only accept the
|
|
161
163
|
* bare spellings; namespaced dotted spellings remain available through the
|
|
162
|
-
* YAML schema instead of the platform CLI surface.
|
|
164
|
+
* MCP/YAML schema instead of the platform CLI surface.
|
|
163
165
|
*/
|
|
164
166
|
protected getAgentInitArgCliMetadata(): ToolCliMetadata | undefined {
|
|
165
167
|
if (!this.initArgSpec?.cli) {
|
|
@@ -270,6 +272,7 @@ export abstract class BaseMidsceneTools<
|
|
|
270
272
|
this.toolDefinitions.push(...platformTools);
|
|
271
273
|
|
|
272
274
|
// 2. Get action space: use pre-set agent if available, otherwise temp device.
|
|
275
|
+
// When called via mcpKitForAgent(), agent is set before initTools().
|
|
273
276
|
// For CLI usage, agent is deferred to the first real command.
|
|
274
277
|
let actionSpace: ActionSpaceItem[];
|
|
275
278
|
if (this.agent) {
|
|
@@ -310,6 +313,28 @@ export abstract class BaseMidsceneTools<
|
|
|
310
313
|
debug('Total tools prepared:', this.toolDefinitions.length);
|
|
311
314
|
}
|
|
312
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Attach to MCP server and register all tools
|
|
318
|
+
*/
|
|
319
|
+
public attachToServer(server: McpServer): void {
|
|
320
|
+
this.mcpServer = server;
|
|
321
|
+
|
|
322
|
+
if (this.toolDefinitions.length === 0) {
|
|
323
|
+
debug('Warning: No tools to register. Tools may be initialized lazily.');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const toolDef of this.toolDefinitions) {
|
|
327
|
+
this.mcpServer.tool(
|
|
328
|
+
toolDef.name,
|
|
329
|
+
toolDef.description,
|
|
330
|
+
toolDef.schema,
|
|
331
|
+
toolDef.handler,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
debug('Registered', this.toolDefinitions.length, 'tools');
|
|
336
|
+
}
|
|
337
|
+
|
|
313
338
|
/**
|
|
314
339
|
* Cleanup method - destroy agent and release resources
|
|
315
340
|
*/
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
MIDSCENE_CHROME_PATH,
|
|
4
|
-
MIDSCENE_MCP_CHROME_PATH,
|
|
5
|
-
globalConfigManager,
|
|
6
|
-
} from '../env';
|
|
7
|
-
import { getDebug } from '../logger';
|
|
8
|
-
|
|
9
|
-
const warnChromePath = getDebug('agent-tools:chrome-path', { console: true });
|
|
10
|
-
let hasWarnedLegacyChromePath = false;
|
|
2
|
+
import { MIDSCENE_MCP_CHROME_PATH, globalConfigManager } from '../env';
|
|
11
3
|
|
|
12
4
|
export function getSystemChromePath(): string | undefined {
|
|
13
5
|
const platform = process.platform;
|
|
@@ -41,18 +33,9 @@ export function getSystemChromePath(): string | undefined {
|
|
|
41
33
|
}
|
|
42
34
|
|
|
43
35
|
export function resolveChromePath(): string {
|
|
44
|
-
const
|
|
45
|
-
globalConfigManager.getEnvConfigValue(MIDSCENE_CHROME_PATH);
|
|
46
|
-
const legacyEnvPath = globalConfigManager.getEnvConfigValue(
|
|
36
|
+
const envPath = globalConfigManager.getEnvConfigValue(
|
|
47
37
|
MIDSCENE_MCP_CHROME_PATH,
|
|
48
38
|
);
|
|
49
|
-
const envPath = primaryEnvPath || legacyEnvPath;
|
|
50
|
-
if (!primaryEnvPath && legacyEnvPath && !hasWarnedLegacyChromePath) {
|
|
51
|
-
warnChromePath(
|
|
52
|
-
'MIDSCENE_MCP_CHROME_PATH is deprecated. Use MIDSCENE_CHROME_PATH instead.',
|
|
53
|
-
);
|
|
54
|
-
hasWarnedLegacyChromePath = true;
|
|
55
|
-
}
|
|
56
39
|
if (envPath && envPath !== 'auto' && existsSync(envPath)) {
|
|
57
40
|
return envPath;
|
|
58
41
|
}
|
|
@@ -60,6 +43,6 @@ export function resolveChromePath(): string {
|
|
|
60
43
|
if (systemPath) return systemPath;
|
|
61
44
|
|
|
62
45
|
throw new Error(
|
|
63
|
-
'Chrome not found. Install Google Chrome or set
|
|
46
|
+
'Chrome not found. Install Google Chrome or set MIDSCENE_MCP_CHROME_PATH environment variable.',
|
|
64
47
|
);
|
|
65
48
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './base-server';
|
|
1
2
|
export * from './base-tools';
|
|
2
3
|
export * from './tool-defaults';
|
|
3
4
|
export * from './agent-behavior-init-args';
|
|
@@ -5,4 +6,6 @@ export * from './init-arg-utils';
|
|
|
5
6
|
export * from './error-formatter';
|
|
6
7
|
export * from './tool-generator';
|
|
7
8
|
export * from './types';
|
|
9
|
+
export * from './inject-report-html-plugin';
|
|
10
|
+
export * from './launcher-helper';
|
|
8
11
|
export * from './chrome-path';
|