qualys-mcp 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Read-only MCP server for the Qualys VMDR API. Exposes host assets, vulnerability
9
9
  | `qualys_vuln_overview` | Estate-wide counts by tag: every client's open vulnerabilities by severity in one call, with optional CVSS threshold |
10
10
  | `qualys_vuln_summary` | Aggregated vulnerability counts by severity, per client tag and per machine — the dashboard/reporting tool |
11
11
  | `qualys_list_tags` | Asset tags and tag hierarchies (how clients are segmented) |
12
- | `qualys_asset_inventory` | Full CSAM/Global AssetView inventory: all assets with criticality, tags, last logged-on user, agent check-in, sources (QQL filterable) |
12
+ | `qualys_asset_inventory` | Full CSAM/Global AssetView inventory: all assets with criticality, tags, last logged-on user, agent check-in, sources (filterable by tag, OS, etc.) |
13
13
  | `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, optional evidence; tag filterable) — the drill-down tool |
14
14
  | `qualys_knowledgebase` | QID details: title, CVEs, threat, impact, solution |
15
15
  | `qualys_api_call` | Raw GET/POST to any other Qualys endpoint — disabled unless `QUALYS_ENABLE_RAW_API=true` |
@@ -93,7 +93,7 @@ Qualys severity (1–5) is not CVSS. If your remediation policy is CVSS-based
93
93
 
94
94
  ## Notes
95
95
 
96
- - `qualys_asset_inventory` uses the CSAM/Global AssetView gateway API (JWT auth, JSON) and answers "show me all machines" — including agent-tracked assets, criticality scores and tags. Filter with QQL, e.g. `tags.name:"Client A"`, `operatingSystem.category1:"Windows"`, `criticality.score>=3`. Paginate with `hasMore`/`lastSeenAssetId`.
96
+ - `qualys_asset_inventory` uses the CSAM/Global AssetView gateway API (JWT auth, JSON) and answers "show me all machines" — including agent-tracked assets, criticality scores and tags. Filter with structured criteria sent in the request body, e.g. `filters: [{"field": "tags.name", "value": "Client A"}]` — this endpoint does not accept QQL strings. Criteria are ANDed; if a field isn't filterable the API error lists every valid field. Paginate with `hasMore`/`lastSeenAssetId`. Note the gateway API has a much stricter rate quota than the VM API — filter rather than sweep repeatedly.
97
97
  - For per-machine critical vulnerability counts, combine it with `qualys_host_detections` using `severities="5"` (or `"4,5"`).
98
98
  - The Qualys VM API v2 returns XML; this server converts it to JSON.
99
99
  - Rate/concurrency limits (HTTP 409/429) are retried automatically using the `X-RateLimit-ToWait-Sec` header, up to 3 times.
package/dist/index.js CHANGED
@@ -28,11 +28,23 @@ const truncationProperties = {
28
28
  const tools = [
29
29
  {
30
30
  name: 'qualys_asset_inventory',
31
- description: 'Full asset inventory from Qualys CSAM/Global AssetView — the same data as the Asset Management UI: every asset (agent-tracked and scanned) with name, IP, OS, asset criticality score, TruRisk score, tags, last logged-on user, agent last check-in and last scan dates. This is the primary tool for "show me all machines/assets". Filter with Qualys QQL, e.g. tags.name:"Client A", operatingSystem.category1:"Windows", or criticality.score>=3. Returns a compact summary per asset by default; set full_details=true for complete raw records (large — software, ports, volumes). For counts of critical vulnerabilities per machine, combine with qualys_host_detections (severities="5").',
31
+ description: 'Full asset inventory from Qualys CSAM/Global AssetView — the same data as the Asset Management UI: every asset (agent-tracked and scanned) with name, IP, OS, asset criticality score, TruRisk score, tags, last logged-on user, agent last check-in and last scan dates. This is the primary tool for "show me all machines/assets". Filter with structured criteria (ANDed), e.g. [{"field": "tags.name", "value": "Client A"}] or [{"field": "operatingSystem.category1", "value": "Windows"}]. Returns a compact summary per asset by default; set full_details=true for complete raw records (large — software, ports, volumes). For counts of critical vulnerabilities per machine, combine with qualys_host_detections (severities="5").',
32
32
  inputSchema: {
33
33
  type: 'object',
34
34
  properties: {
35
- filter: { type: 'string', description: 'QQL filter, e.g. tags.name:"Superfast IT" or lastLoggedOnUser:"jsmith". Omit to return all assets.' },
35
+ filters: {
36
+ type: 'array',
37
+ description: 'Filter criteria, ANDed together. Verified fields: tags.name, operatingSystem.category1 ("Windows"/"Linux"), operatingSystem.category2 ("Server"/"Desktop"). If a field is not filterable, the API error helpfully lists every valid field name — read it and retry. Note: QQL strings are NOT supported by this endpoint.',
38
+ items: {
39
+ type: 'object',
40
+ properties: {
41
+ field: { type: 'string', description: 'Field name, e.g. "tags.name"' },
42
+ operator: { type: 'string', description: 'Default "EQUALS"' },
43
+ value: { type: 'string', description: 'Value to match, e.g. "Gordon Moody"' },
44
+ },
45
+ required: ['field', 'value'],
46
+ },
47
+ },
36
48
  page_size: { type: 'number', description: 'Assets per page (default 100, max 300)' },
37
49
  last_seen_asset_id: { type: 'number', description: 'Pagination cursor: pass the lastSeenAssetId from the previous response to get the next page (response also has hasMore=1/0)' },
38
50
  full_details: { type: 'boolean', description: 'Return complete raw asset records instead of the compact summary. Default false.' },
@@ -377,12 +389,15 @@ async function handleTool(name, args) {
377
389
  }
378
390
  case 'qualys_asset_inventory': {
379
391
  const fullDetails = args.full_details === true;
392
+ const filters = args.filters;
393
+ const body = filters?.length
394
+ ? { filters: filters.map((f) => ({ field: f.field, operator: f.operator ?? 'EQUALS', value: f.value })) }
395
+ : undefined;
380
396
  const result = (await client.gatewayRequest('/rest/2.0/search/am/asset', {
381
- filter: args.filter,
382
397
  pageSize: Math.min(args.page_size ?? 100, 300),
383
398
  lastSeenAssetId: args.last_seen_asset_id,
384
399
  includeFields: fullDetails ? undefined : INVENTORY_FIELDS,
385
- }));
400
+ }, body));
386
401
  const assets = result?.assetListData?.asset ?? [];
387
402
  return JSON.stringify({
388
403
  count: result?.count,
@@ -108,7 +108,7 @@ export class QualysClient {
108
108
  this.tokenExpiresAt = Date.now() + 3.5 * 60 * 60 * 1000;
109
109
  return this.token;
110
110
  }
111
- async gatewayRequest(path, query = {}, method = 'POST') {
111
+ async gatewayRequest(path, query = {}, body, method = 'POST') {
112
112
  const url = new URL(this.gatewayUrl + (path.startsWith('/') ? path : '/' + path));
113
113
  for (const [key, value] of Object.entries(query)) {
114
114
  if (value !== undefined && value !== null && value !== '') {
@@ -117,15 +117,27 @@ export class QualysClient {
117
117
  }
118
118
  for (let attempt = 0;; attempt++) {
119
119
  const token = await this.getToken(attempt > 0);
120
+ const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
121
+ if (body !== undefined)
122
+ headers['Content-Type'] = 'application/json';
120
123
  const response = await fetch(url, {
121
124
  method,
122
- headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
125
+ headers,
126
+ body: body !== undefined ? JSON.stringify(body) : undefined,
123
127
  });
124
128
  if (response.status === 401 && attempt < 1)
125
129
  continue;
126
- if (response.status === 429 && attempt < MAX_RETRIES) {
127
- await new Promise((resolve) => setTimeout(resolve, 10_000 * (attempt + 1)));
128
- continue;
130
+ if (response.status === 429) {
131
+ const toWait = Number(response.headers.get('x-ratelimit-towait-sec')) || 0;
132
+ if (attempt < MAX_RETRIES && toWait <= 120) {
133
+ await new Promise((resolve) => setTimeout(resolve, (toWait || 10 * (attempt + 1)) * 1000));
134
+ continue;
135
+ }
136
+ const rl = (name) => response.headers.get(name) ?? 'n/a';
137
+ throw new Error(`Qualys gateway API rate limit reached for ${url.pathname} as user "${this.username}" ` +
138
+ `(X-RateLimit-Limit: ${rl('x-ratelimit-limit')}, X-RateLimit-Remaining: ${rl('x-ratelimit-remaining')}, ` +
139
+ `X-RateLimit-ToWait-Sec: ${rl('x-ratelimit-towait-sec')}${toWait ? `, ~${Math.ceil(toWait / 3600)} hour(s)` : ''}). ` +
140
+ 'The VM API tools (qualys_vuln_overview/vuln_summary/host_detections/knowledgebase/list_tags) use a separate limit and still work.');
129
141
  }
130
142
  const text = await response.text();
131
143
  if (!response.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualys-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "MCP server for the Qualys VMDR API (read-only) — hosts, vulnerability detections, KnowledgeBase, scans, reports and asset groups",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",