qualys-mcp 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/index.js +293 -0
- package/dist/qualys-client.js +92 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Superfast IT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Qualys MCP Server
|
|
2
|
+
|
|
3
|
+
Read-only MCP server for the Qualys VMDR API. Exposes host assets, vulnerability detections, the KnowledgeBase, scans, reports and asset groups to Claude (Claude Desktop / Cowork, Claude Code, or any MCP client).
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | Purpose |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `qualys_list_hosts` | Scanned host assets (ID, IP, DNS, OS, last scan dates) |
|
|
10
|
+
| `qualys_host_detections` | Vulnerabilities detected per host (QID, severity, status, evidence) |
|
|
11
|
+
| `qualys_knowledgebase` | QID details: title, CVEs, threat, impact, solution |
|
|
12
|
+
| `qualys_list_scans` | Scan history and status |
|
|
13
|
+
| `qualys_list_reports` | Generated reports |
|
|
14
|
+
| `qualys_fetch_report` | Download a finished CSV/XML report inline |
|
|
15
|
+
| `qualys_list_asset_groups` | Asset groups (typically clients/sites) and their IP ranges |
|
|
16
|
+
| `qualys_api_call` | Raw GET/POST to any other Qualys endpoint (read-only actions only) |
|
|
17
|
+
|
|
18
|
+
## Quick start (Claude Desktop / Cowork)
|
|
19
|
+
|
|
20
|
+
Add to `mcpServers` in your `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
"qualys": {
|
|
24
|
+
"command": "npx",
|
|
25
|
+
"args": ["-y", "qualys-mcp"],
|
|
26
|
+
"env": {
|
|
27
|
+
"QUALYS_USERNAME": "your-api-username",
|
|
28
|
+
"QUALYS_PASSWORD": "your-api-password",
|
|
29
|
+
"QUALYS_BASE_URL": "https://qualysapi.qg1.apps.qualys.co.uk"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For Claude Code:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add-json qualys --scope user '{"type":"stdio","command":"npx","args":["-y","qualys-mcp"],"env":{"QUALYS_USERNAME":"your-api-username","QUALYS_PASSWORD":"your-api-password","QUALYS_BASE_URL":"https://qualysapi.qg1.apps.qualys.co.uk"}}'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Node.js 18+.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Three required environment variables:
|
|
45
|
+
|
|
46
|
+
| Variable | Value |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `QUALYS_USERNAME` | Qualys API user login |
|
|
49
|
+
| `QUALYS_PASSWORD` | Qualys API user password |
|
|
50
|
+
| `QUALYS_BASE_URL` | Your platform's API server (see below) |
|
|
51
|
+
|
|
52
|
+
The API server depends on which Qualys platform your subscription lives on — check your Qualys login URL:
|
|
53
|
+
|
|
54
|
+
| You log in at | `QUALYS_BASE_URL` |
|
|
55
|
+
|---|---|
|
|
56
|
+
| qualysguard.qg1.apps.qualys.co.uk (UK1) | `https://qualysapi.qg1.apps.qualys.co.uk` |
|
|
57
|
+
| qualysguard.qualys.eu (EU1) | `https://qualysapi.qualys.eu` |
|
|
58
|
+
| qualysguard.qg2.apps.qualys.eu (EU2) | `https://qualysapi.qg2.apps.qualys.eu` |
|
|
59
|
+
| qualysguard.qualys.com (US1) | `https://qualysapi.qualys.com` |
|
|
60
|
+
| qualysguard.qg2.apps.qualys.com (US2) | `https://qualysapi.qg2.apps.qualys.com` |
|
|
61
|
+
| qualysguard.qg3.apps.qualys.com (US3) | `https://qualysapi.qg3.apps.qualys.com` |
|
|
62
|
+
|
|
63
|
+
Full list: https://www.qualys.com/platform-identification/
|
|
64
|
+
|
|
65
|
+
Best practice: create a dedicated read-only API user in Qualys (Users → New User, role "Reader" with API access enabled) rather than using your main login. Each team member should use their own API credentials.
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone <repo>
|
|
71
|
+
cd qualys-mcp
|
|
72
|
+
npm install
|
|
73
|
+
cp .env.example .env # fill in credentials
|
|
74
|
+
npm run dev # watch mode
|
|
75
|
+
npm run inspector # MCP Inspector UI
|
|
76
|
+
npm run build # compile to dist/
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Notes
|
|
80
|
+
|
|
81
|
+
- The Qualys VM API v2 returns XML; this server converts it to JSON.
|
|
82
|
+
- Rate/concurrency limits (HTTP 409/429) are retried automatically using the `X-RateLimit-ToWait-Sec` header, up to 3 times.
|
|
83
|
+
- 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.
|
|
84
|
+
- `qualys_host_detections` output is verbose. Default truncation is 20 hosts per call; filter by `severities`, `status` or asset group where possible.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { QualysClient, simplify } from './qualys-client.js';
|
|
6
|
+
const username = process.env.QUALYS_USERNAME || '';
|
|
7
|
+
const password = process.env.QUALYS_PASSWORD || '';
|
|
8
|
+
const baseUrl = process.env.QUALYS_BASE_URL || '';
|
|
9
|
+
if (!username || !password || !baseUrl) {
|
|
10
|
+
console.error('Missing required environment variables: QUALYS_USERNAME, QUALYS_PASSWORD, QUALYS_BASE_URL.');
|
|
11
|
+
console.error('QUALYS_BASE_URL is your platform API server, e.g. https://qualysapi.qg1.apps.qualys.co.uk (UK1),');
|
|
12
|
+
console.error('https://qualysapi.qualys.eu (EU1), https://qualysapi.qg2.apps.qualys.eu (EU2), https://qualysapi.qualys.com (US1).');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const client = new QualysClient({ username, password, baseUrl });
|
|
16
|
+
const server = new Server({ name: 'qualys-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
17
|
+
const truncationProperties = {
|
|
18
|
+
truncation_limit: {
|
|
19
|
+
type: 'number',
|
|
20
|
+
description: 'Max records per response. When truncated, the response includes a WARNING with the URL for the next page (use its id_min value with this tool to continue).',
|
|
21
|
+
},
|
|
22
|
+
id_min: {
|
|
23
|
+
type: 'number',
|
|
24
|
+
description: 'Only return records with ID >= this value. Used for paging through truncated results.',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const tools = [
|
|
28
|
+
{
|
|
29
|
+
name: 'qualys_list_hosts',
|
|
30
|
+
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.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
ids: { type: 'string', description: 'Comma-separated host IDs or ranges, e.g. "123,150-200"' },
|
|
35
|
+
ips: { type: 'string', description: 'Comma-separated IPs or ranges, e.g. "10.0.0.1,10.0.1.0-10.0.1.255"' },
|
|
36
|
+
ag_ids: { type: 'string', description: 'Comma-separated asset group IDs (see qualys_list_asset_groups)' },
|
|
37
|
+
ag_titles: { type: 'string', description: 'Comma-separated asset group titles' },
|
|
38
|
+
details: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'Level of detail: "Basic" (default), "Basic/AGs", "All", "All/AGs", or "None" (IDs only)',
|
|
41
|
+
},
|
|
42
|
+
os_pattern: { type: 'string', description: 'PCRE regex to match against host operating system, e.g. "Windows Server 2012"' },
|
|
43
|
+
no_vm_scan_since: { type: 'string', description: 'Only hosts NOT scanned since this date (YYYY-MM-DD) — useful to find stale/unscanned assets' },
|
|
44
|
+
vm_scan_since: { type: 'string', description: 'Only hosts scanned since this date (YYYY-MM-DD)' },
|
|
45
|
+
...truncationProperties,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'qualys_host_detections',
|
|
51
|
+
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.',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
ids: { type: 'string', description: 'Comma-separated host IDs or ranges' },
|
|
56
|
+
ips: { type: 'string', description: 'Comma-separated IPs or ranges' },
|
|
57
|
+
ag_ids: { type: 'string', description: 'Comma-separated asset group IDs' },
|
|
58
|
+
ag_titles: { type: 'string', description: 'Comma-separated asset group titles' },
|
|
59
|
+
qids: { type: 'string', description: 'Comma-separated QIDs (vulnerability IDs) to filter on' },
|
|
60
|
+
severities: { type: 'string', description: 'Comma-separated severity levels 1-5, e.g. "4,5" for high and critical' },
|
|
61
|
+
status: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'Comma-separated detection statuses: "New", "Active", "Re-Opened", "Fixed". Default excludes Fixed.',
|
|
64
|
+
},
|
|
65
|
+
show_igs: { type: 'boolean', description: 'Include information-gathered (IG) detections as well as vulnerabilities. Default false.' },
|
|
66
|
+
detection_updated_since: { type: 'string', description: 'Only detections updated since this date (YYYY-MM-DD)' },
|
|
67
|
+
...truncationProperties,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'qualys_knowledgebase',
|
|
73
|
+
description: 'Look up vulnerabilities in the Qualys KnowledgeBase by QID: title, severity, CVE IDs, vendor references, threat description, impact, solution/patch information, and exploit/malware correlation. Use details="All" for the full write-up including the fix.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
ids: { type: 'string', description: 'Comma-separated QIDs or ranges, e.g. "91234,105000-105999"' },
|
|
78
|
+
details: { type: 'string', description: 'Level of detail: "Basic" (default) or "All" (includes threat, impact and solution text)' },
|
|
79
|
+
last_modified_after: { type: 'string', description: 'Only QIDs modified after this date (YYYY-MM-DD)' },
|
|
80
|
+
id_min: { type: 'number', description: 'Only QIDs >= this value' },
|
|
81
|
+
id_max: { type: 'number', description: 'Only QIDs <= this value' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'qualys_list_scans',
|
|
87
|
+
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.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
scan_ref: { type: 'string', description: 'Specific scan reference, e.g. "scan/1234567890.12345"' },
|
|
92
|
+
state: { type: 'string', description: 'Comma-separated states: "Running", "Paused", "Canceled", "Finished", "Error", "Queued", "Loading"' },
|
|
93
|
+
type: { type: 'string', description: 'Scan type: "On-Demand", "Scheduled" or "API"' },
|
|
94
|
+
target: { type: 'string', description: 'Filter by scan target IPs' },
|
|
95
|
+
launched_after_datetime: { type: 'string', description: 'Only scans launched after this datetime (YYYY-MM-DD or ISO 8601)' },
|
|
96
|
+
launched_before_datetime: { type: 'string', description: 'Only scans launched before this datetime' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'qualys_list_reports',
|
|
102
|
+
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.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
id: { type: 'number', description: 'Specific report ID' },
|
|
107
|
+
state: { type: 'string', description: 'Report state, e.g. "Finished", "Running", "Errors"' },
|
|
108
|
+
user_login: { type: 'string', description: 'Filter by the user who launched the report' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'qualys_fetch_report',
|
|
114
|
+
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.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
id: { type: 'number', description: 'Report ID (must be in Finished state)' },
|
|
119
|
+
max_chars: { type: 'number', description: 'Maximum characters of report content to return (default 100000)' },
|
|
120
|
+
},
|
|
121
|
+
required: ['id'],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'qualys_list_asset_groups',
|
|
126
|
+
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.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
ids: { type: 'string', description: 'Comma-separated asset group IDs' },
|
|
131
|
+
show_attributes: { type: 'string', description: 'Comma-separated attributes to show, e.g. "ALL" or "ID,TITLE,IP_SET". Default "ID,TITLE".' },
|
|
132
|
+
...truncationProperties,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'qualys_api_call',
|
|
138
|
+
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).',
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
path: { type: 'string', description: 'API path starting with /, e.g. "/api/2.0/fo/schedule/scan/"' },
|
|
143
|
+
params: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
description: 'Parameters as key/value pairs, e.g. {"action": "list", "echo_request": "0"}',
|
|
146
|
+
additionalProperties: { type: 'string' },
|
|
147
|
+
},
|
|
148
|
+
method: { type: 'string', description: '"GET" (default) or "POST"' },
|
|
149
|
+
},
|
|
150
|
+
required: ['path'],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
155
|
+
async function handleTool(name, args) {
|
|
156
|
+
switch (name) {
|
|
157
|
+
case 'qualys_list_hosts': {
|
|
158
|
+
const result = await client.request('/api/2.0/fo/asset/host/', {
|
|
159
|
+
action: 'list',
|
|
160
|
+
echo_request: 0,
|
|
161
|
+
ids: args.ids,
|
|
162
|
+
ips: args.ips,
|
|
163
|
+
ag_ids: args.ag_ids,
|
|
164
|
+
ag_titles: args.ag_titles,
|
|
165
|
+
details: args.details ?? 'Basic',
|
|
166
|
+
os_pattern: args.os_pattern,
|
|
167
|
+
no_vm_scan_since: args.no_vm_scan_since,
|
|
168
|
+
vm_scan_since: args.vm_scan_since,
|
|
169
|
+
truncation_limit: args.truncation_limit ?? 100,
|
|
170
|
+
id_min: args.id_min,
|
|
171
|
+
});
|
|
172
|
+
return JSON.stringify(simplify(result.parsed), null, 2);
|
|
173
|
+
}
|
|
174
|
+
case 'qualys_host_detections': {
|
|
175
|
+
const result = await client.request('/api/2.0/fo/asset/host/vm/detection/', {
|
|
176
|
+
action: 'list',
|
|
177
|
+
echo_request: 0,
|
|
178
|
+
ids: args.ids,
|
|
179
|
+
ips: args.ips,
|
|
180
|
+
ag_ids: args.ag_ids,
|
|
181
|
+
ag_titles: args.ag_titles,
|
|
182
|
+
qids: args.qids,
|
|
183
|
+
severities: args.severities,
|
|
184
|
+
status: args.status,
|
|
185
|
+
show_igs: args.show_igs ? 1 : undefined,
|
|
186
|
+
detection_updated_since: args.detection_updated_since,
|
|
187
|
+
truncation_limit: args.truncation_limit ?? 20,
|
|
188
|
+
id_min: args.id_min,
|
|
189
|
+
});
|
|
190
|
+
return JSON.stringify(simplify(result.parsed), null, 2);
|
|
191
|
+
}
|
|
192
|
+
case 'qualys_knowledgebase': {
|
|
193
|
+
const result = await client.request('/api/2.0/fo/knowledge_base/vuln/', {
|
|
194
|
+
action: 'list',
|
|
195
|
+
echo_request: 0,
|
|
196
|
+
ids: args.ids,
|
|
197
|
+
details: args.details ?? 'Basic',
|
|
198
|
+
last_modified_after: args.last_modified_after,
|
|
199
|
+
id_min: args.id_min,
|
|
200
|
+
id_max: args.id_max,
|
|
201
|
+
});
|
|
202
|
+
return JSON.stringify(simplify(result.parsed), null, 2);
|
|
203
|
+
}
|
|
204
|
+
case 'qualys_list_scans': {
|
|
205
|
+
const result = await client.request('/api/2.0/fo/scan/', {
|
|
206
|
+
action: 'list',
|
|
207
|
+
echo_request: 0,
|
|
208
|
+
scan_ref: args.scan_ref,
|
|
209
|
+
state: args.state,
|
|
210
|
+
type: args.type,
|
|
211
|
+
target: args.target,
|
|
212
|
+
launched_after_datetime: args.launched_after_datetime,
|
|
213
|
+
launched_before_datetime: args.launched_before_datetime,
|
|
214
|
+
});
|
|
215
|
+
return JSON.stringify(simplify(result.parsed), null, 2);
|
|
216
|
+
}
|
|
217
|
+
case 'qualys_list_reports': {
|
|
218
|
+
const result = await client.request('/api/2.0/fo/report/', {
|
|
219
|
+
action: 'list',
|
|
220
|
+
echo_request: 0,
|
|
221
|
+
id: args.id,
|
|
222
|
+
state: args.state,
|
|
223
|
+
user_login: args.user_login,
|
|
224
|
+
});
|
|
225
|
+
return JSON.stringify(simplify(result.parsed), null, 2);
|
|
226
|
+
}
|
|
227
|
+
case 'qualys_fetch_report': {
|
|
228
|
+
const result = await client.request('/api/2.0/fo/report/', {
|
|
229
|
+
action: 'fetch',
|
|
230
|
+
id: args.id,
|
|
231
|
+
});
|
|
232
|
+
const maxChars = args.max_chars ?? 100_000;
|
|
233
|
+
const content = result.text ?? JSON.stringify(simplify(result.parsed), null, 2);
|
|
234
|
+
if (content.length > maxChars) {
|
|
235
|
+
return content.slice(0, maxChars) + `\n\n[Truncated: ${content.length} total characters, showing first ${maxChars}]`;
|
|
236
|
+
}
|
|
237
|
+
return content;
|
|
238
|
+
}
|
|
239
|
+
case 'qualys_list_asset_groups': {
|
|
240
|
+
const result = await client.request('/api/2.0/fo/asset/group/', {
|
|
241
|
+
action: 'list',
|
|
242
|
+
echo_request: 0,
|
|
243
|
+
ids: args.ids,
|
|
244
|
+
show_attributes: args.show_attributes ?? 'ID,TITLE',
|
|
245
|
+
truncation_limit: args.truncation_limit ?? 200,
|
|
246
|
+
id_min: args.id_min,
|
|
247
|
+
});
|
|
248
|
+
return JSON.stringify(simplify(result.parsed), null, 2);
|
|
249
|
+
}
|
|
250
|
+
case 'qualys_api_call': {
|
|
251
|
+
const path = args.path;
|
|
252
|
+
if (!path.startsWith('/'))
|
|
253
|
+
throw new Error('path must start with /');
|
|
254
|
+
const method = args.method?.toUpperCase() === 'POST' ? 'POST' : 'GET';
|
|
255
|
+
const params = args.params || {};
|
|
256
|
+
if (!('echo_request' in params) && path.startsWith('/api/'))
|
|
257
|
+
params.echo_request = '0';
|
|
258
|
+
const result = await client.request(path, params, method);
|
|
259
|
+
return result.parsed !== undefined
|
|
260
|
+
? JSON.stringify(simplify(result.parsed), null, 2)
|
|
261
|
+
: (result.text ?? '');
|
|
262
|
+
}
|
|
263
|
+
default:
|
|
264
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
268
|
+
const { name, arguments: args } = request.params;
|
|
269
|
+
try {
|
|
270
|
+
const text = await handleTool(name, args || {});
|
|
271
|
+
return { content: [{ type: 'text', text }] };
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
return {
|
|
275
|
+
content: [
|
|
276
|
+
{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
isError: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
async function main() {
|
|
286
|
+
const transport = new StdioServerTransport();
|
|
287
|
+
await server.connect(transport);
|
|
288
|
+
console.error(`Qualys MCP server running (${baseUrl})`);
|
|
289
|
+
}
|
|
290
|
+
main().catch((error) => {
|
|
291
|
+
console.error('Fatal error:', error);
|
|
292
|
+
process.exit(1);
|
|
293
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
const MAX_RETRIES = 3;
|
|
3
|
+
const ARRAY_TAGS = new Set([
|
|
4
|
+
'HOST',
|
|
5
|
+
'DETECTION',
|
|
6
|
+
'VULN',
|
|
7
|
+
'SCAN',
|
|
8
|
+
'REPORT',
|
|
9
|
+
'ASSET_GROUP',
|
|
10
|
+
'CVE',
|
|
11
|
+
'BUGTRAQ',
|
|
12
|
+
'VENDOR_REFERENCE',
|
|
13
|
+
'COMPLIANCE',
|
|
14
|
+
'CORRELATION',
|
|
15
|
+
'EXPLOIT',
|
|
16
|
+
'MALWARE',
|
|
17
|
+
'ASSET_GROUP_TITLE',
|
|
18
|
+
'TAG',
|
|
19
|
+
'DNS_DATA',
|
|
20
|
+
'WARNING',
|
|
21
|
+
'OPTION_PROFILE',
|
|
22
|
+
]);
|
|
23
|
+
const parser = new XMLParser({
|
|
24
|
+
ignoreAttributes: true,
|
|
25
|
+
parseTagValue: false,
|
|
26
|
+
trimValues: true,
|
|
27
|
+
isArray: (name) => ARRAY_TAGS.has(name),
|
|
28
|
+
});
|
|
29
|
+
export class QualysClient {
|
|
30
|
+
authHeader;
|
|
31
|
+
baseUrl;
|
|
32
|
+
constructor(config) {
|
|
33
|
+
this.authHeader = 'Basic ' + Buffer.from(`${config.username}:${config.password}`).toString('base64');
|
|
34
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
35
|
+
}
|
|
36
|
+
async request(path, params = {}, method = 'GET') {
|
|
37
|
+
const url = new URL(this.baseUrl + (path.startsWith('/') ? path : '/' + path));
|
|
38
|
+
const search = new URLSearchParams();
|
|
39
|
+
for (const [key, value] of Object.entries(params)) {
|
|
40
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
41
|
+
search.set(key, String(value));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const headers = {
|
|
45
|
+
Authorization: this.authHeader,
|
|
46
|
+
'X-Requested-With': 'qualys-mcp',
|
|
47
|
+
};
|
|
48
|
+
let body;
|
|
49
|
+
if (method === 'POST') {
|
|
50
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
51
|
+
body = search.toString();
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
url.search = search.toString();
|
|
55
|
+
}
|
|
56
|
+
for (let attempt = 0;; attempt++) {
|
|
57
|
+
const response = await fetch(url, { method, headers, body });
|
|
58
|
+
if ((response.status === 409 || response.status === 429) && attempt < MAX_RETRIES) {
|
|
59
|
+
const toWait = Number(response.headers.get('x-ratelimit-towait-sec')) ||
|
|
60
|
+
Number(response.headers.get('retry-after')) ||
|
|
61
|
+
10 * (attempt + 1);
|
|
62
|
+
if (toWait <= 120) {
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, toWait * 1000));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const contentType = response.headers.get('content-type') || '';
|
|
68
|
+
const text = await response.text();
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`Qualys API error ${response.status} for ${method} ${url.pathname}: ${text.slice(0, 2000)}`);
|
|
71
|
+
}
|
|
72
|
+
if (text.trimStart().startsWith('<')) {
|
|
73
|
+
return { parsed: parser.parse(text), contentType };
|
|
74
|
+
}
|
|
75
|
+
return { text, contentType };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function simplify(parsed) {
|
|
80
|
+
if (!parsed || typeof parsed !== 'object')
|
|
81
|
+
return parsed;
|
|
82
|
+
const obj = parsed;
|
|
83
|
+
const keys = Object.keys(obj).filter((k) => k !== '?xml');
|
|
84
|
+
if (keys.length === 1) {
|
|
85
|
+
const inner = obj[keys[0]];
|
|
86
|
+
if (inner && typeof inner === 'object' && 'RESPONSE' in inner) {
|
|
87
|
+
return inner.RESPONSE;
|
|
88
|
+
}
|
|
89
|
+
return inner;
|
|
90
|
+
}
|
|
91
|
+
return obj;
|
|
92
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "qualys-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for the Qualys VMDR API (read-only) — hosts, vulnerability detections, KnowledgeBase, scans, reports and asset groups",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"qualys-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"modelcontextprotocol",
|
|
19
|
+
"qualys",
|
|
20
|
+
"vmdr",
|
|
21
|
+
"vulnerability-management",
|
|
22
|
+
"claude"
|
|
23
|
+
],
|
|
24
|
+
"author": "Superfast IT",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.17.4",
|
|
28
|
+
"fast-xml-parser": "^4.5.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.10.2",
|
|
32
|
+
"tsx": "^4.19.2",
|
|
33
|
+
"typescript": "^5.7.2"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"prepublishOnly": "npm run build",
|
|
38
|
+
"dev": "tsx watch --env-file=.env src/index.ts",
|
|
39
|
+
"start": "node dist/index.js",
|
|
40
|
+
"inspector": "npx @modelcontextprotocol/inspector tsx --env-file=.env src/index.ts"
|
|
41
|
+
}
|
|
42
|
+
}
|