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.
Files changed (147) hide show
  1. package/.claude/settings.local.json +42 -1
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  3. package/.github/ISSUE_TEMPLATE/false_positive.md +43 -0
  4. package/.github/ISSUE_TEMPLATE/new_pattern.md +49 -0
  5. package/.github/ISSUE_TEMPLATE/security_report.md +31 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +39 -0
  7. package/.mcpregistry_github_token +1 -0
  8. package/.mcpregistry_registry_token +1 -0
  9. package/CLAUDE.md +197 -0
  10. package/CONTRIBUTING.md +329 -0
  11. package/README.md +111 -45
  12. package/STATUS.md +167 -29
  13. package/SUBMISSION.md +66 -0
  14. package/TROUBLESHOOT-COGNITO-AUTH-20260324-2029.md +415 -0
  15. package/TROUBLESHOOT-COGNITO-JWT-20260324.md +592 -0
  16. package/dist/browser/playwright-renderer.d.ts.map +1 -1
  17. package/dist/browser/playwright-renderer.js +71 -51
  18. package/dist/browser/playwright-renderer.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +49 -6
  21. package/dist/index.js.map +1 -1
  22. package/dist/sanitizer/elicit-runner.d.ts +48 -0
  23. package/dist/sanitizer/elicit-runner.d.ts.map +1 -0
  24. package/dist/sanitizer/elicit-runner.js +100 -0
  25. package/dist/sanitizer/elicit-runner.js.map +1 -0
  26. package/dist/sanitizer/framework-mapper.d.ts +2 -0
  27. package/dist/sanitizer/framework-mapper.d.ts.map +1 -1
  28. package/dist/sanitizer/framework-mapper.js +91 -45
  29. package/dist/sanitizer/framework-mapper.js.map +1 -1
  30. package/dist/sanitizer/hitl-gate.d.ts +69 -0
  31. package/dist/sanitizer/hitl-gate.d.ts.map +1 -0
  32. package/dist/sanitizer/hitl-gate.js +101 -0
  33. package/dist/sanitizer/hitl-gate.js.map +1 -0
  34. package/dist/sanitizer/threat-reporter.d.ts +1 -0
  35. package/dist/sanitizer/threat-reporter.d.ts.map +1 -1
  36. package/dist/sanitizer/threat-reporter.js +10 -7
  37. package/dist/sanitizer/threat-reporter.js.map +1 -1
  38. package/infrastructure/stack.ts +1 -0
  39. package/lambda-deploy/index.js +81512 -0
  40. package/lambda-deploy/index.js.map +7 -0
  41. package/lambda-package/browser/__mocks__/playwright-renderer.d.ts +25 -0
  42. package/lambda-package/browser/__mocks__/playwright-renderer.d.ts.map +1 -0
  43. package/lambda-package/browser/__mocks__/playwright-renderer.js +119 -0
  44. package/lambda-package/browser/__mocks__/playwright-renderer.js.map +1 -0
  45. package/lambda-package/browser/playwright-renderer.d.ts +40 -0
  46. package/lambda-package/browser/playwright-renderer.d.ts.map +1 -0
  47. package/lambda-package/browser/playwright-renderer.js +214 -0
  48. package/lambda-package/browser/playwright-renderer.js.map +1 -0
  49. package/lambda-package/browser/reader.d.ts +31 -0
  50. package/lambda-package/browser/reader.d.ts.map +1 -0
  51. package/lambda-package/browser/reader.js +98 -0
  52. package/lambda-package/browser/reader.js.map +1 -0
  53. package/lambda-package/index.d.ts +18 -0
  54. package/lambda-package/index.d.ts.map +1 -0
  55. package/lambda-package/index.js +238 -0
  56. package/lambda-package/index.js.map +1 -0
  57. package/lambda-package/lambda-handler.d.ts +28 -0
  58. package/lambda-package/lambda-handler.d.ts.map +1 -0
  59. package/lambda-package/lambda-handler.js +257 -0
  60. package/lambda-package/lambda-handler.js.map +1 -0
  61. package/lambda-package/package-lock.json +7435 -0
  62. package/lambda-package/package.json +74 -0
  63. package/lambda-package/runtime.d.ts +50 -0
  64. package/lambda-package/runtime.d.ts.map +1 -0
  65. package/lambda-package/runtime.js +86 -0
  66. package/lambda-package/runtime.js.map +1 -0
  67. package/lambda-package/sanitizer/elicit-runner.d.ts +48 -0
  68. package/lambda-package/sanitizer/elicit-runner.d.ts.map +1 -0
  69. package/lambda-package/sanitizer/elicit-runner.js +100 -0
  70. package/lambda-package/sanitizer/elicit-runner.js.map +1 -0
  71. package/lambda-package/sanitizer/framework-mapper.d.ts +24 -0
  72. package/lambda-package/sanitizer/framework-mapper.d.ts.map +1 -0
  73. package/lambda-package/sanitizer/framework-mapper.js +342 -0
  74. package/lambda-package/sanitizer/framework-mapper.js.map +1 -0
  75. package/lambda-package/sanitizer/hitl-gate.d.ts +69 -0
  76. package/lambda-package/sanitizer/hitl-gate.d.ts.map +1 -0
  77. package/lambda-package/sanitizer/hitl-gate.js +101 -0
  78. package/lambda-package/sanitizer/hitl-gate.js.map +1 -0
  79. package/lambda-package/sanitizer/index.d.ts +63 -0
  80. package/lambda-package/sanitizer/index.d.ts.map +1 -0
  81. package/lambda-package/sanitizer/index.js +105 -0
  82. package/lambda-package/sanitizer/index.js.map +1 -0
  83. package/lambda-package/sanitizer/injection-detector.d.ts +34 -0
  84. package/lambda-package/sanitizer/injection-detector.d.ts.map +1 -0
  85. package/lambda-package/sanitizer/injection-detector.js +89 -0
  86. package/lambda-package/sanitizer/injection-detector.js.map +1 -0
  87. package/lambda-package/sanitizer/patterns.d.ts +30 -0
  88. package/lambda-package/sanitizer/patterns.d.ts.map +1 -0
  89. package/lambda-package/sanitizer/patterns.js +372 -0
  90. package/lambda-package/sanitizer/patterns.js.map +1 -0
  91. package/lambda-package/sanitizer/pii-allowlist.d.ts +49 -0
  92. package/lambda-package/sanitizer/pii-allowlist.d.ts.map +1 -0
  93. package/lambda-package/sanitizer/pii-allowlist.js +231 -0
  94. package/lambda-package/sanitizer/pii-allowlist.js.map +1 -0
  95. package/lambda-package/sanitizer/pii-redactor.d.ts +41 -0
  96. package/lambda-package/sanitizer/pii-redactor.d.ts.map +1 -0
  97. package/lambda-package/sanitizer/pii-redactor.js +213 -0
  98. package/lambda-package/sanitizer/pii-redactor.js.map +1 -0
  99. package/lambda-package/sanitizer/severity-classifier.d.ts +33 -0
  100. package/lambda-package/sanitizer/severity-classifier.d.ts.map +1 -0
  101. package/lambda-package/sanitizer/severity-classifier.js +113 -0
  102. package/lambda-package/sanitizer/severity-classifier.js.map +1 -0
  103. package/lambda-package/sanitizer/threat-reporter.d.ts +66 -0
  104. package/lambda-package/sanitizer/threat-reporter.d.ts.map +1 -0
  105. package/lambda-package/sanitizer/threat-reporter.js +163 -0
  106. package/lambda-package/sanitizer/threat-reporter.js.map +1 -0
  107. package/lambda-package/tools/fetch-structured.d.ts +51 -0
  108. package/lambda-package/tools/fetch-structured.d.ts.map +1 -0
  109. package/lambda-package/tools/fetch-structured.js +237 -0
  110. package/lambda-package/tools/fetch-structured.js.map +1 -0
  111. package/lambda-package/tools/fetch.d.ts +49 -0
  112. package/lambda-package/tools/fetch.d.ts.map +1 -0
  113. package/lambda-package/tools/fetch.js +131 -0
  114. package/lambda-package/tools/fetch.js.map +1 -0
  115. package/lambda-package/tools/read.d.ts +51 -0
  116. package/lambda-package/tools/read.d.ts.map +1 -0
  117. package/lambda-package/tools/read.js +127 -0
  118. package/lambda-package/tools/read.js.map +1 -0
  119. package/lambda-package/tools/search.d.ts +45 -0
  120. package/lambda-package/tools/search.d.ts.map +1 -0
  121. package/lambda-package/tools/search.js +220 -0
  122. package/lambda-package/tools/search.js.map +1 -0
  123. package/lambda-package/types.d.ts +167 -0
  124. package/lambda-package/types.d.ts.map +1 -0
  125. package/lambda-package/types.js +16 -0
  126. package/lambda-package/types.js.map +1 -0
  127. package/lambda-package/utils/format-converter.d.ts +39 -0
  128. package/lambda-package/utils/format-converter.d.ts.map +1 -0
  129. package/lambda-package/utils/format-converter.js +191 -0
  130. package/lambda-package/utils/format-converter.js.map +1 -0
  131. package/lambda-package/utils/truncate.d.ts +26 -0
  132. package/lambda-package/utils/truncate.d.ts.map +1 -0
  133. package/lambda-package/utils/truncate.js +54 -0
  134. package/lambda-package/utils/truncate.js.map +1 -0
  135. package/lambda.zip +0 -0
  136. package/mcp.json +44 -0
  137. package/package.json +9 -8
  138. package/server.json +43 -0
  139. package/src/browser/playwright-renderer.ts +74 -51
  140. package/src/index.ts +78 -6
  141. package/src/sanitizer/elicit-runner.ts +125 -0
  142. package/src/sanitizer/framework-mapper.ts +92 -45
  143. package/src/sanitizer/hitl-gate.ts +111 -0
  144. package/src/sanitizer/threat-reporter.ts +11 -7
  145. package/tests/elicit-runner.test.ts +232 -0
  146. package/tests/hitl-gate.test.ts +267 -0
  147. 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
