diff --git a/src/network-interceptor.ts b/src/network-interceptor.ts index 5e4b5511..57ec471e 100644 --- a/src/network-interceptor.ts +++ b/src/network-interceptor.ts @@ -86,12 +86,22 @@ export class NetworkInterceptor { this._enabled = false; this._offline = false; this.rules.clear(); - await client.evaluate('(function(){if(window.__osOriginalFetch){window.fetch=window.__osOriginalFetch;delete window.__osOriginalFetch}if(window.__osOriginalXHROpen){XMLHttpRequest.prototype.open=window.__osOriginalXHROpen;delete window.__osOriginalXHROpen}delete window.__osInterceptRules;delete window.__osOfflineMode})()'); + await client.evaluate('(function(){if(window.__osOriginalFetch){window.fetch=window.__osOriginalFetch;delete window.__osOriginalFetch}if(window.__osOriginalXHROpen){XMLHttpRequest.prototype.open=window.__osOriginalXHROpen;delete window.__osOriginalXHROpen}if(window.__osOriginalXHRSend){XMLHttpRequest.prototype.send=window.__osOriginalXHRSend;delete window.__osOriginalXHRSend}delete window.__osInterceptRules;delete window.__osOfflineMode})()'); } async setOffline(enabled: boolean, client: InterceptorClient): Promise { 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); } @@ -103,7 +113,7 @@ export class NetworkInterceptor { private async inject(client: InterceptorClient): Promise { const rulesJson = JSON.stringify(this.listRules()); const offlineFlag = this._offline ? 'true' : 'false'; - const script = '(function(){if(!window.__osOriginalFetch){window.__osOriginalFetch=window.fetch.bind(window)}if(!window.__osOriginalXHROpen){window.__osOriginalXHROpen=XMLHttpRequest.prototype.open}window.__osInterceptRules=' + rulesJson + ';window.__osOfflineMode=' + offlineFlag + ';function m(u,p){if(p.indexOf("*")===-1&&p.indexOf("?")===-1)return u.indexOf(p)!==-1;var r="";for(var i=0;i { - 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(); + +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): Omit { + 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( @@ -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', @@ -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) => { - const client = getWebKitClient(); - if (!client) + async (sessionId: string, params: Record) => { + 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, }), }, ], diff --git a/src/tools/network-offline.ts b/src/tools/network-offline.ts index 439636b7..15fac30c 100644 --- a/src/tools/network-offline.ts +++ b/src/tools/network-offline.ts @@ -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( @@ -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) => { - const client = getWebKitClient(); + async (sessionId: string, params: Record) => { + 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' }] }; }, ); diff --git a/tests/unit/network-intercept-tool.test.ts b/tests/unit/network-intercept-tool.test.ts new file mode 100644 index 00000000..bb2289ab --- /dev/null +++ b/tests/unit/network-intercept-tool.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/network-interceptor.test.ts b/tests/unit/network-interceptor.test.ts index d5e88062..3aabf6d9 100644 --- a/tests/unit/network-interceptor.test.ts +++ b/tests/unit/network-interceptor.test.ts @@ -89,12 +89,25 @@ describe('NetworkInterceptor', () => { expect(interceptor.enabled).toBe(true); expect(mockClient.evaluate).toHaveBeenCalled(); }); - it('clears state on disable', async () => { + it('clears state and restores all browser hooks on disable', async () => { interceptor.addRule({ urlPattern: '*.js', action: 'block' }); await interceptor.enable(mockClient); await interceptor.disable(mockClient); expect(interceptor.enabled).toBe(false); expect(interceptor.listRules()).toHaveLength(0); + const restoreScript = mockClient.evaluate.mock.calls.at(-1)?.[0] as string; + expect(restoreScript).toContain('__osOriginalFetch'); + expect(restoreScript).toContain('__osOriginalXHROpen'); + expect(restoreScript).toContain('__osOriginalXHRSend'); + }); + + it('preserves the original XHR send across repeated injections', async () => { + await interceptor.enable(mockClient); + await interceptor.syncRules(mockClient); + const reinjectScript = mockClient.evaluate.mock.calls.at(-1)?.[0] as string; + expect(reinjectScript).toContain('if(!window.__osOriginalXHRSend)'); + expect(reinjectScript).toContain('window.__osOriginalXHRSend.apply(this,arguments)'); + expect(reinjectScript).not.toContain('var origSend=XMLHttpRequest.prototype.send'); }); }); @@ -105,10 +118,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); }); });