qualys-mcp 0.2.0 → 0.4.0

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
@@ -6,15 +6,15 @@ Read-only MCP server for the Qualys VMDR API. Exposes host assets, vulnerability
6
6
 
7
7
  | Tool | Purpose |
8
8
  |---|---|
9
+ | `qualys_vuln_overview` | Estate-wide counts by tag: every client's open vulnerabilities by severity in one call, with optional CVSS threshold |
10
+ | `qualys_vuln_summary` | Aggregated vulnerability counts by severity, per client tag and per machine — the dashboard/reporting tool |
11
+ | `qualys_list_tags` | Asset tags and tag hierarchies (how clients are segmented) |
9
12
  | `qualys_asset_inventory` | Full CSAM/Global AssetView inventory: all assets with criticality, tags, last logged-on user, agent check-in, sources (QQL filterable) |
10
- | `qualys_list_hosts` | Scanned host assets (ID, IP, DNS, OS, last scan dates, optional tags) |
11
- | `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, evidence) |
13
+ | `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, optional evidence; tag filterable) the drill-down tool |
12
14
  | `qualys_knowledgebase` | QID details: title, CVEs, threat, impact, solution |
13
- | `qualys_list_scans` | Scan history and status |
14
- | `qualys_list_reports` | Generated reports |
15
- | `qualys_fetch_report` | Download a finished CSV/XML report inline |
16
- | `qualys_list_asset_groups` | Asset groups (typically clients/sites) and their IP ranges |
17
- | `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.
18
18
 
19
19
  ## Quick start (Claude Desktop / Cowork)
20
20
 
@@ -50,6 +50,7 @@ Three required environment variables:
50
50
  | `QUALYS_PASSWORD` | Qualys API user password |
51
51
  | `QUALYS_BASE_URL` | Your platform's API server (see below) |
52
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. |
53
54
 
54
55
  The API server depends on which Qualys platform your subscription lives on — check your Qualys login URL:
55
56
 
@@ -78,6 +79,18 @@ npm run inspector # MCP Inspector UI
78
79
  npm run build # compile to dist/
79
80
  ```
80
81
 
82
+ ## Reporting across a client base
83
+
84
+ Clients segmented by asset tags? The intended flow for "state of vulnerabilities across our clients" is:
85
+
86
+ 1. `qualys_vuln_overview` — one call sweeps the whole estate and returns a client-by-client table: tag, host count, open vulnerabilities by severity. Use `exclude_tags` to drop system tags (e.g. "Cloud Agent") from the grouping.
87
+ 2. `qualys_vuln_summary` with `tags: "Client A"` — expand one client into its per-machine breakdown, sorted worst-first. Aggregation happens inside the server, so even thousands of detections come back as a few KB of counts.
88
+ 3. `qualys_host_detections` with `tags`/`ids` — drill into the individual findings on a machine, then `qualys_knowledgebase` for the fix.
89
+
90
+ ### CVSS-based thresholds (Cyber Essentials)
91
+
92
+ Qualys severity (1–5) is not CVSS. If your remediation policy is CVSS-based — e.g. Cyber Essentials' "fix CVSS 7.0+ within 14 days" — pass `cvss_min: 7` to `qualys_vuln_overview` or `qualys_vuln_summary`. The server joins each detection against the KnowledgeBase CVSS v3 base score (v2 where no v3 exists) and counts only vulnerabilities at or above the threshold. QIDs with no CVSS data are excluded and reported in the response's `cvssFilter` block. Scores are cached for the server's lifetime, so repeated dashboard queries are fast.
93
+
81
94
  ## Notes
82
95
 
