qualys-mcp 0.3.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,15 +9,12 @@ 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) |
13
- | `qualys_list_hosts` | Scanned host assets (ID, IP, DNS, OS, last scan dates, optional tags; tag 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.) |
14
13
  | `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, optional evidence; tag filterable) — the drill-down tool |
15
14
  | `qualys_knowledgebase` | QID details: title, CVEs, threat, impact, solution |
16
- | `qualys_list_scans` | Scan history and status |
17
- | `qualys_list_reports` | Generated reports |
18
- | `qualys_fetch_report` | Download a finished CSV/XML report inline |
19
- | `qualys_list_asset_groups` | Asset groups (typically clients/sites) and their IP ranges |
20
- | `qualys_api_call` | Raw GET/POST to any other Qualys endpoint (read-only actions only) |
15
+ | `qualys_api_call` | Raw GET/POST to any other Qualys endpoint — disabled unless `QUALYS_ENABLE_RAW_API=true` |
16
+
17
+ The tool set is deliberately lean, focused on vulnerability reporting across a tag-segmented client base. The raw API tool covers anything else (scans, reports, asset groups, schedules) but is off by default so shared deployments stay strictly read-only.
21
18
 
22
19
  ## Quick start (Claude Desktop / Cowork)
23
20
 
@@ -53,6 +50,7 @@ Three required environment variables:
53
50
  | `QUALYS_PASSWORD` | Qualys API user password |
54
51
  | `QUALYS_BASE_URL` | Your platform's API server (see below) |
55
52
  | `QUALYS_GATEWAY_URL` | Optional — the gateway host used by `qualys_asset_inventory` (CSAM/GAV API). Derived automatically from `QUALYS_BASE_URL` (e.g. `https://gateway.qg1.apps.qualys.co.uk` for UK1); only set this if your platform's gateway differs. |
53
+ | `QUALYS_ENABLE_RAW_API` | Optional — set to `true` to expose `qualys_api_call` for arbitrary Qualys API endpoints. Leave unset for team deployments. |
56
54
 
57
55
  The API server depends on which Qualys platform your subscription lives on — check your Qualys login URL:
58
56
 
@@ -95,7 +93,7 @@ Qualys severity (1–5) is not CVSS. If your remediation policy is CVSS-based
95
93
 
96
94
  ## Notes
97
95
 
