diff --git a/scripts/lint-tool-schemas.mjs b/scripts/lint-tool-schemas.mjs index bdb014f05..510fdb615 100644 --- a/scripts/lint-tool-schemas.mjs +++ b/scripts/lint-tool-schemas.mjs @@ -33,6 +33,14 @@ const TOOL_NAME_RE = /^[a-z][a-z0-9_]{2,63}$/; const BASELINE_PATH = resolve(__dirname, 'lint-tool-schemas.baseline.json'); +async function readStdin() { + let data = ''; + process.stdin.setEncoding('utf8'); + for await (const chunk of process.stdin) data += chunk; + return data; +} + + // ── parse CLI args ───────────────────────────────────────────────────────── const args = process.argv.slice(2); const updateBaseline = args.includes('--update-baseline'); @@ -46,7 +54,7 @@ if (!inputFile) { // ── load tools list ──────────────────────────────────────────────────────── let tools; try { - tools = JSON.parse(readFileSync(inputFile === '-' ? 0 : resolve(inputFile), 'utf8')); + tools = JSON.parse(inputFile === '-' ? await readStdin() : readFileSync(resolve(inputFile), 'utf8')); } catch (err) { process.stderr.write(`Error reading tools list: ${err.message}\n`); process.exit(2); diff --git a/src/index.ts b/src/index.ts index 1bea7dc57..322d66bca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,8 +110,10 @@ program const server = new MCPServer(undefined, { initialToolTier: 3 }); registerAllTools(server); const manifest = server.getToolManifest(); - process.stdout.write(JSON.stringify(manifest.tools) + '\n'); - process.exit(0); + await new Promise((resolve) => { + process.stdout.write(JSON.stringify(manifest.tools) + '\n', () => resolve()); + }); + return; } let port = parseInt(options.port, 10); diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 267849f4c..14f8bc518 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -76,6 +76,62 @@ const SKIP_RECORDING_TOOLS = new Set([ * Detect if an error is a Chrome/CDP connection error that may be recoverable * by reconnecting to the browser. */ +export function estimateOutputTokensFromChars(chars: number): number { + // Heuristic only; intentionally avoids provider-specific tokenizer deps. + return Math.max(0, Math.ceil(chars / 4)); +} + +function stringifyResultPayload(result: MCPResult): string { + try { + return JSON.stringify(result); + } catch { + return Array.isArray(result.content) + ? result.content.map((c) => c.text ?? c.data ?? '').join('') + : ''; + } +} + +const CACHE_STATUS_LABELS = new Set(['HIT', 'MISS', 'BYPASS', 'ERROR']); +const CACHE_KEY_VERSION_LABEL_RE = /^v?\d{1,3}$/i; + +function normalizeCacheStatusLabel(raw: string): string { + const normalized = raw.trim().toUpperCase(); + return CACHE_STATUS_LABELS.has(normalized) ? normalized : 'UNKNOWN'; +} + +function normalizeCacheKeyVersionLabel(raw: unknown): string { + if (raw === undefined || raw === null || raw === '') return 'unknown'; + const normalized = String(raw).trim(); + if (normalized === '') return 'unknown'; + return CACHE_KEY_VERSION_LABEL_RE.test(normalized) ? normalized : 'other'; +} + +export function extractCacheStatus(result: MCPResult): { status: string; keyVersion: string } | null { + const raw = (result as Record)._cache + ?? (result as Record).cache + ?? (result as Record).cacheStatus; + if (typeof raw === 'string') { + return { status: normalizeCacheStatusLabel(raw), keyVersion: 'unknown' }; + } + if (raw && typeof raw === 'object') { + const obj = raw as Record; + const status = typeof obj.status === 'string' ? obj.status : typeof obj.cacheStatus === 'string' ? obj.cacheStatus : null; + if (!status) return null; + const keyVersion = obj.keyVersion ?? obj.version ?? 'unknown'; + return { + status: normalizeCacheStatusLabel(status), + keyVersion: normalizeCacheKeyVersionLabel(keyVersion), + }; + } + if (result.structuredContent && typeof result.structuredContent.cacheStatus === 'string') { + return { + status: normalizeCacheStatusLabel(result.structuredContent.cacheStatus), + keyVersion: normalizeCacheKeyVersionLabel(result.structuredContent.cacheKeyVersion), + }; + } + return null; +} + export function isConnectionError(error: unknown): boolean { if (error instanceof OpenChromeConnectionError) return true; const message = formatError(error); @@ -1236,7 +1292,7 @@ export class MCPServer { } catch { // best-effort } - return { + const deniedResult: MCPResult = { content: [ { type: 'text', @@ -1245,6 +1301,8 @@ export class MCPServer { ], isError: true, }; + this.recordToolOutputObservability(toolName, deniedResult); + return deniedResult; } } @@ -1271,7 +1329,7 @@ export class MCPServer { } catch { // best-effort } - return { + const forbiddenResult: MCPResult = { content: [ { type: 'text', @@ -1280,6 +1338,8 @@ export class MCPServer { ], isError: true, }; + this.recordToolOutputObservability(toolName, forbiddenResult); + return forbiddenResult; } // Handle the expand_tools meta-tool before normal tool lookup @@ -1305,9 +1365,11 @@ export class MCPServer { text += `\n\nNewly available tools:\n${JSON.stringify(newTools, null, 2)}\n\nYou can now call these tools directly by name.`; } - return { + const expandResult: MCPResult = { content: [{ type: 'text', text }], }; + this.recordToolOutputObservability(toolName, expandResult); + return expandResult; } const tool = this.tools.get(toolName); @@ -1320,10 +1382,12 @@ export class MCPServer { if (requiredFields && requiredFields.length > 0) { const missing = requiredFields.filter((field) => !(field in toolArgs) || toolArgs[field] === undefined || toolArgs[field] === null); if (missing.length > 0) { - return { + const missingArgsResult: MCPResult = { content: [{ type: 'text', text: `Error: Missing required argument(s): ${missing.join(', ')}` }], isError: true, }; + this.recordToolOutputObservability(toolName, missingArgsResult); + return missingArgsResult; } } @@ -1392,7 +1456,7 @@ export class MCPServer { if (!rateResult.allowed) { console.error(`[MCPServer] Rate limit exceeded for session ${sessionId}, retry after ${rateResult.retryAfterSec}s`); try { getMetricsCollector().inc('openchrome_rate_limit_rejections_total', withTenantLabel({ tool: toolName })); } catch { /* best-effort */ } - return { + const rateLimitResult: MCPResult = { content: [ { type: 'text', @@ -1401,6 +1465,8 @@ export class MCPServer { ], isError: true, }; + this.recordToolOutputObservability(toolName, rateLimitResult); + return rateLimitResult; } } @@ -1440,7 +1506,7 @@ export class MCPServer { }); if (reconnectResult !== 'reconnected') { - return { + const reconnectResultPayload: MCPResult = { content: [ { type: 'text', @@ -1449,6 +1515,8 @@ export class MCPServer { ], isError: true, }; + this.recordToolOutputObservability(toolName, reconnectResultPayload); + return reconnectResultPayload; } console.error(`[MCPServer] Reconnection complete, proceeding with "${toolName}"`); } @@ -1812,7 +1880,7 @@ export class MCPServer { } } - if (compressionConfig?.enabled && compressionConfig?.trackSavings) { + if (compressionConfig?.enabled && compressionConfig?.trackSavings && !(result as Record)._compression) { (result as Record)._compression = { level: compressionConfig.level ?? 'light', verbosity, @@ -1825,7 +1893,9 @@ export class MCPServer { // the substituted input, returned it inside a JSON blob, or surfaced // it via an error message) with `${SECRET:NAME}` placeholders. No-op // when --secrets was not passed. - return redactSecrets(result); + const finalResult = redactSecrets(result); + this.recordToolOutputObservability(toolName, finalResult); + return finalResult; } catch (error) { const message = formatError(error); const abortReason = isClientDisconnect(error) ? 'client_disconnect' : null; @@ -1978,7 +2048,45 @@ export class MCPServer { // Secrets redaction (#834) — see success path. Error messages can // include the literal value (e.g. "type ... failed for value X"). - return redactSecrets(errResult); + const finalErrResult = redactSecrets(errResult); + this.recordToolOutputObservability(toolName, finalErrResult); + return finalErrResult; + } + } + + + private recordToolOutputObservability(toolName: string, result: MCPResult): void { + try { + const metrics = getMetricsCollector(); + const payload = stringifyResultPayload(result); + const bytes = Buffer.byteLength(payload, 'utf8'); + metrics.observe('openchrome_tool_output_bytes', withTenantLabel({ tool: toolName }), bytes); + metrics.observe('openchrome_tool_estimated_tokens', withTenantLabel({ tool: toolName }), estimateOutputTokensFromChars(payload.length)); + + const compression = (result as Record)._compression; + if (compression && typeof compression === 'object') { + const originalChars = (compression as Record).originalChars; + const compressedChars = (compression as Record).compressedChars; + const level = String((compression as Record).level ?? 'unknown'); + if (typeof originalChars === 'number' && typeof compressedChars === 'number' && originalChars > compressedChars) { + metrics.observe( + 'openchrome_tool_compression_saved_bytes', + withTenantLabel({ tool: toolName, mode: level }), + originalChars - compressedChars, + ); + } + } + + const cache = extractCacheStatus(result); + if (cache) { + metrics.inc('openchrome_cache_status_total', withTenantLabel({ + tool: toolName, + status: cache.status, + key_version: cache.keyVersion, + })); + } + } catch { + // Metrics are best-effort and must never affect tool responses. } } diff --git a/src/metrics/collector.ts b/src/metrics/collector.ts index 45ab46c03..067937233 100644 --- a/src/metrics/collector.ts +++ b/src/metrics/collector.ts @@ -247,6 +247,13 @@ export function getMetricsCollector(): MetricsCollector { instance.registerCounter('openchrome_tool_calls_total', 'Total MCP tool calls'); instance.registerHistogram('openchrome_tool_duration_seconds', 'Tool call duration in seconds', [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120]); + instance.registerHistogram('openchrome_tool_output_bytes', 'Final MCP tool result payload size in bytes', + [128, 512, 1024, 4096, 16384, 65536, 262144, 1048576]); + instance.registerHistogram('openchrome_tool_estimated_tokens', 'Estimated MCP tool result output tokens (chars / 4 heuristic)', + [32, 128, 256, 1024, 4096, 16384, 65536, 262144]); + instance.registerHistogram('openchrome_tool_compression_saved_bytes', 'Estimated response bytes saved by response compression or delta modes', + [128, 512, 1024, 4096, 16384, 65536, 262144]); + instance.registerCounter('openchrome_cache_status_total', 'Cache status observations by tool and key version'); instance.registerCounter('openchrome_reconnect_total', 'Total successful CDP reconnections'); instance.registerGauge('openchrome_heap_bytes', 'Node.js heap usage in bytes'); instance.registerGauge('openchrome_active_sessions', 'Current active MCP sessions'); diff --git a/src/tools/crawl-cancel.ts b/src/tools/crawl-cancel.ts index 25254ad84..1f61e1166 100644 --- a/src/tools/crawl-cancel.ts +++ b/src/tools/crawl-cancel.ts @@ -19,7 +19,7 @@ const definition: MCPToolDefinition = { inputSchema: { type: 'object', properties: { - jobId: { type: 'string', description: 'Job id returned by crawl_start.' }, + jobId: { type: 'string', description: 'REQUIRED Job id returned by crawl_start.' }, }, required: ['jobId'], }, diff --git a/src/tools/crawl-start.ts b/src/tools/crawl-start.ts index af084cfd9..4564c355e 100644 --- a/src/tools/crawl-start.ts +++ b/src/tools/crawl-start.ts @@ -20,7 +20,7 @@ const definition: MCPToolDefinition = { inputSchema: { type: 'object', properties: { - url: { type: 'string', description: 'Starting URL to crawl' }, + url: { type: 'string', description: 'REQUIRED Starting URL to crawl' }, max_depth: { type: 'number', description: 'Max link-follow depth. Default: 2' }, max_pages: { type: 'number', description: 'Max pages to crawl. Default: 20' }, scope: { type: 'string', description: 'URL glob limiting which URLs to follow. Default: same origin.' }, diff --git a/src/tools/crawl-status.ts b/src/tools/crawl-status.ts index e903cbbd6..67bac77e2 100644 --- a/src/tools/crawl-status.ts +++ b/src/tools/crawl-status.ts @@ -24,7 +24,7 @@ const definition: MCPToolDefinition = { inputSchema: { type: 'object', properties: { - jobId: { type: 'string', description: 'Job id returned by crawl_start.' }, + jobId: { type: 'string', description: 'REQUIRED Job id returned by crawl_start.' }, advance: { type: 'number', description: 'Max pages to fetch in this call. Default OC_CRAWL_ADVANCE_DEFAULT (5). Use 0 for read-only.', diff --git a/src/tools/read-page.ts b/src/tools/read-page.ts index 5c7276cc2..7bf41d05c 100644 --- a/src/tools/read-page.ts +++ b/src/tools/read-page.ts @@ -599,8 +599,14 @@ const handler: ToolHandler = async ( const statsLine = `[page_stats] url: ${result.pageStats.url} | title: ${result.pageStats.title} | scroll: ${result.pageStats.scrollX},${result.pageStats.scrollY} | viewport: ${result.pageStats.viewportWidth}x${result.pageStats.viewportHeight} | docSize: ${result.pageStats.scrollWidth}x${result.pageStats.scrollHeight}\n\n`; const includePaginationDom = args.includePagination !== false; const domPaginationSection = includePaginationDom ? formatPaginationSection(await detectPagination(page, tabId)) : ''; + const compressedText = statsLine + delta.content + nodeRefsBlock + domPaginationSection; return { - content: [{ type: 'text', text: statsLine + delta.content + nodeRefsBlock + domPaginationSection }], + content: [{ type: 'text', text: compressedText }], + _compression: { + level: 'delta', + originalChars: outputText.length, + compressedChars: compressedText.length, + }, }; } // If not delta (too many changes), fall through to full response diff --git a/tests/metrics/tool-output-observability.test.ts b/tests/metrics/tool-output-observability.test.ts new file mode 100644 index 000000000..28a9cfec3 --- /dev/null +++ b/tests/metrics/tool-output-observability.test.ts @@ -0,0 +1,133 @@ +/// + +import { getMetricsCollector } from '../../src/metrics/collector'; +import { estimateOutputTokensFromChars, extractCacheStatus } from '../../src/mcp-server'; + +describe('tool output observability metrics', () => { + afterEach(() => { + jest.dontMock('../../src/session-manager'); + jest.dontMock('../../src/utils/ref-id-manager'); + jest.dontMock('../../src/dom'); + }); + + test('registers output size, estimated token, compression, and cache metrics', () => { + const m = getMetricsCollector(); + + m.observe('openchrome_tool_output_bytes', { tool: 'read_page' }, 1024); + m.observe('openchrome_tool_estimated_tokens', { tool: 'read_page' }, 256); + m.observe('openchrome_tool_compression_saved_bytes', { tool: 'read_page', mode: 'delta' }, 768); + m.inc('openchrome_cache_status_total', { tool: 'act', status: 'HIT', key_version: '2' }); + + const dump = m.export(); + expect(dump).toMatch(/# TYPE openchrome_tool_output_bytes histogram/); + expect(dump).toMatch(/# TYPE openchrome_tool_estimated_tokens histogram/); + expect(dump).toMatch(/# TYPE openchrome_tool_compression_saved_bytes histogram/); + expect(dump).toMatch(/# TYPE openchrome_cache_status_total counter/); + expect(dump).toContain('openchrome_cache_status_total{tool="act",status="HIT",key_version="2"}'); + }); + + test('metric label examples stay bounded and omit page-specific data', () => { + const m = getMetricsCollector(); + m.observe('openchrome_tool_output_bytes', { tool: 'extract_data', tenant: 'unknown' }, 512); + const dump = m.export(); + + expect(dump).toContain('tool="extract_data"'); + expect(dump).not.toContain('https://'); + expect(dump).not.toContain('selector='); + expect(dump).not.toContain('instruction='); + }); + + test('estimates tokens from characters rather than UTF-8 bytes', () => { + const output = '測試'.repeat(4); + + expect(Buffer.byteLength(output, 'utf8')).toBeGreaterThan(output.length); + expect(estimateOutputTokensFromChars(output.length)).toBe(2); + }); + + test('normalizes cache metric labels to bounded buckets', () => { + expect(extractCacheStatus({ + content: [], + cache: { + status: 'hit:user-123', + keyVersion: '2026-05-12T15:20:00.000Z-request-specific', + }, + })).toEqual({ + status: 'UNKNOWN', + keyVersion: 'other', + }); + + expect(extractCacheStatus({ + content: [], + structuredContent: { + cacheStatus: 'miss', + cacheKeyVersion: 'v2', + }, + })).toEqual({ + status: 'MISS', + keyVersion: 'v2', + }); + }); + + test('read_page delta responses expose real compression savings metadata', async () => { + jest.resetModules(); + const baseLines = Array.from({ length: 80 }, (_, i) => `

