diff --git a/README.md b/README.md index ce550cd..9c356a2 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ To use Docker from your MCP client config (e.g., Cursor or Claude Desktop), repl - **`HUMAN_API_HOST`**: Use a different API endpoint (default: `api.humansecurity.com`) - **`HUMAN_API_VERSION`**: Specify API version (default: `v1`) - **`HTTP_TIMEOUT_MS`**: Request timeout in milliseconds (default: `30000`) +- **`HUMAN_TRAFFIC_API_BASE`**: Override the base URL for traffic data endpoints. Useful for local development against a pxPortal instance (e.g. `http://localhost:3000/api/v1/botDefender/traffic`). When not set, defaults to the standard HUMAN API base. ## ๐Ÿ’ก Usage Examples @@ -108,7 +109,7 @@ To use Docker from your MCP client config (e.g., Cursor or Claude Desktop), repl ## ๐Ÿ“Š Available Tools ### Cyberfraud Protection -- **Traffic Data**: Comprehensive traffic analytics with security metrics +- **Traffic Data**: Comprehensive traffic analytics with overtime time-series, aggregated metrics, and ranked tops breakdowns - **Attack Reporting (Overtime)**: Time-series attack analytics and trend analysis - **Attack Reporting (Overview)**: Detailed attack cluster intelligence and forensics - **Account Information**: Individual account security analysis and incident tracking @@ -150,6 +151,46 @@ If you only need one service, you can configure just that token: } ``` +## ๐Ÿงช Local Development & Testing + +### Running against a local pxPortal instance + +To test the MCP server against a locally running pxPortal (default port `3000`), set `HUMAN_TRAFFIC_API_BASE` to override the traffic data endpoint: + +```json +{ + "mcpServers": { + "human-security": { + "command": "node", + "args": ["/path/to/human-mcp-server/dist/index.cjs"], + "env": { + "HUMAN_CYBERFRAUD_API_TOKEN": "your-token", + "HUMAN_TRAFFIC_API_BASE": "http://localhost:3000/api/v1/botDefender/traffic" + } + } + } +} +``` + +### End-to-end test script + +`scripts/test_local.mjs` spawns the MCP server and runs 25 scenarios against a live backend, covering all modes (`overtime`, `metrics`, `tops`), filters, combined calls, time ranges, and tops field coverage. + +```bash +# Build first +npm run build + +# Run all scenarios against localhost:3000 +HUMAN_CYBERFRAUD_API_TOKEN= node scripts/test_local.mjs + +# Run against a custom backend +HUMAN_CYBERFRAUD_API_TOKEN= \ +HUMAN_TRAFFIC_API_BASE=http://my-host/api/v1/botDefender/traffic \ +node scripts/test_local.mjs +``` + +Expected output: `RESULTS: 25 passed, 0 failed`. + ## ๐Ÿ†˜ Support - **Documentation**: [HUMAN Security Documentation](https://docs.humansecurity.com) diff --git a/scripts/test_local.mjs b/scripts/test_local.mjs new file mode 100644 index 0000000..9fe694b --- /dev/null +++ b/scripts/test_local.mjs @@ -0,0 +1,250 @@ +/** + * End-to-end test script: spawns the MCP server and exercises the + * human_get_traffic_data tool against localhost:3000. + * + * Usage: + * node scripts/test_local.mjs + */ + +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const distPath = path.resolve(__dirname, '../dist/index.cjs'); + +const TOKEN = process.env.HUMAN_CYBERFRAUD_API_TOKEN; +if (!TOKEN) { + console.error('Error: HUMAN_CYBERFRAUD_API_TOKEN env var is required.'); + console.error(' Usage: HUMAN_CYBERFRAUD_API_TOKEN= node scripts/test_local.mjs'); + process.exit(1); +} +const TRAFFIC_BASE = + process.env.HUMAN_TRAFFIC_API_BASE ?? 'http://localhost:3000/api/v1/botDefender/traffic'; + +// โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +let _id = 1; +const nextId = () => _id++; + +let PASS = 0, FAIL = 0; + +function summarise(label, result) { + const d = result?.result?.structuredContent?.data; + const err = result?.result?.structuredContent?.error; + const isError = result?.result?.isError; + + if (isError || err) { + console.log(` โŒ FAIL ${label}`); + console.log(` error: ${err || JSON.stringify(result?.result)}`); + FAIL++; + return; + } + + const parts = []; + if (d?.metrics?.results) { + const r = d.metrics.results; + parts.push(`metrics: total=${r.total ?? '?'} blocked=${r.blocked ?? '?'} legitimate=${r.legitimate ?? '?'}`); + } + if (d?.overtime?.results) { + parts.push(`overtime: ${d.overtime.results.length} intervals, first=${d.overtime.results[0]?.timestamp}`); + } + if (d?.tops) { + for (const [field, rows] of Object.entries(d.tops)) { + parts.push(`tops.${field}: top="${rows[0]?.value}" (${rows[0]?.count}), ${rows.length} rows`); + } + } + console.log(` โœ… PASS ${label}`); + for (const p of parts) console.log(` ${p}`); + PASS++; +} + +function send(proc, msg) { + proc.stdin.write(JSON.stringify(msg) + '\n'); +} + +async function runTests() { + const proc = spawn('node', [distPath], { + env: { + ...process.env, + HUMAN_CYBERFRAUD_API_TOKEN: TOKEN, + HUMAN_TRAFFIC_API_BASE: TRAFFIC_BASE, + }, + stdio: ['pipe', 'pipe', 'inherit'], + }); + + const pending = []; + const queue = []; + let buf = ''; + + proc.stdout.on('data', (chunk) => { + buf += chunk.toString(); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let parsed; + try { parsed = JSON.parse(line); } catch { continue; } + if (pending.length > 0) pending.shift()(parsed); + else queue.push(parsed); + } + }); + + const waitForResponse = () => + new Promise((resolve) => { + if (queue.length > 0) { resolve(queue.shift()); return; } + pending.push(resolve); + }); + + const call = async (label, args) => { + send(proc, { jsonrpc: '2.0', id: nextId(), method: 'tools/call', + params: { name: 'human_get_traffic_data', arguments: args } }); + const res = await waitForResponse(); + summarise(label, res); + return res; + }; + + // โ”€โ”€ Initialize โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + send(proc, { jsonrpc: '2.0', id: nextId(), method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, + clientInfo: { name: 'test-script', version: '0.0.1' } } }); + await waitForResponse(); + send(proc, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); + + const now = Math.floor(Date.now() / 1000); + const t = (offsetSec) => new Date((now + offsetSec) * 1000).toISOString(); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' BASIC MODES'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('metrics only โ€” last 1h', { + startTime: t(-3600), endTime: t(0), metrics: true }); + + await call('overtime only โ€” last 30min', { + startTime: t(-1800), endTime: t(0), overtime: true }); + + await call('tops/incidentTypes only โ€” last 3h', { + startTime: t(-10800), endTime: t(0), tops: ['incidentTypes'] }); + + await call('tops/path only โ€” last 3h, limit 5', { + startTime: t(-10800), endTime: t(0), tops: ['path'], limit: 5 }); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' FILTERS'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('metrics โ€” blocked traffic only (trafficTags filter)', { + startTime: t(-3600), endTime: t(0), metrics: true, + filters: { trafficTags: ['blocked', 'potentialBlock'] } }); + + await call('metrics โ€” login pages only (pageType filter)', { + startTime: t(-3600), endTime: t(0), metrics: true, + filters: { pageType: ['login', 'login_attempt'] } }); + + await call('overtime โ€” goodKnownBots only', { + startTime: t(-3600), endTime: t(0), overtime: true, + filters: { trafficTags: ['goodKnownBots'] } }); + + await call('tops/country โ€” blocked traffic only', { + startTime: t(-10800), endTime: t(0), tops: ['country'], + filters: { trafficTags: ['blocked'] }, limit: 10 }); + + await call('tops/socketIpOrgName โ€” blocked+blacklist, limit 5', { + startTime: t(-10800), endTime: t(0), tops: ['socketIpOrgName'], + filters: { trafficTags: ['blocked', 'blacklist', 'potentialBlock'] }, limit: 5 }); + + await call('tops/knownBot โ€” goodKnownBots only, limit 10', { + startTime: t(-10800), endTime: t(0), tops: ['knownBot'], + filters: { trafficTags: ['goodKnownBots'] }, limit: 10 }); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' PLATFORM'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('metrics โ€” web only', { + startTime: t(-3600), endTime: t(0), metrics: true, + trafficSource: ['web'] }); + + await call('metrics โ€” mobile only', { + startTime: t(-3600), endTime: t(0), metrics: true, + trafficSource: ['mobile'] }); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' OVERTIME seriesFields'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('overtime โ€” split by knownBot', { + startTime: t(-3600), endTime: t(0), overtime: true, + filters: { trafficTags: ['goodKnownBots'] }, + seriesFields: ['knownBot'] }); + + await call('overtime โ€” split by customRule', { + startTime: t(-3600), endTime: t(0), overtime: true, + seriesFields: ['customRule'] }); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' COMBINED CALLS (multiple modes in one)'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('metrics + tops/incidentTypes (executive security summary)', { + startTime: t(-10800), endTime: t(0), + metrics: true, + tops: ['incidentTypes'] }); + + await call('overtime + metrics (dashboard: KPIs + chart)', { + startTime: t(-3600), endTime: t(0), + overtime: true, metrics: true }); + + await call('metrics + tops/path + tops/country (full breakdown)', { + startTime: t(-10800), endTime: t(0), + metrics: true, + tops: ['path', 'country'], + filters: { trafficTags: ['blocked'] }, limit: 5 }); + + await call('overtime + tops/socketIpOrgName โ€” blocked focus', { + startTime: t(-3600), endTime: t(0), + overtime: true, + tops: ['socketIpOrgName'], + filters: { trafficTags: ['blocked', 'potentialBlock'] }, limit: 5 }); + + await call('metrics + overtime + tops/incidentTypes + tops/knownBot (full dashboard)', { + startTime: t(-10800), endTime: t(0), + metrics: true, overtime: true, + tops: ['incidentTypes', 'knownBot'], + trafficSource: ['web', 'mobile'] }); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' TIME RANGES'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('metrics โ€” last 15 minutes', { + startTime: t(-900), endTime: t(0), metrics: true }); + + await call('metrics โ€” last 6 hours', { + startTime: t(-21600), endTime: t(0), metrics: true }); + + await call('overtime โ€” last 2 hours', { + startTime: t(-7200), endTime: t(0), overtime: true }); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(' TOPS FIELDS COVERAGE'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + await call('tops/domain', { + startTime: t(-10800), endTime: t(0), tops: ['domain'], limit: 5 }); + + await call('tops/uaServer (user agent)', { + startTime: t(-10800), endTime: t(0), tops: ['uaServer'], limit: 5 }); + + await call('tops/customRule', { + startTime: t(-10800), endTime: t(0), tops: ['customRule'], limit: 5 }); + + proc.kill(); + + console.log('\nโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(` RESULTS: ${PASS} passed, ${FAIL} failed`); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); +} + +runTests().catch((err) => { console.error('Test failed:', err); process.exit(1); }); diff --git a/src/services/cyberfraudService.ts b/src/services/cyberfraudService.ts index 28104d1..cecdb62 100644 --- a/src/services/cyberfraudService.ts +++ b/src/services/cyberfraudService.ts @@ -1,6 +1,6 @@ import type { HttpClient } from '../utils/httpClient'; import { clampAttackReportingTimes } from '../utils/dateUtils'; -import { HUMAN_API_BASE } from '../utils/constants'; +import { HUMAN_API_BASE, HUMAN_TRAFFIC_API_BASE } from '../utils/constants'; import type { CyberfraudOvertimeParams, CyberfraudOverviewParams, @@ -14,6 +14,13 @@ import type { } from '../types/cyberfraud'; const API_BASE = `${HUMAN_API_BASE}/cyberfraud`; +const TRAFFIC_DATA_BASE = HUMAN_TRAFFIC_API_BASE; + +interface ApiEnvelope { + result: boolean; + message?: string; + content: T; +} function buildAttackReportingUrl(endpoint: string, params: Record) { const queryParams = new URLSearchParams(); @@ -27,43 +34,30 @@ function buildAttackReportingUrl(endpoint: string, params: Record) return `${API_BASE}/attack-reporting${endpoint}?${queryParams.toString()}`; } -function buildTrafficDataQuery(params: { - from: number; - to: number; - appId?: string[]; - source?: string[]; - overtime?: string[]; - tops?: string[]; - traffic?: string[]; - pageType?: string[]; - count?: string[]; - withoutTotals?: boolean; - metricsEnrichment?: any; -}) { +function buildTrafficDataUrl(endpoint: string, from: number, to: number) { const query = new URLSearchParams(); - query.append('from', params.from.toString()); - query.append('to', params.to.toString()); - const sources = params.source && params.source.length > 0 ? params.source : ['web', 'mobile']; - sources.forEach((s) => query.append('source[]', s)); - if (params.appId) params.appId.forEach((id) => query.append('appId[]', id)); - const allOvertime = [ - 'legitimate', - 'blocked', - 'potentialBlock', - 'whitelist', - 'blacklist', - 'goodKnownBots', - 'captchaSolved', - ]; - const overtime = params.overtime && params.overtime.length > 0 ? params.overtime : allOvertime; - overtime.forEach((o) => query.append('overtime[]', o)); - if (params.tops) params.tops.forEach((t) => query.append('tops[]', t)); - if (params.traffic) params.traffic.forEach((t) => query.append('traffic[]', t)); - if (params.pageType) params.pageType.forEach((p) => query.append('pageType[]', p)); - if (params.count) params.count.forEach((c) => query.append('count[]', c)); - if (params.withoutTotals) query.append('withoutTotals', 'true'); - if (params.metricsEnrichment) query.append('metricsEnrichment', JSON.stringify(params.metricsEnrichment)); - return `${API_BASE}/traffic-data?${query.toString()}`; + query.append('from', from.toString()); + query.append('to', to.toString()); + return `${TRAFFIC_DATA_BASE}${endpoint}?${query.toString()}`; +} + +function buildBaseBody(params: TrafficDataInput) { + const body: Record = { + trafficSource: + params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], + }; + if (params.filters) { + body.filters = params.filters; + } + return body; +} + +async function parseApiResponse(res: { json: () => Promise }): Promise { + const json = (await res.json()) as ApiEnvelope; + if (!json.result) { + throw new Error(json.message || 'API request failed'); + } + return json.content; } export class CyberfraudService { @@ -102,12 +96,67 @@ export class CyberfraudService { } async getTrafficData(params: TrafficDataInput): Promise { - const { startTime, endTime, ...rest } = params; + const { startTime, endTime, overtime, metrics, tops, seriesFields, limit, includeNulls } = params; const clamped = clampAttackReportingTimes(startTime, endTime); const from = Math.floor(new Date(clamped.startTime).getTime() / 1000); const to = Math.floor(new Date(clamped.endTime).getTime() / 1000); - const url = buildTrafficDataQuery({ from, to, ...rest }); - const res = await this.http.request(url); - return (await res.json()) as TrafficDataResponse; + + type RequestTask = { kind: 'overtime' } | { kind: 'metrics' } | { kind: 'tops'; field: string }; + + const tasks: RequestTask[] = []; + if (overtime) tasks.push({ kind: 'overtime' }); + if (metrics) tasks.push({ kind: 'metrics' }); + if (tops) { + for (const field of tops) { + tasks.push({ kind: 'tops', field }); + } + } + + const results = await Promise.all( + tasks.map(async (task) => { + if (task.kind === 'overtime') { + const body = { ...buildBaseBody(params) }; + if (seriesFields && seriesFields.length > 0) { + body.seriesFields = seriesFields; + } + const url = buildTrafficDataUrl('/overtime', from, to); + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse>(res); + return { kind: 'overtime' as const, content }; + } + + if (task.kind === 'metrics') { + const body = buildBaseBody(params); + const url = buildTrafficDataUrl('/metrics', from, to); + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse>(res); + return { kind: 'metrics' as const, content }; + } + + const body: Record = { ...buildBaseBody(params) }; + if (limit !== undefined) body.limit = limit; + if (includeNulls !== undefined) body.includeNulls = includeNulls; + + const url = buildTrafficDataUrl(`/tops/${task.field}`, from, to); + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse<{ results: Array<{ value: string; count: number }> }>(res); + return { kind: 'tops' as const, field: task.field, content }; + }), + ); + + const response: TrafficDataResponse = {}; + + for (const result of results) { + if (result.kind === 'overtime') { + response.overtime = result.content; + } else if (result.kind === 'metrics') { + response.metrics = result.content; + } else { + if (!response.tops) response.tops = {}; + response.tops[result.field] = result.content.results; + } + } + + return response; } } diff --git a/src/tools/getTrafficData.ts b/src/tools/getTrafficData.ts index 7f9c18a..acfed2c 100644 --- a/src/tools/getTrafficData.ts +++ b/src/tools/getTrafficData.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { TrafficDataInputSchema, TrafficDataInput, TrafficDataOutputSchema } from '../types/cyberfraud'; +import { TrafficDataInputBaseSchema, TrafficDataInput, TrafficDataOutputSchema } from '../types/cyberfraud'; import { mcpToolHandler } from '../utils/mcpToolHandler'; import { makeStructuredResponseSchema } from '../utils/makeStructuredResponseSchema'; import type { CyberfraudService } from '../services/cyberfraudService'; @@ -9,73 +9,50 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe server.registerTool( 'human_get_traffic_data', { - description: `Fetches comprehensive traffic analytics from HUMAN Security's Cyberfraud API. This powerful tool provides detailed insights into web and mobile traffic patterns, security metrics, and user behavior across your applications. + description: `Fetches traffic analytics from HUMAN Security's Cyberfraud Traffic Dashboard API. One tool call can request one or more views; the MCP decides which underlying endpoints to call and merges the results. ๐ŸŽฏ QUICK DECISION GUIDE: -โ”œโ”€โ”€ Need high-level totals without granular breakdowns? โ†’ Use "count" -โ”œโ”€โ”€ Need trends/charts? โ†’ Use "overtime" -โ”œโ”€โ”€ Need attack types? โ†’ Add "tops": ["incidents"] -โ”œโ”€โ”€ Need URL path information? โ†’ Add "tops": ["path"] -โ”œโ”€โ”€ Security focus only? โ†’ Add "traffic": ["blocked"] -โ””โ”€โ”€ Multi-platform analysis? โ†’ Include "source": ["web", "mobile"] +โ”œโ”€โ”€ Need trend charts / attack timeline? โ†’ overtime: true +โ”œโ”€โ”€ Need high-level totals / KPIs? โ†’ metrics: true +โ”œโ”€โ”€ Need attack types? โ†’ tops: ["incidentTypes"] +โ”œโ”€โ”€ Need URL path breakdown? โ†’ tops: ["path"] +โ”œโ”€โ”€ Need top blocked IP orgs? โ†’ tops: ["socketIpOrgName"] +โ”œโ”€โ”€ Security focus only? โ†’ filters: { trafficTags: ["blocked", "potentialBlock"] } +โ””โ”€โ”€ Multi-platform analysis? โ†’ trafficSource: ["web", "mobile"] -โŒ CRITICAL RULES: -โ€ข NEVER combine "count" + "overtime" (mutually exclusive) -โ€ข NEVER combine "overtime" + "tops" (creates misleading aggregation, see AGGREGATION WARNING below) -โ€ข "tops" completely transforms response structure -โ€ข "count" returns only high-level totals, no granularity -โ€ข Filters stack multiplicatively (can reduce data 87%+) +โœ… REQUEST MODES (combinable in one call): +โ€ข overtime: Per-minute time-series counts. Use seriesFields to break out by knownBot, customRule, or accessTokenName. +โ€ข metrics: Aggregated totals for the full time range (total, blocked, legitimate, goodKnownBots, etc.). +โ€ข tops: Top values for one or more fields, ranked by request count. Each field triggers a separate API call; results are merged keyed by field name. -๐Ÿšจ AGGREGATION WARNING - IMPORTANT API BEHAVIOR: -When combining "overtime" + "tops" parameters, the API exhibits unexpected aggregation: -โ€ข First interval: Contains ALL historical data aggregated (total counts) -โ€ข Subsequent intervals: All show 0 counts (creating false impression of no recent activity) -โ€ข Result: Appears as time-series but is actually front-loaded aggregate data - -CORRECT USAGE: -โ€ข "startTime" and "endTime" should be in ISO 8601 format, e.g. "${DATE_FORMAT_EXAMPLE_START}". -โ€ข Path analysis: Use "tops": ["path"] for path breakdowns (no "count") -โ€ข Time-series: Use "overtime" without "tops" -โ€ข Attack types: Use "count" + "tops": ["incidents"] for totals +๐Ÿ”ง FILTERS (optional, stack multiplicatively): +โ€ข filters.trafficTags: Security classification (blocked, legitimate, goodKnownBots, etc.) +โ€ข filters.pageType: User journey (login, checkout, carding_attempt, etc.) +โ€ข filters.browserFamily / osFamily / country: Device and geo filters โœ… HIGH-VALUE PATTERNS: -1. EXECUTIVE DASHBOARD: - {"count": ["legitimate", "blocked"]} - โ†’ Simple traffic health metrics without breakdowns - -2. PATH ANALYSIS: - {"tops": ["path"]} - โ†’ Most trafficked endpoints with accurate totals - -3. ATTACK TYPE ANALYSIS: - {"tops": ["incidents"]} - โ†’ Attack type breakdown with accurate totals +1. EXECUTIVE TRAFFIC SUMMARY: + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "metrics": true} -4. SECURITY TIMELINE: - {"overtime": ["blocked"]} - โ†’ Pure attack volume trends over time +2. ATTACK TIMELINE: + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "overtime": true, "filters": {"trafficTags": ["blocked", "potentialBlock"]}} -5. FOCUSED ANALYSIS: - {"count": ["blocked"], "pageType": ["login"], "source": ["web"]} - โ†’ Login-specific web security +3. ATTACK TYPE BREAKDOWN: + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "tops": ["incidentTypes"]} -โš ๏ธ ENVIRONMENT NOTES: -โ€ข Mobile traffic may be minimal/absent in some environments -โ€ข Page type filters dramatically reduce scope -โ€ข Incident classification reveals valuable attack taxonomy -โ€ข Time-series uses ~20-minute intervals +4. PATH ANALYSIS: + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "tops": ["path"]} -๐Ÿ”ง PARAMETER BEHAVIOR: -โ€ข "count": Aggregate totals across time range, should be used only when only total numbers are needed as the response will not include more granular breakdowns -โ€ข "overtime": Time-series with intervals (do NOT combine with "tops") -โ€ข "tops": Transforms aggregates to breakdowns (use with "count" only) -โ€ข "traffic": Security-only filter (excludes legitimate) -โ€ข "pageType": Journey-specific filter (very restrictive) -โ€ข "source": Platform filter (web/mobile) +5. FULL SECURITY INVESTIGATION (combined): + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "metrics": true, "overtime": true, "tops": ["incidentTypes", "socketIpOrgName"], "filters": {"trafficTags": ["blocked"]}} -Response provides structured data optimized for security dashboards, threat analysis, and executive reporting with quantifiable metrics and actionable intelligence.`, - inputSchema: TrafficDataInputSchema.shape, +โš ๏ธ NOTES: +โ€ข At least one of overtime, metrics, or tops must be specified. +โ€ข trafficSource defaults to ["web", "mobile"] when omitted. +โ€ข app_id is derived from the API token โ€” do not pass appIds. +โ€ข tops limit defaults to 10 (max 100). Use includeNulls to include null-valued rows.`, + inputSchema: TrafficDataInputBaseSchema.shape, outputSchema: makeStructuredResponseSchema(TrafficDataOutputSchema).shape, annotations: { title: 'HUMAN Get Traffic Data', diff --git a/src/types/cyberfraud/trafficData.ts b/src/types/cyberfraud/trafficData.ts index e98c146..c9dec62 100644 --- a/src/types/cyberfraud/trafficData.ts +++ b/src/types/cyberfraud/trafficData.ts @@ -1,201 +1,145 @@ import { z } from 'zod'; import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../../utils/constants'; -export const TrafficDataSourceEnum = z.enum(['web', 'mobile'], { - description: - 'Platform filter: ["web"], ["mobile"], or ["web","mobile"]. NOTE: Mobile traffic may be minimal/absent in some environments (observed 99.999% web dominance). Use both for complete coverage, individual for platform-specific analysis.', -}); -export const TrafficDataOvertimeEnum = z.enum( - ['legitimate', 'blocked', 'potentialBlock', 'whitelist', 'blacklist', 'goodKnownBots', 'captchaSolved'], - { - description: - 'TIME-SERIES ANALYSIS: Returns intervals with ~20min timestamps for trend visualization. โŒ MUTUALLY EXCLUSIVE with "count" parameter. ๐Ÿšจ CRITICAL: DO NOT combine with "tops" - causes misleading aggregation with all data front-loaded into first interval. โœ… BEST FOR: Attack timelines, trend charts, pattern detection. EXAMPLE: {"overtime": ["blocked"]} โ†’ Attack volume over time. COMBINE WITH: filters for focus, but NEVER with "tops".', - }, -); -export const TrafficDataTopsEnum = z.enum(['incidents', 'path'], { - description: - '๐Ÿ”„ RESPONSE TRANSFORMER: Completely changes response structure from aggregates to detailed breakdowns. ๐Ÿšจ CRITICAL: DO NOT combine with "overtime" - causes misleading aggregation where all historical data appears in first interval with zeros after. โš ๏ธ CRITICAL INSIGHTS: "incidents" reveals attack classification (Bot Behavior, Spoof, Bad Reputation, etc.), "path" shows URL-specific targeting. WITHOUT tops: Aggregate totals. WITH tops: Individual breakdowns per category. โœ… SAFE USAGE: Combine with "count" only.', -}); -export const TrafficDataTrafficEnum = z.enum(['blocked', 'blacklist', 'potentialBlock'], { - description: - 'SECURITY-ONLY FILTER: Shows EXCLUSIVELY blocked/suspicious traffic. COMPLEMENTS count/overtime metrics, does NOT replace them. โœ… USE CASE: Pure security analysis, threat-focused reporting. EXCLUDES: All legitimate traffic. COMBINE WITH: Any count/overtime metrics for security-centric view.', -}); -export const TrafficDataPageTypeEnum = z.enum( - [ - 'login', - 'login_attempt', - 'checkout', - 'purchase', - 'purchase_request', - 'productsAndSearch', - 'research', - 'apiCall', - 'resource', - 'mobileUserAgents', - ], - { - description: - 'PAGE TYPE FILTER: Focuses analysis on specific user journeys. โš ๏ธ SCOPE WARNING: Very restrictive (observed 87% data reduction). USE CASES: Login security analysis, checkout protection, API endpoint monitoring. COMBINE WITH: Security metrics for targeted threat analysis.', - }, -); -export const TrafficDataCountEnum = z.enum( - [ - 'legitimate', - 'potentialBlock', - 'blocked', - 'whitelist', - 'blacklist', - 'goodKnownBots', - 'captchaSolved', - 'mobile', - 'web', - ], - { - description: - 'AGGREGATE ANALYSIS: Returns total counts across entire time range. โŒ MUTUALLY EXCLUSIVE with "overtime" parameter. โš ๏ธ LIMITATION: Will NOT return path breakdowns even with tops=["path"] - returns aggregate totals only. โœ… BEST FOR: Dashboards, KPIs, executive summaries. EXAMPLE: {"count": ["legitimate", "blocked"]} โ†’ Simple totals.', - }, -); +export const TrafficDataSourceEnum = z.enum(['web', 'mobile']); + +export const TrafficDataTrafficTagsEnum = z.enum([ + 'legitimate', + 'blocked', + 'potentialBlock', + 'goodKnownBots', + 'whitelist', + 'blacklist', + 'compromisedLogin', + 'failedLogin', + 'successfulLogin', + 'successfulCompromised', + 'legitimateCompromised', + 'blockedCompromised', + 'captchaSolved', +]); + +export const TrafficDataPageTypeEnum = z.enum([ + 'login', + 'login_attempt', + 'checkout', + 'carding_attempt', + 'purchase', + 'purchase_request', + 'productsAndSearch', + 'research', + 'resource', + 'apiCall', + 'mobileUserAgents', +]); + +export const TrafficDataSeriesFieldsEnum = z.enum(['accessTokenName', 'customRule', 'knownBot']); -export const TrafficDataMetricsEnrichmentSchema = z +export const TrafficDataTopsFieldEnum = z.enum([ + 'socketIp', + 'domain', + 'headerReferer', + 'incidentTypes', + 'socketIpOrgName', + 'path', + 'knownBot', + 'uaServer', + 'providerCloud', + 'providerProxy', + 'providerVpn', + 'providerClassification', + 'country', + 'customRule', + 'accessTokenName', + 'customParam1', + 'customParam2', + 'customParam3', + 'customParam4', + 'customParam5', + 'customParam6', + 'customParam7', + 'customParam8', + 'customParam9', + 'utmSource', + 'utmMedium', + 'utmCampaign', + 'utmTerm', + 'graphqlOperationName', +]); + +export const TrafficDataFiltersSchema = z .object({ - accountName: z - .string() - .optional() - .describe( - '๐Ÿข ACCOUNT CONTEXT: Human-readable account name for dashboard labeling and report generation. ๐Ÿ’ก USE CASES: Multi-tenant environments, executive reporting, audit trails. ๐Ÿ“Š ENRICHMENT: Adds business context without affecting core metrics.', - ), - widgetTitle: z - .string() - .optional() - .describe( - '๐Ÿ“Š DASHBOARD LABELING: Custom title for UI widgets and chart displays. ๐Ÿ’ก USE CASES: Custom dashboards, executive summaries, operational monitoring. ๐ŸŽฏ EXAMPLES: "Login Security Overview", "Attack Timeline - Production".', - ), - uiContext: z - .string() - .optional() - .describe( - '๐Ÿ–ฅ๏ธ UI INTEGRATION: Context identifier for frontend integration and state management. ๐Ÿ’ก USE CASES: Multi-dashboard applications, widget state tracking, user interface coordination. ๐Ÿ“‹ EXAMPLES: "main-dashboard", "security-ops-center", "executive-summary".', - ), + trafficTags: z.array(TrafficDataTrafficTagsEnum).optional(), + pageType: z.array(TrafficDataPageTypeEnum).optional(), + browserFamily: z.array(z.string()).optional(), + osFamily: z.array(z.string()).optional(), + country: z.array(z.string()).optional(), }) - .passthrough(); + .optional(); -export const TrafficDataInputSchema = z.object({ +export const TrafficDataInputBaseSchema = z.object({ startTime: z .string() - .describe( - `โฐ TIME RANGE START: ISO 8601 datetime string defining analysis period beginning. ๐ŸŽฏ FORMAT: "${DATE_FORMAT_EXAMPLE_START}". โš ๏ธ CONSTRAINT: Must be within API limits for data retention. ๐Ÿ’ก STRATEGY: Use shorter windows for real-time monitoring, longer periods for trend analysis and pattern detection.`, - ), + .describe(`Start of the analysis time range in ISO 8601 format, e.g. "${DATE_FORMAT_EXAMPLE_START}".`), endTime: z .string() - .describe( - `๐Ÿ TIME RANGE END: ISO 8601 datetime string defining analysis period conclusion. ๐ŸŽฏ FORMAT: "${DATE_FORMAT_EXAMPLE_END}". โš ๏ธ CONSTRAINT: Must be after startTime. ๐Ÿ’ก STRATEGY: Use current time for live dashboards, specific timestamps for historical analysis and incident investigation.`, - ), - source: z + .describe(`End of the analysis time range in ISO 8601 format, e.g. "${DATE_FORMAT_EXAMPLE_END}".`), + trafficSource: z .array(TrafficDataSourceEnum) .optional() - .describe( - '๐ŸŒ PLATFORM FILTER: ["web"], ["mobile"], or both. NOTE: Mobile traffic often minimal (<0.001% observed). Use for platform-specific security analysis or complete coverage.', - ), - overtime: z - .array(TrafficDataOvertimeEnum) + .describe('Platform filter: ["web"], ["mobile"], or both. Defaults to ["web", "mobile"].'), + filters: TrafficDataFiltersSchema.describe( + 'Optional filters for trafficTags, pageType, browserFamily, osFamily, and country.', + ), + overtime: z.boolean().optional().describe('Fetch per-minute time-series traffic counts over the requested range.'), + seriesFields: z + .array(TrafficDataSeriesFieldsEnum) .optional() - .describe( - '๐Ÿ“ˆ TIME-SERIES DATA: Returns ~20min interval data for trends. โŒ CANNOT combine with "count". ๐Ÿšจ CRITICAL: DO NOT combine with "tops" - causes misleading aggregation with all data front-loaded into first interval. โœ… PERFECT FOR: Attack timelines, trend analysis, pattern detection. EXAMPLES: {"overtime": ["blocked"]} โ†’ Attack volume timeline.', - ), + .describe('Break out additional overtime series per value. Only applies when overtime is true.'), + metrics: z.boolean().optional().describe('Fetch aggregated traffic totals for the time range.'), tops: z - .array(TrafficDataTopsEnum) - .optional() - .describe( - '๐Ÿ”„ BREAKDOWN TRANSFORMER: Changes from totals to detailed categorization. ๐Ÿšจ CRITICAL: DO NOT combine with "overtime" - causes misleading front-loaded aggregation. โš ๏ธ INSIGHTS: "incidents" reveals attack types (Bot Behavior, Spoof, Bad Reputation), "path" shows URL targeting. โœ… SAFE USAGE: Combine with "count" only for accurate breakdowns.', - ), - traffic: z - .array(TrafficDataTrafficEnum) + .array(TrafficDataTopsFieldEnum) .optional() - .describe( - '๐Ÿšจ SECURITY-ONLY FILTER: Shows ONLY threats/blocks. Excludes all legitimate traffic. PERFECT FOR: Pure security analysis, threat hunting, incident investigation. COMBINES WITH: any count/overtime metrics.', - ), - pageType: z - .array(TrafficDataPageTypeEnum) - .optional() - .describe( - '๐ŸŽฏ PAGE JOURNEY FILTER: Focus on specific user flows. โš ๏ธ MAJOR SCOPE REDUCTION: Can eliminate 87%+ of data. BEST FOR: Login security, checkout protection, API monitoring. USE SPARINGLY for targeted analysis.', - ), - count: z - .array(TrafficDataCountEnum) - .optional() - .describe( - '๐Ÿ“Š AGGREGATE TOTALS: Returns summary counts across time range. โŒ CANNOT combine with "overtime". โœ… PERFECT FOR: Executive dashboards, KPI reporting, quick health checks. EXAMPLES: {"count": ["legitimate", "blocked"]} โ†’ Traffic health summary.', - ), - withoutTotals: z - .boolean() - .optional() - .describe( - 'Excludes summary totals from response. Use when only breakdown data is needed to reduce response size.', - ), - metricsEnrichment: TrafficDataMetricsEnrichmentSchema.optional().describe( - '๐Ÿท๏ธ CONTEXTUAL METADATA: Enrichment object for dashboard integration and reporting context. โš ๏ธ NO IMPACT: Does not affect core data or query performance. ๐Ÿ’ก USE CASES: Multi-tenant dashboards, executive reporting, UI state management. ๐Ÿ“Š BENEFITS: Enhanced labeling, audit trails, and business context for analysis.', - ), + .describe('Fetch top values for one or more fields, sorted by request count.'), + limit: z.number().min(1).max(100).optional().describe('Max tops results per field (1-100). Defaults to 10.'), + includeNulls: z.boolean().optional().describe('Include rows where the tops field is null. Defaults to false.'), }); -export type TrafficDataInput = z.infer; - -const TrafficDataIntervalSchema = z - .object({ - timestamp: z - .number() - .optional() - .describe( - 'โฑ๏ธ INTERVAL TIMESTAMP: Timestamp for the ~20-minute interval (milliseconds since epoch). Essential for time-series visualization and trend analysis.', - ), - count: z.number().optional().describe('Count for this interval.'), - }) - .passthrough(); -const TrafficDataSeriesOvertimeSchema = z - .object({ - value: z.string().optional().describe('Label or value for this series.'), - intervals: z.array(TrafficDataIntervalSchema).optional().describe('List of intervals for this series.'), - }) - .passthrough(); +export const TrafficDataInputSchema = TrafficDataInputBaseSchema.refine( + (data) => data.overtime === true || data.metrics === true || (data.tops !== undefined && data.tops.length > 0), + { message: 'At least one of overtime, metrics, or tops must be specified.' }, +); -const TrafficDataSeriesCountSchema = z - .object({ - value: z.string().optional().describe('Label or value for this series.'), - count: z.number().optional().describe('Count for this series.'), - }) - .passthrough(); +export type TrafficDataInput = z.infer; -const TrafficDataTotalsSchema = z +const TrafficDataOvertimeResultSchema = z .object({ - total: z.number().optional().describe('Total count.'), - totalBlocked: z.number().optional().describe('Total blocked count.'), + timestamp: z.string(), }) .passthrough(); -const TrafficDataContentSchema = z - .object({ - legitimate: z.array(TrafficDataSeriesOvertimeSchema).optional(), - blocked: z.array(TrafficDataSeriesOvertimeSchema).optional(), - blacklist: z.array(TrafficDataSeriesOvertimeSchema).optional(), - goodKnownBots: z.array(TrafficDataSeriesOvertimeSchema).optional(), - potentialBlock: z.array(TrafficDataSeriesOvertimeSchema).optional(), - whitelist: z.array(TrafficDataSeriesOvertimeSchema).optional(), - captchaSolved: z.array(TrafficDataSeriesOvertimeSchema).optional(), - incidents: z.array(TrafficDataSeriesCountSchema).optional(), - path: z.array(TrafficDataSeriesCountSchema).optional(), - mobile: z.array(TrafficDataSeriesCountSchema).optional(), - web: z.array(TrafficDataSeriesCountSchema).optional(), - totals: TrafficDataTotalsSchema.optional(), - dataLags: z.array(z.unknown()).optional(), - }) - .passthrough() - .optional() - .describe('Main content data, keyed by metric type.'); +const TrafficDataTopsResultSchema = z.object({ + value: z.string(), + count: z.number(), +}); export const TrafficDataOutputSchema = z .object({ - result: z.boolean().optional().describe('Whether the request was successful.'), - message: z.string().optional().describe('Response message.'), - content: TrafficDataContentSchema, + overtime: z + .object({ + results: z.array(TrafficDataOvertimeResultSchema), + }) + .passthrough() + .optional(), + metrics: z + .object({ + results: z.record(z.string(), z.number()), + labels: z.record(z.string(), z.string()).optional(), + }) + .passthrough() + .optional(), + tops: z.record(z.string(), z.array(TrafficDataTopsResultSchema)).optional(), }) .passthrough(); + export type TrafficDataResponse = z.infer; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 4655158..839f255 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -4,7 +4,13 @@ export const DATE_FORMAT_EXAMPLE_START = '2025-06-23T10:00:00Z'; export const DATE_FORMAT_EXAMPLE_END = '2025-06-23T16:00:00Z'; export const HUMAN_API_HOST = process.env.HUMAN_API_HOST || 'api.humansecurity.com'; export const HUMAN_API_VERSION = process.env.HUMAN_API_VERSION || 'v1'; -export const HUMAN_API_BASE = `https://${HUMAN_API_HOST}/${HUMAN_API_VERSION}`; +const isLocalhost = HUMAN_API_HOST.startsWith('localhost') || HUMAN_API_HOST.startsWith('127.0.0.1'); +export const HUMAN_API_BASE = `${isLocalhost ? 'http' : 'https'}://${HUMAN_API_HOST}/${HUMAN_API_VERSION}`; + +// Override the full traffic-data base URL (e.g. for local pxPortal testing). +// Default: /cyberfraud/traffic-data (production gateway path) +// Local: http://localhost:3000/api/v1/botDefender/traffic +export const HUMAN_TRAFFIC_API_BASE = process.env.HUMAN_TRAFFIC_API_BASE ?? `${HUMAN_API_BASE}/cyberfraud/traffic-data`; export const HTTP_TIMEOUT_MS = process.env.HTTP_TIMEOUT_MS ? parseInt(process.env.HTTP_TIMEOUT_MS, 10) : 30000; export const MCP_VERSION_HEADER = 'x-px-mcp-version'; export const MCP_VERSION = packageJson.version; diff --git a/src/utils/httpClient.ts b/src/utils/httpClient.ts index 17536bb..a86ed22 100644 --- a/src/utils/httpClient.ts +++ b/src/utils/httpClient.ts @@ -38,6 +38,9 @@ export class HttpClient { if (this.apiToken) { headers['Authorization'] = `Bearer ${this.apiToken}`; } + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } headers[MCP_VERSION_HEADER] = MCP_VERSION; const res = await this.fetchImpl(url, { method: options.method || 'GET', diff --git a/test/services/cyberfraudService.test.ts b/test/services/cyberfraudService.test.ts index a236177..0a301f4 100644 --- a/test/services/cyberfraudService.test.ts +++ b/test/services/cyberfraudService.test.ts @@ -105,4 +105,134 @@ describe('CyberfraudService', () => { const url = httpClient.request.firstCall.args[0]; expect(url).to.include('/overview/cid123'); }); + + describe('getTrafficData', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + const baseParams = { startTime, endTime }; + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('calls POST /overtime with correct URL, method, and body', async () => { + const overtimeContent = { results: [{ timestamp: '2026-06-23T05:57:00.000Z', blocked: 100 }] }; + httpClient.request.resolves(mockApiResponse(overtimeContent)); + + const result = await service.getTrafficData({ ...baseParams, overtime: true } as any); + + expect(httpClient.request.calledOnce).to.be.true; + const [url, options] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/overtime'); + expect(url).to.include('from='); + expect(url).to.include('to='); + expect(options.method).to.equal('POST'); + expect(options.body.trafficSource).to.deep.equal(['web', 'mobile']); + expect(result.overtime).to.deep.equal(overtimeContent); + }); + + it('calls POST /metrics with filters in body', async () => { + const metricsContent = { + results: { total: 1000, blocked: 50 }, + labels: { total: 'Total Requests' }, + }; + httpClient.request.resolves(mockApiResponse(metricsContent)); + + const result = await service.getTrafficData({ + ...baseParams, + metrics: true, + trafficSource: ['web'], + filters: { trafficTags: ['blocked'] }, + } as any); + + expect(httpClient.request.calledOnce).to.be.true; + const [url, options] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/metrics'); + expect(options.method).to.equal('POST'); + expect(options.body.trafficSource).to.deep.equal(['web']); + expect(options.body.filters).to.deep.equal({ trafficTags: ['blocked'] }); + expect(result.metrics).to.deep.equal(metricsContent); + }); + + it('calls POST /tops/:field for a single tops field', async () => { + const topsContent = { results: [{ value: '/login', count: 500 }] }; + httpClient.request.resolves(mockApiResponse(topsContent)); + + const result = await service.getTrafficData({ + ...baseParams, + tops: ['path'], + limit: 5, + } as any); + + expect(httpClient.request.calledOnce).to.be.true; + const [url, options] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/tops/path'); + expect(options.method).to.equal('POST'); + expect(options.body.limit).to.equal(5); + expect(result.tops).to.deep.equal({ path: topsContent.results }); + }); + + it('issues parallel tops calls and merges results keyed by field', async () => { + httpClient.request + .onFirstCall() + .resolves(mockApiResponse({ results: [{ value: 'Bot Behavior', count: 100 }] })); + httpClient.request + .onSecondCall() + .resolves(mockApiResponse({ results: [{ value: '/checkout', count: 200 }] })); + + const result = await service.getTrafficData({ + ...baseParams, + tops: ['incidentTypes', 'path'], + } as any); + + expect(httpClient.request.calledTwice).to.be.true; + expect(httpClient.request.firstCall.args[0]).to.include('/tops/incidentTypes'); + expect(httpClient.request.secondCall.args[0]).to.include('/tops/path'); + expect(result.tops).to.deep.equal({ + incidentTypes: [{ value: 'Bot Behavior', count: 100 }], + path: [{ value: '/checkout', count: 200 }], + }); + }); + + it('combines overtime, metrics, and tops in one call', async () => { + httpClient.request + .onCall(0) + .resolves(mockApiResponse({ results: [{ timestamp: '2026-06-23T05:57:00.000Z' }] })); + httpClient.request.onCall(1).resolves(mockApiResponse({ results: { total: 1000 } })); + httpClient.request.onCall(2).resolves(mockApiResponse({ results: [{ value: 'US', count: 300 }] })); + + const result = await service.getTrafficData({ + ...baseParams, + overtime: true, + metrics: true, + tops: ['country'], + seriesFields: ['knownBot'], + } as any); + + expect(httpClient.request.calledThrice).to.be.true; + expect(result.overtime).to.exist; + expect(result.metrics).to.exist; + expect(result.tops?.country).to.deep.equal([{ value: 'US', count: 300 }]); + expect(httpClient.request.firstCall.args[1].body.seriesFields).to.deep.equal(['knownBot']); + }); + + it('throws when API returns result: false', async () => { + httpClient.request.resolves({ + json: async () => ({ result: false, message: 'Invalid time range' }), + ok: true, + }); + + await expect(service.getTrafficData({ ...baseParams, metrics: true } as any)).to.be.rejectedWith( + 'Invalid time range', + ); + }); + + it('propagates httpClient.request error', async () => { + httpClient.request.rejects(new Error('network fail')); + await expect(service.getTrafficData({ ...baseParams, metrics: true } as any)).to.be.rejectedWith( + 'network fail', + ); + }); + }); }); diff --git a/test/tools/getTrafficData.test.ts b/test/tools/getTrafficData.test.ts new file mode 100644 index 0000000..9cdf178 --- /dev/null +++ b/test/tools/getTrafficData.test.ts @@ -0,0 +1,24 @@ +import * as chai from 'chai'; +import sinon from 'sinon'; +import { registerCyberfraudGetTrafficData } from '../../src/tools/getTrafficData'; + +describe('registerCyberfraudGetTrafficData', () => { + const { expect } = chai; + + it('registers tool and handler calls service', async () => { + const server = { registerTool: sinon.stub() }; + const service = { getTrafficData: sinon.stub().resolves({ metrics: { results: { total: 100 } } }) }; + registerCyberfraudGetTrafficData(server as any, service as any); + expect(server.registerTool.calledOnce).to.be.true; + const [name, config, handler] = server.registerTool.firstCall.args; + expect(name).to.equal('human_get_traffic_data'); + expect(config).to.have.property('description'); + const params = { + startTime: '2025-06-23T10:00:00Z', + endTime: '2025-06-23T16:00:00Z', + metrics: true, + }; + await handler(params); + expect(service.getTrafficData.calledWith(params)).to.be.true; + }); +}); diff --git a/test/utils/httpClient.test.ts b/test/utils/httpClient.test.ts index 32f6e42..0a436dd 100644 --- a/test/utils/httpClient.test.ts +++ b/test/utils/httpClient.test.ts @@ -58,6 +58,20 @@ describe('HttpClient', () => { expect(args.body).to.equal(JSON.stringify({ foo: 'bar' })); }); + it('sets Content-Type application/json when body is present', async () => { + fetchStub.resolves(new MockResponse('{}', { status: 200 })); + await client.request(url, { method: 'POST', body: { foo: 'bar' } }); + const headers = fetchStub.firstCall.args[1].headers; + expect(headers['Content-Type']).to.equal('application/json'); + }); + + it('does not set Content-Type when body is absent', async () => { + fetchStub.resolves(new MockResponse('{}', { status: 200 })); + await client.request(url); + const headers = fetchStub.firstCall.args[1].headers; + expect(headers['Content-Type']).to.be.undefined; + }); + it('adds Authorization header if apiToken is set', async () => { fetchStub.resolves(new MockResponse('{}', { status: 200 })); await client.request(url);