Skip to content

Commit cf3d464

Browse files
committed
feat(mcp): add MCP server with 39 tools and unit test suite
Introduce a standalone MCP server binary (novawindows-mcp) that exposes the NovaWindows driver capabilities over the Model Context Protocol.
1 parent 4e37498 commit cf3d464

27 files changed

+2517
-4
lines changed

lib/mcp/appium-manager.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import * as http from 'node:http';
2+
import * as path from 'node:path';
3+
import { spawn, type ChildProcess } from 'node:child_process';
4+
import type { McpConfig } from './config.js';
5+
6+
const POLL_INTERVAL_MS = 500;
7+
const STARTUP_TIMEOUT_MS = 30_000;
8+
const SHUTDOWN_TIMEOUT_MS = 5_000;
9+
10+
function isAppiumReady(host: string, port: number): Promise<boolean> {
11+
return new Promise((resolve) => {
12+
const req = http.get(
13+
{ hostname: host, port, path: '/status', timeout: 2000 },
14+
(res) => {
15+
let body = '';
16+
res.on('data', (chunk) => { body += chunk; });
17+
res.on('end', () => {
18+
try {
19+
const json = JSON.parse(body);
20+
resolve(json?.value?.ready === true);
21+
} catch {
22+
resolve(false);
23+
}
24+
});
25+
}
26+
);
27+
req.on('error', () => resolve(false));
28+
req.on('timeout', () => { req.destroy(); resolve(false); });
29+
});
30+
}
31+
32+
function waitForAppium(host: string, port: number): Promise<void> {
33+
return new Promise((resolve, reject) => {
34+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
35+
const poll = async () => {
36+
if (await isAppiumReady(host, port)) {
37+
resolve();
38+
return;
39+
}
40+
if (Date.now() >= deadline) {
41+
reject(new Error(`Appium did not become ready within ${STARTUP_TIMEOUT_MS / 1000}s on ${host}:${port}`));
42+
return;
43+
}
44+
setTimeout(poll, POLL_INTERVAL_MS);
45+
};
46+
poll();
47+
});
48+
}
49+
50+
function resolveAppiumBinary(configBinary?: string): string {
51+
if (configBinary) {return configBinary;}
52+
53+
// Prefer a local node_modules/.bin/appium (3 levels up from build/lib/mcp/)
54+
const localBin = path.resolve(__dirname, '..', '..', '..', 'node_modules', '.bin', 'appium');
55+
if (require('node:fs').existsSync(localBin)) {return localBin;}
56+
57+
// Fall back to appium on the system PATH (global install: npm install -g appium)
58+
return 'appium';
59+
}
60+
61+
export class AppiumManager {
62+
private process: ChildProcess | null = null;
63+
private managed = false;
64+
65+
async ensureRunning(config: McpConfig): Promise<void> {
66+
const { appiumHost: host, appiumPort: port } = config;
67+
68+
if (await isAppiumReady(host, port)) {
69+
process.stderr.write(`[MCP] Appium already running on ${host}:${port}\n`);
70+
return;
71+
}
72+
73+
if (!config.appiumAutoStart) {
74+
throw new Error(
75+
`Appium is not running on ${host}:${port}.\n` +
76+
`Start it with: appium --port ${port}\n` +
77+
`Or set APPIUM_AUTO_START=true to start it automatically.`
78+
);
79+
}
80+
81+
const binary = resolveAppiumBinary(config.appiumBinary);
82+
process.stderr.write(`[MCP] Starting Appium: ${binary} --port ${port} --address ${host}\n`);
83+
84+
const child = spawn(binary, ['--port', String(port), '--address', host], {
85+
stdio: ['ignore', 'pipe', 'pipe'],
86+
shell: process.platform === 'win32',
87+
});
88+
89+
// Attach error handler immediately to prevent unhandled error crash
90+
await new Promise<void>((resolve, reject) => {
91+
child.once('error', (err) => {
92+
reject(new Error(
93+
`Failed to spawn Appium binary at "${binary}": ${err.message}\n` +
94+
`Install Appium globally with: npm install -g appium\n` +
95+
`Or set APPIUM_BINARY to the full path of the appium executable.`
96+
));
97+
});
98+
// If no error fires synchronously, we're past the spawn phase
99+
setImmediate(resolve);
100+
});
101+
102+
this.process = child;
103+
this.managed = true;
104+
105+
this.process.stdout?.on('data', (data: Buffer) => {
106+
process.stderr.write(`[Appium] ${data}`);
107+
});
108+
this.process.stderr?.on('data', (data: Buffer) => {
109+
process.stderr.write(`[Appium] ${data}`);
110+
});
111+
this.process.on('exit', (code) => {
112+
process.stderr.write(`[MCP] Appium process exited with code ${code}\n`);
113+
});
114+
115+
await waitForAppium(host, port);
116+
process.stderr.write(`[MCP] Appium ready on ${host}:${port}\n`);
117+
}
118+
119+
async shutdown(): Promise<void> {
120+
if (!this.managed || !this.process) {return;}
121+
122+
process.stderr.write('[MCP] Stopping Appium...\n');
123+
this.process.kill('SIGTERM');
124+
125+
const child = this.process;
126+
await new Promise<void>((resolve) => {
127+
const timeout = setTimeout(() => {
128+
child.kill('SIGKILL');
129+
resolve();
130+
}, SHUTDOWN_TIMEOUT_MS);
131+
132+
child.on('exit', () => {
133+
clearTimeout(timeout);
134+
resolve();
135+
});
136+
});
137+
138+
this.process = null;
139+
this.managed = false;
140+
}
141+
}

