Skip to content

Commit 2c33f78

Browse files
authored
Merge pull request #84 from webdriverio/feature/transport-mode
feat: Introduce StreamableHTTP transport at /mcp endpoint
2 parents 80e8bfa + e52ad99 commit 2c33f78

8 files changed

Lines changed: 361 additions & 93 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
"postbundle": "npm pack",
4040
"lint": "eslint src/ tests/ --fix && tsc --noEmit",
4141
"start": "node lib/server.js",
42+
"start:http": "node lib/server.js --http --allowedOrigins http://localhost:8080",
4243
"dev": "tsx --watch src/server.ts",
44+
"dev:http": "tsx --watch src/server.ts --http --allowedOrigins http://localhost:8080",
4345
"prepare": "husky",
4446
"test": "vitest run"
4547
},

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"url": "https://github.com/webdriverio/mcp",
77
"source": "github"
88
},
9-
"version": "2.5.1",
9+
"version": "3.3.0",
1010
"packages": [
1111
{
1212
"registryType": "npm",
1313
"identifier": "@wdio/mcp",
14-
"version": "2.5.1",
14+
"version": "3.3.0",
1515
"transport": {
1616
"type": "stdio"
1717
}

src/server.ts

Lines changed: 174 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
#!/usr/bin/env node
22
import pkg from '../package.json' with { type: 'json' };
3+
import http from 'node:http';
34
import type { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
45
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
56
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8+
import { parseArgs } from './utils/parse-args';
9+
import { extractHost, sendJsonRpcError } from './utils/http-helpers';
610
import type { ToolDefinition } from './types/tool';
711
import type { ResourceDefinition } from './types/resource';
812
import { navigateTool, navigateToolDefinition } from './tools/navigate.tool';
@@ -62,12 +66,7 @@ import {
6266
startSessionToolDefinition
6367
} from './tools/session.tool';
6468
import { switchTabTool, switchTabToolDefinition } from './tools/tabs.tool';
65-
import {
66-
listAppsTool,
67-
listAppsToolDefinition,
68-
uploadAppTool,
69-
uploadAppToolDefinition,
70-
} from './tools/browserstack.tool';
69+
import { listAppsTool, listAppsToolDefinition, uploadAppTool, uploadAppToolDefinition, } from './tools/browserstack.tool';
7170
import { screenshotTool, screenshotToolDefinition } from './tools/screenshot.tool';
7271
import { accessibilityTool, accessibilityToolDefinition } from './tools/accessibility.tool';
7372
import { getTabsTool, getTabsToolDefinition } from './tools/get-tabs.tool';
@@ -80,105 +79,190 @@ console.info = (...args) => console.error('[INFO]', ...args);
8079
console.warn = (...args) => console.error('[WARN]', ...args);
8180
console.debug = (...args) => console.error('[DEBUG]', ...args);
8281

83-
const server = new McpServer({
84-
title: 'WebdriverIO MCP Server',
85-
name: pkg.name,
86-
version: pkg.version,
87-
description: pkg.description,
88-
websiteUrl: 'https://github.com/webdriverio/mcp',
89-
}, {
90-
instructions: 'MCP server for browser and mobile app automation using WebDriverIO. Supports Chrome, Firefox, Edge, and Safari browser control plus iOS/Android native app testing via Appium.',
91-
capabilities: {
92-
tools: {},
93-
resources: {},
94-
},
95-
});
96-
97-
const registerTool = (definition: ToolDefinition, callback: ToolCallback) =>
98-
server.registerTool(definition.name, {
99-
description: definition.description,
100-
inputSchema: definition.inputSchema,
101-
}, callback);
102-
103-
const registerResource = (definition: ResourceDefinition) => {
104-
if ('uri' in definition) {
105-
server.registerResource(
106-
definition.name,
107-
definition.uri,
108-
{ description: definition.description },
109-
definition.handler,
110-
);
111-
} else {
112-
server.registerResource(
113-
definition.name,
114-
definition.template,
115-
{ description: definition.description },
116-
definition.handler,
117-
);
118-
}
119-
};
82+
function createServer(): McpServer {
83+
const server = new McpServer({
84+
title: 'WebdriverIO MCP Server',
85+
name: pkg.name,
86+
version: pkg.version,
87+
description: pkg.description,
88+
websiteUrl: 'https://github.com/webdriverio/mcp',
89+
}, {
90+
instructions: 'MCP server for browser and mobile app automation using WebDriverIO. Supports Chrome, Firefox, Edge, and Safari browser control plus iOS/Android native app testing via Appium.',
91+
capabilities: {
92+
tools: {},
93+
resources: {},
94+
},
95+
});
96+
97+
const registerTool = (definition: ToolDefinition, callback: ToolCallback) =>
98+
server.registerTool(definition.name, {
99+
description: definition.description,
100+
inputSchema: definition.inputSchema,
101+
}, callback);
102+
103+
const registerResource = (definition: ResourceDefinition) => {
104+
if ('uri' in definition) {
105+
server.registerResource(
106+
definition.name,
107+
definition.uri,
108+
{ description: definition.description },
109+
definition.handler,
110+
);
111+
} else {
112+
server.registerResource(
113+
definition.name,
114+
definition.template,
115+
{ description: definition.description },
116+
definition.handler,
117+
);
118+
}
119+
};
120120

121-
registerTool(startSessionToolDefinition, withRecording('start_session', startSessionTool));
122-
registerTool(closeSessionToolDefinition, closeSessionTool);
123-
registerTool(launchChromeToolDefinition, withRecording('launch_chrome', launchChromeTool));
124-
registerTool(emulateDeviceToolDefinition, emulateDeviceTool);
125-
registerTool(navigateToolDefinition, withRecording('navigate', navigateTool));
121+
registerTool(startSessionToolDefinition, withRecording('start_session', startSessionTool));
122+
registerTool(closeSessionToolDefinition, closeSessionTool);
123+
registerTool(launchChromeToolDefinition, withRecording('launch_chrome', launchChromeTool));
124+
registerTool(emulateDeviceToolDefinition, emulateDeviceTool);
125+
registerTool(navigateToolDefinition, withRecording('navigate', navigateTool));
126126

127-
registerTool(switchTabToolDefinition, switchTabTool);
127+
registerTool(switchTabToolDefinition, switchTabTool);
128128

129-
registerTool(scrollToolDefinition, withRecording('scroll', scrollTool));
129+
registerTool(scrollToolDefinition, withRecording('scroll', scrollTool));
130130

131-
registerTool(clickToolDefinition, withRecording('click_element', clickTool));
132-
registerTool(setValueToolDefinition, withRecording('set_value', setValueTool));
131+
registerTool(clickToolDefinition, withRecording('click_element', clickTool));
132+
registerTool(setValueToolDefinition, withRecording('set_value', setValueTool));
133133

134-
registerTool(setCookieToolDefinition, setCookieTool);
135-
registerTool(deleteCookiesToolDefinition, deleteCookiesTool);
134+
registerTool(setCookieToolDefinition, setCookieTool);
135+
registerTool(deleteCookiesToolDefinition, deleteCookiesTool);
136136

137-
registerTool(tapElementToolDefinition, withRecording('tap_element', tapElementTool));
138-
registerTool(swipeToolDefinition, withRecording('swipe', swipeTool));
139-
registerTool(dragAndDropToolDefinition, withRecording('drag_and_drop', dragAndDropTool));
137+
registerTool(tapElementToolDefinition, withRecording('tap_element', tapElementTool));
138+
registerTool(swipeToolDefinition, withRecording('swipe', swipeTool));
139+
registerTool(dragAndDropToolDefinition, withRecording('drag_and_drop', dragAndDropTool));
140140

141-
registerTool(switchContextToolDefinition, switchContextTool);
141+
registerTool(switchContextToolDefinition, switchContextTool);
142142

143-
registerTool(rotateDeviceToolDefinition, rotateDeviceTool);
144-
registerTool(hideKeyboardToolDefinition, hideKeyboardTool);
145-
registerTool(setGeolocationToolDefinition, setGeolocationTool);
143+
registerTool(rotateDeviceToolDefinition, rotateDeviceTool);
144+
registerTool(hideKeyboardToolDefinition, hideKeyboardTool);
145+
registerTool(setGeolocationToolDefinition, setGeolocationTool);
146146

147-
registerTool(executeScriptToolDefinition, withRecording('execute_script', executeScriptTool));
148-
registerTool(getElementsToolDefinition, getElementsTool);
147+
registerTool(executeScriptToolDefinition, withRecording('execute_script', executeScriptTool));
148+
registerTool(getElementsToolDefinition, getElementsTool);
149149

150-
registerTool(listAppsToolDefinition, listAppsTool);
151-
registerTool(uploadAppToolDefinition, uploadAppTool);
150+
registerTool(listAppsToolDefinition, listAppsTool);
151+
registerTool(uploadAppToolDefinition, uploadAppTool);
152152

153-
registerTool(screenshotToolDefinition, screenshotTool);
154-
registerTool(accessibilityToolDefinition, accessibilityTool);
155-
registerTool(getTabsToolDefinition, getTabsTool);
156-
registerTool(getContextsToolDefinition, getContextsTool);
157-
registerTool(appStateToolDefinition, appStateTool);
158-
registerTool(getCookiesToolDefinition, getCookiesTool);
153+
registerTool(screenshotToolDefinition, screenshotTool);
154+
registerTool(accessibilityToolDefinition, accessibilityTool);
155+
registerTool(getTabsToolDefinition, getTabsTool);
156+
registerTool(getContextsToolDefinition, getContextsTool);
157+
registerTool(appStateToolDefinition, appStateTool);
158+
registerTool(getCookiesToolDefinition, getCookiesTool);
159159

160-
registerResource(sessionsIndexResource);
161-
registerResource(sessionCurrentStepsResource);
162-
registerResource(sessionCurrentCodeResource);
163-
registerResource(sessionStepsResource);
164-
registerResource(sessionCodeResource);
160+
registerResource(sessionsIndexResource);
161+
registerResource(sessionCurrentStepsResource);
162+
registerResource(sessionCurrentCodeResource);
163+
registerResource(sessionStepsResource);
164+
registerResource(sessionCodeResource);
165165

166-
registerResource(browserstackLocalBinaryResource);
167-
registerResource(capabilitiesResource);
168-
registerResource(elementsResource);
169-
registerResource(accessibilityResource);
170-
registerResource(screenshotResource);
171-
registerResource(cookiesResource);
172-
registerResource(appStateResource);
173-
registerResource(contextsResource);
174-
registerResource(contextResource);
175-
registerResource(geolocationResource);
176-
registerResource(tabsResource);
166+
registerResource(browserstackLocalBinaryResource);
167+
registerResource(capabilitiesResource);
168+
registerResource(elementsResource);
169+
registerResource(accessibilityResource);
170+
registerResource(screenshotResource);
171+
registerResource(cookiesResource);
172+
registerResource(appStateResource);
173+
registerResource(contextsResource);
174+
registerResource(contextResource);
175+
registerResource(geolocationResource);
176+
registerResource(tabsResource);
177+
178+
return server;
179+
}
177180

178181
async function main() {
179-
const transport = new StdioServerTransport();
180-
await server.connect(transport);
181-
console.error('WebdriverIO MCP Server running on stdio');
182+
let args: { http: boolean; port: number; allowedHosts: string[]; allowedOrigins: string[] };
183+
try {
184+
args = parseArgs(process.argv.slice(2));
185+
} catch (e) {
186+
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
187+
process.exit(1);
188+
}
189+
190+
if (args.http) {
191+
http.createServer((req, res) => {
192+
193+
const host = extractHost(req.headers.host ?? '');
194+
if (!args.allowedHosts.includes(host)) {
195+
sendJsonRpcError(res, 403, -32000, 'Host not allowed');
196+
return;
197+
}
198+
199+
const origin = req.headers.origin;
200+
if (origin) {
201+
const wildcard = args.allowedOrigins.includes('*');
202+
const allowed = wildcard || args.allowedOrigins.includes(origin);
203+
if (!allowed) {
204+
console.error(`[WARN] Blocked origin: ${origin}. Add --allowedOrigins ${origin} (or '*' for all) to allow it.`);
205+
sendJsonRpcError(res, 403, -32000, 'Origin not allowed');
206+
return;
207+
}
208+
res.setHeader('Access-Control-Allow-Origin', wildcard ? '*' : origin);
209+
if (!wildcard) res.setHeader('Vary', 'Origin');
210+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
211+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, mcp-session-id, mcp-protocol-version');
212+
}
213+
214+
if (req.method === 'OPTIONS') {
215+
res.writeHead(204).end();
216+
return;
217+
}
218+
219+
if (!req.url?.startsWith('/mcp')) {
220+
sendJsonRpcError(res, 404, -32601, 'Not found');
221+
return;
222+
}
223+
224+
void (async () => {
225+
try {
226+
const chunks: Buffer[] = [];
227+
let totalSize = 0;
228+
for await (const chunk of req) {
229+
totalSize += (chunk as Buffer).length;
230+
if (totalSize > 1024 * 1024) {
231+
sendJsonRpcError(res, 413, -32600, 'Payload too large');
232+
return;
233+
}
234+
chunks.push(chunk as Buffer);
235+
}
236+
const raw = Buffer.concat(chunks).toString();
237+
let body: unknown;
238+
try {
239+
body = raw ? JSON.parse(raw) : undefined;
240+
} catch {
241+
sendJsonRpcError(res, 400, -32700, 'Parse error');
242+
return;
243+
}
244+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
245+
await createServer().connect(transport);
246+
await transport.handleRequest(req, res, body);
247+
} catch (e) {
248+
const code = (e as NodeJS.ErrnoException).code;
249+
if (code === 'ECONNRESET' || code === 'ECONNABORTED' || (e as Error).message === 'aborted') return;
250+
console.error('[WARN] Request failed:', e);
251+
if (!res.headersSent) sendJsonRpcError(res, 500, -32603, 'Internal error');
252+
}
253+
})();
254+
}).listen(args.port, () => {
255+
const originsMsg = args.allowedOrigins.length ? args.allowedOrigins.join(', ') : '(none — browsers blocked)';
256+
console.error(`WebdriverIO MCP Server running on Streamable HTTP at http://localhost:${args.port}/mcp`);
257+
console.error(` allowed hosts: ${args.allowedHosts.join(', ')}`);
258+
console.error(` allowed origins: ${originsMsg}`);
259+
});
260+
261+
} else {
262+
const transport = new StdioServerTransport();
263+
await createServer().connect(transport);
264+
console.error('WebdriverIO MCP Server running on stdio');
265+
}
182266
}
183267

184268
main().catch((error) => {

src/session/lifecycle.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export function registerSession(
5555
if (oldMetadata?.provider) {
5656
const oldHistory = state.sessionHistory.get(oldSessionId);
5757
const provider = getProvider(oldMetadata.provider, oldMetadata.type);
58-
await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {});
58+
await provider.onSessionClose?.(oldSessionId, oldMetadata.type, getSessionResult(oldHistory), oldMetadata.tunnelHandle).catch(() => {
59+
});
5960
}
6061
await oldBrowser.deleteSession().catch(() => {
6162
// Ignore errors during force-close of orphaned session

src/utils/http-helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { ServerResponse } from 'node:http';
2+
3+
export function extractHost(header: string): string {
4+
if (header.startsWith('[')) {
5+
const end = header.indexOf(']');
6+
return end === -1 ? header : header.slice(1, end);
7+
}
8+
return header.split(':')[0];
9+
}
10+
11+
export function sendJsonRpcError(res: ServerResponse, httpStatus: number, code: number, message: string): void {
12+
const body = JSON.stringify({ jsonrpc: '2.0', id: null, error: { code, message } });
13+
res.writeHead(httpStatus, { 'Content-Type': 'application/json' });
14+
res.end(body);
15+
}

src/utils/parse-args.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export interface ParsedArgs {
2+
http: boolean;
3+
port: number;
4+
allowedHosts: string[];
5+
allowedOrigins: string[];
6+
}
7+
8+
function parseList(argv: string[], flag: string): string[] | null {
9+
const idx = argv.indexOf(flag);
10+
if (idx === -1) return null;
11+
const raw = argv[idx + 1];
12+
if (!raw || raw.startsWith('--')) {
13+
throw new Error(`${flag} requires a comma-separated list`);
14+
}
15+
return raw.split(',').map(s => s.trim()).filter(Boolean);
16+
}
17+
18+
export function parseArgs(argv: string[]): ParsedArgs {
19+
const http = argv.includes('--http');
20+
21+
const portIdx = argv.indexOf('--port');
22+
let port = 3000;
23+
if (portIdx !== -1) {
24+
const raw = argv[portIdx + 1];
25+
const parsed = Number(raw);
26+
if (!raw || !Number.isInteger(parsed) || parsed <= 0) {
27+
throw new Error('--port must be a valid number');
28+
}
29+
port = parsed;
30+
}
31+
32+
const allowedHosts = parseList(argv, '--allowedHosts') ?? ['localhost', '127.0.0.1', '::1'];
33+
const allowedOrigins = parseList(argv, '--allowedOrigins') ?? [];
34+
35+
return { http, port, allowedHosts, allowedOrigins };
36+
}

0 commit comments

Comments
 (0)