83
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`.
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),');
@@ -39,27 +40,45 @@ const tools = [
39
40
  },
40
41
  },
41
42
  {
42
- name: 'qualys_list_hosts',
43
- 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.',
43
+ name: 'qualys_vuln_overview',
44
+ description: 'Estate-wide vulnerability overview grouped by asset tag one call answers "how many open vulnerabilities does each client have?". Sweeps all detection data server-side, groups counts by each host\'s tags, and returns a table of tag → host count and vulnerability counts by severity. Set cvss_min=7 to count only CVSS 7.0+ vulnerabilities (the Cyber Essentials remediation threshold) — scores are joined from the KnowledgeBase (CVSS v3 base, falling back to v2). Hosts with multiple tags are counted under each tag. Use qualys_vuln_summary to expand one client into its per-machine breakdown.',
44
45
  inputSchema: {
45
46
  type: 'object',
46
47
  properties: {
47
- ids: { type: 'string', description: 'Comma-separated host IDs or ranges, e.g. "123,150-200"' },
48
- ips: { type: 'string', description: 'Comma-separated IPs or ranges, e.g. "10.0.0.1,10.0.1.0-10.0.1.255"' },
49
- ag_ids: { type: 'string', description: 'Comma-separated asset group IDs (see qualys_list_asset_groups)' },
50
- ag_titles: { type: 'string', description: 'Comma-separated asset group titles' },
51
- details: {
52
- type: 'string',
53
- description: 'Level of detail: "Basic" (default), "Basic/AGs", "All", "All/AGs", or "None" (IDs only)',
54
- },
55
- os_pattern: { type: 'string', description: 'PCRE regex to match against host operating system, e.g. "Windows Server 2012"' },
56
- no_vm_scan_since: { type: 'string', description: 'Only hosts NOT scanned since this date (YYYY-MM-DD) — useful to find stale/unscanned assets' },
57
- vm_scan_since: { type: 'string', description: 'Only hosts scanned since this date (YYYY-MM-DD)' },
58
- show_tags: { type: 'boolean', description: 'Include asset tags for each host. Default false.' },
59
- ...truncationProperties,
48
+ cvss_min: { type: 'number', description: 'Only count vulnerabilities with CVSS base score >= this value, e.g. 7 for Cyber Essentials. Omit to count everything.' },
49
+ tags: { type: 'string', description: 'Optionally restrict the sweep to hosts with these comma-separated tags. Default: whole estate.' },
50
+ exclude_tags: { type: 'string', description: 'Comma-separated tag names to omit from the grouping (e.g. system tags like "Cloud Agent")' },
51
+ severities: { type: 'string', description: 'Only count these Qualys severity levels, e.g. "4,5". Usually unnecessary when cvss_min is set.' },
52
+ status: { type: 'string', description: 'Detection statuses to count. Default "New,Active,Re-Opened" (open vulnerabilities).' },
53
+ max_hosts: { type: 'number', description: 'Safety cap on hosts to sweep (default 10000)' },
60
54
  },
61
55
  },
62
56
  },
57
+ {
58
+ name: 'qualys_vuln_summary',
59
+ description: 'Aggregated vulnerability counts by severity — the dashboard tool. Give it one or more client tags (or IPs/asset group IDs) and it pulls ALL matching detection data server-side and returns compact counts: totals by severity (1-5, confirmed vs potential) plus a per-machine breakdown sorted worst-first. Use this to report on the state of vulnerabilities per client; use qualys_host_detections to drill into the individual findings on a specific machine afterwards. Call qualys_list_tags first to see available client tags.',
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: {
63
+ tags: { type: 'string', description: 'Comma-separated asset tag names to scope to, e.g. "Client A" — tags are how clients are segmented' },
64
+ tag_include_selector: { type: 'string', description: '"any" (default) or "all" — whether hosts must have any or all of the tags' },
65
+ ips: { type: 'string', description: 'Alternatively scope by comma-separated IPs or ranges' },
66
+ ag_ids: { type: 'string', description: 'Alternatively scope by comma-separated asset group IDs' },
67
+ cvss_min: { type: 'number', description: 'Only count vulnerabilities with CVSS base score >= this value, e.g. 7 for Cyber Essentials (CVSS v3 base, v2 fallback, joined from the KnowledgeBase)' },
68
+ severities: { type: 'string', description: 'Only count these Qualys severity levels, e.g. "4,5". Default: all.' },
69
+ status: { type: 'string', description: 'Detection statuses to count. Default "New,Active,Re-Opened" (open vulnerabilities). Add "Fixed" to include remediated ones.' },
70
+ max_hosts: { type: 'number', description: 'Safety cap on hosts to aggregate (default 5000)' },
71
+ },
72
+ },
73
+ },
74
+ {
75
+ name: 'qualys_list_tags',
76
+ description: 'List asset tags defined in Qualys, including tag hierarchies (parent tags with their children). Tags are how clients are segmented — use this to discover the client list, then feed tag names into qualys_vuln_summary or qualys_asset_inventory. Returns the first 100 tags.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {},
80
+ },
81
+ },
63
82
  {
64
83
  name: 'qualys_host_detections',
65
84
  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.',
@@ -78,6 +97,9 @@ const tools = [
78
97
  },
79
98
  show_igs: { type: 'boolean', description: 'Include information-gathered (IG) detections as well as vulnerabilities. Default false.' },
80
99
  detection_updated_since: { type: 'string', description: 'Only detections updated since this date (YYYY-MM-DD)' },
100
+ tags: { type: 'string', description: 'Comma-separated asset tag names to scope to (clients are segmented by tags)' },
101
+ tag_include_selector: { type: 'string', description: '"any" (default) or "all" — whether hosts must have any or all of the tags' },
102
+ include_results: { type: 'boolean', description: 'Include the detection RESULTS evidence text (verbose). Default false.' },
81
103
  ...truncationProperties,
82
104
  },
83
105
  },
@@ -96,57 +118,6 @@ const tools = [
96
118
  },
97
119
  },
98
120
  },
99
- {
100
- name: 'qualys_list_scans',
101
- 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.',
102
- inputSchema: {
103
- type: 'object',
104
- properties: {
105
- scan_ref: { type: 'string', description: 'Specific scan reference, e.g. "scan/1234567890.12345"' },
106
- state: { type: 'string', description: 'Comma-separated states: "Running", "Paused", "Canceled", "Finished", "Error", "Queued", "Loading"' },
107
- type: { type: 'string', description: 'Scan type: "On-Demand", "Scheduled" or "API"' },
108
- target: { type: 'string', description: 'Filter by scan target IPs' },
109
- launched_after_datetime: { type: 'string', description: 'Only scans launched after this datetime (YYYY-MM-DD or ISO 8601)' },
110
- launched_before_datetime: { type: 'string', description: 'Only scans launched before this datetime' },
111
- },
112
- },
113
- },
114
- {
115
- name: 'qualys_list_reports',
116
- 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.',
117
- inputSchema: {
118
- type: 'object',
119
- properties: {
120
- id: { type: 'number', description: 'Specific report ID' },
121
- state: { type: 'string', description: 'Report state, e.g. "Finished", "Running", "Errors"' },
122
- user_login: { type: 'string', description: 'Filter by the user who launched the report' },
123
- },
124
- },
125
- },
126
- {
127
- name: 'qualys_fetch_report',
128
- 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.',
129
- inputSchema: {
130
- type: 'object',
131
- properties: {
132
- id: { type: 'number', description: 'Report ID (must be in Finished state)' },
133
- max_chars: { type: 'number', description: 'Maximum characters of report content to return (default 100000)' },
134
- },
135
- required: ['id'],
136
- },
137
- },
138
- {
139
- name: 'qualys_list_asset_groups',
140
- 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.',
141
- inputSchema: {
142
- type: 'object',
143
- properties: {
144
- ids: { type: 'string', description: 'Comma-separated asset group IDs' },
145
- show_attributes: { type: 'string', description: 'Comma-separated attributes to show, e.g. "ALL" or "ID,TITLE,IP_SET". Default "ID,TITLE".' },
146
- ...truncationProperties,
147
- },
148
- },
149
- },
150
121
  {
151
122
  name: 'qualys_api_call',
152
123
  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).',
@@ -165,7 +136,9 @@ const tools = [
165
136
  },
166
137
  },
167
138
  ];
168
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
139
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
140
+ tools: rawApiEnabled ? tools : tools.filter((t) => t.name !== 'qualys_api_call'),
141
+ }));
169
142
  function toIso(ms) {
170
143
  return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
171
144
  }
@@ -195,8 +168,213 @@ function compactAsset(a) {
195
168
  });
196
169
  }
197
170
  const INVENTORY_FIELDS = 'assetId,assetName,address,dnsName,operatingSystem,criticality,riskScore,lastLoggedOnUser,tag,agent,sensor,inventory,assetType,lastModifiedDate';
171
+ function tagParams(args) {
172
+ if (!args.tags)
173
+ return {};
174
+ return {
175
+ use_tags: 1,
176
+ tag_set_by: 'name',
177
+ tag_include_selector: args.tag_include_selector ?? 'any',
178
+ tag_set_include: args.tags,
179
+ };
180
+ }
181
+ async function sweepDetections(args, showTags, maxHosts) {
182
+ const hosts = [];
183
+ let idMin;
184
+ let truncated = false;
185
+ while (true) {
186
+ const result = await client.request('/api/2.0/fo/asset/host/vm/detection/', {
187
+ action: 'list',
188
+ echo_request: 0,
189
+ ...tagParams(args),
190
+ ips: args.ips,
191
+ ag_ids: args.ag_ids,
192
+ severities: args.severities,
193
+ status: args.status ?? 'New,Active,Re-Opened',
194
+ show_results: 0,
195
+ show_igs: 0,
196
+ show_tags: showTags ? 1 : undefined,
197
+ truncation_limit: Math.min(1000, maxHosts),
198
+ id_min: idMin,
199
+ });
200
+ const resp = simplify(result.parsed);
201
+ const pageHosts = resp?.HOST_LIST?.HOST ?? [];
202
+ for (const h of pageHosts) {
203
+ const detections = h?.DETECTION_LIST?.DETECTION ?? [];
204
+ hosts.push({
205
+ id: String(h.ID),
206
+ ip: h.IP,
207
+ dns: h.DNS,
208
+ os: h.OS,
209
+ lastScan: h.LAST_SCAN_DATETIME,
210
+ tags: (h.TAGS?.TAG ?? []).map((t) => String(t.NAME)),
211
+ detections: detections.map((d) => ({ qid: String(d.QID), sev: String(d.SEVERITY), type: String(d.TYPE) })),
212
+ });
213
+ }
214
+ const warningUrl = resp?.WARNING?.[0]?.URL;
215
+ const nextIdMin = warningUrl?.match(/id_min=(\d+)/)?.[1];
216
+ if (!nextIdMin)
217
+ break;
218
+ if (hosts.length >= maxHosts) {
219
+ truncated = true;
220
+ break;
221
+ }
222
+ idMin = Number(nextIdMin);
223
+ }
224
+ return { hosts, truncated };
225
+ }
226
+ const cvssCache = new Map();
227
+ async function fetchCvssScores(qids) {
228
+ const missing = [...new Set(qids)].filter((q) => !cvssCache.has(q));
229
+ const num = (x) => {
230
+ const n = parseFloat(String(x ?? ''));
231
+ return Number.isFinite(n) ? n : undefined;
232
+ };
233
+ for (let i = 0; i < missing.length; i += 400) {
234
+ const chunk = missing.slice(i, i + 400);
235
+ const result = await client.request('/api/2.0/fo/knowledge_base/vuln/', { action: 'list', echo_request: 0, ids: chunk.join(','), details: 'Basic' }, 'POST');
236
+ const resp = simplify(result.parsed);
237
+ const vulns = resp?.VULN_LIST?.VULN ?? [];
238
+ for (const v of vulns) {
239
+ cvssCache.set(String(v.QID), { v2: num(v.CVSS?.BASE), v3: num(v.CVSS_V3?.BASE) });
240
+ }
241
+ for (const q of chunk)
242
+ if (!cvssCache.has(q))
243
+ cvssCache.set(q, {});
244
+ }
245
+ }
246
+ async function applyCvssFilter(hosts, cvssMin) {
247
+ await fetchCvssScores(hosts.flatMap((h) => h.detections.map((d) => d.qid)));
248
+ const unknown = new Set();
249
+ let excludedUnknown = 0;
250
+ for (const h of hosts) {
251
+ h.detections = h.detections.filter((d) => {
252
+ const score = cvssCache.get(d.qid);
253
+ const base = score?.v3 ?? score?.v2;
254
+ if (base === undefined) {
255
+ unknown.add(d.qid);
256
+ excludedUnknown += 1;
257
+ return false;
258
+ }
259
+ return base >= cvssMin;
260
+ });
261
+ }
262
+ return {
263
+ basis: `CVSS v3 base score >= ${cvssMin} (v2 base used where no v3 score exists)`,
264
+ unknownCvssQids: unknown.size,
265
+ detectionsExcludedUnknownCvss: excludedUnknown,
266
+ };
267
+ }
268
+ function countBySeverity(detections, totals) {
269
+ const counts = { confirmed: {}, potential: {} };
270
+ for (const d of detections) {
271
+ const bucket = d.type === 'Potential' ? 'potential' : 'confirmed';
272
+ counts[bucket][d.sev] = (counts[bucket][d.sev] ?? 0) + 1;
273
+ if (totals)
274
+ totals[bucket][d.sev] = (totals[bucket][d.sev] ?? 0) + 1;
275
+ }
276
+ return counts;
277
+ }
278
+ const hostWeight = (c, total) => (c.confirmed['5'] ?? 0) * 1e6 + (c.confirmed['4'] ?? 0) * 1e3 + total;
198
279
  async function handleTool(name, args) {
199
280
  switch (name) {
281
+ case 'qualys_vuln_summary': {
282
+ const maxHosts = args.max_hosts ?? 5000;
283
+ const { hosts, truncated } = await sweepDetections(args, false, maxHosts);
284
+ const cvssInfo = args.cvss_min !== undefined ? await applyCvssFilter(hosts, args.cvss_min) : undefined;
285
+ const totals = { confirmed: {}, potential: {} };
286
+ const rows = hosts.map((h) => {
287
+ const counts = countBySeverity(h.detections, totals);
288
+ return {
289
+ id: h.id,
290
+ ip: h.ip,
291
+ dns: h.dns,
292
+ os: h.os,
293
+ lastScan: h.lastScan,
294
+ confirmed: counts.confirmed,
295
+ potential: counts.potential,
296
+ total: h.detections.length,
297
+ };
298
+ });
299
+ rows.sort((a, b) => hostWeight(b, b.total) - hostWeight(a, a.total));
300
+ return JSON.stringify(stripEmpty({
301
+ scope: {
302
+ tags: args.tags,
303
+ ips: args.ips,
304
+ ag_ids: args.ag_ids,
305
+ severities: args.severities,
306
+ status: args.status ?? 'New,Active,Re-Opened',
307
+ },
308
+ cvssFilter: cvssInfo,
309
+ hostCount: rows.length,
310
+ totalsBySeverity: totals,
311
+ truncated: truncated
312
+ ? `Stopped at max_hosts=${maxHosts} — counts are incomplete; narrow the scope or raise max_hosts`
313
+ : undefined,
314
+ hosts: rows,
315
+ }), null, 2);
316
+ }
317
+ case 'qualys_vuln_overview': {
318
+ const maxHosts = args.max_hosts ?? 10000;
319
+ const { hosts, truncated } = await sweepDetections(args, true, maxHosts);
320
+ const cvssInfo = args.cvss_min !== undefined ? await applyCvssFilter(hosts, args.cvss_min) : undefined;
321
+ const excluded = new Set((args.exclude_tags ?? '')
322
+ .split(',')
323
+ .map((t) => t.trim())
324
+ .filter(Boolean));
325
+ const overall = { confirmed: {}, potential: {} };
326
+ const byTag = new Map();
327
+ for (const h of hosts) {
328
+ countBySeverity(h.detections, overall);
329
+ const tags = h.tags.filter((t) => !excluded.has(t));
330
+ for (const tag of tags.length > 0 ? tags : ['(untagged)']) {
331
+ let row = byTag.get(tag);
332
+ if (!row) {
333
+ row = { hosts: 0, counts: { confirmed: {}, potential: {} }, total: 0 };
334
+ byTag.set(tag, row);
335
+ }
336
+ row.hosts += 1;
337
+ countBySeverity(h.detections, row.counts);
338
+ row.total += h.detections.length;
339
+ }
340
+ }
341
+ const tagRows = [...byTag.entries()]
342
+ .map(([tag, r]) => ({ tag, hosts: r.hosts, confirmed: r.counts.confirmed, potential: r.counts.potential, total: r.total }))
343
+ .sort((a, b) => hostWeight(b, b.total) - hostWeight(a, a.total));
344
+ return JSON.stringify(stripEmpty({
345
+ scope: {
346
+ tags: args.tags,
347
+ severities: args.severities,
348
+ status: args.status ?? 'New,Active,Re-Opened',
349
+ exclude_tags: args.exclude_tags,
350
+ },
351
+ cvssFilter: cvssInfo,
352
+ hostCount: hosts.length,
353
+ note: 'Hosts carrying multiple tags are counted under each tag, so tag rows can overlap.',
354
+ truncated: truncated
355
+ ? `Stopped at max_hosts=${maxHosts} — counts are incomplete; narrow the scope or raise max_hosts`
356
+ : undefined,
357
+ overallBySeverity: overall,
358
+ byTag: tagRows,
359
+ }), null, 2);
360
+ }
361
+ case 'qualys_list_tags': {
362
+ const result = await client.request('/qps/rest/2.0/search/am/tag', {}, 'POST');
363
+ const resp = simplify(result.parsed);
364
+ const rawTags = resp?.data?.Tag ?? [];
365
+ const tags = (Array.isArray(rawTags) ? rawTags : [rawTags]).map((t) => stripEmpty({
366
+ id: t.id,
367
+ name: t.name,
368
+ created: t.created,
369
+ children: (() => {
370
+ const kids = t.children?.list?.TagSimple;
371
+ if (!kids)
372
+ return undefined;
373
+ return (Array.isArray(kids) ? kids : [kids]).map((k) => ({ id: k.id, name: k.name }));
374
+ })(),
375
+ }));
376
+ return JSON.stringify({ count: resp?.count, hasMoreRecords: resp?.hasMoreRecords, tags }, null, 2);
377
+ }
200
378
  case 'qualys_asset_inventory': {
201
379
  const fullDetails = args.full_details === true;
202
380
  const result = (await client.gatewayRequest('/rest/2.0/search/am/asset', {
@@ -213,24 +391,6 @@ async function handleTool(name, args) {
213
391
  assets: fullDetails ? assets.map((a) => stripEmpty(a)) : assets.map(compactAsset),
214
392
  }, null, 2);
215
393
  }
216
- case 'qualys_list_hosts': {
217
- const result = await client.request('/api/2.0/fo/asset/host/', {
218
- action: 'list',
219
- echo_request: 0,
220
- ids: args.ids,
221
- ips: args.ips,
222
- ag_ids: args.ag_ids,
223
- ag_titles: args.ag_titles,
224
- details: args.details ?? 'Basic',
225
- os_pattern: args.os_pattern,
226
- no_vm_scan_since: args.no_vm_scan_since,
227
- vm_scan_since: args.vm_scan_since,
228
- show_tags: args.show_tags ? 1 : undefined,
229
- truncation_limit: args.truncation_limit ?? 100,
230
- id_min: args.id_min,
231
- });
232
- return JSON.stringify(simplify(result.parsed), null, 2);
233
- }
234
394
  case 'qualys_host_detections': {
235
395
  const result = await client.request('/api/2.0/fo/asset/host/vm/detection/', {
236
396
  action: 'list',
@@ -243,7 +403,9 @@ async function handleTool(name, args) {
243
403
  severities: args.severities,
244
404
  status: args.status,
245
405
  show_igs: args.show_igs ? 1 : undefined,
406
+ show_results: args.include_results ? undefined : 0,
246
407
  detection_updated_since: args.detection_updated_since,
408
+ ...tagParams(args),
247
409
  truncation_limit: args.truncation_limit ?? 20,
248
410
  id_min: args.id_min,
249
411
  });
@@ -261,53 +423,10 @@ async function handleTool(name, args) {
261
423
  });
262
424
  return JSON.stringify(simplify(result.parsed), null, 2);
263
425
  }
264
- case 'qualys_list_scans': {
265
- const result = await client.request('/api/2.0/fo/scan/', {
266
- action: 'list',
267
- echo_request: 0,
268
- scan_ref: args.scan_ref,
269
- state: args.state,
270
- type: args.type,
271
- target: args.target,
272
- launched_after_datetime: args.launched_after_datetime,
273
- launched_before_datetime: args.launched_before_datetime,
274
- });
275
- return JSON.stringify(simplify(result.parsed), null, 2);
276
- }
277
- case 'qualys_list_reports': {
278
- const result = await client.request('/api/2.0/fo/report/', {
279
- action: 'list',
280
- echo_request: 0,
281
- id: args.id,
282
- state: args.state,
283
- user_login: args.user_login,
284
- });
285
- return JSON.stringify(simplify(result.parsed), null, 2);
286
- }
287
- case 'qualys_fetch_report': {
288
- const result = await client.request('/api/2.0/fo/report/', {
289
- action: 'fetch',
290
- id: args.id,
291
- });
292
- const maxChars = args.max_chars ?? 100_000;
293
- const content = result.text ?? JSON.stringify(simplify(result.parsed), null, 2);
294
- if (content.length > maxChars) {
295
- return content.slice(0, maxChars) + `\n\n[Truncated: ${content.length} total characters, showing first ${maxChars}]`;
296
- }
297
- return content;
298
- }
299
- case 'qualys_list_asset_groups': {
300
- const result = await client.request('/api/2.0/fo/asset/group/', {
301
- action: 'list',
302
- echo_request: 0,
303
- ids: args.ids,
304
- show_attributes: args.show_attributes ?? 'ID,TITLE',
305
- truncation_limit: args.truncation_limit ?? 200,
306
- id_min: args.id_min,
307
- });
308
- return JSON.stringify(simplify(result.parsed), null, 2);
309
- }
310
426
  case 'qualys_api_call': {
427
+ if (!rawApiEnabled) {
428
+ throw new Error('The raw API tool is disabled. Set QUALYS_ENABLE_RAW_API=true to enable it.');
429
+ }
311
430
  const path = args.path;
312
431
  if (!path.startsWith('/'))
313
432
  throw new Error('path must start with /');
@@ -345,7 +464,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
345
464
  async function main() {
346
465
  const transport = new StdioServerTransport();
347
466
  await server.connect(transport);
348
- console.error(`Qualys MCP server running (${baseUrl})`);
467
+ console.error(`Qualys MCP server running (${baseUrl}, raw API ${rawApiEnabled ? 'enabled' : 'disabled'})`);
349
468
  }
350
469
  main().catch((error) => {
351
470
  console.error('Fatal error:', error);
@@ -61,7 +61,7 @@ export class QualysClient {
61
61
  'X-Requested-With': 'qualys-mcp',
62
62
  };
63
63
  let body;
64
- if (method === 'POST') {
64
+ if (method === 'POST' && [...search.keys()].length > 0) {
65
65
  headers['Content-Type'] = 'application/x-www-form-urlencoded';
66
66
  body = search.toString();
67
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualys-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",