Skip to content

Commit 5309ffc

Browse files
committed
fix(stdio): keep logs off JSON-RPC stdout
1 parent 5fb770a commit 5309ffc

12 files changed

Lines changed: 198 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,8 @@ const server = await createAppiumMcpServer({
538538
await server.start({ transportType: 'stdio' });
539539
```
540540

541+
`start({ transportType: 'stdio' })` keeps Appium and WebDriver logs off stdout so JSON-RPC stays intact. httpStream is unchanged.
542+
541543
Plugin lifecycle:
542544

543545
- `register(registry, core)`: called during server construction. Register custom tools, prompts, resources, and resource templates here.

src/cli/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import log from '../logger.js';
1+
import log, {configureStdioTransportLogging} from '../logger.js';
22

33
export async function runCli(args: string[] = process.argv.slice(2)): Promise<void> {
44
const command = args[0];
55
if (command === '--help' || command === '-h' || command === 'help') {
66
printHelp();
7-
} else {
8-
await startServer(args);
7+
return;
98
}
9+
10+
if (!args.includes('--httpStream')) {
11+
configureStdioTransportLogging();
12+
}
13+
14+
await startServer(args);
1015
}
1116

1217
function printHelp(): void {

src/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
export {createAppiumMcpServer} from './create-server.js';
99
export type {CreateAppiumMcpServerOptions} from './create-server.js';
10+
export {configureStdioTransportLogging} from './logger.js';
1011
export {evaluatePolicyTarget} from './policy.js';
1112
export type {AppiumMcpPolicy, PolicyDecision, PolicyDecisionReason, PolicyTargetKind} from './policy.js';
1213
export {AppiumMcpCore, formatVerificationReport, McpRegistry, PluginManager, verifyAppiumMcpNames} from './plugin.js';

src/create-server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import {FastMCP} from 'fastmcp';
1717

1818
import pkg from '../package.json' with {type: 'json'};
19-
import log from './logger.js';
19+
import log, {configureStdioTransportLogging} from './logger.js';
2020
import {PluginManager} from './plugin.js';
2121
import type {AppiumMcpPlugin} from './plugin.js';
2222
import {installPolicy, type AppiumMcpPolicy} from './policy.js';
@@ -118,6 +118,7 @@ export async function createAppiumMcpServer(options: CreateAppiumMcpServerOption
118118
enabled: false,
119119
},
120120
});
121+
wrapStartForStdioLogging(server);
121122

122123
installPolicy(server, policy);
123124
try {
@@ -263,6 +264,16 @@ export async function createAppiumMcpServer(options: CreateAppiumMcpServerOption
263264
return server;
264265
}
265266

267+
function wrapStartForStdioLogging(server: FastMCP): void {
268+
const originalStart = server.start.bind(server);
269+
server.start = (async (startOptions) => {
270+
if ((startOptions?.transportType ?? 'stdio') === 'stdio') {
271+
configureStdioTransportLogging();
272+
}
273+
return originalStart(startOptions);
274+
}) as FastMCP['start'];
275+
}
276+
266277
function disconnectSessionPolicyFromEnv(): DisconnectSessionPolicy {
267278
const raw = process.env.APPIUM_MCP_ON_CLIENT_DISCONNECT?.trim().toLowerCase();
268279
if (raw === 'skip') {

src/logger.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
import {logger} from '@appium/support';
22

3+
import {isStdioTransportLoggingConfigured, markStdioTransportLoggingConfigured} from './stdio-logging-state.js';
4+
35
const log = logger.getLogger('appium-mcp');
46

7+
/** npmlog must not write to stdout (stdio JSON-RPC). Skip if the host already set a custom stream. */
8+
export function ensureLoggerWritesToStderr(): void {
9+
const root = log.unwrap();
10+
if (root.stream === process.stdout) {
11+
root.stream = process.stderr;
12+
}
13+
}
14+
15+
/** stdio transport: drop info/debug so they cannot sit on stdout. */
16+
export function configureStdioTransportLogging(): void {
17+
if (isStdioTransportLoggingConfigured()) {
18+
return;
19+
}
20+
markStdioTransportLoggingConfigured();
21+
ensureLoggerWritesToStderr();
22+
log.level = 'warn';
23+
if (!process.env.WDIO_LOG_LEVEL) {
24+
process.env.WDIO_LOG_LEVEL = 'warn';
25+
}
26+
}
27+
528
export default log;
629
export {log};
730

src/stdio-logging-state.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
let stdioLoggingConfigured = false;
2+
3+
export function isStdioTransportLoggingConfigured(): boolean {
4+
return stdioLoggingConfigured;
5+
}
6+
7+
export function markStdioTransportLoggingConfigured(): void {
8+
stdioLoggingConfigured = true;
9+
}

src/tests/__mocks__/@appium/support.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import {constants, existsSync, promises as fsPromises} from 'node:fs';
22

33
const noop = () => {};
44

5+
const npmlog = {
6+
stream: process.stderr as NodeJS.WritableStream | null,
7+
};
8+
59
export const logger = {
610
getLogger: (_name: string) => ({
711
debug: noop,
812
info: noop,
913
warn: noop,
1014
error: noop,
1115
trace: noop,
16+
level: 'info' as string,
17+
unwrap: () => npmlog,
1218
}),
1319
};
1420

src/tests/create-server.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ class MockFastMCP {
9393
await handler(event);
9494
}
9595
}
96+
97+
async start(_options?: unknown): Promise<void> {}
9698
}
9799

98100
await jest.unstable_mockModule('fastmcp', () => ({
@@ -147,13 +149,16 @@ await jest.unstable_mockModule('../session-store', () => ({
147149
safeDeleteAllSessions,
148150
}));
149151

152+
const configureStdioTransportLogging = jest.fn();
153+
150154
await jest.unstable_mockModule('../logger', () => ({
151155
default: {
152156
debug: jest.fn(),
153157
info: jest.fn(),
154158
warn: jest.fn(),
155159
error: jest.fn(),
156160
},
161+
configureStdioTransportLogging,
157162
}));
158163

159164
const {createAppiumMcpServer} = await import('../create-server.js');
@@ -167,6 +172,7 @@ afterEach(() => {
167172
jest.mocked(log.error).mockReset();
168173
jest.mocked(log.info).mockReset();
169174
jest.mocked(log.warn).mockReset();
175+
configureStdioTransportLogging.mockReset();
170176
delete process.env.APPIUM_MCP_ON_CLIENT_DISCONNECT;
171177
});
172178

@@ -195,6 +201,24 @@ describe('createAppiumMcpServer plugin lifecycle', () => {
195201
expect(log.info).toHaveBeenCalledWith('fastmcp log');
196202
});
197203

204+
test('stdio start quiets logging; httpStream does not', async () => {
205+
const server = await createAppiumMcpServer();
206+
207+
await server.start();
208+
expect(configureStdioTransportLogging).toHaveBeenCalledTimes(1);
209+
210+
configureStdioTransportLogging.mockClear();
211+
await server.start({transportType: 'stdio'});
212+
expect(configureStdioTransportLogging).toHaveBeenCalledTimes(1);
213+
214+
configureStdioTransportLogging.mockClear();
215+
await server.start({
216+
transportType: 'httpStream',
217+
httpStream: {port: 8080},
218+
});
219+
expect(configureStdioTransportLogging).not.toHaveBeenCalled();
220+
});
221+
198222
test('registers plugin capabilities during construction but initializes lazily', async () => {
199223
let registerCalled = false;
200224
let initialized = false;

src/tests/logger.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {afterEach, describe, expect, test} from '@jest/globals';
2+
3+
import {configureStdioTransportLogging, ensureLoggerWritesToStderr, log} from '../logger.js';
4+
import {isStdioTransportLoggingConfigured, markStdioTransportLoggingConfigured} from '../stdio-logging-state.js';
5+
import {QUIET_WEBDRIVER_LOG_LEVEL, withQuietWebDriverLogging} from '../utils/webdriver-client-options.js';
6+
7+
describe('stdio transport logging', () => {
8+
const originalWdioLogLevel = process.env.WDIO_LOG_LEVEL;
9+
10+
afterEach(() => {
11+
if (originalWdioLogLevel === undefined) {
12+
delete process.env.WDIO_LOG_LEVEL;
13+
} else {
14+
process.env.WDIO_LOG_LEVEL = originalWdioLogLevel;
15+
}
16+
});
17+
18+
test('ensureLoggerWritesToStderr only replaces stdout', () => {
19+
log.unwrap().stream = process.stdout;
20+
ensureLoggerWritesToStderr();
21+
expect(log.unwrap().stream).toBe(process.stderr);
22+
});
23+
24+
test('ensureLoggerWritesToStderr leaves stderr and custom sinks in place', () => {
25+
log.unwrap().stream = process.stderr;
26+
ensureLoggerWritesToStderr();
27+
expect(log.unwrap().stream).toBe(process.stderr);
28+
29+
const custom = {write: () => {}} as unknown as NodeJS.WriteStream;
30+
log.unwrap().stream = custom;
31+
ensureLoggerWritesToStderr();
32+
expect(log.unwrap().stream).toBe(custom);
33+
});
34+
35+
test('withQuietWebDriverLogging is a no-op until stdio logging is configured', () => {
36+
expect(isStdioTransportLoggingConfigured()).toBe(false);
37+
expect(
38+
withQuietWebDriverLogging({
39+
hostname: '127.0.0.1',
40+
port: 4723,
41+
}),
42+
).toEqual({
43+
hostname: '127.0.0.1',
44+
port: 4723,
45+
});
46+
});
47+
48+
test('configureStdioTransportLogging quiets info logs and WDIO when unset', () => {
49+
delete process.env.WDIO_LOG_LEVEL;
50+
log.unwrap().stream = process.stdout;
51+
52+
configureStdioTransportLogging();
53+
54+
expect(isStdioTransportLoggingConfigured()).toBe(true);
55+
expect(log.unwrap().stream).toBe(process.stderr);
56+
expect(log.level).toBe('warn');
57+
expect(process.env.WDIO_LOG_LEVEL).toBe('warn');
58+
});
59+
60+
test('withQuietWebDriverLogging sets warn after stdio logging is configured', () => {
61+
markStdioTransportLoggingConfigured();
62+
expect(
63+
withQuietWebDriverLogging({
64+
hostname: '127.0.0.1',
65+
port: 4723,
66+
}),
67+
).toEqual({
68+
hostname: '127.0.0.1',
69+
port: 4723,
70+
logLevel: QUIET_WEBDRIVER_LOG_LEVEL,
71+
});
72+
});
73+
});

src/tools/session/create-session.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {setSession, listSessions} from '../../session-store.js';
1212
import {createUIResource, createSessionDashboardUI, addUIResourceToResponse} from '../../ui/mcp-ui-utils.js';
1313
import {findFreePort, releaseReservedPorts} from '../../utils/ports.js';
1414
import {getPortFromUrl} from '../../utils/url.js';
15+
import {withQuietWebDriverLogging} from '../../utils/webdriver-client-options.js';
1516
import {errorResult, textResult, toolErrorMessage} from '../tool-response.js';
1617
import {clearSelectedDevice, getSelectedLocalDevice} from './select-device.js';
1718

@@ -355,14 +356,16 @@ export async function createSessionAction(args: {
355356
log.info(
356357
`Sending capabilities to remote server: ${protocol}://${remoteUrl.hostname}:${port}${remoteUrl.pathname}`,
357358
);
358-
const client = await WebDriver.newSession({
359-
protocol,
360-
hostname: remoteUrl.hostname,
361-
port,
362-
path: remoteUrl.pathname,
363-
...(user && key ? {user, key} : {}),
364-
capabilities: finalCapabilities,
365-
});
359+
const client = await WebDriver.newSession(
360+
withQuietWebDriverLogging({
361+
protocol,
362+
hostname: remoteUrl.hostname,
363+
port,
364+
path: remoteUrl.pathname,
365+
...(user && key ? {user, key} : {}),
366+
capabilities: finalCapabilities,
367+
}),
368+
);
366369
sessionId = client.sessionId;
367370
await setSession(client, client.sessionId, finalCapabilities, 'owned', args.remoteServerUrl);
368371
} else {

0 commit comments

Comments
 (0)