Stable copy ${i}

`); + const domSnapshots = [ + ['', '', '

Title

', ...baseLines, '', ''].join('\n'), + ['', '', '

Title

', ...baseLines, 'new', '', ''].join('\n'), + ]; + let callIndex = 0; + + jest.doMock('../../src/session-manager', () => ({ + getSessionManager: () => ({ + getPage: jest.fn().mockResolvedValue({}), + getAvailableTargets: jest.fn().mockResolvedValue([]), + getCDPClient: jest.fn().mockReturnValue({ send: jest.fn() }), + }), + })); + jest.doMock('../../src/utils/ref-id-manager', () => ({ + getRefIdManager: () => ({}), + })); + jest.doMock('../../src/dom', () => ({ + serializeDOM: jest.fn().mockImplementation(async () => ({ + content: domSnapshots[Math.min(callIndex++, domSnapshots.length - 1)], + pageStats: { + url: 'https://example.test/page', + title: 'Example', + scrollX: 0, + scrollY: 0, + viewportWidth: 800, + viewportHeight: 600, + scrollWidth: 800, + scrollHeight: 1200, + }, + })), + })); + + const { SnapshotStore } = await import('../../src/compression/snapshot-store'); + SnapshotStore.getInstance().clear(); + const { registerReadPageTool } = await import('../../src/tools/read-page'); + const tools = new Map Promise>(); + registerReadPageTool({ + registerTool: (name: string, handler: (...args: unknown[]) => Promise) => { + tools.set(name, handler); + }, + } as never); + const handler = tools.get('read_page')!; + + await handler('session-a', { + tabId: 'tab-a', + mode: 'dom', + compression: 'delta', + includePagination: false, + }); + const deltaResult = await handler('session-a', { + tabId: 'tab-a', + mode: 'dom', + compression: 'delta', + includePagination: false, + }) as { _compression?: { level?: string; originalChars?: number; compressedChars?: number } }; + + expect(deltaResult._compression).toMatchObject({ level: 'delta' }); + expect(deltaResult._compression?.originalChars).toBeGreaterThan(deltaResult._compression?.compressedChars ?? Infinity); + }); +}); diff --git a/tests/transports/http-bearer-auth.test.ts b/tests/transports/http-bearer-auth.test.ts index ad5fb52f7..a81e30055 100644 --- a/tests/transports/http-bearer-auth.test.ts +++ b/tests/transports/http-bearer-auth.test.ts @@ -10,7 +10,14 @@ import * as net from 'node:net'; // Inline require to avoid TS module resolution issues with dynamic transport loading const { HTTPTransport } = require('../../src/transports/http'); -const TEST_PORT = 19876; +const TEST_PORT_START = 20_000 + (process.pid % 400) * 100; +let nextTestPort = TEST_PORT_START; +let activePort = TEST_PORT_START; + +function allocatePort(): number { + activePort = nextTestPort++; + return activePort; +} const TEST_TOKEN = 'test-s...c123'; const TRUSTED_ORIGIN = 'http://127.0.0.1:5173'; @@ -22,7 +29,7 @@ function request( ): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> { return new Promise((resolve, reject) => { const req = http.request( - { hostname: '127.0.0.1', port: TEST_PORT, path, method, headers, timeout: 3000 }, + { hostname: '127.0.0.1', port: activePort, path, method, headers, timeout: 3000 }, (res) => { const chunks: Buffer[] = []; res.on('data', (c: Buffer) => chunks.push(c)); @@ -46,7 +53,7 @@ function rawRequest( raw: string, ): Promise<{ status: number; body: string; headers: Record }> { return new Promise((resolve, reject) => { - const socket = net.connect({ host: '127.0.0.1', port: TEST_PORT }); + const socket = net.connect({ host: '127.0.0.1', port: activePort }); let response = ''; socket.setTimeout(3000); @@ -119,7 +126,7 @@ describe('HTTP Bearer Token Auth', () => { describe('with auth token configured', () => { beforeEach(async () => { - transport = new HTTPTransport(TEST_PORT, '127.0.0.1', TEST_TOKEN); + transport = new HTTPTransport(allocatePort(), '127.0.0.1', TEST_TOKEN); await startTransport(transport); }); @@ -171,11 +178,11 @@ describe('HTTP Bearer Token Auth', () => { describe('unauthenticated HTTP policy', () => { it('fails closed by default when no auth is configured', () => { - expect(() => new HTTPTransport(TEST_PORT, '127.0.0.1')).toThrow(/Refusing to start unauthenticated HTTP transport/); + expect(() => new HTTPTransport(allocatePort(), '127.0.0.1')).toThrow(/Refusing to start unauthenticated HTTP transport/); }); it('allows explicit loopback-only development mode', async () => { - transport = new HTTPTransport(TEST_PORT, '127.0.0.1', undefined, { allowUnauthenticatedHttp: true }); + transport = new HTTPTransport(allocatePort(), '127.0.0.1', undefined, { allowUnauthenticatedHttp: true }); await startTransport(transport); const res = await request('/mcp', 'POST', { 'Content-Type': 'application/json' }, JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} })); @@ -184,14 +191,14 @@ describe('HTTP Bearer Token Auth', () => { it('allows explicit loopback development mode via env flag', async () => { process.env.OPENCHROME_ALLOW_UNAUTHENTICATED_HTTP = '1'; - transport = new HTTPTransport(TEST_PORT, '127.0.0.1'); + transport = new HTTPTransport(allocatePort(), '127.0.0.1'); await startTransport(transport); const res = await request('/health'); expect(res.status).toBe(200); }); it('refuses external bind without auth even with development opt-in', () => { - expect(() => new HTTPTransport(TEST_PORT, '0.0.0.0', undefined, { allowUnauthenticatedHttp: true })) + expect(() => new HTTPTransport(allocatePort(), '0.0.0.0', undefined, { allowUnauthenticatedHttp: true })) .toThrow(/non-loopback host/); }); }); @@ -199,7 +206,7 @@ describe('HTTP Bearer Token Auth', () => { describe('CORS allowlist', () => { beforeEach(async () => { process.env.OPENCHROME_HTTP_CORS_ORIGINS = TRUSTED_ORIGIN; - transport = new HTTPTransport(TEST_PORT, '127.0.0.1', undefined, { allowUnauthenticatedHttp: true }); + transport = new HTTPTransport(allocatePort(), '127.0.0.1', undefined, { allowUnauthenticatedHttp: true }); await startTransport(transport); }); @@ -225,14 +232,14 @@ describe('HTTP Bearer Token Auth', () => { }); it('accepts same-origin MCP preflight even when Origin is not in allowlist', async () => { - const res = await request('/mcp', 'OPTIONS', { Origin: `http://127.0.0.1:${TEST_PORT}` }); + const res = await request('/mcp', 'OPTIONS', { Origin: `http://127.0.0.1:${activePort}` }); expect(res.status).toBe(204); }); it('accepts same-origin MCP POST even when Origin is not in allowlist', async () => { const res = await request('/mcp', 'POST', { 'Content-Type': 'application/json', - Origin: `http://127.0.0.1:${TEST_PORT}`, + Origin: `http://127.0.0.1:${activePort}`, }, JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} })); expect(res.status).toBe(200); }); @@ -242,7 +249,7 @@ describe('HTTP Bearer Token Auth', () => { // same host:port is cross-origin per the CORS scheme/host/port tuple. const res = await request('/mcp', 'POST', { 'Content-Type': 'application/json', - Origin: `https://127.0.0.1:${TEST_PORT}`, + Origin: `https://127.0.0.1:${activePort}`, }, JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} })); expect(res.status).toBe(403); });