visus-mcp 0.6.0 → 0.6.2
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/.claude/settings.local.json +42 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/false_positive.md +43 -0
- package/.github/ISSUE_TEMPLATE/new_pattern.md +49 -0
- package/.github/ISSUE_TEMPLATE/security_report.md +31 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +39 -0
- package/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/CLAUDE.md +197 -0
- package/CONTRIBUTING.md +329 -0
- package/README.md +111 -45
- package/STATUS.md +167 -29
- package/SUBMISSION.md +66 -0
- package/TROUBLESHOOT-COGNITO-AUTH-20260324-2029.md +415 -0
- package/TROUBLESHOOT-COGNITO-JWT-20260324.md +592 -0
- package/dist/browser/playwright-renderer.d.ts.map +1 -1
- package/dist/browser/playwright-renderer.js +71 -51
- package/dist/browser/playwright-renderer.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -6
- package/dist/index.js.map +1 -1
- package/dist/sanitizer/elicit-runner.d.ts +48 -0
- package/dist/sanitizer/elicit-runner.d.ts.map +1 -0
- package/dist/sanitizer/elicit-runner.js +100 -0
- package/dist/sanitizer/elicit-runner.js.map +1 -0
- package/dist/sanitizer/framework-mapper.d.ts +2 -0
- package/dist/sanitizer/framework-mapper.d.ts.map +1 -1
- package/dist/sanitizer/framework-mapper.js +91 -45
- package/dist/sanitizer/framework-mapper.js.map +1 -1
- package/dist/sanitizer/hitl-gate.d.ts +69 -0
- package/dist/sanitizer/hitl-gate.d.ts.map +1 -0
- package/dist/sanitizer/hitl-gate.js +101 -0
- package/dist/sanitizer/hitl-gate.js.map +1 -0
- package/dist/sanitizer/threat-reporter.d.ts +1 -0
- package/dist/sanitizer/threat-reporter.d.ts.map +1 -1
- package/dist/sanitizer/threat-reporter.js +10 -7
- package/dist/sanitizer/threat-reporter.js.map +1 -1
- package/infrastructure/stack.ts +1 -0
- package/lambda-deploy/index.js +81512 -0
- package/lambda-deploy/index.js.map +7 -0
- package/lambda-package/browser/__mocks__/playwright-renderer.d.ts +25 -0
- package/lambda-package/browser/__mocks__/playwright-renderer.d.ts.map +1 -0
- package/lambda-package/browser/__mocks__/playwright-renderer.js +119 -0
- package/lambda-package/browser/__mocks__/playwright-renderer.js.map +1 -0
- package/lambda-package/browser/playwright-renderer.d.ts +40 -0
- package/lambda-package/browser/playwright-renderer.d.ts.map +1 -0
- package/lambda-package/browser/playwright-renderer.js +214 -0
- package/lambda-package/browser/playwright-renderer.js.map +1 -0
- package/lambda-package/browser/reader.d.ts +31 -0
- package/lambda-package/browser/reader.d.ts.map +1 -0
- package/lambda-package/browser/reader.js +98 -0
- package/lambda-package/browser/reader.js.map +1 -0
- package/lambda-package/index.d.ts +18 -0
- package/lambda-package/index.d.ts.map +1 -0
- package/lambda-package/index.js +238 -0
- package/lambda-package/index.js.map +1 -0
- package/lambda-package/lambda-handler.d.ts +28 -0
- package/lambda-package/lambda-handler.d.ts.map +1 -0
- package/lambda-package/lambda-handler.js +257 -0
- package/lambda-package/lambda-handler.js.map +1 -0
- package/lambda-package/package-lock.json +7435 -0
- package/lambda-package/package.json +74 -0
- package/lambda-package/runtime.d.ts +50 -0
- package/lambda-package/runtime.d.ts.map +1 -0
- package/lambda-package/runtime.js +86 -0
- package/lambda-package/runtime.js.map +1 -0
- package/lambda-package/sanitizer/elicit-runner.d.ts +48 -0
- package/lambda-package/sanitizer/elicit-runner.d.ts.map +1 -0
- package/lambda-package/sanitizer/elicit-runner.js +100 -0
- package/lambda-package/sanitizer/elicit-runner.js.map +1 -0
- package/lambda-package/sanitizer/framework-mapper.d.ts +24 -0
- package/lambda-package/sanitizer/framework-mapper.d.ts.map +1 -0
- package/lambda-package/sanitizer/framework-mapper.js +342 -0
- package/lambda-package/sanitizer/framework-mapper.js.map +1 -0
- package/lambda-package/sanitizer/hitl-gate.d.ts +69 -0
- package/lambda-package/sanitizer/hitl-gate.d.ts.map +1 -0
- package/lambda-package/sanitizer/hitl-gate.js +101 -0
- package/lambda-package/sanitizer/hitl-gate.js.map +1 -0
- package/lambda-package/sanitizer/index.d.ts +63 -0
- package/lambda-package/sanitizer/index.d.ts.map +1 -0
- package/lambda-package/sanitizer/index.js +105 -0
- package/lambda-package/sanitizer/index.js.map +1 -0
- package/lambda-package/sanitizer/injection-detector.d.ts +34 -0
- package/lambda-package/sanitizer/injection-detector.d.ts.map +1 -0
- package/lambda-package/sanitizer/injection-detector.js +89 -0
- package/lambda-package/sanitizer/injection-detector.js.map +1 -0
- package/lambda-package/sanitizer/patterns.d.ts +30 -0
- package/lambda-package/sanitizer/patterns.d.ts.map +1 -0
- package/lambda-package/sanitizer/patterns.js +372 -0
- package/lambda-package/sanitizer/patterns.js.map +1 -0
- package/lambda-package/sanitizer/pii-allowlist.d.ts +49 -0
- package/lambda-package/sanitizer/pii-allowlist.d.ts.map +1 -0
- package/lambda-package/sanitizer/pii-allowlist.js +231 -0
- package/lambda-package/sanitizer/pii-allowlist.js.map +1 -0
- package/lambda-package/sanitizer/pii-redactor.d.ts +41 -0
- package/lambda-package/sanitizer/pii-redactor.d.ts.map +1 -0
- package/lambda-package/sanitizer/pii-redactor.js +213 -0
- package/lambda-package/sanitizer/pii-redactor.js.map +1 -0
- package/lambda-package/sanitizer/severity-classifier.d.ts +33 -0
- package/lambda-package/sanitizer/severity-classifier.d.ts.map +1 -0
- package/lambda-package/sanitizer/severity-classifier.js +113 -0
- package/lambda-package/sanitizer/severity-classifier.js.map +1 -0
- package/lambda-package/sanitizer/threat-reporter.d.ts +66 -0
- package/lambda-package/sanitizer/threat-reporter.d.ts.map +1 -0
- package/lambda-package/sanitizer/threat-reporter.js +163 -0
- package/lambda-package/sanitizer/threat-reporter.js.map +1 -0
- package/lambda-package/tools/fetch-structured.d.ts +51 -0
- package/lambda-package/tools/fetch-structured.d.ts.map +1 -0
- package/lambda-package/tools/fetch-structured.js +237 -0
- package/lambda-package/tools/fetch-structured.js.map +1 -0
- package/lambda-package/tools/fetch.d.ts +49 -0
- package/lambda-package/tools/fetch.d.ts.map +1 -0
- package/lambda-package/tools/fetch.js +131 -0
- package/lambda-package/tools/fetch.js.map +1 -0
- package/lambda-package/tools/read.d.ts +51 -0
- package/lambda-package/tools/read.d.ts.map +1 -0
- package/lambda-package/tools/read.js +127 -0
- package/lambda-package/tools/read.js.map +1 -0
- package/lambda-package/tools/search.d.ts +45 -0
- package/lambda-package/tools/search.d.ts.map +1 -0
- package/lambda-package/tools/search.js +220 -0
- package/lambda-package/tools/search.js.map +1 -0
- package/lambda-package/types.d.ts +167 -0
- package/lambda-package/types.d.ts.map +1 -0
- package/lambda-package/types.js +16 -0
- package/lambda-package/types.js.map +1 -0
- package/lambda-package/utils/format-converter.d.ts +39 -0
- package/lambda-package/utils/format-converter.d.ts.map +1 -0
- package/lambda-package/utils/format-converter.js +191 -0
- package/lambda-package/utils/format-converter.js.map +1 -0
- package/lambda-package/utils/truncate.d.ts +26 -0
- package/lambda-package/utils/truncate.d.ts.map +1 -0
- package/lambda-package/utils/truncate.js +54 -0
- package/lambda-package/utils/truncate.js.map +1 -0
- package/lambda.zip +0 -0
- package/mcp.json +44 -0
- package/package.json +9 -8
- package/server.json +43 -0
- package/src/browser/playwright-renderer.ts +74 -51
- package/src/index.ts +78 -6
- package/src/sanitizer/elicit-runner.ts +125 -0
- package/src/sanitizer/framework-mapper.ts +92 -45
- package/src/sanitizer/hitl-gate.ts +111 -0
- package/src/sanitizer/threat-reporter.ts +11 -7
- package/tests/elicit-runner.test.ts +232 -0
- package/tests/hitl-gate.test.ts +267 -0
- package/tests/threat-reporter.test.ts +69 -1
package/server.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.visus-mcp/visus-mcp",
|
|
4
|
+
"description": "Security-first web access for Claude. Sanitizes pages, blocks injection, redacts PII.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/visus-mcp/visus-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.6.2",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "visus-mcp",
|
|
14
|
+
"version": "0.6.2",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
},
|
|
18
|
+
"environmentVariables": [
|
|
19
|
+
{
|
|
20
|
+
"description": "Optional timeout in milliseconds for page fetching (default: 10000)",
|
|
21
|
+
"isRequired": false,
|
|
22
|
+
"format": "number",
|
|
23
|
+
"isSecret": false,
|
|
24
|
+
"name": "VISUS_TIMEOUT_MS"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"description": "Optional maximum content size in KB before truncation (default: 512)",
|
|
28
|
+
"isRequired": false,
|
|
29
|
+
"format": "number",
|
|
30
|
+
"isSecret": false,
|
|
31
|
+
"name": "VISUS_MAX_CONTENT_KB"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"description": "Optional URL for managed Lambda renderer (Phase 2 - not yet available)",
|
|
35
|
+
"isRequired": false,
|
|
36
|
+
"format": "string",
|
|
37
|
+
"isSecret": false,
|
|
38
|
+
"name": "VISUS_RENDERER_URL"
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* Lambda to the local process before Claude sees it. PHI never touches Lateos infrastructure.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { request } from 'undici';
|
|
13
12
|
import type { BrowserRenderResult, Result } from '../types.js';
|
|
14
13
|
import { Ok, Err } from '../types.js';
|
|
15
14
|
|
|
@@ -99,22 +98,28 @@ async function renderWithLambda(
|
|
|
99
98
|
try {
|
|
100
99
|
// Retry Lambda calls with exponential backoff (3 attempts)
|
|
101
100
|
const response = await retryWithBackoff(async () => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout_ms + 5000);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
return await fetch(`${RENDERER_URL}/render`, {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: {
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
url,
|
|
112
|
+
timeout_ms,
|
|
113
|
+
content_limit_bytes: 512000, // 500KB default
|
|
114
|
+
}),
|
|
115
|
+
signal: controller.signal,
|
|
116
|
+
});
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timeoutId);
|
|
119
|
+
}
|
|
115
120
|
}, 3, 1000); // 3 retries, starting with 1s delay
|
|
116
121
|
|
|
117
|
-
const body = await response.
|
|
122
|
+
const body = await response.json() as LambdaRenderSuccess | LambdaRenderError;
|
|
118
123
|
|
|
119
124
|
// Check if response is an error
|
|
120
125
|
if ('error' in body) {
|
|
@@ -145,7 +150,7 @@ async function renderWithLambda(
|
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
/**
|
|
148
|
-
* Render a page using
|
|
153
|
+
* Render a page using native fetch (fallback)
|
|
149
154
|
*/
|
|
150
155
|
async function renderWithFetch(
|
|
151
156
|
url: string,
|
|
@@ -154,39 +159,51 @@ async function renderWithFetch(
|
|
|
154
159
|
logRenderer('fetch', url);
|
|
155
160
|
|
|
156
161
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
'User-Agent': 'Visus-MCP/0.2.0 (Security-focused web content fetcher; +https://github.com/visus-mcp/visus-mcp)',
|
|
161
|
-
},
|
|
162
|
-
bodyTimeout: timeout_ms,
|
|
163
|
-
headersTimeout: timeout_ms,
|
|
164
|
-
});
|
|
162
|
+
// Use AbortController for timeout
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout_ms);
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch(url, {
|
|
168
|
+
method: 'GET',
|
|
169
|
+
headers: {
|
|
170
|
+
'User-Agent': 'Visus-MCP/0.3.1 (Security-focused web content fetcher; +https://github.com/lateos/visus-mcp)',
|
|
171
|
+
},
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
});
|
|
167
174
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
? contentTypeHeader.split(';')[0].trim() // Remove charset and other params
|
|
172
|
-
: 'text/html'; // Default to HTML if missing
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
return Err(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
177
|
+
}
|
|
173
178
|
|
|
174
|
-
|
|
175
|
-
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
|
176
|
-
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
179
|
+
const html = await response.text();
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
// Capture Content-Type header
|
|
182
|
+
const contentTypeHeader = response.headers.get('content-type');
|
|
183
|
+
const contentType = contentTypeHeader
|
|
184
|
+
? contentTypeHeader.split(';')[0].trim() // Remove charset and other params
|
|
185
|
+
: 'text/html'; // Default to HTML if missing
|
|
186
|
+
|
|
187
|
+
// Extract title using regex (simple fallback)
|
|
188
|
+
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
|
189
|
+
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
190
|
+
|
|
191
|
+
return Ok({
|
|
192
|
+
html,
|
|
193
|
+
title,
|
|
194
|
+
url,
|
|
195
|
+
contentType,
|
|
196
|
+
text: undefined,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timeoutId);
|
|
201
|
+
}
|
|
185
202
|
|
|
186
203
|
} catch (error) {
|
|
187
204
|
if (error instanceof Error) {
|
|
188
|
-
// Handle timeout errors
|
|
189
|
-
if (error.
|
|
205
|
+
// Handle abort/timeout errors
|
|
206
|
+
if (error.name === 'AbortError') {
|
|
190
207
|
return Err(new Error(`Navigation timeout after ${timeout_ms}ms`));
|
|
191
208
|
}
|
|
192
209
|
|
|
@@ -256,17 +273,23 @@ export async function checkUrl(
|
|
|
256
273
|
timeout_ms = 5000
|
|
257
274
|
): Promise<Result<boolean, Error>> {
|
|
258
275
|
try {
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout_ms);
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetch(url, {
|
|
281
|
+
method: 'HEAD',
|
|
282
|
+
signal: controller.signal,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Consider 2xx and 3xx status codes as accessible
|
|
286
|
+
const isAccessible = (response.status >= 200 && response.status < 400);
|
|
264
287
|
|
|
265
|
-
|
|
266
|
-
const statusCode = response.statusCode;
|
|
267
|
-
const isAccessible = (statusCode >= 200 && statusCode < 400);
|
|
288
|
+
return Ok(isAccessible);
|
|
268
289
|
|
|
269
|
-
|
|
290
|
+
} finally {
|
|
291
|
+
clearTimeout(timeoutId);
|
|
292
|
+
}
|
|
270
293
|
|
|
271
294
|
} catch (error) {
|
|
272
295
|
// URL is not accessible
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,9 @@ import { visusRead, visusReadToolDefinition } from './tools/read.js';
|
|
|
31
31
|
import { visusSearch, visusSearchToolDefinition } from './tools/search.js';
|
|
32
32
|
import { closeBrowser } from './browser/playwright-renderer.js';
|
|
33
33
|
import { detectRuntime, logRuntimeConfig, validateRuntime } from './runtime.js';
|
|
34
|
+
import { shouldElicit } from './sanitizer/hitl-gate.js';
|
|
35
|
+
import { runElicitation } from './sanitizer/elicit-runner.js';
|
|
36
|
+
import type { ThreatReport } from './sanitizer/threat-reporter.js';
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* Create and configure the MCP server
|
|
@@ -38,7 +41,7 @@ import { detectRuntime, logRuntimeConfig, validateRuntime } from './runtime.js';
|
|
|
38
41
|
const server = new Server(
|
|
39
42
|
{
|
|
40
43
|
name: 'visus-mcp',
|
|
41
|
-
version: '0.
|
|
44
|
+
version: '0.6.0'
|
|
42
45
|
},
|
|
43
46
|
{
|
|
44
47
|
capabilities: {
|
|
@@ -61,6 +64,50 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
61
64
|
};
|
|
62
65
|
});
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Helper function to handle HITL elicitation for CRITICAL threats
|
|
69
|
+
*
|
|
70
|
+
* Returns modified output with threat_report removed if user declined,
|
|
71
|
+
* or blocked response if user declined to proceed.
|
|
72
|
+
*/
|
|
73
|
+
async function handleCriticalThreatElicitation(
|
|
74
|
+
output: any,
|
|
75
|
+
url: string
|
|
76
|
+
): Promise<{ output: any; blocked: boolean }> {
|
|
77
|
+
const threatReport = output.threat_report as ThreatReport | undefined;
|
|
78
|
+
|
|
79
|
+
// Check if elicitation is needed
|
|
80
|
+
if (shouldElicit(threatReport ?? null)) {
|
|
81
|
+
const { proceed, includeReport } = await runElicitation(
|
|
82
|
+
server,
|
|
83
|
+
threatReport!,
|
|
84
|
+
url
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (!proceed) {
|
|
88
|
+
// User declined — return blocked response with threat report
|
|
89
|
+
return {
|
|
90
|
+
output: {
|
|
91
|
+
url,
|
|
92
|
+
blocked: true,
|
|
93
|
+
reason: 'User declined to proceed after CRITICAL threat detected',
|
|
94
|
+
threat_report: threatReport
|
|
95
|
+
},
|
|
96
|
+
blocked: true
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// User accepted — proceed with sanitized content
|
|
101
|
+
// Remove threat_report if user didn't request it
|
|
102
|
+
if (!includeReport && output.threat_report) {
|
|
103
|
+
const { threat_report, ...outputWithoutReport } = output;
|
|
104
|
+
return { output: outputWithoutReport, blocked: false };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { output, blocked: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
64
111
|
/**
|
|
65
112
|
* Handle tool execution requests
|
|
66
113
|
*/
|
|
@@ -79,11 +126,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
79
126
|
);
|
|
80
127
|
}
|
|
81
128
|
|
|
129
|
+
// Handle HITL elicitation for CRITICAL threats
|
|
130
|
+
const { output } = await handleCriticalThreatElicitation(
|
|
131
|
+
result.value,
|
|
132
|
+
(args as any).url
|
|
133
|
+
);
|
|
134
|
+
|
|
82
135
|
return {
|
|
83
136
|
content: [
|
|
84
137
|
{
|
|
85
138
|
type: 'text',
|
|
86
|
-
text: JSON.stringify(
|
|
139
|
+
text: JSON.stringify(output, null, 2)
|
|
87
140
|
}
|
|
88
141
|
]
|
|
89
142
|
};
|
|
@@ -99,11 +152,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
99
152
|
);
|
|
100
153
|
}
|
|
101
154
|
|
|
155
|
+
// Handle HITL elicitation for CRITICAL threats
|
|
156
|
+
const { output } = await handleCriticalThreatElicitation(
|
|
157
|
+
result.value,
|
|
158
|
+
(args as any).url
|
|
159
|
+
);
|
|
160
|
+
|
|
102
161
|
return {
|
|
103
162
|
content: [
|
|
104
163
|
{
|
|
105
164
|
type: 'text',
|
|
106
|
-
text: JSON.stringify(
|
|
165
|
+
text: JSON.stringify(output, null, 2)
|
|
107
166
|
}
|
|
108
167
|
]
|
|
109
168
|
};
|
|
@@ -119,11 +178,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
119
178
|
);
|
|
120
179
|
}
|
|
121
180
|
|
|
181
|
+
// Handle HITL elicitation for CRITICAL threats
|
|
182
|
+
const { output } = await handleCriticalThreatElicitation(
|
|
183
|
+
result.value,
|
|
184
|
+
(args as any).url
|
|
185
|
+
);
|
|
186
|
+
|
|
122
187
|
return {
|
|
123
188
|
content: [
|
|
124
189
|
{
|
|
125
190
|
type: 'text',
|
|
126
|
-
text: JSON.stringify(
|
|
191
|
+
text: JSON.stringify(output, null, 2)
|
|
127
192
|
}
|
|
128
193
|
]
|
|
129
194
|
};
|
|
@@ -139,11 +204,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
139
204
|
);
|
|
140
205
|
}
|
|
141
206
|
|
|
207
|
+
// Handle HITL elicitation for CRITICAL threats
|
|
208
|
+
// For search, use the query as the "URL" in the elicitation message
|
|
209
|
+
const { output } = await handleCriticalThreatElicitation(
|
|
210
|
+
result.value,
|
|
211
|
+
`search: ${(args as any).query}`
|
|
212
|
+
);
|
|
213
|
+
|
|
142
214
|
return {
|
|
143
215
|
content: [
|
|
144
216
|
{
|
|
145
217
|
type: 'text',
|
|
146
|
-
text: JSON.stringify(
|
|
218
|
+
text: JSON.stringify(output, null, 2)
|
|
147
219
|
}
|
|
148
220
|
]
|
|
149
221
|
};
|
|
@@ -181,7 +253,7 @@ async function startMcpServer() {
|
|
|
181
253
|
timestamp: new Date().toISOString(),
|
|
182
254
|
event: 'mcp_server_started',
|
|
183
255
|
name: 'visus-mcp',
|
|
184
|
-
version: '0.
|
|
256
|
+
version: '0.6.0',
|
|
185
257
|
tools: ['visus_fetch', 'visus_fetch_structured', 'visus_read', 'visus_search']
|
|
186
258
|
}));
|
|
187
259
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elicitation Runner
|
|
3
|
+
*
|
|
4
|
+
* Executes MCP elicitation requests with comprehensive error handling
|
|
5
|
+
* and fail-safe behavior. If elicitation fails for ANY reason, the
|
|
6
|
+
* sanitized content is delivered — security is never compromised.
|
|
7
|
+
*
|
|
8
|
+
* Error handling includes:
|
|
9
|
+
* - Client doesn't support elicitation
|
|
10
|
+
* - Client timeout
|
|
11
|
+
* - Network errors
|
|
12
|
+
* - Unexpected responses
|
|
13
|
+
*
|
|
14
|
+
* Fail-safe principle: Elicitation is UX. Sanitization is security.
|
|
15
|
+
* Never block content delivery due to elicitation failures.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
+
import type { ThreatReport } from './threat-reporter.js';
|
|
20
|
+
import { buildElicitMessage, ElicitSchema } from './hitl-gate.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result of elicitation execution
|
|
24
|
+
*/
|
|
25
|
+
export interface ElicitationResult {
|
|
26
|
+
/** Whether user chose to proceed with sanitized content */
|
|
27
|
+
proceed: boolean;
|
|
28
|
+
/** Whether to include threat report in response */
|
|
29
|
+
includeReport: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Runs MCP elicitation for CRITICAL threat confirmation
|
|
34
|
+
*
|
|
35
|
+
* Three possible outcomes:
|
|
36
|
+
* 1. User accepts → proceed: true, includeReport: user's choice
|
|
37
|
+
* 2. User declines → proceed: false, includeReport: false
|
|
38
|
+
* 3. User cancels → proceed: false, includeReport: false
|
|
39
|
+
*
|
|
40
|
+
* Fail-safe: Any error → proceed: true, includeReport: true
|
|
41
|
+
* (Content reaches user in sanitized form, security maintained)
|
|
42
|
+
*
|
|
43
|
+
* CRITICAL: Only ONE elicitation per tool call is allowed per MCP spec.
|
|
44
|
+
* Calling this function twice in the same request will cause timeout.
|
|
45
|
+
*
|
|
46
|
+
* @param server The MCP server instance
|
|
47
|
+
* @param threatReport The CRITICAL threat report
|
|
48
|
+
* @param url The source URL
|
|
49
|
+
* @returns Elicitation result with proceed and includeReport flags
|
|
50
|
+
*/
|
|
51
|
+
export async function runElicitation(
|
|
52
|
+
server: Server,
|
|
53
|
+
threatReport: ThreatReport,
|
|
54
|
+
url: string
|
|
55
|
+
): Promise<ElicitationResult> {
|
|
56
|
+
try {
|
|
57
|
+
// Build user-facing message
|
|
58
|
+
const message = buildElicitMessage(threatReport, url);
|
|
59
|
+
|
|
60
|
+
// Execute elicitation
|
|
61
|
+
const result = await server.elicitInput({
|
|
62
|
+
mode: 'form' as const,
|
|
63
|
+
message,
|
|
64
|
+
requestedSchema: ElicitSchema as any // Type cast due to SDK's strict schema definition
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Handle user response
|
|
68
|
+
if (result.action === 'accept') {
|
|
69
|
+
// User explicitly accepted
|
|
70
|
+
// Content values can be string | number | boolean | string[]
|
|
71
|
+
const proceed = result.content?.proceed === true || result.content?.proceed === 'true';
|
|
72
|
+
const includeReport = result.content?.view_report === true || result.content?.view_report === 'true' || result.content?.view_report === undefined;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
proceed,
|
|
76
|
+
includeReport: proceed ? includeReport : false // Only include report if proceeding
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result.action === 'decline') {
|
|
81
|
+
// User explicitly declined
|
|
82
|
+
return {
|
|
83
|
+
proceed: false,
|
|
84
|
+
includeReport: false
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (result.action === 'cancel') {
|
|
89
|
+
// User canceled or dismissed dialog
|
|
90
|
+
return {
|
|
91
|
+
proceed: false,
|
|
92
|
+
includeReport: false
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unknown action (should never happen)
|
|
97
|
+
console.error(JSON.stringify({
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
event: 'elicitation_unknown_action',
|
|
100
|
+
action: result.action,
|
|
101
|
+
message: 'Unexpected elicitation action, proceeding with sanitized content (fail-safe)'
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
proceed: true,
|
|
106
|
+
includeReport: true
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
} catch (error) {
|
|
110
|
+
// Elicitation failed — FAIL SAFE
|
|
111
|
+
// Client may not support elicitation, or timeout occurred
|
|
112
|
+
// Proceed with sanitized content + include report
|
|
113
|
+
console.error(JSON.stringify({
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
event: 'elicitation_failed',
|
|
116
|
+
error: error instanceof Error ? error.message : String(error),
|
|
117
|
+
message: 'Elicitation not supported or timed out, proceeding with sanitized content (fail-safe)'
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
proceed: true,
|
|
122
|
+
includeReport: true
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|