98
- - `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.
99
97
  - For per-machine critical vulnerability counts, combine it with `qualys_host_detections` using `severities="5"` (or `"4,5"`).
100
98
  - The Qualys VM API v2 returns XML; this server converts it to JSON.
101
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
@@ -6,6 +6,7 @@ import { QualysClient, simplify, stripEmpty } from './qualys-client.js';
6
6
  const username = process.env.QUALYS_USERNAME || '';
7
7
  const password = process.env.QUALYS_PASSWORD || '';
8
8
  const baseUrl = process.env.QUALYS_BASE_URL || '';
9
+ const rawApiEnabled = process.env.QUALYS_ENABLE_RAW_API === 'true';
9
10
  if (!username || !password || !baseUrl) {
10
11
  console.error('Missing required environment variables: QUALYS_USERNAME, QUALYS_PASSWORD, QUALYS_BASE_URL.');
11
12
  console.error('QUALYS_BASE_URL is your platform API server, e.g. https://qualysapi.qg1.apps.qualys.co.uk (UK1),');
@@ -27,11 +28,23 @@ const truncationProperties = {
27
28
  const tools = [
28
29
  {
29
30
  name: 'qualys_asset_inventory',
30
- 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").',
31
32
  inputSchema: {
32
33
  type: 'object',
33
34
  properties: {
34
- 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
+ },
35
48
  page_size: { type: 'number', description: 'Assets per page (default 100, max 300)' },
36
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)' },
37
50
  full_details: { type: 'boolean', description: 'Return complete raw asset records instead of the compact summary. Default false.' },
@@ -78,30 +91,6 @@ const tools = [
78
91
  properties: {},
79
92
  },
80
93
  },
81
- {
82
- name: 'qualys_list_hosts',
83
- description: 'List scanned host assets in Qualys VMDR with their ID, IP, DNS name, OS and last scan dates. Use this to find host IDs before pulling their vulnerability detections. Filter by IPs, asset group or scan recency.',
84
- inputSchema: {
85
- type: 'object',
86
- properties: {
87
- ids: { type: 'string', description: 'Comma-separated host IDs or ranges, e.g. "123,150-200"' },
88
- ips: { type: 'string', description: 'Comma-separated IPs or ranges, e.g. "10.0.0.1,10.0.1.0-10.0.1.255"' },
89
- ag_ids: { type: 'string', description: 'Comma-separated asset group IDs (see qualys_list_asset_groups)' },
90
- ag_titles: { type: 'string', description: 'Comma-separated asset group titles' },
91
- details: {
92
- type: 'string',
93
- description: 'Level of detail: "Basic" (default), "Basic/AGs", "All", "All/AGs", or "None" (IDs only)',
94
- },
95
- os_pattern: { type: 'string', description: 'PCRE regex to match against host operating system, e.g. "Windows Server 2012"' },
96
- no_vm_scan_since: { type: 'string', description: 'Only hosts NOT scanned since this date (YYYY-MM-DD) — useful to find stale/unscanned assets' },
97
- vm_scan_since: { type: 'string', description: 'Only hosts scanned since this date (YYYY-MM-DD)' },
98
- show_tags: { type: 'boolean', description: 'Include asset tags for each host. Default false.' },
99
- tags: { type: 'string', description: 'Comma-separated asset tag names to scope to (clients are segmented by tags)' },
100
- tag_include_selector: { type: 'string', description: '"any" (default) or "all" — whether hosts must have any or all of the tags' },
101
- ...truncationProperties,
102
- },
103
- },
104
- },
105
94
  {
106
95
  name: 'qualys_host_detections',
107
96
  description: 'List vulnerability detections per host — the core VMDR data. Returns each host with its detected vulnerabilities (QID, severity, status, first/last found, results evidence). Filter by host, QID, severity or detection status. Look up QID titles and fixes with qualys_knowledgebase. Output can be large: keep truncation_limit modest and filter where possible.',
@@ -141,57 +130,6 @@ const tools = [
141
130
  },
142
131
  },
143
132
  },
144
- {
145
- name: 'qualys_list_scans',
146
- description: 'List vulnerability scans in Qualys VMDR with their reference, title, launch date, status and target. Useful for checking scan history and whether scheduled scans are completing.',
147
- inputSchema: {
148
- type: 'object',
149
- properties: {
150
- scan_ref: { type: 'string', description: 'Specific scan reference, e.g. "scan/1234567890.12345"' },
151
- state: { type: 'string', description: 'Comma-separated states: "Running", "Paused", "Canceled", "Finished", "Error", "Queued", "Loading"' },
152
- type: { type: 'string', description: 'Scan type: "On-Demand", "Scheduled" or "API"' },
153
- target: { type: 'string', description: 'Filter by scan target IPs' },
154
- launched_after_datetime: { type: 'string', description: 'Only scans launched after this datetime (YYYY-MM-DD or ISO 8601)' },
155
- launched_before_datetime: { type: 'string', description: 'Only scans launched before this datetime' },
156
- },
157
- },
158
- },
159
- {
160
- name: 'qualys_list_reports',
161
- description: 'List generated reports in Qualys with their ID, title, type, output format, status and expiry. Use qualys_fetch_report to download a finished report.',
162
- inputSchema: {
163
- type: 'object',
164
- properties: {
165
- id: { type: 'number', description: 'Specific report ID' },
166
- state: { type: 'string', description: 'Report state, e.g. "Finished", "Running", "Errors"' },
167
- user_login: { type: 'string', description: 'Filter by the user who launched the report' },
168
- },
169
- },
170
- },
171
- {
172
- name: 'qualys_fetch_report',
173
- description: 'Download a finished Qualys report by ID. Only sensible for text-based formats (CSV, XML) — the content is returned inline, truncated to max_chars. Check qualys_list_reports first for the report status and format.',
174
- inputSchema: {
175
- type: 'object',
176
- properties: {
177
- id: { type: 'number', description: 'Report ID (must be in Finished state)' },
178
- max_chars: { type: 'number', description: 'Maximum characters of report content to return (default 100000)' },
179
- },
180
- required: ['id'],
181
- },
182
- },
183
- {
184
- name: 'qualys_list_asset_groups',
185
- description: 'List asset groups in Qualys with their ID, title and IP ranges. Asset groups typically map to clients or sites — use their IDs to scope qualys_list_hosts and qualys_host_detections.',
186
- inputSchema: {
187
- type: 'object',
188
- properties: {
189
- ids: { type: 'string', description: 'Comma-separated asset group IDs' },
190
- show_attributes: { type: 'string', description: 'Comma-separated attributes to show, e.g. "ALL" or "ID,TITLE,IP_SET". Default "ID,TITLE".' },
191
- ...truncationProperties,
192
- },
193
- },
194
- },
195
133
  {
196
134
  name: 'qualys_api_call',
197
135
  description: 'Make a raw call to any Qualys API endpoint not covered by the other tools, e.g. /api/2.0/fo/report/template/scan/?action=list, /api/2.0/fo/schedule/scan/?action=list, or Asset Management endpoints under /qps/rest/2.0/. Only use list/read actions — do not launch scans or modify data. Params are sent as query string (GET) or form body (POST; some endpoints require POST).',
@@ -210,7 +148,9 @@ const tools = [
210
148
  },
211
149
  },
212
150
  ];
213
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
151
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
152
+ tools: rawApiEnabled ? tools : tools.filter((t) => t.name !== 'qualys_api_call'),
153
+ }));
214
154
  function toIso(ms) {
215
155
  return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
216
156
  }
@@ -449,12 +389,15 @@ async function handleTool(name, args) {
449
389
  }
450
390
  case 'qualys_asset_inventory': {
451
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;
452
396
  const result = (await client.gatewayRequest('/rest/2.0/search/am/asset', {
453
- filter: args.filter,
454
397
  pageSize: Math.min(args.page_size ?? 100, 300),
455
398
  lastSeenAssetId: args.last_seen_asset_id,
456
399
  includeFields: fullDetails ? undefined : INVENTORY_FIELDS,
457
- }));
400
+ }, body));
458
401
  const assets = result?.assetListData?.asset ?? [];
459
402
  return JSON.stringify({
460
403
  count: result?.count,
@@ -463,25 +406,6 @@ async function handleTool(name, args) {
463
406
  assets: fullDetails ? assets.map((a) => stripEmpty(a)) : assets.map(compactAsset),
464
407
  }, null, 2);
465
408
  }
466
- case 'qualys_list_hosts': {
467
- const result = await client.request('/api/2.0/fo/asset/host/', {
468
- action: 'list',
469
- echo_request: 0,
470
- ids: args.ids,
471
- ips: args.ips,
472
- ag_ids: args.ag_ids,
473
- ag_titles: args.ag_titles,
474
- details: args.details ?? 'Basic',
475
- os_pattern: args.os_pattern,
476
- no_vm_scan_since: args.no_vm_scan_since,
477
- vm_scan_since: args.vm_scan_since,
478
- show_tags: args.show_tags ? 1 : undefined,
479
- ...tagParams(args),
480
- truncation_limit: args.truncation_limit ?? 100,
481
- id_min: args.id_min,
482
- });
483
- return JSON.stringify(simplify(result.parsed), null, 2);
484
- }
485
409
  case 'qualys_host_detections': {
486
410
  const result = await client.request('/api/2.0/fo/asset/host/vm/detection/', {
487
411
  action: 'list',
@@ -514,53 +438,10 @@ async function handleTool(name, args) {
514
438
  });
515
439
  return JSON.stringify(simplify(result.parsed), null, 2);
516
440
  }
517
- case 'qualys_list_scans': {
518
- const result = await client.request('/api/2.0/fo/scan/', {
519
- action: 'list',
520
- echo_request: 0,
521
- scan_ref: args.scan_ref,
522
- state: args.state,
523
- type: args.type,
524
- target: args.target,
525
- launched_after_datetime: args.launched_after_datetime,
526
- launched_before_datetime: args.launched_before_datetime,
527
- });
528
- return JSON.stringify(simplify(result.parsed), null, 2);
529
- }
530
- case 'qualys_list_reports': {
531
- const result = await client.request('/api/2.0/fo/report/', {
532
- action: 'list',
533
- echo_request: 0,
534
- id: args.id,
535
- state: args.state,
536
- user_login: args.user_login,
537
- });
538
- return JSON.stringify(simplify(result.parsed), null, 2);
539
- }
540
- case 'qualys_fetch_report': {
541
- const result = await client.request('/api/2.0/fo/report/', {
542
- action: 'fetch',
543
- id: args.id,
544
- });
545
- const maxChars = args.max_chars ?? 100_000;
546
- const content = result.text ?? JSON.stringify(simplify(result.parsed), null, 2);
547
- if (content.length > maxChars) {
548
- return content.slice(0, maxChars) + `\n\n[Truncated: ${content.length} total characters, showing first ${maxChars}]`;
549
- }
550
- return content;
551
- }
552
- case 'qualys_list_asset_groups': {
553
- const result = await client.request('/api/2.0/fo/asset/group/', {
554
- action: 'list',
555
- echo_request: 0,
556
- ids: args.ids,
557
- show_attributes: args.show_attributes ?? 'ID,TITLE',
558
- truncation_limit: args.truncation_limit ?? 200,
559
- id_min: args.id_min,
560
- });
561
- return JSON.stringify(simplify(result.parsed), null, 2);
562
- }
563
441
  case 'qualys_api_call': {
442
+ if (!rawApiEnabled) {
443
+ throw new Error('The raw API tool is disabled. Set QUALYS_ENABLE_RAW_API=true to enable it.');
444
+ }
564
445
  const path = args.path;
565
446
  if (!path.startsWith('/'))
566
447
  throw new Error('path must start with /');
@@ -598,7 +479,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
598
479
  async function main() {
599
480
  const transport = new StdioServerTransport();
600
481
  await server.connect(transport);
601
- console.error(`Qualys MCP server running (${baseUrl})`);
482
+ console.error(`Qualys MCP server running (${baseUrl}, raw API ${rawApiEnabled ? 'enabled' : 'disabled'})`);
602
483
  }
603
484
  main().catch((error) => {
604
485
  console.error('Fatal error:', error);
@@ -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.3.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",