lib/mcp/config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** Infrastructure config — read from env vars at startup. */
2+
export interface McpConfig {
3+
appiumHost: string;
4+
appiumPort: number;
5+
appiumAutoStart: boolean;
6+
appiumBinary?: string;
7+
}
8+
9+
export function loadConfig(): McpConfig {
10+
const appiumPort = parseInt(process.env.APPIUM_PORT ?? '4723', 10);
11+
if (isNaN(appiumPort) || appiumPort < 1 || appiumPort > 65535) {
12+
throw new Error(`APPIUM_PORT must be a valid port number (1-65535), got: '${process.env.APPIUM_PORT}'`);
13+
}
14+
15+
return {
16+
appiumHost: process.env.APPIUM_HOST ?? '127.0.0.1',
17+
appiumPort,
18+
appiumAutoStart: process.env.APPIUM_AUTO_START !== 'false',
19+
appiumBinary: process.env.APPIUM_BINARY,
20+
};
21+
}

lib/mcp/errors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function formatError(err: unknown): string {
2+
if (err instanceof Error) {return `${err.constructor.name}: ${err.message}`;}
3+
return String(err);
4+
}

lib/mcp/index.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env node
2+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4+
import { loadConfig } from './config.js';
5+
import { AppiumManager } from './appium-manager.js';
6+
import { AppiumSession } from './session.js';
7+
import { registerAllTools } from './tools/index.js';
8+
9+
async function main() {
10+
// Step 1: Load infrastructure config (host, port, auto-start only — no app required)
11+
let config;
12+
try {
13+
config = loadConfig();
14+
} catch (err) {
15+
process.stderr.write(`[MCP] Configuration error: ${err instanceof Error ? err.message : String(err)}\n`);
16+
process.exit(1);
17+
}
18+
19+
// Step 2: Ensure Appium is running
20+
const appiumManager = new AppiumManager();
21+
try {
22+
await appiumManager.ensureRunning(config);
23+
} catch (err) {
24+
process.stderr.write(`[MCP] Failed to start Appium: ${err instanceof Error ? err.message : String(err)}\n`);
25+
process.exit(1);
26+
}
27+
28+
// Step 3: Create session holder (no app launched yet — agent calls create_session)
29+
const session = new AppiumSession(config);
30+
31+
// Step 4: Create and configure MCP server
32+
const server = new McpServer({
33+
name: 'novawindows-mcp',
34+
version: '1.3.0',
35+
});
36+
37+
// Step 5: Register all tools (including create_session / delete_session)
38+
registerAllTools(server, session);
39+
40+
// Step 6: Shutdown handler
41+
let shuttingDown = false;
42+
async function shutdown(reason: string) {
43+
if (shuttingDown) {return;}
44+
shuttingDown = true;
45+
process.stderr.write(`[MCP] Shutting down (${reason})...\n`);
46+
47+
if (session.isActive()) {
48+
await Promise.race([
49+
session.delete(),
50+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
51+
]);
52+
}
53+
54+
await appiumManager.shutdown();
55+
process.exit(0);
56+
}
57+
58+
process.on('SIGINT', () => { shutdown('SIGINT'); });
59+
process.on('SIGTERM', () => { shutdown('SIGTERM'); });
60+
process.stdin.on('end', () => { shutdown('stdin closed'); });
61+
62+
// Step 7: Connect transport (stdout is owned by MCP protocol — all logs go to stderr)
63+
const transport = new StdioServerTransport();
64+
await server.connect(transport);
65+
process.stderr.write('[MCP] novawindows-mcp server ready. Call create_session to launch an app.\n');
66+
}
67+
68+
main();

