diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 5627d2b8..d1a7590f 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -477,9 +477,26 @@ function getTools() { pageId: { type: 'string' }, urlIncludes: { type: 'string' }, method: { type: 'string' }, + resourceType: { type: 'string' }, + urlPattern: { type: 'string' }, + urlRegex: { type: 'string' }, + headerMatchers: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + valueIncludes: { type: 'string' }, + valueRegex: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + priority: { type: 'integer' }, action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] }, statusCode: { type: 'integer', minimum: 100, maximum: 599 }, - headers: { + responseHeaders: { type: 'array', items: { type: 'object', @@ -832,19 +849,19 @@ function parseOptionalStatusCode(value) { return value; } -function parseOptionalHeaders(value, contentType) { +function parseOptionalResponseHeaders(value, contentType) { const headers = []; if (Array.isArray(value)) { for (const entry of value) { if (!entry || typeof entry !== 'object') { - throw new Error('headers entries must be objects with name and value'); + throw new Error('responseHeaders entries must be objects with name and value'); } - const name = requireNonEmptyString(entry.name, 'headers.name'); + const name = requireNonEmptyString(entry.name, 'responseHeaders.name'); const headerValue = String(entry.value ?? ''); headers.push({ name, value: headerValue }); } } else if (value !== undefined) { - throw new Error('headers must be an array'); + throw new Error('responseHeaders must be an array'); } if (contentType && !headers.some((header) => header.name.toLowerCase() === 'content-type')) { headers.push({ name: 'Content-Type', value: contentType }); @@ -880,6 +897,89 @@ function parseOptionalUrlIncludes(value) { return requireNonEmptyString(value, 'urlIncludes'); } +function parseOptionalResourceType(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'resourceType'); +} + +function parseOptionalUrlPattern(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlPattern'); +} + +function parseOptionalUrlRegex(value) { + if (value === undefined) { + return ''; + } + const raw = requireNonEmptyString(value, 'urlRegex'); + try { + new RegExp(raw); + } catch { + throw new Error('urlRegex must be a valid regular expression'); + } + return raw; +} + +function parseOptionalPriority(value) { + if (value === undefined) { + return 0; + } + if (!Number.isInteger(value)) { + throw new Error('priority must be an integer'); + } + return value; +} + +function parseOptionalHeaderMatchers(value) { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error('headerMatchers must be an array'); + } + return value.map((entry) => { + if (!entry || typeof entry !== 'object') { + throw new Error('headerMatchers entries must be objects'); + } + const name = requireNonEmptyString(entry.name, 'headerMatchers.name'); + const valueIncludes = + entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes'); + const valueRegex = + entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex'); + if (!valueIncludes && !valueRegex) { + throw new Error('headerMatchers entry must include valueIncludes or valueRegex'); + } + if (valueRegex) { + try { + new RegExp(valueRegex); + } catch { + throw new Error('headerMatchers.valueRegex must be a valid regular expression'); + } + } + return { name, valueIncludes, valueRegex }; + }); +} + +function validateInterceptMatcherSet({ + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, +}) { + if (urlPattern && urlRegex) { + throw new Error('urlPattern and urlRegex cannot be used together'); + } + if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) { + throw new Error('at least one matching condition is required'); + } +} + function createInterceptRuleId() { return `rule-${nextInterceptRuleCounter++}`; } @@ -920,6 +1020,11 @@ function formatInterceptRules(rules) { `pageTitle: ${rule.pageTitleSnapshot || ''}`, `urlIncludes: ${rule.urlIncludes || ''}`, `method: ${rule.method || ''}`, + `resourceType: ${rule.resourceType || ''}`, + `urlPattern: ${rule.urlPattern || ''}`, + `urlRegex: ${rule.urlRegex || ''}`, + `headerMatchers: ${Array.isArray(rule.headerMatchers) ? rule.headerMatchers.length : 0}`, + `priority: ${Number.isInteger(rule.priority) ? rule.priority : 0}`, `action: ${rule.action}`, `statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`, `contentType: ${rule.action === 'fulfill' ? rule.contentType || '' : 'n/a'}`, @@ -2519,11 +2624,69 @@ function getRulesForPage(pageId) { return interceptRules.filter((rule) => rule.pageId === pageId); } +function getRulesForMatching(pageId) { + return getRulesForPage(pageId) + .slice() + .sort((left, right) => right.priority - left.priority); +} + +function matchesUrlPattern(pattern, url) { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); + return new RegExp(`^${escaped}$`).test(url); +} + +function normalizePausedRequestHeaders(paused) { + const normalized = new Map(); + const headers = paused.request?.headers; + if (!headers || typeof headers !== 'object') { + return normalized; + } + for (const [name, value] of Object.entries(headers)) { + normalized.set(name.toLowerCase(), String(value)); + } + return normalized; +} + +function matchesHeaderMatchers(headerMatchers, paused) { + if (!headerMatchers.length) { + return true; + } + const normalizedHeaders = normalizePausedRequestHeaders(paused); + return headerMatchers.every((matcher) => { + const headerValue = normalizedHeaders.get(matcher.name.toLowerCase()); + if (headerValue === undefined) { + return false; + } + if (matcher.valueIncludes && !headerValue.includes(matcher.valueIncludes)) { + return false; + } + if (matcher.valueRegex && !new RegExp(matcher.valueRegex).test(headerValue)) { + return false; + } + return true; + }); +} + function matchesInterceptRule(rule, paused) { - if (rule.method && rule.method !== String(paused.request?.method || '').toUpperCase()) { + const requestMethod = String(paused.request?.method || '').toUpperCase(); + const requestUrl = String(paused.request?.url || ''); + const requestResourceType = String(paused.resourceType || ''); + if (rule.method && rule.method !== requestMethod) { return false; } - if (rule.urlIncludes && !String(paused.request?.url || '').includes(rule.urlIncludes)) { + if (rule.urlIncludes && !requestUrl.includes(rule.urlIncludes)) { + return false; + } + if (rule.urlPattern && !matchesUrlPattern(rule.urlPattern, requestUrl)) { + return false; + } + if (rule.urlRegex && !new RegExp(rule.urlRegex).test(requestUrl)) { + return false; + } + if (rule.resourceType && rule.resourceType.toLowerCase() !== requestResourceType.toLowerCase()) { + return false; + } + if (!matchesHeaderMatchers(rule.headerMatchers || [], paused)) { return false; } return true; @@ -2626,7 +2789,7 @@ async function ensureInterceptSession(page) { return; } const paused = message.params || {}; - const matchedRule = getRulesForPage(page.id).find((rule) => matchesInterceptRule(rule, paused)); + const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused)); const action = matchedRule ? matchedRule.action : 'continue'; if (action === 'fail') { ws.send( @@ -2644,7 +2807,7 @@ async function ensureInterceptSession(page) { params: { requestId: paused.requestId, responseCode: matchedRule.statusCode, - responseHeaders: matchedRule.headers, + responseHeaders: matchedRule.responseHeaders, body: encodeFulfillBody(matchedRule.body), }, }) @@ -2772,25 +2935,40 @@ async function handleAddInterceptRule(toolArgs) { } const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes); const method = parseOptionalMethod(toolArgs.method); + const resourceType = parseOptionalResourceType(toolArgs.resourceType); + const urlPattern = parseOptionalUrlPattern(toolArgs.urlPattern); + const urlRegex = parseOptionalUrlRegex(toolArgs.urlRegex); + const headerMatchers = parseOptionalHeaderMatchers(toolArgs.headerMatchers); + const priority = parseOptionalPriority(toolArgs.priority); const action = parseInterceptAction(toolArgs.action); - if (!urlIncludes && !method) { - throw new Error('urlIncludes or method is required'); - } + validateInterceptMatcherSet({ + urlIncludes, + method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, + }); const statusCode = parseOptionalStatusCode(toolArgs.statusCode); const contentType = toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); const body = parseOptionalBody(toolArgs.body); - const headers = parseOptionalHeaders(toolArgs.headers, contentType); + const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType); const rule = { ruleId: createInterceptRuleId(), pageId: page.id, pageTitleSnapshot: page.title || '', urlIncludes, method, + resourceType, + urlPattern, + urlRegex, + headerMatchers, + priority, action, statusCode: action === 'fulfill' ? statusCode : 0, contentType: action === 'fulfill' ? contentType : '', - headers: action === 'fulfill' ? headers : [], + responseHeaders: action === 'fulfill' ? responseHeaders : [], body: action === 'fulfill' ? body : '', createdAt: new Date().toISOString(), };