Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/network-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,17 @@ export class NetworkInterceptor {

async setOffline(enabled: boolean, client: InterceptorClient): Promise<void> {
this._offline = enabled;
if (enabled && !this._enabled) this._enabled = true;
if (enabled) {
if (!this._enabled) this._enabled = true;
await this.inject(client);
return;
}

if (!this._enabled) return;
if (this.rules.size === 0) {
await this.disable(client);
return;
}
await this.inject(client);
}

Expand Down
204 changes: 68 additions & 136 deletions src/tools/network-intercept.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,55 @@
import { MCPServer, getWebKitClient } from '../mcp-server';
import { BrowserBackend } from '../types/browser-backend';

/**
* Network interceptor state manager.
*
* Uses JavaScript injection to intercept fetch/XHR requests in Safari.
* WebKit Remote Debugging Protocol does not expose a Network interception
* domain, so we override window.fetch and XMLHttpRequest at the page level.
*/
class NetworkInterceptor {
private offline = false;

/**
* Enable or disable offline simulation by overriding fetch and XHR.
*/
async setOffline(enabled: boolean, client: BrowserBackend): Promise<void> {
this.offline = enabled;

const script = enabled
? `(function() {
if (window.__opensafariOfflineActive) return;
window.__opensafariOfflineActive = true;
window.__opensafariOriginalFetch = window.fetch;
window.__opensafariOriginalXHROpen = XMLHttpRequest.prototype.open;
window.__opensafariOriginalXHRSend = XMLHttpRequest.prototype.send;

window.fetch = function() {
return Promise.reject(new TypeError('Failed to fetch'));
};

XMLHttpRequest.prototype.open = function() {
this.__opensafariArgs = arguments;
return window.__opensafariOriginalXHROpen.apply(this, arguments);
};

XMLHttpRequest.prototype.send = function() {
var xhr = this;
setTimeout(function() {
if (typeof xhr.onerror === 'function') {
xhr.onerror(new Event('error'));
}
xhr.dispatchEvent(new Event('error'));
}, 0);
};
})()`
: `(function() {
if (!window.__opensafariOfflineActive) return;
window.__opensafariOfflineActive = false;
if (window.__opensafariOriginalFetch) {
window.fetch = window.__opensafariOriginalFetch;
delete window.__opensafariOriginalFetch;
}
if (window.__opensafariOriginalXHROpen) {
XMLHttpRequest.prototype.open = window.__opensafariOriginalXHROpen;
delete window.__opensafariOriginalXHROpen;
}
if (window.__opensafariOriginalXHRSend) {
XMLHttpRequest.prototype.send = window.__opensafariOriginalXHRSend;
delete window.__opensafariOriginalXHRSend;
}
})()`;

await client.evaluate(script);
import {
NetworkInterceptor,
type InterceptorClient,
type InterceptRule,
} from '../network-interceptor';

const DEFAULT_INTERCEPTOR_SCOPE = '__default__';
const interceptorsBySession = new Map<string, NetworkInterceptor>();

export function getNetworkInterceptorForSession(sessionId?: string): NetworkInterceptor {
const key = sessionId || DEFAULT_INTERCEPTOR_SCOPE;
let interceptor = interceptorsBySession.get(key);
if (!interceptor) {
interceptor = new NetworkInterceptor();
interceptorsBySession.set(key, interceptor);
}
return interceptor;
}

/**
* Return current offline state.
*/
isOffline(): boolean {
return this.offline;
}
export function resetNetworkInterceptorsForTest(): void {
interceptorsBySession.clear();
}

export const networkInterceptor = new NetworkInterceptor();
/** Legacy singleton for callers that are not yet session-aware. */
export const networkInterceptor = getNetworkInterceptorForSession(DEFAULT_INTERCEPTOR_SCOPE);

interface InterceptRule {
urlPattern: string;
action: 'block' | 'modify';
statusCode?: number;
body?: string;
function resolveClient(deviceId: unknown): InterceptorClient | null {
return getWebKitClient(typeof deviceId === 'string' ? deviceId : undefined);
}

const activeRules: InterceptRule[] = [];
function mapRule(params: Record<string, unknown>): Omit<InterceptRule, 'id'> {
const urlPattern = params.urlPattern as string | undefined;
if (!urlPattern) {
throw new Error('urlPattern is required when clear is not set');
}

const action = ((params.action as string) || 'block') as 'block' | 'modify';
if (action === 'block') return { urlPattern, action: 'block' };

const statusCode = typeof params.statusCode === 'number' ? params.statusCode : 200;
const body = typeof params.body === 'string' ? params.body : '';
return {
urlPattern,
action: 'mock',
mockResponse: {
status: statusCode,
headers: { 'Content-Type': 'text/plain' },
body,
},
};
}

export function registerNetworkInterceptTool(server: MCPServer): void {
server.registerTool(
Expand All @@ -94,7 +62,7 @@ export function registerNetworkInterceptTool(server: MCPServer): void {
properties: {
urlPattern: {
type: 'string',
description: 'URL pattern to match (substring match against request URL)',
description: 'URL pattern to match (substring/glob match against request URL)',
},
action: {
type: 'string',
Expand All @@ -111,88 +79,52 @@ export function registerNetworkInterceptTool(server: MCPServer): void {
},
clear: {
type: 'boolean',
description: 'If true, remove all intercept rules and restore original network behavior',
description: 'If true, remove all intercept rules and restore original network behavior for this MCP session',
},
device_id: {
type: 'string',
description: 'Simulator UDID / WebKit connection to target (uses active device if omitted)',
},
},
required: [],
},
},
async (_sessionId: string, params: Record<string, unknown>) => {
const client = getWebKitClient();
if (!client)
async (sessionId: string, params: Record<string, unknown>) => {
const client = resolveClient(params.device_id);
if (!client) {
return { content: [{ type: 'text' as const, text: 'Error: Safari not connected' }], isError: true };
}

const clear = params.clear as boolean | undefined;
if (clear) {
activeRules.length = 0;
const restoreScript = `(function() {
if (window.__opensafariInterceptActive) {
window.__opensafariInterceptActive = false;
if (window.__opensafariInterceptOriginalFetch) {
window.fetch = window.__opensafariInterceptOriginalFetch;
delete window.__opensafariInterceptOriginalFetch;
}
}
})()`;
await client.evaluate(restoreScript);
const interceptor = getNetworkInterceptorForSession(sessionId);
if (params.clear === true) {
await interceptor.disable(client);
return { content: [{ type: 'text' as const, text: 'All intercept rules cleared' }] };
}

const urlPattern = params.urlPattern as string | undefined;
if (!urlPattern) {
let rule: InterceptRule;
try {
rule = interceptor.addRule(mapRule(params));
} catch (err) {
return {
content: [{ type: 'text' as const, text: 'Error: urlPattern is required when clear is not set' }],
content: [{ type: 'text' as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
isError: true,
};
}

const action = ((params.action as string) || 'block') as 'block' | 'modify';
const statusCode = (params.statusCode as number) || 200;
const body = (params.body as string) || '';

const rule: InterceptRule = { urlPattern, action, statusCode, body };
activeRules.push(rule);

const rulesJson = JSON.stringify(activeRules);
const interceptScript = `(function() {
window.__opensafariInterceptRules = ${rulesJson};
if (window.__opensafariInterceptActive) return;
window.__opensafariInterceptActive = true;
window.__opensafariInterceptOriginalFetch = window.fetch;

window.fetch = function(input, init) {
var url = typeof input === 'string' ? input : (input && input.url ? input.url : '');
var rules = window.__opensafariInterceptRules || [];
for (var i = 0; i < rules.length; i++) {
var rule = rules[i];
if (url.indexOf(rule.urlPattern) !== -1) {
if (rule.action === 'block') {
return Promise.reject(new TypeError('Request blocked by intercept rule'));
}
if (rule.action === 'modify') {
return Promise.resolve(new Response(rule.body || '', {
status: rule.statusCode || 200,
headers: { 'Content-Type': 'text/plain' }
}));
}
}
}
return window.__opensafariInterceptOriginalFetch.apply(this, arguments);
};
})()`;

await client.evaluate(interceptScript);
await interceptor.enable(client);

const action = rule.action === 'mock' ? 'modify' : 'block';
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
status: 'intercepting',
urlPattern,
ruleId: rule.id,
urlPattern: rule.urlPattern,
action,
...(action === 'modify' ? { statusCode, body } : {}),
totalRules: activeRules.length,
...(rule.mockResponse ? { statusCode: rule.mockResponse.status, body: rule.mockResponse.body } : {}),
totalRules: interceptor.listRules().length,
}),
},
],
Expand Down
9 changes: 5 additions & 4 deletions src/tools/network-offline.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MCPServer, getWebKitClient } from '../mcp-server';
import { networkInterceptor } from './network-intercept';
import { getNetworkInterceptorForSession } from './network-intercept';

export function registerNetworkOfflineTool(server: MCPServer): void {
server.registerTool(
Expand All @@ -10,16 +10,17 @@ export function registerNetworkOfflineTool(server: MCPServer): void {
type: 'object' as const,
properties: {
enabled: { type: 'boolean', description: 'true to go offline, false to restore connectivity' },
device_id: { type: 'string', description: 'Simulator UDID / WebKit connection to target (uses active device if omitted)' },
},
required: ['enabled'],
},
},
async (_sessionId: string, params: Record<string, unknown>) => {
const client = getWebKitClient();
async (sessionId: string, params: Record<string, unknown>) => {
const client = getWebKitClient(typeof params.device_id === 'string' ? params.device_id : undefined);
if (!client)
return { content: [{ type: 'text' as const, text: 'Error: Safari not connected' }], isError: true };
const enabled = params.enabled as boolean;
await networkInterceptor.setOffline(enabled, client);
await getNetworkInterceptorForSession(sessionId).setOffline(enabled, client);
return { content: [{ type: 'text' as const, text: enabled ? 'Offline mode enabled' : 'Online mode restored' }] };
},
);
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/network-intercept-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getNetworkInterceptorForSession, resetNetworkInterceptorsForTest } from '../../src/tools/network-intercept';

describe('network_intercept session scoping', () => {
beforeEach(() => resetNetworkInterceptorsForTest());

it('keeps rule state isolated by MCP session id', () => {
const a = getNetworkInterceptorForSession('session-a');
const b = getNetworkInterceptorForSession('session-b');

a.addRule({ urlPattern: '**/api/**', action: 'block' });

expect(a.listRules()).toHaveLength(1);
expect(b.listRules()).toHaveLength(0);
});

it('returns the same interceptor for repeated access to one session', () => {
const a1 = getNetworkInterceptorForSession('session-a');
const a2 = getNetworkInterceptorForSession('session-a');
a1.addRule({
urlPattern: '/login',
action: 'mock',
mockResponse: { status: 200, headers: { 'Content-Type': 'text/plain' }, body: 'ok' },
});

expect(a2.listRules()).toHaveLength(1);
expect(a2.findMatchingRule('https://example.com/login')?.action).toBe('mock');
});

it('clear/disable restores and clears only the selected session', async () => {
const a = getNetworkInterceptorForSession('session-a');
const b = getNetworkInterceptorForSession('session-b');
const client = { evaluate: jest.fn().mockResolvedValue(undefined) };

a.addRule({ urlPattern: '/a', action: 'block' });
b.addRule({ urlPattern: '/b', action: 'block' });
await a.disable(client);

expect(a.listRules()).toHaveLength(0);
expect(b.listRules()).toHaveLength(1);
expect(client.evaluate.mock.calls[0][0]).toContain('__osOriginalXHROpen');
});
});
13 changes: 12 additions & 1 deletion tests/unit/network-interceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,21 @@ describe('NetworkInterceptor', () => {
expect(interceptor.offline).toBe(true);
expect(interceptor.enabled).toBe(true);
});
it('disables offline', async () => {
it('disables offline and restores hooks when no intercept rules remain', async () => {
await interceptor.setOffline(true, mockClient);
await interceptor.setOffline(false, mockClient);
expect(interceptor.offline).toBe(false);
expect(interceptor.enabled).toBe(false);
expect(mockClient.evaluate.mock.calls.at(-1)?.[0]).toContain('__osOriginalXHROpen');
});
it('disables offline without clearing active intercept rules', async () => {
interceptor.addRule({ urlPattern: '/api', action: 'block' });
await interceptor.enable(mockClient);
await interceptor.setOffline(true, mockClient);
await interceptor.setOffline(false, mockClient);
expect(interceptor.offline).toBe(false);
expect(interceptor.enabled).toBe(true);
expect(interceptor.listRules()).toHaveLength(1);
});
});

Expand Down
Loading