From 0e172122a8ba64ea38c8a352251740f29673d9c2 Mon Sep 17 00:00:00 2001 From: asafcohen Date: Thu, 2 Jul 2026 15:21:26 +0300 Subject: [PATCH 1/6] raw activities capablities and query search --- CHANGELOG.md | 11 ++ package.json | 2 +- src/services/cyberfraudService.ts | 104 +++++++++++++++ src/tools/getTrafficData.ts | 42 ++++++- src/tools/index.ts | 2 + src/tools/investigateBlock.ts | 68 ++++++++++ src/types/cyberfraud/trafficData.ts | 149 ++++++++++++++++++++++ test/services/cyberfraudService.test.ts | 161 ++++++++++++++++++++++++ test/tools/investigateBlock.test.ts | 41 ++++++ 9 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 src/tools/investigateBlock.ts create mode 100644 test/tools/investigateBlock.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2897f55..e936bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.2.0] - 2026-07-02 + +### Added + +* New `human_investigate_block` tool: given a Block ID / Reference ID or an IP address and a short time window (max 1 hour), returns matching raw activity records (up to 20), the total count, and aggregated traffic metrics to help analyze why traffic is being blocked. Key analysis fields surfaced: `filterOriginReason`, `ruleName`, `displayScore`, `incidentTypes`, `trafficTags`, and `blockReference`. +* `searchQuery` support across all traffic data endpoints (`/overtime`, `/metrics`, `/tops/*`) for field-level filtering with boolean logic (AND, OR, NOT, parentheses). Available filter fields include: `socketIp`, `blockReference`, `displayScore`, `domain`, `path`, `filterOriginReason`, `ruleName`, `uaServer`, `knownBot`, `userEmail`, `httpMethod`, `httpStatusCode`, and more. + +### Changed + +* `human_get_traffic_data` tool description updated to document the new `searchQuery` capability and all available filter field keys. + ## [1.1.1] - 2026-07-02 ### Fixed diff --git a/package.json b/package.json index e1624dc..b89ee04 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@humansecurity/human-mcp-server", "description": "Model Context Protocol (MCP) server providing comprehensive cybersecurity intelligence from HUMAN Security. Offers real-time attack monitoring, threat detection, fraud prevention, PCI DSS compliance validation, and supply chain security for AI-powered applications.", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "main": "./dist/index.cjs", "bin": "./dist/index.cjs", diff --git a/src/services/cyberfraudService.ts b/src/services/cyberfraudService.ts index cecdb62..c78f5a3 100644 --- a/src/services/cyberfraudService.ts +++ b/src/services/cyberfraudService.ts @@ -11,8 +11,15 @@ import type { CyberfraudCustomRulesResponse, TrafficDataInput, TrafficDataResponse, + InvestigateBlockInput, } from '../types/cyberfraud'; +type InvestigateBlockOutput = { + count: number; + activities: Record[]; + metrics?: { results: Record; labels?: Record }; +}; + const API_BASE = `${HUMAN_API_BASE}/cyberfraud`; const TRAFFIC_DATA_BASE = HUMAN_TRAFFIC_API_BASE; @@ -49,9 +56,26 @@ function buildBaseBody(params: TrafficDataInput) { if (params.filters) { body.filters = params.filters; } + if (params.searchQuery && params.searchQuery.length > 0) { + body.searchQuery = params.searchQuery; + } return body; } +const MAX_INVESTIGATE_RANGE_MS = 4 * 60 * 60 * 1000; // 4 hours + +function enforceInvestigateTimeRange(startTime: string, endTime: string): void { + const start = new Date(startTime).getTime(); + const end = new Date(endTime).getTime(); + const diffMs = end - start; + if (diffMs > MAX_INVESTIGATE_RANGE_MS) { + throw new Error( + `The investigation time range must not exceed 4 hours (raw activity queries are expensive). ` + + `Provided range: ${Math.round(diffMs / 60000)} minutes. Please narrow the window to at most 240 minutes.`, + ); + } +} + async function parseApiResponse(res: { json: () => Promise }): Promise { const json = (await res.json()) as ApiEnvelope; if (!json.result) { @@ -159,4 +183,84 @@ export class CyberfraudService { return response; } + + async getRawActivities( + params: Pick & { + searchQuery?: TrafficDataInput['searchQuery']; + limit?: number; + offset?: number; + }, + ): Promise[]> { + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); + const from = Math.floor(new Date(clamped.startTime).getTime() / 1000); + const to = Math.floor(new Date(clamped.endTime).getTime() / 1000); + const url = buildTrafficDataUrl('/activities', from, to); + const body: Record = { + trafficSource: + params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], + }; + if (params.filters) body.filters = params.filters; + if (params.searchQuery?.length) body.searchQuery = params.searchQuery; + if (params.limit !== undefined) body.limit = params.limit; + if (params.offset !== undefined) body.offset = params.offset; + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse<{ results: Record[] }>(res); + return content.results; + } + + async getRawActivitiesCount( + params: Pick & { + searchQuery?: TrafficDataInput['searchQuery']; + }, + ): Promise { + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); + const from = Math.floor(new Date(clamped.startTime).getTime() / 1000); + const to = Math.floor(new Date(clamped.endTime).getTime() / 1000); + const url = buildTrafficDataUrl('/activities/count', from, to); + const body: Record = { + trafficSource: + params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], + }; + if (params.filters) body.filters = params.filters; + if (params.searchQuery?.length) body.searchQuery = params.searchQuery; + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse<{ total: number }>(res); + return content.total; + } + + async investigateBlock(params: InvestigateBlockInput): Promise { + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); + enforceInvestigateTimeRange(clamped.startTime, clamped.endTime); + + const searchQuery: NonNullable = []; + if (params.blockReference) { + searchQuery.push({ type: 'field', key: 'blockReference', operator: '=', value: params.blockReference }); + } + if (params.socketIp) { + if (params.blockReference) { + searchQuery.push({ type: 'operator', operator: 'OR' }); + } + searchQuery.push({ type: 'field', key: 'socketIp', operator: '=', value: params.socketIp }); + } + + const baseParams = { + startTime: clamped.startTime, + endTime: clamped.endTime, + trafficSource: params.trafficSource, + filters: params.filters, + searchQuery, + }; + + const [activities, count, metricsData] = await Promise.all([ + this.getRawActivities({ ...baseParams, limit: 20, offset: 0 }), + this.getRawActivitiesCount(baseParams), + this.getTrafficData({ ...baseParams, metrics: true }), + ]); + + return { + count, + activities, + metrics: metricsData.metrics, + }; + } } diff --git a/src/tools/getTrafficData.ts b/src/tools/getTrafficData.ts index acfed2c..e0e69cd 100644 --- a/src/tools/getTrafficData.ts +++ b/src/tools/getTrafficData.ts @@ -18,7 +18,8 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe ├── 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"] +├── Multi-platform analysis? → trafficSource: ["web", "mobile"] +└── Need to investigate a specific Block ID or IP? → use human_investigate_block instead ✅ REQUEST MODES (combinable in one call): • overtime: Per-minute time-series counts. Use seriesFields to break out by knownBot, customRule, or accessTokenName. @@ -30,6 +31,39 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe • filters.pageType: User journey (login, checkout, carding_attempt, etc.) • filters.browserFamily / osFamily / country: Device and geo filters +🔍 SEARCH QUERY (optional, for field-level filtering with boolean logic): +Use searchQuery to filter by any specific field value. Items are either field expressions or logical operators. +Field expression: { type: "field", key: "", operator: "", value: "" } +Logical operator: { type: "operator", operator: "AND" | "OR" | "NOT" | "(" | ")" } + +Available field keys and their operators: +• socketIp (IP/CIDR): =, != +• blockReference (Block ID): =, exists, notExists +• displayScore (risk score 0-100): =, !=, >, <, >=, <= +• incidentTypes: =, !=, exists, notExists + Valid values: "UI Anomaly", "Bot Behavior", "Automation Tool", "Spoof", "Behavioral Anomalies", + "Bad Reputation", "Volumetric Rule", "Volumetric Anomaly", "Missing Sensor Data", + "Cloud Service", "Anonymizing Service", "Denylisted Service", "Custom Denylist", "Captcha Solving Attack" +• knownBot: =, !=, exists, notExists + Example values: "Googlebot", "Bing Bot", "GPTBot", "ClaudeBot", "PerplexityBot", + "Facebook Crawler", "Twitterbot", "Pingdom", "Datadog - Synthetics", "Semrush Bot", "Ahrefs" + (150+ bots total — use exists/notExists for broad match) +• domain, path, uaServer, socketIpOrgName, userEmail, headerReferer: =, !=, contains, exists, notExists +• filterOriginReason, vid, accessTokenName: =, !=, exists, notExists +• httpStatusCode: =, !=, >, <, >=, <= +• httpMethod: =, != — values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS +• graphqlOperationType: =, !=, exists, notExists — values: query, mutation, subscription +• graphqlOperationName: =, !=, exists, notExists +• customRule: =, !=, contains, exists, notExists +• customParam1–customParam10: =, !=, contains, exists, notExists + +Example — traffic from a CIDR with high risk score: + "searchQuery": [ + { "type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.0/24" }, + { "type": "operator", "operator": "AND" }, + { "type": "field", "key": "displayScore", "operator": ">=", "value": 80 } + ] + ✅ HIGH-VALUE PATTERNS: 1. EXECUTIVE TRAFFIC SUMMARY: @@ -47,11 +81,15 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe 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"]}} +6. SEARCH QUERY — TOP IPs FROM A SPECIFIC CIDR: + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "tops": ["socketIp"], "searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.0/24"}]} + ⚠️ 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.`, +• tops limit defaults to 10 (max 100). Use includeNulls to include null-valued rows. +• searchQuery and filters are additive — both are applied when provided.`, inputSchema: TrafficDataInputBaseSchema.shape, outputSchema: makeStructuredResponseSchema(TrafficDataOutputSchema).shape, annotations: { diff --git a/src/tools/index.ts b/src/tools/index.ts index 86b5013..0709175 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ import { registerCodeDefenderGetHeaderInventory } from './codeDefenderGetHeaderI import type { CyberfraudService } from '../services/cyberfraudService'; import type { CodeDefenderService } from '../services/codeDefenderService'; import { registerCyberfraudGetTrafficData } from './getTrafficData'; +import { registerInvestigateBlock } from './investigateBlock'; export function registerTools( server: McpServer, @@ -24,6 +25,7 @@ export function registerTools( registerCyberfraudGetCustomRules(server, services.cyberfraudService); registerCyberfraudGetAccountInfo(server, services.cyberfraudService); registerCyberfraudGetTrafficData(server, services.cyberfraudService); + registerInvestigateBlock(server, services.cyberfraudService); } // Register Code Defender tools if service is available diff --git a/src/tools/investigateBlock.ts b/src/tools/investigateBlock.ts new file mode 100644 index 0000000..f7d0901 --- /dev/null +++ b/src/tools/investigateBlock.ts @@ -0,0 +1,68 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { InvestigateBlockBaseSchema, InvestigateBlockOutputSchema } from '../types/cyberfraud'; +import { mcpToolHandler } from '../utils/mcpToolHandler'; +import { makeStructuredResponseSchema } from '../utils/makeStructuredResponseSchema'; +import type { CyberfraudService } from '../services/cyberfraudService'; +import type { InvestigateBlockInput } from '../types/cyberfraud'; +import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../utils/constants'; + +export function registerInvestigateBlock(server: McpServer, cyberfraudService: CyberfraudService) { + server.registerTool( + 'human_investigate_block', + { + description: `Investigates why a specific Block ID or IP address is being blocked by HUMAN Security. +Fetches matching raw activity records, total count, and aggregated traffic metrics for the given time window. + +⚠️ TIME RANGE CONSTRAINT: Raw activity queries are expensive. The time window MUST be at most 4 hours. +Use short windows like the last 30, 60, or 240 minutes. If the user does not specify, default to the last 1 hour. + +🎯 WHEN TO USE THIS TOOL: +• User provides a Block ID / Reference ID and wants to understand why it was blocked +• User provides an IP address and wants to understand what traffic came from it and why it was blocked +• User wants to drill into a specific incident at the request level + +🔍 KEY RESPONSE FIELDS FOR BLOCK ANALYSIS (in activities[]): +• filterOriginReason — the specific reason the request was filtered/blocked +• ruleName — which rule triggered the block +• displayScore — risk score (0–100); higher = more suspicious +• incidentTypes — detected threat signals — valid values: "Bot Behavior", "Automation Tool", "Spoof", "Behavioral Anomalies", "UI Anomaly", "Bad Reputation", "Volumetric Rule", "Volumetric Anomaly", "Missing Sensor Data", "Cloud Service", "Anonymizing Service", "Denylisted Service", "Custom Denylist", "Captcha Solving Attack" +• trafficTags — traffic classification (e.g. "Blocked Requests", "Simulated Block") +• blockReference — the Block ID associated with this request + +📊 RESPONSE STRUCTURE: +• count: total number of matching records in the time window +• activities: up to 20 sample records with all available fields +• metrics: aggregated totals (blocked, legitimate, total, etc.) for the matching traffic + +✅ USAGE PATTERNS: + +1. INVESTIGATE BY BLOCK ID: + {"blockReference": "b5e0-b1d1-a54de", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +2. INVESTIGATE BY IP: + {"socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +3. INVESTIGATE IP WITH CIDR: + {"socketIp": "203.0.113.0/24", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +4. INVESTIGATE BOTH (returns OR match): + {"blockReference": "b5e0-b1d1-a54de", "socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +5. NARROW TO BLOCKED TRAFFIC ONLY: + {"socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "filters": {"trafficTags": ["blocked", "potentialBlock"]}} + +⚠️ NOTES: +• Provide at least one of blockReference or socketIp. +• trafficSource defaults to ["web", "mobile"] when omitted. +• For broader traffic analysis without a specific Block ID or IP, use human_get_traffic_data instead.`, + inputSchema: InvestigateBlockBaseSchema.shape, + outputSchema: makeStructuredResponseSchema(InvestigateBlockOutputSchema).shape, + annotations: { + title: 'HUMAN Investigate Block', + readOnlyHint: true, + openWorldHint: true, + }, + }, + async (params: InvestigateBlockInput) => mcpToolHandler(async () => cyberfraudService.investigateBlock(params)), + ); +} diff --git a/src/types/cyberfraud/trafficData.ts b/src/types/cyberfraud/trafficData.ts index c9dec62..3025efb 100644 --- a/src/types/cyberfraud/trafficData.ts +++ b/src/types/cyberfraud/trafficData.ts @@ -1,6 +1,62 @@ import { z } from 'zod'; import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../../utils/constants'; +// ============================================================================= +// Search Query +// ============================================================================= + +export const SearchQueryFieldItemSchema = z.object({ + type: z.literal('field'), + key: z.string().describe('Field key to filter on (e.g. socketIp, blockReference, displayScore, domain, path).'), + operator: z + .enum(['=', '!=', '>', '<', '>=', '<=', 'contains', 'exists', 'notExists']) + .describe( + 'Comparison operator. Use exists/notExists to check presence. Numeric operators for displayScore/httpStatusCode.', + ), + value: z + .union([z.string(), z.number()]) + .optional() + .describe('Value to compare. Omit for exists/notExists operators.'), + caseSensitive: z + .boolean() + .optional() + .describe('When true, comparison is case-sensitive. Defaults to case-insensitive.'), +}); + +export const SearchQueryOperatorItemSchema = z.object({ + type: z.literal('operator'), + operator: z.enum(['AND', 'OR', 'NOT', '(', ')']), +}); + +export const SearchQueryItemSchema = z.discriminatedUnion('type', [ + SearchQueryFieldItemSchema, + SearchQueryOperatorItemSchema, +]); + +export type SearchQueryItem = z.infer; + +export const SearchQuerySchema = z + .array(SearchQueryItemSchema) + .min(1) + .optional() + .describe( + 'Optional structured search query for field-level filtering with boolean logic. ' + + 'Each item is either a field expression ({type:"field", key, operator, value}) or a logical connector ({type:"operator", operator:"AND"|"OR"|"NOT"|"("|")"}). ' + + 'Available field keys and their operators:\n' + + '- socketIp (IP/CIDR): =, !=\n' + + '- blockReference (Block ID): =, exists, notExists\n' + + '- displayScore (risk score 0-100): =, !=, >, <, >=, <=\n' + + '- incidentTypes: =, !=, exists, notExists — valid values: "UI Anomaly", "Denylisted Service", "Custom Denylist", "Cloud Service", "Anonymizing Service", "Bot Behavior", "Spoof", "Behavioral Anomalies", "Automation Tool", "Bad Reputation", "Volumetric Rule", "Missing Sensor Data", "Volumetric Anomaly", "Captcha Solving Attack"\n' + + '- knownBot (Known Bot / IP Classification): =, !=, exists, notExists — example values: "Googlebot", "Bing Bot", "DuckDuckGo Bot", "GPTBot", "ClaudeBot", "PerplexityBot", "Facebook Crawler", "Twitterbot", "Pingdom", "Datadog - Synthetics", "UptimeRobot", "Semrush Bot", "Ahrefs" (many more exist — use exists/notExists for a broad match)\n' + + '- filterOriginReason, vid, accessTokenName: =, !=, exists, notExists\n' + + '- domain, path, uaServer, socketIpOrgName, userEmail, headerReferer: =, !=, contains, exists, notExists\n' + + '- httpMethod: =, != — values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS\n' + + '- httpStatusCode: =, !=, >, <, >=, <=\n' + + '- graphqlOperationName, graphqlOperationType: =, !=, exists, notExists — graphqlOperationType values: query, mutation, subscription\n' + + '- customRule: =, !=, contains, exists, notExists\n' + + '- customParam1–customParam10: =, !=, contains, exists, notExists', + ); + export const TrafficDataSourceEnum = z.enum(['web', 'mobile']); export const TrafficDataTrafficTagsEnum = z.enum([ @@ -91,6 +147,7 @@ export const TrafficDataInputBaseSchema = z.object({ filters: TrafficDataFiltersSchema.describe( 'Optional filters for trafficTags, pageType, browserFamily, osFamily, and country.', ), + searchQuery: SearchQuerySchema, overtime: z.boolean().optional().describe('Fetch per-minute time-series traffic counts over the requested range.'), seriesFields: z .array(TrafficDataSeriesFieldsEnum) @@ -143,3 +200,95 @@ export const TrafficDataOutputSchema = z .passthrough(); export type TrafficDataResponse = z.infer; + +// ============================================================================= +// Investigate Block +// ============================================================================= + +export const InvestigateBlockBaseSchema = z.object({ + blockReference: z + .string() + .optional() + .describe('Block ID / Reference ID to investigate (e.g. "b5e0-b1d1-a54de"). Provide this OR socketIp.'), + socketIp: z + .string() + .optional() + .describe( + 'IP address or CIDR range to investigate (e.g. "203.0.113.10" or "203.0.113.0/24"). Provide this OR blockReference.', + ), + startTime: z + .string() + .describe( + `Start of the investigation window in ISO 8601 format. Must be within 4 hours of endTime. e.g. "${DATE_FORMAT_EXAMPLE_START}".`, + ), + endTime: z + .string() + .describe( + `End of the investigation window in ISO 8601 format. Must be within 4 hours of startTime. e.g. "${DATE_FORMAT_EXAMPLE_END}".`, + ), + trafficSource: z + .array(TrafficDataSourceEnum) + .optional() + .describe('Platform filter: ["web"], ["mobile"], or both. Defaults to ["web", "mobile"].'), + filters: TrafficDataFiltersSchema.describe('Optional additional filters to narrow the investigation scope.'), +}); + +export const InvestigateBlockInputSchema = InvestigateBlockBaseSchema.refine( + (data) => data.blockReference !== undefined || data.socketIp !== undefined, + { message: 'At least one of blockReference or socketIp must be provided.' }, +); + +export type InvestigateBlockInput = z.infer; + +const RawActivitySchema = z + .object({ + timestamp: z.string().nullish(), + appId: z.string().nullish(), + appName: z.string().nullish(), + trafficTags: z.string().nullish(), + reqTag: z.string().nullish(), + ruleName: z.string().nullish(), + filterOriginReason: z.string().nullish(), + blockReference: z.string().nullish(), + socketIp: z.string().nullish(), + trafficSource: z.string().nullish(), + displayScore: z.number().nullish(), + incidentTypes: z.array(z.unknown()).nullish(), + vid: z.string().nullish(), + userEmail: z.string().nullish(), + osFamily: z.string().nullish(), + browserDisplay: z.string().nullish(), + country: z.string().nullish(), + socketIpOrgName: z.string().nullish(), + uaServer: z.string().nullish(), + knownBot: z.string().nullish(), + httpMethod: z.string().nullish(), + httpStatusCode: z.string().nullish(), + domain: z.string().nullish(), + path: z.string().nullish(), + headerReferer: z.string().nullish(), + filterId: z.string().nullish(), + accessTokenName: z.string().nullish(), + graphqlOperationName: z.string().nullish(), + graphqlOperationType: z.string().nullish(), + }) + .passthrough(); + +export const InvestigateBlockOutputSchema = z.object({ + count: z.number().describe('Total number of matching activity records in the requested time window.'), + activities: z + .array(RawActivitySchema) + .describe( + 'Sample of up to 20 matching raw activity records. Key fields for block analysis: ' + + 'filterOriginReason (why it was filtered), ruleName (which rule triggered), ' + + 'displayScore (risk score 0-100), incidentTypes (detected threats), trafficTags (traffic classification).', + ), + metrics: z + .object({ + results: z.record(z.string(), z.number()), + labels: z.record(z.string(), z.string()).optional(), + }) + .passthrough() + .optional() + .describe('Aggregated traffic metrics for the matching traffic (total, blocked, legitimate, etc.).'), +}); diff --git a/test/services/cyberfraudService.test.ts b/test/services/cyberfraudService.test.ts index 0a301f4..c14687d 100644 --- a/test/services/cyberfraudService.test.ts +++ b/test/services/cyberfraudService.test.ts @@ -234,5 +234,166 @@ describe('CyberfraudService', () => { 'network fail', ); }); + + it('includes searchQuery in request body when provided', async () => { + const metricsContent = { results: { total: 500 }, labels: {} }; + httpClient.request.resolves(mockApiResponse(metricsContent)); + + await service.getTrafficData({ + ...baseParams, + metrics: true, + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + } as any); + + const [, options] = httpClient.request.firstCall.args; + expect(options.body.searchQuery).to.deep.equal([ + { type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }, + ]); + }); + + it('omits searchQuery from body when not provided', async () => { + const metricsContent = { results: { total: 500 }, labels: {} }; + httpClient.request.resolves(mockApiResponse(metricsContent)); + + await service.getTrafficData({ ...baseParams, metrics: true } as any); + + const [, options] = httpClient.request.firstCall.args; + expect(options.body).to.not.have.property('searchQuery'); + }); + }); + + describe('getRawActivities', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('calls POST /activities with correct URL and body', async () => { + const activitiesContent = { results: [{ socketIp: '1.2.3.4', displayScore: 90 }] }; + httpClient.request.resolves(mockApiResponse(activitiesContent)); + + const result = await service.getRawActivities({ + startTime, + endTime, + limit: 20, + offset: 0, + }); + + expect(httpClient.request.calledOnce).to.be.true; + const [url, options] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/activities'); + expect(url).to.include('from='); + expect(url).to.include('to='); + expect(options.method).to.equal('POST'); + expect(options.body.limit).to.equal(20); + expect(options.body.offset).to.equal(0); + expect(result).to.deep.equal(activitiesContent.results); + }); + + it('includes searchQuery in body when provided', async () => { + httpClient.request.resolves(mockApiResponse({ results: [] })); + + await service.getRawActivities({ + startTime, + endTime, + searchQuery: [{ type: 'field', key: 'blockReference', operator: '=', value: 'abc123' }], + }); + + const [, options] = httpClient.request.firstCall.args; + expect(options.body.searchQuery).to.deep.equal([ + { type: 'field', key: 'blockReference', operator: '=', value: 'abc123' }, + ]); + }); + }); + + describe('getRawActivitiesCount', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('calls POST /activities/count and returns total', async () => { + httpClient.request.resolves(mockApiResponse({ total: 42 })); + + const result = await service.getRawActivitiesCount({ startTime, endTime }); + + expect(httpClient.request.calledOnce).to.be.true; + const [url] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/activities/count'); + expect(result).to.equal(42); + }); + }); + + describe('investigateBlock', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('rejects when time range exceeds 4 hours', async () => { + const wideStart = new Date(now.getTime() - 5 * 60 * 60 * 1000).toISOString(); + await expect( + service.investigateBlock({ blockReference: 'abc', startTime: wideStart, endTime }), + ).to.be.rejectedWith('must not exceed 4 hours'); + }); + + it('orchestrates parallel calls and returns combined result', async () => { + const activitiesContent = { results: [{ socketIp: '1.2.3.4' }] }; + const countContent = { total: 5 }; + const metricsContent = { results: { total: 10, blocked: 5 }, labels: {} }; + + httpClient.request.onCall(0).resolves(mockApiResponse(activitiesContent)); + httpClient.request.onCall(1).resolves(mockApiResponse(countContent)); + httpClient.request.onCall(2).resolves(mockApiResponse(metricsContent)); + + const result = await service.investigateBlock({ blockReference: 'b5e0-b1d1', startTime, endTime }); + + expect(httpClient.request.calledThrice).to.be.true; + expect(result.count).to.equal(5); + expect(result.activities).to.deep.equal(activitiesContent.results); + expect(result.metrics).to.deep.equal(metricsContent); + }); + + it('builds searchQuery with blockReference only', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + await service.investigateBlock({ blockReference: 'myblock', startTime, endTime }).catch(() => {}); + + const firstCall = httpClient.request.firstCall; + const body = firstCall?.args[1]?.body; + expect(body?.searchQuery).to.deep.include({ + type: 'field', + key: 'blockReference', + operator: '=', + value: 'myblock', + }); + }); + + it('builds searchQuery with OR when both blockReference and socketIp provided', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + await service + .investigateBlock({ + blockReference: 'myblock', + socketIp: '1.2.3.4', + startTime, + endTime, + }) + .catch(() => {}); + + const firstCall = httpClient.request.firstCall; + const sq = firstCall?.args[1]?.body?.searchQuery; + const operatorItem = sq?.find((item: any) => item.type === 'operator'); + expect(operatorItem?.operator).to.equal('OR'); + }); }); }); diff --git a/test/tools/investigateBlock.test.ts b/test/tools/investigateBlock.test.ts new file mode 100644 index 0000000..a389a52 --- /dev/null +++ b/test/tools/investigateBlock.test.ts @@ -0,0 +1,41 @@ +import * as chai from 'chai'; +import sinon from 'sinon'; +import { registerInvestigateBlock } from '../../src/tools/investigateBlock'; + +describe('registerInvestigateBlock', () => { + const { expect } = chai; + + it('registers tool and handler calls service', async () => { + const server = { registerTool: sinon.stub() }; + const service = { + investigateBlock: sinon.stub().resolves({ + count: 5, + activities: [{ timestamp: '2026-07-02T10:00:00Z', socketIp: '203.0.113.10' }], + metrics: { results: { total: 10, blocked: 5 } }, + }), + }; + registerInvestigateBlock(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_investigate_block'); + expect(config).to.have.property('description'); + expect(config.description).to.include('blocked'); + + const params = { + socketIp: '203.0.113.10', + startTime: '2026-07-02T10:00:00Z', + endTime: '2026-07-02T10:30:00Z', + }; + await handler(params); + expect(service.investigateBlock.calledWith(params)).to.be.true; + }); + + it('registers with readOnlyHint and openWorldHint annotations', () => { + const server = { registerTool: sinon.stub() }; + const service = { investigateBlock: sinon.stub() }; + registerInvestigateBlock(server as any, service as any); + const [, config] = server.registerTool.firstCall.args; + expect(config.annotations.readOnlyHint).to.be.true; + expect(config.annotations.openWorldHint).to.be.true; + }); +}); From 87ec55fd64bd01834a5604f9adb0ecb4ddacfb04 Mon Sep 17 00:00:00 2001 From: asafcohen Date: Thu, 2 Jul 2026 15:29:56 +0300 Subject: [PATCH 2/6] raw activities capablities and query search --- src/services/cyberfraudService.ts | 28 +++++++++--- src/tools/investigateBlock.ts | 60 ++++++++++++++++++------- src/types/cyberfraud/trafficData.ts | 17 +++++-- test/services/cyberfraudService.test.ts | 42 ++++++++++++++++- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/src/services/cyberfraudService.ts b/src/services/cyberfraudService.ts index c78f5a3..233f426 100644 --- a/src/services/cyberfraudService.ts +++ b/src/services/cyberfraudService.ts @@ -232,15 +232,33 @@ export class CyberfraudService { const clamped = clampAttackReportingTimes(params.startTime, params.endTime); enforceInvestigateTimeRange(clamped.startTime, clamped.endTime); - const searchQuery: NonNullable = []; + const shortcutItems: NonNullable = []; if (params.blockReference) { - searchQuery.push({ type: 'field', key: 'blockReference', operator: '=', value: params.blockReference }); + shortcutItems.push({ type: 'field', key: 'blockReference', operator: '=', value: params.blockReference }); } if (params.socketIp) { - if (params.blockReference) { - searchQuery.push({ type: 'operator', operator: 'OR' }); + if (shortcutItems.length > 0) { + shortcutItems.push({ type: 'operator', operator: 'OR' }); } - searchQuery.push({ type: 'field', key: 'socketIp', operator: '=', value: params.socketIp }); + shortcutItems.push({ type: 'field', key: 'socketIp', operator: '=', value: params.socketIp }); + } + + let searchQuery: NonNullable; + const explicitQuery = params.searchQuery ?? []; + if (shortcutItems.length > 0 && explicitQuery.length > 0) { + searchQuery = [ + { type: 'operator', operator: '(' as const }, + ...shortcutItems, + { type: 'operator', operator: ')' as const }, + { type: 'operator', operator: 'AND' as const }, + { type: 'operator', operator: '(' as const }, + ...explicitQuery, + { type: 'operator', operator: ')' as const }, + ]; + } else if (shortcutItems.length > 0) { + searchQuery = shortcutItems; + } else { + searchQuery = explicitQuery; } const baseParams = { diff --git a/src/tools/investigateBlock.ts b/src/tools/investigateBlock.ts index f7d0901..eee05e5 100644 --- a/src/tools/investigateBlock.ts +++ b/src/tools/investigateBlock.ts @@ -10,24 +10,44 @@ export function registerInvestigateBlock(server: McpServer, cyberfraudService: C server.registerTool( 'human_investigate_block', { - description: `Investigates why a specific Block ID or IP address is being blocked by HUMAN Security. -Fetches matching raw activity records, total count, and aggregated traffic metrics for the given time window. + description: `Fetches raw activity records (individual request logs), total count, and aggregated traffic metrics from HUMAN Security for a given search criteria and time window. + +Use this tool to drill into specific traffic at the request level — whether investigating a Block ID, an IP, a user email, a domain, or any other supported field. ⚠️ TIME RANGE CONSTRAINT: Raw activity queries are expensive. The time window MUST be at most 4 hours. Use short windows like the last 30, 60, or 240 minutes. If the user does not specify, default to the last 1 hour. 🎯 WHEN TO USE THIS TOOL: -• User provides a Block ID / Reference ID and wants to understand why it was blocked -• User provides an IP address and wants to understand what traffic came from it and why it was blocked -• User wants to drill into a specific incident at the request level +• User wants to investigate a specific Block ID / Reference ID +• User wants to investigate traffic from a specific IP or CIDR +• User wants to find raw activity records for a user email, domain, path, VID, or any other field +• User wants to drill into specific incidents at the request level +• User asks for raw request logs matching a condition + +📋 SEARCH OPTIONS: +There are two ways to specify search criteria (can be combined — they are merged with AND): + +1. SHORTCUTS (convenience fields): + • blockReference — auto-builds searchQuery for Block ID lookup + • socketIp — auto-builds searchQuery for IP/CIDR lookup + +2. FULL searchQuery (same syntax as human_get_traffic_data): + Use for any field: userEmail, domain, path, displayScore, vid, uaServer, knownBot, + incidentTypes, filterOriginReason, httpMethod, httpStatusCode, customRule, + accessTokenName, headerReferer, graphqlOperationName, graphqlOperationType, + customParam1–customParam10, socketIpOrgName, agent, and more. -🔍 KEY RESPONSE FIELDS FOR BLOCK ANALYSIS (in activities[]): + Field expression: { type: "field", key: "", operator: "", value: "" } + Logical operator: { type: "operator", operator: "AND" | "OR" | "NOT" | "(" | ")" } + +🔍 KEY RESPONSE FIELDS FOR ANALYSIS (in activities[]): • filterOriginReason — the specific reason the request was filtered/blocked • ruleName — which rule triggered the block • displayScore — risk score (0–100); higher = more suspicious -• incidentTypes — detected threat signals — valid values: "Bot Behavior", "Automation Tool", "Spoof", "Behavioral Anomalies", "UI Anomaly", "Bad Reputation", "Volumetric Rule", "Volumetric Anomaly", "Missing Sensor Data", "Cloud Service", "Anonymizing Service", "Denylisted Service", "Custom Denylist", "Captcha Solving Attack" +• incidentTypes — detected threat signals: "Bot Behavior", "Automation Tool", "Spoof", "Behavioral Anomalies", "UI Anomaly", "Bad Reputation", "Volumetric Rule", "Volumetric Anomaly", "Missing Sensor Data", "Cloud Service", "Anonymizing Service", "Denylisted Service", "Custom Denylist", "Captcha Solving Attack" • trafficTags — traffic classification (e.g. "Blocked Requests", "Simulated Block") • blockReference — the Block ID associated with this request +• socketIp, domain, path, userEmail, vid, httpMethod, httpStatusCode, country, browserDisplay, osFamily 📊 RESPONSE STRUCTURE: • count: total number of matching records in the time window @@ -36,29 +56,35 @@ Use short windows like the last 30, 60, or 240 minutes. If the user does not spe ✅ USAGE PATTERNS: -1. INVESTIGATE BY BLOCK ID: +1. INVESTIGATE BY BLOCK ID (shortcut): {"blockReference": "b5e0-b1d1-a54de", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} -2. INVESTIGATE BY IP: +2. INVESTIGATE BY IP (shortcut): {"socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} -3. INVESTIGATE IP WITH CIDR: - {"socketIp": "203.0.113.0/24", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} +3. INVESTIGATE BY USER EMAIL: + {"searchQuery": [{"type": "field", "key": "userEmail", "operator": "=", "value": "user@example.com"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +4. INVESTIGATE BY DOMAIN + HIGH RISK: + {"searchQuery": [{"type": "field", "key": "domain", "operator": "contains", "value": "example.com"}, {"type": "operator", "operator": "AND"}, {"type": "field", "key": "displayScore", "operator": ">=", "value": 80}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +5. INVESTIGATE BY VID: + {"searchQuery": [{"type": "field", "key": "vid", "operator": "=", "value": "abc-123-def"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} -4. INVESTIGATE BOTH (returns OR match): - {"blockReference": "b5e0-b1d1-a54de", "socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} +6. SHORTCUT + SEARCHQUERY COMBINED (IP AND blocked traffic with automation): + {"socketIp": "203.0.113.10", "searchQuery": [{"type": "field", "key": "incidentTypes", "operator": "=", "value": "Automation Tool"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} -5. NARROW TO BLOCKED TRAFFIC ONLY: +7. NARROW TO BLOCKED TRAFFIC ONLY (via filters): {"socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "filters": {"trafficTags": ["blocked", "potentialBlock"]}} ⚠️ NOTES: -• Provide at least one of blockReference or socketIp. +• At least one of blockReference, socketIp, or searchQuery must be provided. • trafficSource defaults to ["web", "mobile"] when omitted. -• For broader traffic analysis without a specific Block ID or IP, use human_get_traffic_data instead.`, +• For broader traffic analytics (overtime, metrics, tops) without raw records, use human_get_traffic_data instead.`, inputSchema: InvestigateBlockBaseSchema.shape, outputSchema: makeStructuredResponseSchema(InvestigateBlockOutputSchema).shape, annotations: { - title: 'HUMAN Investigate Block', + title: 'HUMAN Investigate Traffic', readOnlyHint: true, openWorldHint: true, }, diff --git a/src/types/cyberfraud/trafficData.ts b/src/types/cyberfraud/trafficData.ts index 3025efb..8a2dc8b 100644 --- a/src/types/cyberfraud/trafficData.ts +++ b/src/types/cyberfraud/trafficData.ts @@ -209,13 +209,19 @@ export const InvestigateBlockBaseSchema = z.object({ blockReference: z .string() .optional() - .describe('Block ID / Reference ID to investigate (e.g. "b5e0-b1d1-a54de"). Provide this OR socketIp.'), + .describe( + 'Shortcut: Block ID / Reference ID to investigate (e.g. "b5e0-b1d1-a54de"). Auto-added to searchQuery.', + ), socketIp: z .string() .optional() .describe( - 'IP address or CIDR range to investigate (e.g. "203.0.113.10" or "203.0.113.0/24"). Provide this OR blockReference.', + 'Shortcut: IP address or CIDR range to investigate (e.g. "203.0.113.10" or "203.0.113.0/24"). Auto-added to searchQuery.', ), + searchQuery: SearchQuerySchema.describe( + 'Full searchQuery for any field-level filtering. Same syntax as human_get_traffic_data. ' + + 'If blockReference or socketIp shortcuts are also provided, they are merged with AND.', + ), startTime: z .string() .describe( @@ -234,8 +240,11 @@ export const InvestigateBlockBaseSchema = z.object({ }); export const InvestigateBlockInputSchema = InvestigateBlockBaseSchema.refine( - (data) => data.blockReference !== undefined || data.socketIp !== undefined, - { message: 'At least one of blockReference or socketIp must be provided.' }, + (data) => + data.blockReference !== undefined || + data.socketIp !== undefined || + (data.searchQuery !== undefined && data.searchQuery.length > 0), + { message: 'At least one of blockReference, socketIp, or searchQuery must be provided.' }, ); export type InvestigateBlockInput = z.infer; diff --git a/test/services/cyberfraudService.test.ts b/test/services/cyberfraudService.test.ts index c14687d..6c66cc7 100644 --- a/test/services/cyberfraudService.test.ts +++ b/test/services/cyberfraudService.test.ts @@ -392,8 +392,46 @@ describe('CyberfraudService', () => { const firstCall = httpClient.request.firstCall; const sq = firstCall?.args[1]?.body?.searchQuery; - const operatorItem = sq?.find((item: any) => item.type === 'operator'); - expect(operatorItem?.operator).to.equal('OR'); + const operatorItem = sq?.find((item: any) => item.type === 'operator' && item.operator === 'OR'); + expect(operatorItem).to.exist; + }); + + it('accepts searchQuery only (no blockReference or socketIp)', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + await service + .investigateBlock({ + searchQuery: [{ type: 'field', key: 'userEmail', operator: '=', value: 'test@example.com' }], + startTime, + endTime, + }) + .catch(() => {}); + + const firstCall = httpClient.request.firstCall; + const sq = firstCall?.args[1]?.body?.searchQuery; + expect(sq).to.deep.equal([{ type: 'field', key: 'userEmail', operator: '=', value: 'test@example.com' }]); + }); + + it('merges shortcut fields with explicit searchQuery using AND', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + await service + .investigateBlock({ + socketIp: '1.2.3.4', + searchQuery: [{ type: 'field', key: 'displayScore', operator: '>=', value: 80 }], + startTime, + endTime, + }) + .catch(() => {}); + + const firstCall = httpClient.request.firstCall; + const sq = firstCall?.args[1]?.body?.searchQuery; + const andOp = sq?.find((item: any) => item.type === 'operator' && item.operator === 'AND'); + expect(andOp).to.exist; + const ipItem = sq?.find((item: any) => item.key === 'socketIp'); + expect(ipItem).to.exist; + const scoreItem = sq?.find((item: any) => item.key === 'displayScore'); + expect(scoreItem).to.exist; }); }); }); From ace7897f585fdc7514cbbadb2714d4ed88bb3b68 Mon Sep 17 00:00:00 2001 From: asafcohen Date: Thu, 2 Jul 2026 15:42:50 +0300 Subject: [PATCH 3/6] raw activities capablities and query search --- CHANGELOG.md | 2 +- src/services/cyberfraudService.ts | 16 +++++++++++++++- src/tools/investigateBlock.ts | 9 +++++++-- src/types/cyberfraud/trafficData.ts | 15 +++++++++++++++ test/services/cyberfraudService.test.ts | 20 ++++++++++++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e936bf0..2f3cbb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -* New `human_investigate_block` tool: given a Block ID / Reference ID or an IP address and a short time window (max 1 hour), returns matching raw activity records (up to 20), the total count, and aggregated traffic metrics to help analyze why traffic is being blocked. Key analysis fields surfaced: `filterOriginReason`, `ruleName`, `displayScore`, `incidentTypes`, `trafficTags`, and `blockReference`. +* New `human_investigate_block` tool: given a Block ID / Reference ID or an IP address and a short time window (max 4 hours), returns matching raw activity records, the total count, and aggregated traffic metrics to help analyze why traffic is being blocked. Supports `limit` (1-100, default 20) and `offset` for paginating through matching records (sorted newest-first). Key analysis fields surfaced: `filterOriginReason`, `ruleName`, `displayScore`, `incidentTypes`, `trafficTags`, and `blockReference`. * `searchQuery` support across all traffic data endpoints (`/overtime`, `/metrics`, `/tops/*`) for field-level filtering with boolean logic (AND, OR, NOT, parentheses). Available filter fields include: `socketIp`, `blockReference`, `displayScore`, `domain`, `path`, `filterOriginReason`, `ruleName`, `uaServer`, `knownBot`, `userEmail`, `httpMethod`, `httpStatusCode`, and more. ### Changed diff --git a/src/services/cyberfraudService.ts b/src/services/cyberfraudService.ts index 233f426..98da1ec 100644 --- a/src/services/cyberfraudService.ts +++ b/src/services/cyberfraudService.ts @@ -229,6 +229,20 @@ export class CyberfraudService { } async investigateBlock(params: InvestigateBlockInput): Promise { + // Guard the "at least one criterion" contract here: the tool exposes the un-refined + // base schema to the MCP SDK, so the refinement on InvestigateBlockInputSchema is not + // applied at the boundary. Without this, a criteria-less call would fetch all unfiltered + // traffic in the window (an expensive, meaningless query). + const hasCriteria = + !!params.blockReference || + !!params.socketIp || + (params.searchQuery !== undefined && params.searchQuery.length > 0); + if (!hasCriteria) { + throw new Error( + 'At least one of blockReference, socketIp, or searchQuery must be provided to investigate traffic.', + ); + } + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); enforceInvestigateTimeRange(clamped.startTime, clamped.endTime); @@ -270,7 +284,7 @@ export class CyberfraudService { }; const [activities, count, metricsData] = await Promise.all([ - this.getRawActivities({ ...baseParams, limit: 20, offset: 0 }), + this.getRawActivities({ ...baseParams, limit: params.limit ?? 20, offset: params.offset ?? 0 }), this.getRawActivitiesCount(baseParams), this.getTrafficData({ ...baseParams, metrics: true }), ]); diff --git a/src/tools/investigateBlock.ts b/src/tools/investigateBlock.ts index eee05e5..d996aea 100644 --- a/src/tools/investigateBlock.ts +++ b/src/tools/investigateBlock.ts @@ -50,10 +50,14 @@ There are two ways to specify search criteria (can be combined — they are merg • socketIp, domain, path, userEmail, vid, httpMethod, httpStatusCode, country, browserDisplay, osFamily 📊 RESPONSE STRUCTURE: -• count: total number of matching records in the time window -• activities: up to 20 sample records with all available fields +• count: total number of matching records in the time window (always the full total, regardless of limit/offset) +• activities: matching records, newest first — up to 'limit' (default 20, max 100) starting at 'offset' • metrics: aggregated totals (blocked, legitimate, total, etc.) for the matching traffic +📄 PAGINATION: +• limit: how many raw records to return (1-100, default 20). +• offset: how many to skip (records are sorted newest-first). Use count + offset to page through results. + ✅ USAGE PATTERNS: 1. INVESTIGATE BY BLOCK ID (shortcut): @@ -80,6 +84,7 @@ There are two ways to specify search criteria (can be combined — they are merg ⚠️ NOTES: • At least one of blockReference, socketIp, or searchQuery must be provided. • trafficSource defaults to ["web", "mobile"] when omitted. +• Use limit/offset to page through more than the default 20 records when count is large. • For broader traffic analytics (overtime, metrics, tops) without raw records, use human_get_traffic_data instead.`, inputSchema: InvestigateBlockBaseSchema.shape, outputSchema: makeStructuredResponseSchema(InvestigateBlockOutputSchema).shape, diff --git a/src/types/cyberfraud/trafficData.ts b/src/types/cyberfraud/trafficData.ts index 8a2dc8b..800dc7b 100644 --- a/src/types/cyberfraud/trafficData.ts +++ b/src/types/cyberfraud/trafficData.ts @@ -237,6 +237,21 @@ export const InvestigateBlockBaseSchema = z.object({ .optional() .describe('Platform filter: ["web"], ["mobile"], or both. Defaults to ["web", "mobile"].'), filters: TrafficDataFiltersSchema.describe('Optional additional filters to narrow the investigation scope.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + 'Max raw activity records to return (1-100). Defaults to 20. The count field always reflects the full total.', + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of records to skip for pagination (records are sorted newest-first). Defaults to 0.'), }); export const InvestigateBlockInputSchema = InvestigateBlockBaseSchema.refine( diff --git a/test/services/cyberfraudService.test.ts b/test/services/cyberfraudService.test.ts index 6c66cc7..135aed7 100644 --- a/test/services/cyberfraudService.test.ts +++ b/test/services/cyberfraudService.test.ts @@ -346,6 +346,13 @@ describe('CyberfraudService', () => { ).to.be.rejectedWith('must not exceed 4 hours'); }); + it('rejects when no criteria (blockReference, socketIp, or searchQuery) is provided', async () => { + await expect(service.investigateBlock({ startTime, endTime } as any)).to.be.rejectedWith( + 'At least one of blockReference, socketIp, or searchQuery must be provided', + ); + expect(httpClient.request.called).to.be.false; + }); + it('orchestrates parallel calls and returns combined result', async () => { const activitiesContent = { results: [{ socketIp: '1.2.3.4' }] }; const countContent = { total: 5 }; @@ -363,6 +370,19 @@ describe('CyberfraudService', () => { expect(result.metrics).to.deep.equal(metricsContent); }); + it('forwards limit and offset to the raw activities request and defaults to 20/0', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + await service.investigateBlock({ socketIp: '1.2.3.4', startTime, endTime, limit: 50, offset: 40 }); + + const activitiesCall = httpClient.request + .getCalls() + .find((c: any) => typeof c.args[0] === 'string' && c.args[0].includes('/activities?')); + expect(activitiesCall).to.exist; + expect(activitiesCall!.args[1].body.limit).to.equal(50); + expect(activitiesCall!.args[1].body.offset).to.equal(40); + }); + it('builds searchQuery with blockReference only', async () => { httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); From acaa2c557ee2a273186602171cf55ade639fe408 Mon Sep 17 00:00:00 2001 From: asafcohen Date: Thu, 2 Jul 2026 15:48:59 +0300 Subject: [PATCH 4/6] fix naming --- CHANGELOG.md | 4 ++-- src/tools/getTrafficData.ts | 2 +- src/tools/investigateBlock.ts | 4 ++-- test/tools/investigateBlock.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3cbb7..a7a40d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -* New `human_investigate_block` tool: given a Block ID / Reference ID or an IP address and a short time window (max 4 hours), returns matching raw activity records, the total count, and aggregated traffic metrics to help analyze why traffic is being blocked. Supports `limit` (1-100, default 20) and `offset` for paginating through matching records (sorted newest-first). Key analysis fields surfaced: `filterOriginReason`, `ruleName`, `displayScore`, `incidentTypes`, `trafficTags`, and `blockReference`. +* New `human_get_raw_activities` tool: given a Block ID / Reference ID or an IP address and a short time window (max 4 hours), returns matching raw activity records, the total count, and aggregated traffic metrics to help analyze why traffic is being blocked. Supports `limit` (1-100, default 20) and `offset` for paginating through matching records (sorted newest-first). Key analysis fields surfaced: `filterOriginReason`, `ruleName`, `displayScore`, `incidentTypes`, `trafficTags`, and `blockReference`. * `searchQuery` support across all traffic data endpoints (`/overtime`, `/metrics`, `/tops/*`) for field-level filtering with boolean logic (AND, OR, NOT, parentheses). Available filter fields include: `socketIp`, `blockReference`, `displayScore`, `domain`, `path`, `filterOriginReason`, `ruleName`, `uaServer`, `knownBot`, `userEmail`, `httpMethod`, `httpStatusCode`, and more. ### Changed -* `human_get_traffic_data` tool description updated to document the new `searchQuery` capability and all available filter field keys. +* `human_get_traffic_data` tool description updated to document the new `searchQuery` capability, all available filter field keys, and a pointer to `human_get_raw_activities` for request-level records. ## [1.1.1] - 2026-07-02 diff --git a/src/tools/getTrafficData.ts b/src/tools/getTrafficData.ts index e0e69cd..3c04604 100644 --- a/src/tools/getTrafficData.ts +++ b/src/tools/getTrafficData.ts @@ -19,7 +19,7 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe ├── Need top blocked IP orgs? → tops: ["socketIpOrgName"] ├── Security focus only? → filters: { trafficTags: ["blocked", "potentialBlock"] } ├── Multi-platform analysis? → trafficSource: ["web", "mobile"] -└── Need to investigate a specific Block ID or IP? → use human_investigate_block instead +└── Need raw request-level records? → use human_get_raw_activities instead ✅ REQUEST MODES (combinable in one call): • overtime: Per-minute time-series counts. Use seriesFields to break out by knownBot, customRule, or accessTokenName. diff --git a/src/tools/investigateBlock.ts b/src/tools/investigateBlock.ts index d996aea..bad9c06 100644 --- a/src/tools/investigateBlock.ts +++ b/src/tools/investigateBlock.ts @@ -8,7 +8,7 @@ import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../utils/con export function registerInvestigateBlock(server: McpServer, cyberfraudService: CyberfraudService) { server.registerTool( - 'human_investigate_block', + 'human_get_raw_activities', { description: `Fetches raw activity records (individual request logs), total count, and aggregated traffic metrics from HUMAN Security for a given search criteria and time window. @@ -89,7 +89,7 @@ There are two ways to specify search criteria (can be combined — they are merg inputSchema: InvestigateBlockBaseSchema.shape, outputSchema: makeStructuredResponseSchema(InvestigateBlockOutputSchema).shape, annotations: { - title: 'HUMAN Investigate Traffic', + title: 'HUMAN Get Raw Activities', readOnlyHint: true, openWorldHint: true, }, diff --git a/test/tools/investigateBlock.test.ts b/test/tools/investigateBlock.test.ts index a389a52..962a998 100644 --- a/test/tools/investigateBlock.test.ts +++ b/test/tools/investigateBlock.test.ts @@ -17,7 +17,7 @@ describe('registerInvestigateBlock', () => { registerInvestigateBlock(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_investigate_block'); + expect(name).to.equal('human_get_raw_activities'); expect(config).to.have.property('description'); expect(config.description).to.include('blocked'); From d13c7840df9648b8b353a76dd8cc0216e113467e Mon Sep 17 00:00:00 2001 From: asafcohen Date: Thu, 2 Jul 2026 15:51:02 +0300 Subject: [PATCH 5/6] fix naming --- src/tools/{investigateBlock.ts => getRawActivities.ts} | 0 src/tools/index.ts | 2 +- .../{investigateBlock.test.ts => getRawActivities.test.ts} | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/tools/{investigateBlock.ts => getRawActivities.ts} (100%) rename test/tools/{investigateBlock.test.ts => getRawActivities.test.ts} (95%) diff --git a/src/tools/investigateBlock.ts b/src/tools/getRawActivities.ts similarity index 100% rename from src/tools/investigateBlock.ts rename to src/tools/getRawActivities.ts diff --git a/src/tools/index.ts b/src/tools/index.ts index 0709175..a291a86 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,7 +9,7 @@ import { registerCodeDefenderGetHeaderInventory } from './codeDefenderGetHeaderI import type { CyberfraudService } from '../services/cyberfraudService'; import type { CodeDefenderService } from '../services/codeDefenderService'; import { registerCyberfraudGetTrafficData } from './getTrafficData'; -import { registerInvestigateBlock } from './investigateBlock'; +import { registerInvestigateBlock } from './getRawActivities'; export function registerTools( server: McpServer, diff --git a/test/tools/investigateBlock.test.ts b/test/tools/getRawActivities.test.ts similarity index 95% rename from test/tools/investigateBlock.test.ts rename to test/tools/getRawActivities.test.ts index 962a998..f44dd38 100644 --- a/test/tools/investigateBlock.test.ts +++ b/test/tools/getRawActivities.test.ts @@ -1,6 +1,6 @@ import * as chai from 'chai'; import sinon from 'sinon'; -import { registerInvestigateBlock } from '../../src/tools/investigateBlock'; +import { registerInvestigateBlock } from '../../src/tools/getRawActivities'; describe('registerInvestigateBlock', () => { const { expect } = chai; From 71a3b66bf9dd7271301b3da50a98cdfeb48e1405 Mon Sep 17 00:00:00 2001 From: asafcohen Date: Thu, 2 Jul 2026 16:20:45 +0300 Subject: [PATCH 6/6] pr --- src/services/cyberfraudService.ts | 77 +++------------- src/tools/getRawActivities.ts | 53 +++++------ src/tools/index.ts | 4 +- src/types/cyberfraud/trafficData.ts | 53 ++++------- test/services/cyberfraudService.test.ts | 116 +++++++----------------- test/tools/getRawActivities.test.ts | 17 ++-- 6 files changed, 93 insertions(+), 227 deletions(-) diff --git a/src/services/cyberfraudService.ts b/src/services/cyberfraudService.ts index 98da1ec..2454188 100644 --- a/src/services/cyberfraudService.ts +++ b/src/services/cyberfraudService.ts @@ -11,15 +11,9 @@ import type { CyberfraudCustomRulesResponse, TrafficDataInput, TrafficDataResponse, - InvestigateBlockInput, + RawActivitiesInput, } from '../types/cyberfraud'; -type InvestigateBlockOutput = { - count: number; - activities: Record[]; - metrics?: { results: Record; labels?: Record }; -}; - const API_BASE = `${HUMAN_API_BASE}/cyberfraud`; const TRAFFIC_DATA_BASE = HUMAN_TRAFFIC_API_BASE; @@ -185,8 +179,7 @@ export class CyberfraudService { } async getRawActivities( - params: Pick & { - searchQuery?: TrafficDataInput['searchQuery']; + params: Pick & { limit?: number; offset?: number; }, @@ -200,7 +193,7 @@ export class CyberfraudService { params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], }; if (params.filters) body.filters = params.filters; - if (params.searchQuery?.length) body.searchQuery = params.searchQuery; + body.searchQuery = params.searchQuery; if (params.limit !== undefined) body.limit = params.limit; if (params.offset !== undefined) body.offset = params.offset; const res = await this.http.request(url, { method: 'POST', body }); @@ -209,9 +202,7 @@ export class CyberfraudService { } async getRawActivitiesCount( - params: Pick & { - searchQuery?: TrafficDataInput['searchQuery']; - }, + params: Pick, ): Promise { const clamped = clampAttackReportingTimes(params.startTime, params.endTime); const from = Math.floor(new Date(clamped.startTime).getTime() / 1000); @@ -222,77 +213,31 @@ export class CyberfraudService { params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], }; if (params.filters) body.filters = params.filters; - if (params.searchQuery?.length) body.searchQuery = params.searchQuery; + body.searchQuery = params.searchQuery; const res = await this.http.request(url, { method: 'POST', body }); const content = await parseApiResponse<{ total: number }>(res); return content.total; } - async investigateBlock(params: InvestigateBlockInput): Promise { - // Guard the "at least one criterion" contract here: the tool exposes the un-refined - // base schema to the MCP SDK, so the refinement on InvestigateBlockInputSchema is not - // applied at the boundary. Without this, a criteria-less call would fetch all unfiltered - // traffic in the window (an expensive, meaningless query). - const hasCriteria = - !!params.blockReference || - !!params.socketIp || - (params.searchQuery !== undefined && params.searchQuery.length > 0); - if (!hasCriteria) { - throw new Error( - 'At least one of blockReference, socketIp, or searchQuery must be provided to investigate traffic.', - ); - } - + async fetchRawActivities( + params: RawActivitiesInput, + ): Promise<{ count: number; activities: Record[] }> { const clamped = clampAttackReportingTimes(params.startTime, params.endTime); enforceInvestigateTimeRange(clamped.startTime, clamped.endTime); - const shortcutItems: NonNullable = []; - if (params.blockReference) { - shortcutItems.push({ type: 'field', key: 'blockReference', operator: '=', value: params.blockReference }); - } - if (params.socketIp) { - if (shortcutItems.length > 0) { - shortcutItems.push({ type: 'operator', operator: 'OR' }); - } - shortcutItems.push({ type: 'field', key: 'socketIp', operator: '=', value: params.socketIp }); - } - - let searchQuery: NonNullable; - const explicitQuery = params.searchQuery ?? []; - if (shortcutItems.length > 0 && explicitQuery.length > 0) { - searchQuery = [ - { type: 'operator', operator: '(' as const }, - ...shortcutItems, - { type: 'operator', operator: ')' as const }, - { type: 'operator', operator: 'AND' as const }, - { type: 'operator', operator: '(' as const }, - ...explicitQuery, - { type: 'operator', operator: ')' as const }, - ]; - } else if (shortcutItems.length > 0) { - searchQuery = shortcutItems; - } else { - searchQuery = explicitQuery; - } - const baseParams = { startTime: clamped.startTime, endTime: clamped.endTime, trafficSource: params.trafficSource, filters: params.filters, - searchQuery, + searchQuery: params.searchQuery, }; - const [activities, count, metricsData] = await Promise.all([ + const [activities, count] = await Promise.all([ this.getRawActivities({ ...baseParams, limit: params.limit ?? 20, offset: params.offset ?? 0 }), this.getRawActivitiesCount(baseParams), - this.getTrafficData({ ...baseParams, metrics: true }), ]); - return { - count, - activities, - metrics: metricsData.metrics, - }; + return { count, activities }; } } diff --git a/src/tools/getRawActivities.ts b/src/tools/getRawActivities.ts index bad9c06..bc16d5f 100644 --- a/src/tools/getRawActivities.ts +++ b/src/tools/getRawActivities.ts @@ -1,16 +1,16 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { InvestigateBlockBaseSchema, InvestigateBlockOutputSchema } from '../types/cyberfraud'; +import { RawActivitiesInputBaseSchema, RawActivitiesOutputSchema } from '../types/cyberfraud'; import { mcpToolHandler } from '../utils/mcpToolHandler'; import { makeStructuredResponseSchema } from '../utils/makeStructuredResponseSchema'; import type { CyberfraudService } from '../services/cyberfraudService'; -import type { InvestigateBlockInput } from '../types/cyberfraud'; +import type { RawActivitiesInput } from '../types/cyberfraud'; import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../utils/constants'; -export function registerInvestigateBlock(server: McpServer, cyberfraudService: CyberfraudService) { +export function registerGetRawActivities(server: McpServer, cyberfraudService: CyberfraudService) { server.registerTool( 'human_get_raw_activities', { - description: `Fetches raw activity records (individual request logs), total count, and aggregated traffic metrics from HUMAN Security for a given search criteria and time window. + description: `Fetches raw activity records (individual request logs) and total count from HUMAN Security for a given search criteria and time window. Use this tool to drill into specific traffic at the request level — whether investigating a Block ID, an IP, a user email, a domain, or any other supported field. @@ -24,21 +24,15 @@ Use short windows like the last 30, 60, or 240 minutes. If the user does not spe • User wants to drill into specific incidents at the request level • User asks for raw request logs matching a condition -📋 SEARCH OPTIONS: -There are two ways to specify search criteria (can be combined — they are merged with AND): +📋 SEARCH QUERY (required, same syntax as human_get_traffic_data): +Use searchQuery for field-level filtering with boolean logic. +Field expression: { type: "field", key: "", operator: "", value: "" } +Logical operator: { type: "operator", operator: "AND" | "OR" | "NOT" | "(" | ")" } -1. SHORTCUTS (convenience fields): - • blockReference — auto-builds searchQuery for Block ID lookup - • socketIp — auto-builds searchQuery for IP/CIDR lookup - -2. FULL searchQuery (same syntax as human_get_traffic_data): - Use for any field: userEmail, domain, path, displayScore, vid, uaServer, knownBot, - incidentTypes, filterOriginReason, httpMethod, httpStatusCode, customRule, - accessTokenName, headerReferer, graphqlOperationName, graphqlOperationType, - customParam1–customParam10, socketIpOrgName, agent, and more. - - Field expression: { type: "field", key: "", operator: "", value: "" } - Logical operator: { type: "operator", operator: "AND" | "OR" | "NOT" | "(" | ")" } +Supported field keys include: blockReference, socketIp, userEmail, domain, path, displayScore, vid, uaServer, knownBot, +incidentTypes, filterOriginReason, httpMethod, httpStatusCode, customRule, +accessTokenName, headerReferer, graphqlOperationName, graphqlOperationType, +customParam1–customParam10, socketIpOrgName, agent, and more. 🔍 KEY RESPONSE FIELDS FOR ANALYSIS (in activities[]): • filterOriginReason — the specific reason the request was filtered/blocked @@ -52,7 +46,6 @@ There are two ways to specify search criteria (can be combined — they are merg 📊 RESPONSE STRUCTURE: • count: total number of matching records in the time window (always the full total, regardless of limit/offset) • activities: matching records, newest first — up to 'limit' (default 20, max 100) starting at 'offset' -• metrics: aggregated totals (blocked, legitimate, total, etc.) for the matching traffic 📄 PAGINATION: • limit: how many raw records to return (1-100, default 20). @@ -60,11 +53,11 @@ There are two ways to specify search criteria (can be combined — they are merg ✅ USAGE PATTERNS: -1. INVESTIGATE BY BLOCK ID (shortcut): - {"blockReference": "b5e0-b1d1-a54de", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} +1. INVESTIGATE BY BLOCK ID: + {"searchQuery": [{"type": "field", "key": "blockReference", "operator": "=", "value": "b5e0-b1d1-a54de"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} -2. INVESTIGATE BY IP (shortcut): - {"socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} +2. INVESTIGATE BY IP: + {"searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.10"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} 3. INVESTIGATE BY USER EMAIL: {"searchQuery": [{"type": "field", "key": "userEmail", "operator": "=", "value": "user@example.com"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} @@ -75,25 +68,25 @@ There are two ways to specify search criteria (can be combined — they are merg 5. INVESTIGATE BY VID: {"searchQuery": [{"type": "field", "key": "vid", "operator": "=", "value": "abc-123-def"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} -6. SHORTCUT + SEARCHQUERY COMBINED (IP AND blocked traffic with automation): - {"socketIp": "203.0.113.10", "searchQuery": [{"type": "field", "key": "incidentTypes", "operator": "=", "value": "Automation Tool"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} +6. IP WITH AUTOMATION INCIDENT: + {"searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.10"}, {"type": "operator", "operator": "AND"}, {"type": "field", "key": "incidentTypes", "operator": "=", "value": "Automation Tool"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} 7. NARROW TO BLOCKED TRAFFIC ONLY (via filters): - {"socketIp": "203.0.113.10", "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "filters": {"trafficTags": ["blocked", "potentialBlock"]}} + {"searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.10"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "filters": {"trafficTags": ["blocked", "potentialBlock"]}} ⚠️ NOTES: -• At least one of blockReference, socketIp, or searchQuery must be provided. +• searchQuery is required and must contain at least one expression. • trafficSource defaults to ["web", "mobile"] when omitted. • Use limit/offset to page through more than the default 20 records when count is large. • For broader traffic analytics (overtime, metrics, tops) without raw records, use human_get_traffic_data instead.`, - inputSchema: InvestigateBlockBaseSchema.shape, - outputSchema: makeStructuredResponseSchema(InvestigateBlockOutputSchema).shape, + inputSchema: RawActivitiesInputBaseSchema.shape, + outputSchema: makeStructuredResponseSchema(RawActivitiesOutputSchema).shape, annotations: { title: 'HUMAN Get Raw Activities', readOnlyHint: true, openWorldHint: true, }, }, - async (params: InvestigateBlockInput) => mcpToolHandler(async () => cyberfraudService.investigateBlock(params)), + async (params: RawActivitiesInput) => mcpToolHandler(async () => cyberfraudService.fetchRawActivities(params)), ); } diff --git a/src/tools/index.ts b/src/tools/index.ts index a291a86..3ca3e6f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,7 +9,7 @@ import { registerCodeDefenderGetHeaderInventory } from './codeDefenderGetHeaderI import type { CyberfraudService } from '../services/cyberfraudService'; import type { CodeDefenderService } from '../services/codeDefenderService'; import { registerCyberfraudGetTrafficData } from './getTrafficData'; -import { registerInvestigateBlock } from './getRawActivities'; +import { registerGetRawActivities } from './getRawActivities'; export function registerTools( server: McpServer, @@ -25,7 +25,7 @@ export function registerTools( registerCyberfraudGetCustomRules(server, services.cyberfraudService); registerCyberfraudGetAccountInfo(server, services.cyberfraudService); registerCyberfraudGetTrafficData(server, services.cyberfraudService); - registerInvestigateBlock(server, services.cyberfraudService); + registerGetRawActivities(server, services.cyberfraudService); } // Register Code Defender tools if service is available diff --git a/src/types/cyberfraud/trafficData.ts b/src/types/cyberfraud/trafficData.ts index 800dc7b..a08be4e 100644 --- a/src/types/cyberfraud/trafficData.ts +++ b/src/types/cyberfraud/trafficData.ts @@ -202,26 +202,20 @@ export const TrafficDataOutputSchema = z export type TrafficDataResponse = z.infer; // ============================================================================= -// Investigate Block +// Raw Activities // ============================================================================= -export const InvestigateBlockBaseSchema = z.object({ - blockReference: z - .string() - .optional() - .describe( - 'Shortcut: Block ID / Reference ID to investigate (e.g. "b5e0-b1d1-a54de"). Auto-added to searchQuery.', - ), - socketIp: z - .string() - .optional() - .describe( - 'Shortcut: IP address or CIDR range to investigate (e.g. "203.0.113.10" or "203.0.113.0/24"). Auto-added to searchQuery.', - ), - searchQuery: SearchQuerySchema.describe( - 'Full searchQuery for any field-level filtering. Same syntax as human_get_traffic_data. ' + - 'If blockReference or socketIp shortcuts are also provided, they are merged with AND.', - ), +const RequiredSearchQuerySchema = z + .array(SearchQueryItemSchema) + .min(1) + .describe( + 'Required structured search query for field-level filtering with boolean logic. ' + + 'Same syntax as human_get_traffic_data. ' + + 'Each item is either a field expression ({type:"field", key, operator, value}) or a logical connector ({type:"operator", operator:"AND"|"OR"|"NOT"|"("|")"}).', + ); + +export const RawActivitiesInputBaseSchema = z.object({ + searchQuery: RequiredSearchQuerySchema, startTime: z .string() .describe( @@ -254,15 +248,8 @@ export const InvestigateBlockBaseSchema = z.object({ .describe('Number of records to skip for pagination (records are sorted newest-first). Defaults to 0.'), }); -export const InvestigateBlockInputSchema = InvestigateBlockBaseSchema.refine( - (data) => - data.blockReference !== undefined || - data.socketIp !== undefined || - (data.searchQuery !== undefined && data.searchQuery.length > 0), - { message: 'At least one of blockReference, socketIp, or searchQuery must be provided.' }, -); - -export type InvestigateBlockInput = z.infer; +export const RawActivitiesInputSchema = RawActivitiesInputBaseSchema; +export type RawActivitiesInput = z.infer; const RawActivitySchema = z .object({ @@ -298,21 +285,13 @@ const RawActivitySchema = z }) .passthrough(); -export const InvestigateBlockOutputSchema = z.object({ +export const RawActivitiesOutputSchema = z.object({ count: z.number().describe('Total number of matching activity records in the requested time window.'), activities: z .array(RawActivitySchema) .describe( - 'Sample of up to 20 matching raw activity records. Key fields for block analysis: ' + + 'Sample of matching raw activity records. Key fields for analysis: ' + 'filterOriginReason (why it was filtered), ruleName (which rule triggered), ' + 'displayScore (risk score 0-100), incidentTypes (detected threats), trafficTags (traffic classification).', ), - metrics: z - .object({ - results: z.record(z.string(), z.number()), - labels: z.record(z.string(), z.string()).optional(), - }) - .passthrough() - .optional() - .describe('Aggregated traffic metrics for the matching traffic (total, blocked, legitimate, etc.).'), }); diff --git a/test/services/cyberfraudService.test.ts b/test/services/cyberfraudService.test.ts index 135aed7..ee82733 100644 --- a/test/services/cyberfraudService.test.ts +++ b/test/services/cyberfraudService.test.ts @@ -278,6 +278,7 @@ describe('CyberfraudService', () => { const result = await service.getRawActivities({ startTime, endTime, + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], limit: 20, offset: 0, }); @@ -321,7 +322,11 @@ describe('CyberfraudService', () => { it('calls POST /activities/count and returns total', async () => { httpClient.request.resolves(mockApiResponse({ total: 42 })); - const result = await service.getRawActivitiesCount({ startTime, endTime }); + const result = await service.getRawActivitiesCount({ + startTime, + endTime, + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + }); expect(httpClient.request.calledOnce).to.be.true; const [url] = httpClient.request.firstCall.args; @@ -330,10 +335,11 @@ describe('CyberfraudService', () => { }); }); - describe('investigateBlock', () => { + describe('fetchRawActivities', () => { const now = new Date(); const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); const endTime = now.toISOString(); + const searchQuery = [{ type: 'field' as const, key: 'blockReference', operator: '=' as const, value: 'abc' }]; function mockApiResponse(content: unknown) { return { json: async () => ({ result: true, message: 'success', content }), ok: true }; @@ -341,39 +347,39 @@ describe('CyberfraudService', () => { it('rejects when time range exceeds 4 hours', async () => { const wideStart = new Date(now.getTime() - 5 * 60 * 60 * 1000).toISOString(); - await expect( - service.investigateBlock({ blockReference: 'abc', startTime: wideStart, endTime }), - ).to.be.rejectedWith('must not exceed 4 hours'); - }); - - it('rejects when no criteria (blockReference, socketIp, or searchQuery) is provided', async () => { - await expect(service.investigateBlock({ startTime, endTime } as any)).to.be.rejectedWith( - 'At least one of blockReference, socketIp, or searchQuery must be provided', + await expect(service.fetchRawActivities({ searchQuery, startTime: wideStart, endTime })).to.be.rejectedWith( + 'must not exceed 4 hours', ); - expect(httpClient.request.called).to.be.false; }); it('orchestrates parallel calls and returns combined result', async () => { const activitiesContent = { results: [{ socketIp: '1.2.3.4' }] }; const countContent = { total: 5 }; - const metricsContent = { results: { total: 10, blocked: 5 }, labels: {} }; httpClient.request.onCall(0).resolves(mockApiResponse(activitiesContent)); httpClient.request.onCall(1).resolves(mockApiResponse(countContent)); - httpClient.request.onCall(2).resolves(mockApiResponse(metricsContent)); - const result = await service.investigateBlock({ blockReference: 'b5e0-b1d1', startTime, endTime }); + const result = await service.fetchRawActivities({ + searchQuery: [{ type: 'field', key: 'blockReference', operator: '=', value: 'b5e0-b1d1' }], + startTime, + endTime, + }); - expect(httpClient.request.calledThrice).to.be.true; + expect(httpClient.request.calledTwice).to.be.true; expect(result.count).to.equal(5); expect(result.activities).to.deep.equal(activitiesContent.results); - expect(result.metrics).to.deep.equal(metricsContent); }); it('forwards limit and offset to the raw activities request and defaults to 20/0', async () => { httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); - await service.investigateBlock({ socketIp: '1.2.3.4', startTime, endTime, limit: 50, offset: 40 }); + await service.fetchRawActivities({ + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + startTime, + endTime, + limit: 50, + offset: 40, + }); const activitiesCall = httpClient.request .getCalls() @@ -383,75 +389,19 @@ describe('CyberfraudService', () => { expect(activitiesCall!.args[1].body.offset).to.equal(40); }); - it('builds searchQuery with blockReference only', async () => { - httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); - - await service.investigateBlock({ blockReference: 'myblock', startTime, endTime }).catch(() => {}); - - const firstCall = httpClient.request.firstCall; - const body = firstCall?.args[1]?.body; - expect(body?.searchQuery).to.deep.include({ - type: 'field', - key: 'blockReference', - operator: '=', - value: 'myblock', - }); - }); - - it('builds searchQuery with OR when both blockReference and socketIp provided', async () => { + it('passes searchQuery through to both activities and count requests', async () => { httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); - await service - .investigateBlock({ - blockReference: 'myblock', - socketIp: '1.2.3.4', - startTime, - endTime, - }) - .catch(() => {}); - - const firstCall = httpClient.request.firstCall; - const sq = firstCall?.args[1]?.body?.searchQuery; - const operatorItem = sq?.find((item: any) => item.type === 'operator' && item.operator === 'OR'); - expect(operatorItem).to.exist; - }); - - it('accepts searchQuery only (no blockReference or socketIp)', async () => { - httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); - - await service - .investigateBlock({ - searchQuery: [{ type: 'field', key: 'userEmail', operator: '=', value: 'test@example.com' }], - startTime, - endTime, - }) - .catch(() => {}); - - const firstCall = httpClient.request.firstCall; - const sq = firstCall?.args[1]?.body?.searchQuery; - expect(sq).to.deep.equal([{ type: 'field', key: 'userEmail', operator: '=', value: 'test@example.com' }]); - }); - - it('merges shortcut fields with explicit searchQuery using AND', async () => { - httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + const query = [ + { type: 'field' as const, key: 'userEmail', operator: '=' as const, value: 'test@example.com' }, + ]; + await service.fetchRawActivities({ searchQuery: query, startTime, endTime }).catch(() => {}); - await service - .investigateBlock({ - socketIp: '1.2.3.4', - searchQuery: [{ type: 'field', key: 'displayScore', operator: '>=', value: 80 }], - startTime, - endTime, - }) - .catch(() => {}); - - const firstCall = httpClient.request.firstCall; - const sq = firstCall?.args[1]?.body?.searchQuery; - const andOp = sq?.find((item: any) => item.type === 'operator' && item.operator === 'AND'); - expect(andOp).to.exist; - const ipItem = sq?.find((item: any) => item.key === 'socketIp'); - expect(ipItem).to.exist; - const scoreItem = sq?.find((item: any) => item.key === 'displayScore'); - expect(scoreItem).to.exist; + const calls = httpClient.request.getCalls(); + expect(calls).to.have.lengthOf(2); + for (const call of calls) { + expect(call.args[1].body.searchQuery).to.deep.equal(query); + } }); }); }); diff --git a/test/tools/getRawActivities.test.ts b/test/tools/getRawActivities.test.ts index f44dd38..1d98f88 100644 --- a/test/tools/getRawActivities.test.ts +++ b/test/tools/getRawActivities.test.ts @@ -1,20 +1,19 @@ import * as chai from 'chai'; import sinon from 'sinon'; -import { registerInvestigateBlock } from '../../src/tools/getRawActivities'; +import { registerGetRawActivities } from '../../src/tools/getRawActivities'; -describe('registerInvestigateBlock', () => { +describe('registerGetRawActivities', () => { const { expect } = chai; it('registers tool and handler calls service', async () => { const server = { registerTool: sinon.stub() }; const service = { - investigateBlock: sinon.stub().resolves({ + fetchRawActivities: sinon.stub().resolves({ count: 5, activities: [{ timestamp: '2026-07-02T10:00:00Z', socketIp: '203.0.113.10' }], - metrics: { results: { total: 10, blocked: 5 } }, }), }; - registerInvestigateBlock(server as any, service as any); + registerGetRawActivities(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_raw_activities'); @@ -22,18 +21,18 @@ describe('registerInvestigateBlock', () => { expect(config.description).to.include('blocked'); const params = { - socketIp: '203.0.113.10', + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '203.0.113.10' }], startTime: '2026-07-02T10:00:00Z', endTime: '2026-07-02T10:30:00Z', }; await handler(params); - expect(service.investigateBlock.calledWith(params)).to.be.true; + expect(service.fetchRawActivities.calledWith(params)).to.be.true; }); it('registers with readOnlyHint and openWorldHint annotations', () => { const server = { registerTool: sinon.stub() }; - const service = { investigateBlock: sinon.stub() }; - registerInvestigateBlock(server as any, service as any); + const service = { fetchRawActivities: sinon.stub() }; + registerGetRawActivities(server as any, service as any); const [, config] = server.registerTool.firstCall.args; expect(config.annotations.readOnlyHint).to.be.true; expect(config.annotations.openWorldHint).to.be.true;