qualys-mcp 0.1.0 → 0.2.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,7 +6,8 @@ Read-only MCP server for the Qualys VMDR API. Exposes host assets, vulnerability
6
6
 
7
7
  | Tool | Purpose |
8
8
  |---|---|
9
- | `qualys_list_hosts` | Scanned host assets (ID, IP, DNS, OS, last scan dates) |
9
+ | `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) |
10
11
  | `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, evidence) |
11
12
  | `qualys_knowledgebase` | QID details: title, CVEs, threat, impact, solution |
12
13
  | `qualys_list_scans` | Scan history and status |
@@ -48,6 +49,7 @@ Three required environment variables:
48
49
  | `QUALYS_USERNAME` | Qualys API user login |
49
50
  | `QUALYS_PASSWORD` | Qualys API user password |
50
51
  | `QUALYS_BASE_URL` | Your platform's API server (see below) |
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. |
51
53
 
52
54
  The API server depends on which Qualys platform your subscription lives on — check your Qualys login URL:
53
55
 
@@ -78,6 +80,8 @@ npm run build # compile to dist/
78
80
 
79
81
  ## Notes
80
82
 
83
+ - `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`.
84
+ - For per-machine critical vulnerability counts, combine it with `qualys_host_detections` using `severities="5"` (or `"4,5"`).
81
85
  - The Qualys VM API v2 returns XML; this server converts it to JSON.
82
86
  - Rate/concurrency limits (HTTP 409/429) are retried automatically using the `X-RateLimit-ToWait-Sec` header, up to 3 times.
83
87
  - Large result sets are truncated by Qualys; the response then contains a `WARNING` element with the URL for the next page — pass its `id_min` value back to the same tool to continue.
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
- import { QualysClient, simplify } from './qualys-client.js';
5
+ 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 || '';
@@ -12,7 +12,7 @@ if (!username || !password || !baseUrl) {
12
12
  console.error('https://qualysapi.qualys.eu (EU1), https://qualysapi.qg2.apps.qualys.eu (EU2), https://qualysapi.qualys.com (US1).');
13
13
  process.exit(1);
14
14
  }