lib/mcp/session.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { remote } from 'webdriverio';
2+
import type { Browser } from 'webdriverio';
3+
import type { McpConfig } from './config.js';
4+
5+
/** Session parameters provided by the agent via the create_session tool. */
6+
export interface SessionParams {
7+
app: string;
8+
appArguments?: string;
9+
appWorkingDir?: string;
10+
waitForAppLaunch?: number;
11+
shouldCloseApp?: boolean;
12+
implicitTimeout?: number;
13+
delayAfterClick?: number;
14+
delayBeforeClick?: number;
15+
smoothPointerMove?: string;
16+
}
17+
18+
export class AppiumSession {
19+
private driver: Browser | null = null;
20+
21+
constructor(private readonly appiumConfig: McpConfig) {}
22+
23+
async create(params: SessionParams): Promise<void> {
24+
if (this.driver) {
25+
throw new Error('A session is already active. Call delete_session first.');
26+
}
27+
28+
process.stderr.write(`[MCP] Creating Appium session for app: ${params.app}\n`);
29+
30+
const caps: Record<string, unknown> = {
31+
platformName: 'Windows',
32+
'appium:automationName': 'NovaWindows',
33+
'appium:app': params.app,
34+
};
35+
36+
if (params.appArguments !== undefined) {caps['appium:appArguments'] = params.appArguments;}
37+
if (params.appWorkingDir !== undefined) {caps['appium:appWorkingDir'] = params.appWorkingDir;}
38+
if (params.waitForAppLaunch !== undefined) {caps['appium:ms:waitForAppLaunch'] = params.waitForAppLaunch;}
39+
if (params.shouldCloseApp !== undefined) {caps['appium:shouldCloseApp'] = params.shouldCloseApp;}
40+
if (params.delayAfterClick !== undefined) {caps['appium:delayAfterClick'] = params.delayAfterClick;}
41+
if (params.delayBeforeClick !== undefined) {caps['appium:delayBeforeClick'] = params.delayBeforeClick;}
42+
if (params.smoothPointerMove !== undefined) {caps['appium:smoothPointerMove'] = params.smoothPointerMove;}
43+
44+
this.driver = await remote({
45+
hostname: this.appiumConfig.appiumHost,
46+
port: this.appiumConfig.appiumPort,
47+
path: '/',
48+
capabilities: caps as WebdriverIO.Capabilities,
49+
});
50+
51+
await this.driver.setTimeout({ implicit: params.implicitTimeout ?? 1500 });
52+
process.stderr.write('[MCP] Session created successfully\n');
53+
}
54+
55+
async delete(): Promise<void> {
56+
if (!this.driver) {return;}
57+
try {
58+
await this.driver.deleteSession();
59+
process.stderr.write('[MCP] Session deleted\n');
60+
} catch (err) {
61+
process.stderr.write(`[MCP] Warning: session delete failed: ${err}\n`);
62+
} finally {
63+
this.driver = null;
64+
}
65+
}
66+
67+
isActive(): boolean {
68+
return this.driver !== null;
69+
}
70+
71+
getDriver(): Browser {
72+
if (!this.driver) {throw new Error('No active session. Call create_session first.');}
73+
return this.driver;
74+
}
75+
}

0 commit comments

Comments
 (0)