visus-mcp 0.2.0 → 0.6.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/.claude/settings.local.json +22 -0
- package/LINKEDIN-STRATEGY.md +367 -0
- package/README.md +491 -16
- package/ROADMAP.md +214 -34
- package/SECURITY-AUDIT-v1.md +277 -0
- package/STATUS.md +801 -42
- package/TROUBLESHOOT-AUTH-20260322-2019.md +291 -0
- package/TROUBLESHOOT-JEST-20260323-1357.md +139 -0
- package/TROUBLESHOOT-LAMBDA-20260322-1945.md +183 -0
- package/VISUS-CLAUDE-CODE-PROMPT.md +1 -1
- package/VISUS-PROJECT-PLAN.md +7 -0
- package/dist/browser/playwright-renderer.d.ts.map +1 -1
- package/dist/browser/playwright-renderer.js +7 -0
- package/dist/browser/playwright-renderer.js.map +1 -1
- package/dist/browser/reader.d.ts +31 -0
- package/dist/browser/reader.d.ts.map +1 -0
- package/dist/browser/reader.js +98 -0
- package/dist/browser/reader.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -5
- package/dist/index.js.map +1 -1
- package/dist/lambda-handler.d.ts +0 -6
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +97 -25
- package/dist/lambda-handler.js.map +1 -1
- package/dist/sanitizer/framework-mapper.d.ts +22 -0
- package/dist/sanitizer/framework-mapper.d.ts.map +1 -0
- package/dist/sanitizer/framework-mapper.js +296 -0
- package/dist/sanitizer/framework-mapper.js.map +1 -0
- package/dist/sanitizer/index.d.ts +10 -2
- package/dist/sanitizer/index.d.ts.map +1 -1
- package/dist/sanitizer/index.js +22 -6
- package/dist/sanitizer/index.js.map +1 -1
- package/dist/sanitizer/patterns.js +1 -1
- package/dist/sanitizer/patterns.js.map +1 -1
- package/dist/sanitizer/pii-allowlist.d.ts +49 -0
- package/dist/sanitizer/pii-allowlist.d.ts.map +1 -0
- package/dist/sanitizer/pii-allowlist.js +231 -0
- package/dist/sanitizer/pii-allowlist.js.map +1 -0
- package/dist/sanitizer/pii-redactor.d.ts +13 -1
- package/dist/sanitizer/pii-redactor.d.ts.map +1 -1
- package/dist/sanitizer/pii-redactor.js +26 -2
- package/dist/sanitizer/pii-redactor.js.map +1 -1
- package/dist/sanitizer/severity-classifier.d.ts +33 -0
- package/dist/sanitizer/severity-classifier.d.ts.map +1 -0
- package/dist/sanitizer/severity-classifier.js +113 -0
- package/dist/sanitizer/severity-classifier.js.map +1 -0
- package/dist/sanitizer/threat-reporter.d.ts +65 -0
- package/dist/sanitizer/threat-reporter.d.ts.map +1 -0
- package/dist/sanitizer/threat-reporter.js +160 -0
- package/dist/sanitizer/threat-reporter.js.map +1 -0
- package/dist/tools/fetch-structured.d.ts +5 -0
- package/dist/tools/fetch-structured.d.ts.map +1 -1
- package/dist/tools/fetch-structured.js +59 -8
- package/dist/tools/fetch-structured.js.map +1 -1
- package/dist/tools/fetch.d.ts +5 -0
- package/dist/tools/fetch.d.ts.map +1 -1
- package/dist/tools/fetch.js +43 -9
- package/dist/tools/fetch.js.map +1 -1
- package/dist/tools/read.d.ts +51 -0
- package/dist/tools/read.d.ts.map +1 -0
- package/dist/tools/read.js +127 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/search.d.ts +45 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +220 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/types.d.ts +74 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/format-converter.d.ts +39 -0
- package/dist/utils/format-converter.d.ts.map +1 -0
- package/dist/utils/format-converter.js +191 -0
- package/dist/utils/format-converter.js.map +1 -0
- package/dist/utils/truncate.d.ts +26 -0
- package/dist/utils/truncate.d.ts.map +1 -0
- package/dist/utils/truncate.js +54 -0
- package/dist/utils/truncate.js.map +1 -0
- package/infrastructure/stack.ts +55 -6
- package/jest.config.js +3 -0
- package/package.json +9 -2
- package/src/browser/playwright-renderer.ts +8 -0
- package/src/browser/reader.ts +129 -0
- package/src/index.ts +49 -5
- package/src/lambda-handler.ts +131 -26
- package/src/sanitizer/framework-mapper.ts +347 -0
- package/src/sanitizer/index.ts +28 -6
- package/src/sanitizer/patterns.ts +1 -1
- package/src/sanitizer/pii-allowlist.ts +273 -0
- package/src/sanitizer/pii-redactor.ts +43 -2
- package/src/sanitizer/severity-classifier.ts +132 -0
- package/src/sanitizer/threat-reporter.ts +261 -0
- package/src/tools/fetch-structured.ts +63 -8
- package/src/tools/fetch.ts +45 -9
- package/src/tools/read.ts +143 -0
- package/src/tools/search.ts +263 -0
- package/src/types.ts +71 -0
- package/src/utils/format-converter.ts +236 -0
- package/src/utils/truncate.ts +64 -0
- package/tests/auth-smoke.test.ts +480 -0
- package/tests/fetch-tool.test.ts +595 -2
- package/tests/pii-allowlist.test.ts +282 -0
- package/tests/reader.test.ts +353 -0
- package/tests/sanitizer.test.ts +52 -0
- package/tests/search.test.ts +456 -0
- package/tests/threat-reporter.test.ts +266 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format Converter - Content-Type based format detection and conversion
|
|
3
|
+
*
|
|
4
|
+
* Handles format-appropriate conversion based on detected Content-Type.
|
|
5
|
+
* Supports HTML, JSON, XML, and RSS/Atom feeds.
|
|
6
|
+
*/
|
|
7
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
8
|
+
/**
|
|
9
|
+
* Detect format from Content-Type header
|
|
10
|
+
*
|
|
11
|
+
* @param contentType - Content-Type header value (e.g., "application/json", "text/html; charset=utf-8")
|
|
12
|
+
* @returns Detected format type
|
|
13
|
+
*/
|
|
14
|
+
export function detectFormat(contentType) {
|
|
15
|
+
// Normalize: lowercase and extract MIME type (before semicolon)
|
|
16
|
+
const mimeType = contentType.toLowerCase().split(';')[0].trim();
|
|
17
|
+
// HTML formats
|
|
18
|
+
if (mimeType === 'text/html' || mimeType === 'application/xhtml+xml') {
|
|
19
|
+
return 'html';
|
|
20
|
+
}
|
|
21
|
+
// JSON formats
|
|
22
|
+
if (mimeType === 'application/json' || mimeType === 'text/json') {
|
|
23
|
+
return 'json';
|
|
24
|
+
}
|
|
25
|
+
// RSS/Atom feed formats
|
|
26
|
+
if (mimeType === 'application/rss+xml' ||
|
|
27
|
+
mimeType === 'application/atom+xml' ||
|
|
28
|
+
mimeType === 'application/feed+json') {
|
|
29
|
+
return 'rss';
|
|
30
|
+
}
|
|
31
|
+
// XML formats (must come after RSS check)
|
|
32
|
+
if (mimeType === 'application/xml' || mimeType === 'text/xml') {
|
|
33
|
+
return 'xml';
|
|
34
|
+
}
|
|
35
|
+
// Default to HTML for unknown types
|
|
36
|
+
return 'html';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convert JSON content to formatted string
|
|
40
|
+
*
|
|
41
|
+
* @param raw - Raw JSON string
|
|
42
|
+
* @returns Formatted JSON string with prefix, or raw string if parse fails
|
|
43
|
+
*/
|
|
44
|
+
export function convertJson(raw) {
|
|
45
|
+
try {
|
|
46
|
+
// Parse and re-stringify with 2-space indent for readability
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
49
|
+
return `JSON Response:\n\n${formatted}`;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// Parse failed, return raw string unchanged
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Convert XML content to clean text representation
|
|
58
|
+
*
|
|
59
|
+
* @param raw - Raw XML string
|
|
60
|
+
* @returns Formatted XML representation with prefix, or tag-stripped fallback if parse fails
|
|
61
|
+
*/
|
|
62
|
+
export function convertXml(raw) {
|
|
63
|
+
try {
|
|
64
|
+
const parser = new XMLParser({
|
|
65
|
+
ignoreAttributes: false,
|
|
66
|
+
attributeNamePrefix: '@_',
|
|
67
|
+
textNodeName: '#text',
|
|
68
|
+
ignoreDeclaration: true,
|
|
69
|
+
ignorePiTags: true,
|
|
70
|
+
removeNSPrefix: true,
|
|
71
|
+
});
|
|
72
|
+
const parsed = parser.parse(raw);
|
|
73
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
74
|
+
return `XML Response:\n\n${formatted}`;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Parse failed, strip XML tags using regex and return
|
|
78
|
+
const stripped = raw.replace(/<[^>]+>/g, '').trim();
|
|
79
|
+
return `XML Response:\n\n${stripped}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Convert RSS/Atom feed content to clean Markdown
|
|
84
|
+
*
|
|
85
|
+
* @param raw - Raw RSS/Atom XML string
|
|
86
|
+
* @returns Formatted Markdown representation, or falls back to convertXml if parse fails
|
|
87
|
+
*/
|
|
88
|
+
export function convertRss(raw) {
|
|
89
|
+
try {
|
|
90
|
+
const parser = new XMLParser({
|
|
91
|
+
ignoreAttributes: false,
|
|
92
|
+
attributeNamePrefix: '@_',
|
|
93
|
+
textNodeName: '#text',
|
|
94
|
+
removeNSPrefix: true,
|
|
95
|
+
});
|
|
96
|
+
const parsed = parser.parse(raw);
|
|
97
|
+
// Handle RSS 2.0 format
|
|
98
|
+
if (parsed.rss && parsed.rss.channel) {
|
|
99
|
+
return formatRss2(parsed.rss.channel);
|
|
100
|
+
}
|
|
101
|
+
// Handle Atom format
|
|
102
|
+
if (parsed.feed) {
|
|
103
|
+
return formatAtom(parsed.feed);
|
|
104
|
+
}
|
|
105
|
+
// Handle RSS 1.0 (RDF) format
|
|
106
|
+
if (parsed.rdf && parsed.rdf.channel) {
|
|
107
|
+
return formatRss2(parsed.rdf.channel);
|
|
108
|
+
}
|
|
109
|
+
// Unknown feed format, fall back to XML
|
|
110
|
+
return convertXml(raw);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// Parse failed, fall back to XML converter
|
|
114
|
+
return convertXml(raw);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Format RSS 2.0 feed data as Markdown
|
|
119
|
+
*/
|
|
120
|
+
function formatRss2(channel) {
|
|
121
|
+
const title = channel.title || 'Untitled Feed';
|
|
122
|
+
const description = channel.description || '';
|
|
123
|
+
const items = Array.isArray(channel.item) ? channel.item : (channel.item ? [channel.item] : []);
|
|
124
|
+
let markdown = `RSS Feed:\n\n# ${title}\n`;
|
|
125
|
+
if (description) {
|
|
126
|
+
markdown += `${description}\n`;
|
|
127
|
+
}
|
|
128
|
+
markdown += '\n## Items\n\n';
|
|
129
|
+
// Extract up to 10 items
|
|
130
|
+
const itemsToShow = items.slice(0, 10);
|
|
131
|
+
for (const item of itemsToShow) {
|
|
132
|
+
const itemTitle = item.title || 'Untitled';
|
|
133
|
+
const itemLink = item.link || '';
|
|
134
|
+
const itemDescription = item.description || '';
|
|
135
|
+
const itemPubDate = item.pubDate || '';
|
|
136
|
+
// Truncate description to 200 chars
|
|
137
|
+
const truncatedDesc = itemDescription.length > 200
|
|
138
|
+
? itemDescription.substring(0, 200) + '...'
|
|
139
|
+
: itemDescription;
|
|
140
|
+
markdown += `### ${itemTitle}\n\n`;
|
|
141
|
+
if (truncatedDesc) {
|
|
142
|
+
markdown += `${truncatedDesc}\n\n`;
|
|
143
|
+
}
|
|
144
|
+
if (itemLink) {
|
|
145
|
+
markdown += `Link: ${itemLink}\n`;
|
|
146
|
+
}
|
|
147
|
+
if (itemPubDate) {
|
|
148
|
+
markdown += `Published: ${itemPubDate}\n`;
|
|
149
|
+
}
|
|
150
|
+
markdown += '\n---\n\n';
|
|
151
|
+
}
|
|
152
|
+
return markdown;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Format Atom feed data as Markdown
|
|
156
|
+
*/
|
|
157
|
+
function formatAtom(feed) {
|
|
158
|
+
const title = feed.title || 'Untitled Feed';
|
|
159
|
+
const subtitle = feed.subtitle || '';
|
|
160
|
+
const entries = Array.isArray(feed.entry) ? feed.entry : (feed.entry ? [feed.entry] : []);
|
|
161
|
+
let markdown = `RSS Feed:\n\n# ${title}\n`;
|
|
162
|
+
if (subtitle) {
|
|
163
|
+
markdown += `${subtitle}\n`;
|
|
164
|
+
}
|
|
165
|
+
markdown += '\n## Items\n\n';
|
|
166
|
+
// Extract up to 10 entries
|
|
167
|
+
const entriesToShow = entries.slice(0, 10);
|
|
168
|
+
for (const entry of entriesToShow) {
|
|
169
|
+
const entryTitle = entry.title || 'Untitled';
|
|
170
|
+
const entryLink = entry.link ? (entry.link['@_href'] || entry.link) : '';
|
|
171
|
+
const entrySummary = entry.summary || entry.content || '';
|
|
172
|
+
const entryPublished = entry.published || entry.updated || '';
|
|
173
|
+
// Truncate summary to 200 chars
|
|
174
|
+
const truncatedSummary = entrySummary.length > 200
|
|
175
|
+
? entrySummary.substring(0, 200) + '...'
|
|
176
|
+
: entrySummary;
|
|
177
|
+
markdown += `### ${entryTitle}\n\n`;
|
|
178
|
+
if (truncatedSummary) {
|
|
179
|
+
markdown += `${truncatedSummary}\n\n`;
|
|
180
|
+
}
|
|
181
|
+
if (entryLink) {
|
|
182
|
+
markdown += `Link: ${entryLink}\n`;
|
|
183
|
+
}
|
|
184
|
+
if (entryPublished) {
|
|
185
|
+
markdown += `Published: ${entryPublished}\n`;
|
|
186
|
+
}
|
|
187
|
+
markdown += '\n---\n\n';
|
|
188
|
+
}
|
|
189
|
+
return markdown;
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=format-converter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format-converter.js","sourceRoot":"","sources":["../../src/utils/format-converter.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-aware content truncation utility
|
|
3
|
+
*
|
|
4
|
+
* Anthropic MCP Directory enforces a 25,000 token response limit.
|
|
5
|
+
* This utility provides safe truncation with token estimation.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Truncate content if it exceeds the token ceiling
|
|
9
|
+
*
|
|
10
|
+
* @param content Content to potentially truncate
|
|
11
|
+
* @returns Truncated content and metadata
|
|
12
|
+
*/
|
|
13
|
+
export declare function truncateContent(content: string): {
|
|
14
|
+
content: string;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
truncated_at_chars?: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Estimate token count for a given string
|
|
20
|
+
* Uses conservative 4 chars per token approximation
|
|
21
|
+
*
|
|
22
|
+
* @param text Text to estimate
|
|
23
|
+
* @returns Estimated token count
|
|
24
|
+
*/
|
|
25
|
+
export declare function estimateTokens(text: string): number;
|
|
26
|
+
//# sourceMappingURL=truncate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../src/utils/truncate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAmBH;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;IACnB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B,CAkBA;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-aware content truncation utility
|
|
3
|
+
*
|
|
4
|
+
* Anthropic MCP Directory enforces a 25,000 token response limit.
|
|
5
|
+
* This utility provides safe truncation with token estimation.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Maximum tokens allowed in MCP response (Anthropic Directory limit)
|
|
9
|
+
* We target 24,000 to leave headroom for metadata/JSON structure
|
|
10
|
+
*/
|
|
11
|
+
const MAX_TOKENS = 24000;
|
|
12
|
+
/**
|
|
13
|
+
* Conservative token estimation: 1 token ≈ 4 characters
|
|
14
|
+
* This is a safe approximation that errs on the side of caution
|
|
15
|
+
*/
|
|
16
|
+
const CHARS_PER_TOKEN = 4;
|
|
17
|
+
/**
|
|
18
|
+
* Maximum characters based on token limit
|
|
19
|
+
*/
|
|
20
|
+
const MAX_CHARS = MAX_TOKENS * CHARS_PER_TOKEN; // 96,000 characters
|
|
21
|
+
/**
|
|
22
|
+
* Truncate content if it exceeds the token ceiling
|
|
23
|
+
*
|
|
24
|
+
* @param content Content to potentially truncate
|
|
25
|
+
* @returns Truncated content and metadata
|
|
26
|
+
*/
|
|
27
|
+
export function truncateContent(content) {
|
|
28
|
+
if (content.length <= MAX_CHARS) {
|
|
29
|
+
// Content is within limits
|
|
30
|
+
return {
|
|
31
|
+
content,
|
|
32
|
+
truncated: false
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Content exceeds limit - truncate with warning message
|
|
36
|
+
const truncatedContent = content.substring(0, MAX_CHARS);
|
|
37
|
+
const warningMessage = `\n\n--- CONTENT TRUNCATED ---\nOriginal length: ${content.length} characters (~${Math.ceil(content.length / CHARS_PER_TOKEN)} tokens)\nTruncated to: ${MAX_CHARS} characters (~${MAX_TOKENS} tokens)\nReason: Anthropic MCP Directory enforces a 25,000 token response limit\n`;
|
|
38
|
+
return {
|
|
39
|
+
content: truncatedContent + warningMessage,
|
|
40
|
+
truncated: true,
|
|
41
|
+
truncated_at_chars: MAX_CHARS
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Estimate token count for a given string
|
|
46
|
+
* Uses conservative 4 chars per token approximation
|
|
47
|
+
*
|
|
48
|
+
* @param text Text to estimate
|
|
49
|
+
* @returns Estimated token count
|
|
50
|
+
*/
|
|
51
|
+
export function estimateTokens(text) {
|
|
52
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=truncate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"truncate.js","sourceRoot":"","sources":["../../src/utils/truncate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;GAGG;AACH,MAAM,UAAU,GAAG,KAAK,CAAC;AAEzB;;;GAGG;AACH,MAAM,eAAe,GAAG,CAAC,CAAC;AAE1B;;GAEG;AACH,MAAM,SAAS,GAAG,UAAU,GAAG,eAAe,CAAC,CAAC,oBAAoB;AAEpE;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,OAAe;IAK7C,IAAI,OAAO,CAAC,MAAM,IAAI,SAAS,EAAE,CAAC;QAChC,2BAA2B;QAC3B,OAAO;YACL,OAAO;YACP,SAAS,EAAE,KAAK;SACjB,CAAC;IACJ,CAAC;IAED,wDAAwD;IACxD,MAAM,gBAAgB,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC;IACzD,MAAM,cAAc,GAAG,mDAAmD,OAAO,CAAC,MAAM,iBAAiB,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,GAAG,eAAe,CAAC,2BAA2B,SAAS,iBAAiB,UAAU,oFAAoF,CAAC;IAExS,OAAO;QACL,OAAO,EAAE,gBAAgB,GAAG,cAAc;QAC1C,SAAS,EAAE,IAAI;QACf,kBAAkB,EAAE,SAAS;KAC9B,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,eAAe,CAAC,CAAC;AAClD,CAAC"}
|
package/infrastructure/stack.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import * as cdk from 'aws-cdk-lib';
|
|
21
21
|
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
22
|
+
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
|
|
22
23
|
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
|
|
23
24
|
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
|
24
25
|
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
|
|
@@ -69,6 +70,7 @@ export class VisusStack extends cdk.Stack {
|
|
|
69
70
|
removalPolicy: environment === 'prod'
|
|
70
71
|
? cdk.RemovalPolicy.RETAIN
|
|
71
72
|
: cdk.RemovalPolicy.DESTROY,
|
|
73
|
+
timeToLiveAttribute: 'ttl', // Auto-delete audit logs after 30 days
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
// Global Secondary Index for querying by request_id
|
|
@@ -142,12 +144,12 @@ export class VisusStack extends cdk.Stack {
|
|
|
142
144
|
// Grant KMS decrypt access (for reading encrypted DynamoDB data if needed)
|
|
143
145
|
kmsKey.grantEncryptDecrypt(lambdaRole);
|
|
144
146
|
|
|
145
|
-
// Lambda function
|
|
146
|
-
const visusFn = new
|
|
147
|
+
// Lambda function (NodejsFunction with automatic bundling)
|
|
148
|
+
const visusFn = new lambdaNodejs.NodejsFunction(this, 'VisusFunction', {
|
|
147
149
|
functionName: `visus-mcp-${environment}`,
|
|
148
150
|
runtime: lambda.Runtime.NODEJS_20_X,
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
entry: 'src/lambda-handler.ts', // Entry point for bundler
|
|
152
|
+
handler: 'handler', // Export name in the entry file
|
|
151
153
|
timeout: cdk.Duration.seconds(30), // Playwright page loads can take time
|
|
152
154
|
memorySize: 1024, // Chromium requires significant memory
|
|
153
155
|
reservedConcurrentExecutions: environment === 'prod' ? 100 : 10, // RULE 7: Cost protection
|
|
@@ -155,12 +157,21 @@ export class VisusStack extends cdk.Stack {
|
|
|
155
157
|
environment: {
|
|
156
158
|
AUDIT_TABLE_NAME: auditTable.tableName,
|
|
157
159
|
ENVIRONMENT: environment,
|
|
160
|
+
ALLOWED_ORIGINS: 'https://claude.ai,https://app.claude.ai,http://localhost:3000',
|
|
158
161
|
NODE_OPTIONS: '--enable-source-maps', // For debugging
|
|
159
162
|
},
|
|
160
163
|
logRetention: environment === 'prod'
|
|
161
164
|
? logs.RetentionDays.ONE_MONTH
|
|
162
165
|
: logs.RetentionDays.ONE_WEEK,
|
|
163
166
|
description: `Visus MCP sanitization service (${environment})`,
|
|
167
|
+
bundling: {
|
|
168
|
+
minify: false, // Keep readable for debugging
|
|
169
|
+
sourceMap: true,
|
|
170
|
+
externalModules: [
|
|
171
|
+
'playwright-core', // Playwright is huge, will be added via layer
|
|
172
|
+
'@sparticuz/chromium', // Chromium binary
|
|
173
|
+
],
|
|
174
|
+
},
|
|
164
175
|
});
|
|
165
176
|
|
|
166
177
|
// ========================================
|
|
@@ -178,12 +189,44 @@ export class VisusStack extends cdk.Stack {
|
|
|
178
189
|
metricsEnabled: true,
|
|
179
190
|
},
|
|
180
191
|
defaultCorsPreflightOptions: {
|
|
181
|
-
allowOrigins:
|
|
182
|
-
|
|
192
|
+
allowOrigins: [
|
|
193
|
+
'https://claude.ai',
|
|
194
|
+
'https://app.claude.ai',
|
|
195
|
+
'http://localhost:3000', // local dev only
|
|
196
|
+
],
|
|
197
|
+
allowMethods: ['POST', 'OPTIONS'],
|
|
183
198
|
allowHeaders: ['Content-Type', 'Authorization'],
|
|
184
199
|
},
|
|
185
200
|
});
|
|
186
201
|
|
|
202
|
+
// Usage plan with rate limiting and quota
|
|
203
|
+
const usagePlan = api.addUsagePlan('VisusUsagePlan', {
|
|
204
|
+
name: `visus-usage-plan-${environment}`,
|
|
205
|
+
description: 'Rate limiting and quota management for Visus API',
|
|
206
|
+
throttle: {
|
|
207
|
+
rateLimit: 10, // 10 requests per second
|
|
208
|
+
burstLimit: 20, // 20 request burst
|
|
209
|
+
},
|
|
210
|
+
quota: {
|
|
211
|
+
limit: 1000, // 1000 requests per day
|
|
212
|
+
period: apigateway.Period.DAY,
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Add deployment stage to usage plan
|
|
217
|
+
usagePlan.addApiStage({
|
|
218
|
+
stage: api.deploymentStage,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Create API key for the usage plan
|
|
222
|
+
const apiKey = api.addApiKey('VisusApiKey', {
|
|
223
|
+
apiKeyName: `visus-api-key-${environment}`,
|
|
224
|
+
description: `API key for Visus ${environment} environment`,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Associate API key with usage plan
|
|
228
|
+
usagePlan.addApiKey(apiKey);
|
|
229
|
+
|
|
187
230
|
// Cognito authorizer
|
|
188
231
|
const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, 'VisusAuthorizer', {
|
|
189
232
|
cognitoUserPools: [userPool],
|
|
@@ -244,5 +287,11 @@ export class VisusStack extends cdk.Stack {
|
|
|
244
287
|
description: 'Lambda function ARN',
|
|
245
288
|
exportName: `visus-lambda-arn-${environment}`,
|
|
246
289
|
});
|
|
290
|
+
|
|
291
|
+
new cdk.CfnOutput(this, 'ApiKeyId', {
|
|
292
|
+
value: apiKey.keyId,
|
|
293
|
+
description: 'API Gateway API Key ID (use aws apigateway get-api-key to retrieve value)',
|
|
294
|
+
exportName: `visus-api-key-id-${environment}`,
|
|
295
|
+
});
|
|
247
296
|
}
|
|
248
297
|
}
|
package/jest.config.js
CHANGED
|
@@ -15,6 +15,9 @@ export default {
|
|
|
15
15
|
},
|
|
16
16
|
testMatch: ['**/tests/**/*.test.ts'],
|
|
17
17
|
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
|
18
|
+
transformIgnorePatterns: [
|
|
19
|
+
'node_modules/(?!(@mozilla/readability|jsdom|@exodus/bytes|html-encoding-sniffer|@toon-format)/)',
|
|
20
|
+
],
|
|
18
21
|
testTimeout: 15000,
|
|
19
22
|
forceExit: true,
|
|
20
23
|
detectOpenHandles: false,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visus-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Secure web access for Claude
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Secure, sanitized web access for Claude. Detects prompt injection (43 patterns), redacts PII, renders JavaScript pages, generates NIST AI 600-1 / OWASP / MITRE ATLAS threat reports, and auto-detects JSON/XML/RSS content types.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"visus-mcp": "dist/index.js"
|
|
@@ -39,15 +39,22 @@
|
|
|
39
39
|
"node": ">=18"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@aws-sdk/client-dynamodb": "^3.1014.0",
|
|
43
|
+
"@aws-sdk/lib-dynamodb": "^3.1014.0",
|
|
42
44
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
45
|
+
"@mozilla/readability": "^0.6.0",
|
|
43
46
|
"@playwright/test": "^1.58.2",
|
|
47
|
+
"@toon-format/toon": "^2.1.0",
|
|
44
48
|
"cheerio": "^1.2.0",
|
|
49
|
+
"fast-xml-parser": "^5.5.8",
|
|
50
|
+
"jsdom": "^29.0.1",
|
|
45
51
|
"playwright": "^1.58.2",
|
|
46
52
|
"undici": "^7.24.5"
|
|
47
53
|
},
|
|
48
54
|
"devDependencies": {
|
|
49
55
|
"@types/aws-lambda": "^8.10.161",
|
|
50
56
|
"@types/jest": "^29.5.14",
|
|
57
|
+
"@types/jsdom": "^28.0.1",
|
|
51
58
|
"@types/node": "^20.19.37",
|
|
52
59
|
"aws-cdk": "^2.1112.0",
|
|
53
60
|
"aws-cdk-lib": "^2.244.0",
|
|
@@ -126,6 +126,7 @@ async function renderWithLambda(
|
|
|
126
126
|
html: body.html,
|
|
127
127
|
title: body.title,
|
|
128
128
|
url,
|
|
129
|
+
contentType: 'text/html', // Lambda renderer defaults to HTML
|
|
129
130
|
text: undefined, // Lambda renderer doesn't extract text
|
|
130
131
|
});
|
|
131
132
|
|
|
@@ -164,6 +165,12 @@ async function renderWithFetch(
|
|
|
164
165
|
|
|
165
166
|
const html = await response.body.text();
|
|
166
167
|
|
|
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
|
|
173
|
+
|
|
167
174
|
// Extract title using regex (simple fallback)
|
|
168
175
|
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
|
169
176
|
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
@@ -172,6 +179,7 @@ async function renderWithFetch(
|
|
|
172
179
|
html,
|
|
173
180
|
title,
|
|
174
181
|
url,
|
|
182
|
+
contentType,
|
|
175
183
|
text: undefined,
|
|
176
184
|
});
|
|
177
185
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader Mode - Mozilla Readability Integration
|
|
3
|
+
*
|
|
4
|
+
* Extracts clean article content from web pages using Mozilla's Readability.js.
|
|
5
|
+
* This module strips navigation, ads, and boilerplate to return main article content.
|
|
6
|
+
*
|
|
7
|
+
* CRITICAL: Content extraction happens BEFORE sanitization. The pipeline is:
|
|
8
|
+
* Playwright renders → Readability extracts → Sanitizer runs → Token ceiling applied
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Readability } from '@mozilla/readability';
|
|
12
|
+
import { JSDOM } from 'jsdom';
|
|
13
|
+
import type { Result } from '../types.js';
|
|
14
|
+
import { Ok, Err } from '../types.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Result from reader mode extraction
|
|
18
|
+
*/
|
|
19
|
+
export interface ReaderResult {
|
|
20
|
+
title: string;
|
|
21
|
+
byline: string | null; // Author
|
|
22
|
+
publishedTime: string | null; // ISO timestamp or null
|
|
23
|
+
content: string; // Extracted text content
|
|
24
|
+
excerpt: string | null; // Short summary
|
|
25
|
+
wordCount: number; // Estimated word count
|
|
26
|
+
readerModeAvailable: boolean; // True if Readability succeeded
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract clean article content using Mozilla Readability
|
|
31
|
+
*
|
|
32
|
+
* @param html - Rendered HTML from Playwright
|
|
33
|
+
* @param url - Original URL (required for relative link resolution)
|
|
34
|
+
* @returns Result containing extracted article or fallback to full HTML
|
|
35
|
+
*/
|
|
36
|
+
export function extractArticle(
|
|
37
|
+
html: string,
|
|
38
|
+
url: string
|
|
39
|
+
): Result<ReaderResult, Error> {
|
|
40
|
+
try {
|
|
41
|
+
// Parse HTML with jsdom
|
|
42
|
+
const dom = new JSDOM(html, { url });
|
|
43
|
+
const document = dom.window.document;
|
|
44
|
+
|
|
45
|
+
// Attempt extraction with Readability
|
|
46
|
+
const reader = new Readability(document);
|
|
47
|
+
const article = reader.parse();
|
|
48
|
+
|
|
49
|
+
// If Readability succeeds, return extracted content
|
|
50
|
+
if (article && article.textContent) {
|
|
51
|
+
const wordCount = estimateWordCount(article.textContent);
|
|
52
|
+
|
|
53
|
+
return Ok({
|
|
54
|
+
title: article.title || 'Untitled',
|
|
55
|
+
byline: article.byline || null,
|
|
56
|
+
publishedTime: article.publishedTime || null,
|
|
57
|
+
content: article.textContent,
|
|
58
|
+
excerpt: article.excerpt || null,
|
|
59
|
+
wordCount,
|
|
60
|
+
readerModeAvailable: true
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Readability failed - fallback to raw text extraction
|
|
65
|
+
const fallbackText = extractFallbackText(document);
|
|
66
|
+
const wordCount = estimateWordCount(fallbackText);
|
|
67
|
+
|
|
68
|
+
// Extract title from <title> tag as fallback
|
|
69
|
+
const titleElement = document.querySelector('title');
|
|
70
|
+
const fallbackTitle = titleElement?.textContent?.trim() || 'Untitled';
|
|
71
|
+
|
|
72
|
+
return Ok({
|
|
73
|
+
title: fallbackTitle,
|
|
74
|
+
byline: null,
|
|
75
|
+
publishedTime: null,
|
|
76
|
+
content: fallbackText,
|
|
77
|
+
excerpt: null,
|
|
78
|
+
wordCount,
|
|
79
|
+
readerModeAvailable: false
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return Err(
|
|
84
|
+
error instanceof Error
|
|
85
|
+
? error
|
|
86
|
+
: new Error(`Reader extraction failed: ${String(error)}`)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Estimate word count from text content
|
|
93
|
+
*
|
|
94
|
+
* @param text - Text content to count
|
|
95
|
+
* @returns Estimated word count
|
|
96
|
+
*/
|
|
97
|
+
function estimateWordCount(text: string): number {
|
|
98
|
+
if (!text || text.trim().length === 0) {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Split on whitespace and filter out empty strings
|
|
103
|
+
const words = text.trim().split(/\s+/).filter(word => word.length > 0);
|
|
104
|
+
return words.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fallback text extraction when Readability fails
|
|
109
|
+
*
|
|
110
|
+
* Extracts visible text from the page, skipping script/style elements.
|
|
111
|
+
*
|
|
112
|
+
* @param document - JSDOM document
|
|
113
|
+
* @returns Extracted text content
|
|
114
|
+
*/
|
|
115
|
+
function extractFallbackText(document: Document): string {
|
|
116
|
+
// Remove script and style elements
|
|
117
|
+
const scripts = document.querySelectorAll('script, style, noscript');
|
|
118
|
+
scripts.forEach(el => el.remove());
|
|
119
|
+
|
|
120
|
+
// Extract body text
|
|
121
|
+
const bodyText = document.body?.textContent || '';
|
|
122
|
+
|
|
123
|
+
// Clean up whitespace
|
|
124
|
+
return bodyText
|
|
125
|
+
.split('\n')
|
|
126
|
+
.map(line => line.trim())
|
|
127
|
+
.filter(line => line.length > 0)
|
|
128
|
+
.join('\n');
|
|
129
|
+
}
|