- return await request(`${RENDERER_URL}/render`, {
103
- method: 'POST',
104
- headers: {
105
- 'Content-Type': 'application/json',
106
- },
107
- body: JSON.stringify({
108
- url,
109
- timeout_ms,
110
- content_limit_bytes: 512000, // 500KB default
111
- }),
112
- bodyTimeout: timeout_ms + 5000, // Add 5s buffer for network overhead
113
- headersTimeout: timeout_ms + 5000,
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.body.json() as LambdaRenderSuccess | LambdaRenderError;
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 undici fetch (fallback)
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
- const response = await request(url, {
158
- method: 'GET',
159
- headers: {
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
- const html = await response.body.text();
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
- // Capture Content-Type header
169
- const contentTypeHeader = response.headers['content-type'];
170
- const contentType = typeof contentTypeHeader === 'string'
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
- // Extract title using regex (simple fallback)
175
- const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
176
- const title = titleMatch ? titleMatch[1].trim() : '';
179
+ const html = await response.text();
177
180
 
178
- return Ok({
179
- html,
180
- title,
181
- url,
182
- contentType,
183
- text: undefined,
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.message.includes('timeout') || error.message.includes('UND_ERR')) {
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 response = await request(url, {
260
- method: 'HEAD',
261
- headersTimeout: timeout_ms,
262
- bodyTimeout: timeout_ms,
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
- // Consider 2xx and 3xx status codes as accessible
266
- const statusCode = response.statusCode;
267
- const isAccessible = (statusCode >= 200 && statusCode < 400);
288
+ return Ok(isAccessible);
268
289
 
269
- return Ok(isAccessible);
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.2.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(result.value, null, 2)
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(result.value, null, 2)
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(result.value, null, 2)
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(result.value, null, 2)
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.2.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
+ }