15
- const client = new QualysClient({ username, password, baseUrl });
15
+ const client = new QualysClient({ username, password, baseUrl, gatewayUrl: process.env.QUALYS_GATEWAY_URL });
16
16
  const server = new Server({ name: 'qualys-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
17
17
  const truncationProperties = {
18
18
  truncation_limit: {
@@ -25,6 +25,19 @@ const truncationProperties = {
25
25
  },
26
26
  };
27
27
  const tools = [
28
+ {
29
+ 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
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ filter: { type: 'string', description: 'QQL filter, e.g. tags.name:"Superfast IT" or lastLoggedOnUser:"jsmith". Omit to return all assets.' },
35
+ page_size: { type: 'number', description: 'Assets per page (default 100, max 300)' },
36
+ 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
+ full_details: { type: 'boolean', description: 'Return complete raw asset records instead of the compact summary. Default false.' },
38
+ },
39
+ },
40
+ },
28
41
  {
29
42
  name: 'qualys_list_hosts',
30
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.',
@@ -42,6 +55,7 @@ const tools = [
42
55
  os_pattern: { type: 'string', description: 'PCRE regex to match against host operating system, e.g. "Windows Server 2012"' },
43
56
  no_vm_scan_since: { type: 'string', description: 'Only hosts NOT scanned since this date (YYYY-MM-DD) — useful to find stale/unscanned assets' },
44
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.' },
45
59
  ...truncationProperties,
46
60
  },
47
61
  },
@@ -152,8 +166,53 @@ const tools = [
152
166
  },
153
167
  ];
154
168
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
169
+ function toIso(ms) {
170
+ return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
171
+ }
172
+ function compactAsset(a) {
173
+ return stripEmpty({
174
+ assetId: a.assetId,
175
+ assetName: a.assetName,
176
+ address: a.address,
177
+ dnsName: a.dnsName,
178
+ operatingSystem: a.operatingSystem?.fullName ?? a.operatingSystem?.osName,
179
+ criticality: a.criticality?.score,
180
+ riskScore: a.riskScore,
181
+ lastLoggedOnUser: a.lastLoggedOnUser,
182
+ tags: a.tagList?.tag?.map((t) => t.tagName),
183
+ agent: a.agent
184
+ ? {
185
+ status: a.agent.status,
186
+ version: a.agent.version,
187
+ lastCheckedIn: toIso(a.agent.lastCheckedIn) ?? a.agent.lastCheckedIn,
188
+ lastActivity: toIso(a.agent.lastActivity) ?? a.agent.lastActivity,
189
+ }
190
+ : undefined,
191
+ lastVMScan: toIso(a.sensor?.lastVMScan) ?? a.sensor?.lastVMScan,
192
+ lastComplianceScan: toIso(a.sensor?.lastComplianceScan) ?? a.sensor?.lastComplianceScan,
193
+ sources: a.inventory?.source ?? a.assetType,
194
+ lastModifiedDate: a.lastModifiedDate,
195
+ });
196
+ }
197
+ const INVENTORY_FIELDS = 'assetId,assetName,address,dnsName,operatingSystem,criticality,riskScore,lastLoggedOnUser,tag,agent,sensor,inventory,assetType,lastModifiedDate';
155
198
  async function handleTool(name, args) {
156
199
  switch (name) {
200
+ case 'qualys_asset_inventory': {
201
+ const fullDetails = args.full_details === true;
202
+ const result = (await client.gatewayRequest('/rest/2.0/search/am/asset', {
203
+ filter: args.filter,
204
+ pageSize: Math.min(args.page_size ?? 100, 300),
205
+ lastSeenAssetId: args.last_seen_asset_id,
206
+ includeFields: fullDetails ? undefined : INVENTORY_FIELDS,
207
+ }));
208
+ const assets = result?.assetListData?.asset ?? [];
209
+ return JSON.stringify({
210
+ count: result?.count,
211
+ hasMore: result?.hasMore,
212
+ lastSeenAssetId: result?.lastSeenAssetId,
213
+ assets: fullDetails ? assets.map((a) => stripEmpty(a)) : assets.map(compactAsset),
214
+ }, null, 2);
215
+ }
157
216
  case 'qualys_list_hosts': {
158
217
  const result = await client.request('/api/2.0/fo/asset/host/', {
159
218
  action: 'list',
@@ -166,6 +225,7 @@ async function handleTool(name, args) {
166
225
  os_pattern: args.os_pattern,
167
226
  no_vm_scan_since: args.no_vm_scan_since,
168
227
  vm_scan_since: args.vm_scan_since,
228
+ show_tags: args.show_tags ? 1 : undefined,
169
229
  truncation_limit: args.truncation_limit ?? 100,
170
230
  id_min: args.id_min,
171
231
  });
@@ -26,12 +26,27 @@ const parser = new XMLParser({
26
26
  trimValues: true,
27
27
  isArray: (name) => ARRAY_TAGS.has(name),
28
28
  });
29
+ export function deriveGatewayUrl(baseUrl) {
30
+ const url = new URL(baseUrl);
31
+ if (url.hostname.includes('.apps.')) {
32
+ return 'https://' + url.hostname.replace(/^qualysapi\./, 'gateway.');
33
+ }
34
+ return 'https://' + url.hostname.replace(/^qualysapi\./, 'gateway.qg1.apps.');
35
+ }
29
36
  export class QualysClient {
37
+ username;
38
+ password;
30
39
  authHeader;
31
40
  baseUrl;
41
+ gatewayUrl;
42
+ token = null;
43
+ tokenExpiresAt = 0;
32
44
  constructor(config) {
45
+ this.username = config.username;
46
+ this.password = config.password;
33
47
  this.authHeader = 'Basic ' + Buffer.from(`${config.username}:${config.password}`).toString('base64');
34
48
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
49
+ this.gatewayUrl = (config.gatewayUrl || deriveGatewayUrl(this.baseUrl)).replace(/\/+$/, '');
35
50
  }
36
51
  async request(path, params = {}, method = 'GET') {
37
52
  const url = new URL(this.baseUrl + (path.startsWith('/') ? path : '/' + path));
@@ -75,6 +90,55 @@ export class QualysClient {
75
90
  return { text, contentType };
76
91
  }
77
92
  }
93
+ async getToken(forceRefresh = false) {
94
+ if (!forceRefresh && this.token && Date.now() < this.tokenExpiresAt) {
95
+ return this.token;
96
+ }
97
+ const response = await fetch(this.gatewayUrl + '/auth', {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
100
+ body: new URLSearchParams({ username: this.username, password: this.password, token: 'true' }),
101
+ });
102
+ const text = await response.text();
103
+ if (!response.ok) {
104
+ throw new Error(`Qualys gateway auth failed (${response.status}) at ${this.gatewayUrl}/auth: ${text.slice(0, 500)}. ` +
105
+ 'If your platform gateway differs, set QUALYS_GATEWAY_URL.');
106
+ }
107
+ this.token = text.trim();
108
+ this.tokenExpiresAt = Date.now() + 3.5 * 60 * 60 * 1000;
109
+ return this.token;
110
+ }
111
+ async gatewayRequest(path, query = {}, method = 'POST') {
112
+ const url = new URL(this.gatewayUrl + (path.startsWith('/') ? path : '/' + path));
113
+ for (const [key, value] of Object.entries(query)) {
114
+ if (value !== undefined && value !== null && value !== '') {
115
+ url.searchParams.set(key, String(value));
116
+ }
117
+ }
118
+ for (let attempt = 0;; attempt++) {
119
+ const token = await this.getToken(attempt > 0);
120
+ const response = await fetch(url, {
121
+ method,
122
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
123
+ });
124
+ if (response.status === 401 && attempt < 1)
125
+ continue;
126
+ if (response.status === 429 && attempt < MAX_RETRIES) {
127
+ await new Promise((resolve) => setTimeout(resolve, 10_000 * (attempt + 1)));
128
+ continue;
129
+ }
130
+ const text = await response.text();
131
+ if (!response.ok) {
132
+ throw new Error(`Qualys gateway API error ${response.status} for ${method} ${url.pathname}: ${text.slice(0, 2000)}`);
133
+ }
134
+ try {
135
+ return JSON.parse(text);
136
+ }
137
+ catch {
138
+ return text;
139
+ }
140
+ }
141
+ }
78
142
  }
79
143
  export function simplify(parsed) {
80
144
  if (!parsed || typeof parsed !== 'object')
@@ -90,3 +154,19 @@ export function simplify(parsed) {
90
154
  }
91
155
  return obj;
92
156
  }
157
+ export function stripEmpty(value) {
158
+ if (Array.isArray(value)) {
159
+ const arr = value.map(stripEmpty).filter((v) => v !== undefined);
160
+ return arr.length > 0 ? arr : undefined;
161
+ }
162
+ if (value && typeof value === 'object') {
163
+ const out = {};
164
+ for (const [k, v] of Object.entries(value)) {
165
+ const stripped = stripEmpty(v);
166
+ if (stripped !== undefined)
167
+ out[k] = stripped;
168
+ }
169
+ return Object.keys(out).length > 0 ? out : undefined;
170
+ }
171
+ return value === null || value === undefined ? undefined : value;
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualys-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",