Skip to content

Commit 93270fe

Browse files
committed
refactor(mcp): enhance type definitions and improve error handling in MCP server and tools
1 parent 61a28a5 commit 93270fe

File tree

5 files changed

+265
-68
lines changed

5 files changed

+265
-68
lines changed

packages/mcp/src/web-tools.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {
33
MIDSCENE_MCP_USE_PUPPETEER_MODE,
44
globalConfigManager,
55
} from '@midscene/shared/env';
6-
import { BaseMidsceneTools, type ToolDefinition } from '@midscene/shared/mcp';
6+
import {
7+
type BaseAgent,
8+
BaseMidsceneTools,
9+
type ToolDefinition,
10+
} from '@midscene/shared/mcp';
711
import { AgentOverChromeBridge } from '@midscene/web/bridge-mode';
812
import { type PuppeteerBrowserAgent, ensureBrowser } from './puppeteer';
913

@@ -13,7 +17,11 @@ export class WebMidsceneTools extends BaseMidsceneTools {
1317
);
1418

1519
protected createTemporaryDevice() {
16-
// Import PuppeteerWebPage class
20+
// Import PuppeteerWebPage class using dynamic ESM import
21+
// This is intentionally synchronous despite the async nature of createTemporaryDevice
22+
// because we need the class constructor immediately for tool initialization
23+
// The alternative would be to make all tool initialization async, which is a larger refactor
24+
// eslint-disable-next-line @typescript-eslint/no-var-requires
1725
const { PuppeteerWebPage } = require('@midscene/web');
1826

1927
// Create minimal mock page object that satisfies the interface
@@ -36,11 +44,13 @@ export class WebMidsceneTools extends BaseMidsceneTools {
3644
return new PuppeteerWebPage(mockPage as any, {});
3745
}
3846

39-
protected async ensureAgent(openNewTabWithUrl?: string): Promise<any> {
47+
protected async ensureAgent(openNewTabWithUrl?: string): Promise<BaseAgent> {
4048
// Re-init if URL provided
4149
if (this.agent && openNewTabWithUrl) {
4250
try {
43-
await this.agent.destroy();
51+
if (this.agent.destroy) {
52+
await this.agent.destroy();
53+
}
4454
} catch (e) {
4555
console.debug('Failed to destroy agent during re-init:', e);
4656
}
@@ -58,9 +68,13 @@ export class WebMidsceneTools extends BaseMidsceneTools {
5868
'Bridge mode requires a URL. Use web_connect tool to connect to a page first.',
5969
);
6070
}
61-
this.agent = await this.initAgentByBridgeMode(openNewTabWithUrl);
71+
this.agent = (await this.initAgentByBridgeMode(
72+
openNewTabWithUrl,
73+
)) as unknown as BaseAgent;
6274
} else {
63-
this.agent = await this.initPuppeteerAgent(openNewTabWithUrl);
75+
this.agent = (await this.initPuppeteerAgent(
76+
openNewTabWithUrl,
77+
)) as unknown as BaseAgent;
6478
}
6579

6680
return this.agent;
@@ -104,9 +118,21 @@ export class WebMidsceneTools extends BaseMidsceneTools {
104118
schema: {
105119
url: z.string().url().describe('URL to connect to'),
106120
},
107-
handler: async ({ url }) => {
121+
handler: async (args) => {
122+
const { url } = args as { url: string };
108123
const agent = await this.ensureAgent(url);
109-
const screenshot = await agent.page.screenshotBase64();
124+
const screenshot = await agent.page?.screenshotBase64();
125+
if (!screenshot) {
126+
return {
127+
content: [
128+
{
129+
type: 'text',
130+
text: `Connected to: ${url}`,
131+
},
132+
],
133+
};
134+
}
135+
110136
const { parseBase64 } = await import('@midscene/shared/img');
111137
const { mimeType, body } = parseBase64(screenshot);
112138

@@ -122,7 +148,6 @@ export class WebMidsceneTools extends BaseMidsceneTools {
122148
mimeType,
123149
},
124150
],
125-
isError: false,
126151
};
127152
},
128153
autoDestroy: false,

packages/shared/src/mcp/base-server.ts

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,27 @@ export abstract class BaseMCPServer {
117117
await this.initializeToolsManager();
118118

119119
const transport = new StdioServerTransport();
120-
await this.mcpServer.connect(transport);
121120

121+
try {
122+
await this.mcpServer.connect(transport);
123+
} catch (error: unknown) {
124+
const message = error instanceof Error ? error.message : String(error);
125+
console.error(`Failed to connect MCP stdio transport: ${message}`);
126+
throw new Error(`Failed to initialize MCP stdio transport: ${message}`);
127+
}
128+
129+
// Setup cleanup handlers
122130
process.stdin.on('close', () => this.performCleanup());
131+
132+
// Setup signal handlers for graceful shutdown
133+
const cleanup = () => {
134+
console.error(`${this.config.name} shutting down...`);
135+
this.performCleanup();
136+
process.exit(0);
137+
};
138+
139+
process.once('SIGINT', cleanup);
140+
process.once('SIGTERM', cleanup);
123141
}
124142

125143
/**
@@ -149,6 +167,9 @@ export abstract class BaseMCPServer {
149167
const sessions = new Map<string, SessionData>();
150168

151169
app.all('/mcp', async (req: Request, res: Response) => {
170+
const startTime = Date.now();
171+
const requestId = randomUUID().substring(0, 8);
172+
152173
try {
153174
const rawSessionId = req.headers['mcp-session-id'];
154175
const sessionId = Array.isArray(rawSessionId)
@@ -159,26 +180,42 @@ export abstract class BaseMCPServer {
159180
if (!session && req.method === 'POST') {
160181
// Check session limit to prevent DoS
161182
if (sessions.size >= MAX_SESSIONS) {
183+
console.error(
184+
`[${new Date().toISOString()}] [${requestId}] Session limit reached: ${sessions.size}/${MAX_SESSIONS}`,
185+
);
162186
res.status(503).json({
163187
error: 'Too many active sessions',
164188
message: 'Server is at maximum capacity. Please try again later.',
165189
});
166190
return;
167191
}
168192
session = await this.createHttpSession(sessions);
193+
console.log(
194+
`[${new Date().toISOString()}] [${requestId}] New session created: ${session.transport.sessionId}`,
195+
);
169196
}
170197

171198
if (session) {
172199
session.lastAccessedAt = new Date();
173200
await session.transport.handleRequest(req, res, req.body);
201+
const duration = Date.now() - startTime;
202+
console.log(
203+
`[${new Date().toISOString()}] [${requestId}] Request completed in ${duration}ms`,
204+
);
174205
} else {
206+
console.error(
207+
`[${new Date().toISOString()}] [${requestId}] Invalid session or GET without session`,
208+
);
175209
res
176210
.status(400)
177211
.json({ error: 'Invalid session or GET without session' });
178212
}
179213
} catch (error: unknown) {
180214
const message = error instanceof Error ? error.message : String(error);
181-
console.error('MCP request error:', message);
215+
const duration = Date.now() - startTime;
216+
console.error(
217+
`[${new Date().toISOString()}] [${requestId}] MCP request error after ${duration}ms: ${message}`,
218+
);
182219
if (!res.headersSent) {
183220
res.status(500).json({
184221
error: 'Internal server error',
@@ -200,15 +237,11 @@ export abstract class BaseMCPServer {
200237
.on('error', (error: NodeJS.ErrnoException) => {
201238
if (error.code === 'EADDRINUSE') {
202239
console.error(
203-
`ERROR: Port ${options.port} is already in use.\n` +
204-
`Please try a different port: --port=<number>\n` +
205-
`Example: --mode=http --port=${options.port + 1}`,
240+
`ERROR: Port ${options.port} is already in use.\nPlease try a different port: --port=<number>\nExample: --mode=http --port=${options.port + 1}`,
206241
);
207242
} else if (error.code === 'EACCES') {
208243
console.error(
209-
`ERROR: Permission denied to bind to port ${options.port}.\n` +
210-
`Ports below 1024 require root/admin privileges.\n` +
211-
`Please use a port above 1024 or run with elevated privileges.`,
244+
`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.`,
212245
);
213246
} else {
214247
console.error(
@@ -238,22 +271,28 @@ export abstract class BaseMCPServer {
238271
createdAt: new Date(),
239272
lastAccessedAt: new Date(),
240273
});
241-
console.log(`Session ${sid} created`);
274+
console.log(
275+
`[${new Date().toISOString()}] Session ${sid} initialized (total: ${sessions.size})`,
276+
);
242277
},
243278
});
244279

245280
transport.onclose = () => {
246281
if (transport.sessionId) {
247282
sessions.delete(transport.sessionId);
248-
console.log(`Session ${transport.sessionId} closed`);
283+
console.log(
284+
`[${new Date().toISOString()}] Session ${transport.sessionId} closed (remaining: ${sessions.size})`,
285+
);
249286
}
250287
};
251288

252289
try {
253290
await this.mcpServer.connect(transport);
254291
} catch (error: unknown) {
255292
const message = error instanceof Error ? error.message : String(error);
256-
console.error(`Failed to connect MCP transport: ${message}`);
293+
console.error(
294+
`[${new Date().toISOString()}] Failed to connect MCP transport: ${message}`,
295+
);
257296
// Clean up the failed transport
258297
if (transport.sessionId) {
259298
sessions.delete(transport.sessionId);
@@ -281,12 +320,14 @@ export abstract class BaseMCPServer {
281320
try {
282321
session.transport.close();
283322
sessions.delete(sid);
284-
console.log(`Session ${sid} cleaned up due to inactivity`);
323+
console.log(
324+
`[${new Date().toISOString()}] Session ${sid} cleaned up due to inactivity (remaining: ${sessions.size})`,
325+
);
285326
} catch (error: unknown) {
286327
const message =
287328
error instanceof Error ? error.message : String(error);
288329
console.error(
289-
`Failed to close session ${sid} during cleanup: ${message}`,
330+
`[${new Date().toISOString()}] Failed to close session ${sid} during cleanup: ${message}`,
290331
);
291332
// Still delete from map to prevent retry loops
292333
sessions.delete(sid);
@@ -313,21 +354,33 @@ export abstract class BaseMCPServer {
313354
try {
314355
session.transport.close();
315356
} catch (error: unknown) {
316-
const message = error instanceof Error ? error.message : String(error);
357+
const message =
358+
error instanceof Error ? error.message : String(error);
317359
console.error(`Error closing session during shutdown: ${message}`);
318360
}
319361
}
320362
sessions.clear();
321363

322-
// Close HTTP server
364+
// Close HTTP server gracefully
323365
try {
324-
server.close();
366+
server.close(() => {
367+
// Server closed callback - all connections finished
368+
this.performCleanup();
369+
process.exit(0);
370+
});
371+
372+
// Set a timeout in case server.close() hangs
373+
setTimeout(() => {
374+
console.error('Forcefully shutting down after timeout');
375+
this.performCleanup();
376+
process.exit(1);
377+
}, 5000);
325378
} catch (error: unknown) {
326379
const message = error instanceof Error ? error.message : String(error);
327380
console.error(`Error closing HTTP server: ${message}`);
381+
this.performCleanup();
382+
process.exit(1);
328383
}
329-
330-
this.performCleanup();
331384
};
332385

333386
// Use once() to prevent multiple registrations

packages/shared/src/mcp/base-tools.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,25 @@ import {
44
generateCommonTools,
55
generateToolsFromActionSpace,
66
} from './tool-generator';
7-
import type { IMidsceneTools, ToolDefinition } from './types';
7+
import type {
8+
ActionSpaceItem,
9+
BaseAgent,
10+
BaseDevice,
11+
IMidsceneTools,
12+
ToolDefinition,
13+
} from './types';
814

915
const debug = getDebug('mcp:base-tools');
1016

1117
export abstract class BaseMidsceneTools implements IMidsceneTools {
1218
protected mcpServer?: McpServer;
13-
protected agent?: any;
19+
protected agent?: BaseAgent;
1420
protected toolDefinitions: ToolDefinition[] = [];
1521

1622
/**
1723
* Must be implemented by subclasses to create platform-specific agent
1824
*/
19-
protected abstract ensureAgent(initParam?: string): Promise<any>;
25+
protected abstract ensureAgent(initParam?: string): Promise<BaseAgent>;
2026

2127
/**
2228
* Optional: prepare platform-specific tools (e.g., device connection)
@@ -29,7 +35,7 @@ export abstract class BaseMidsceneTools implements IMidsceneTools {
2935
* Must be implemented by subclasses to create a temporary device instance
3036
* This allows getting real actionSpace without connecting to device
3137
*/
32-
protected abstract createTemporaryDevice(): any;
38+
protected abstract createTemporaryDevice(): BaseDevice;
3339

3440
/**
3541
* Initialize all tools by querying actionSpace
@@ -46,14 +52,14 @@ export abstract class BaseMidsceneTools implements IMidsceneTools {
4652
this.toolDefinitions.push(...platformTools);
4753

4854
// 2. Try to get agent and its action space (two-layer fallback)
49-
let actionSpace: any[];
55+
let actionSpace: ActionSpaceItem[];
5056
try {
5157
// Layer 1: Try to use connected agent
5258
const agent = await this.ensureAgent();
5359
actionSpace = await agent.getActionSpace();
5460
debug(
5561
'Action space from connected agent:',
56-
actionSpace.map((a: any) => a.name).join(', '),
62+
actionSpace.map((a) => a.name).join(', '),
5763
);
5864
} catch (error) {
5965
// Layer 2: Create temporary device instance to read actionSpace
@@ -62,7 +68,7 @@ export abstract class BaseMidsceneTools implements IMidsceneTools {
6268
actionSpace = tempDevice.actionSpace();
6369
debug(
6470
'Action space from temporary device:',
65-
actionSpace.map((a: any) => a.name).join(', '),
71+
actionSpace.map((a) => a.name).join(', '),
6672
);
6773

6874
// Destroy temporary instance using optional chaining
@@ -132,7 +138,9 @@ export abstract class BaseMidsceneTools implements IMidsceneTools {
132138
} finally {
133139
if (!process.env.MIDSCENE_MCP_DISABLE_AGENT_AUTO_DESTROY) {
134140
try {
135-
await this.agent?.destroy();
141+
if (this.agent?.destroy) {
142+
await this.agent.destroy();
143+
}
136144
} catch (e) {
137145
debug('Failed to destroy agent during cleanup:', e);
138146
}
@@ -146,6 +154,8 @@ export abstract class BaseMidsceneTools implements IMidsceneTools {
146154
* Cleanup method
147155
*/
148156
public async closeBrowser(): Promise<void> {
149-
await this.agent?.destroy();
157+
if (this.agent?.destroy) {
158+
await this.agent.destroy();
159+
}
150160
}
151161
}

0 commit comments

Comments
 (0)