@amplify-studio/open-mcp 0.8.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 +244 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.js +68 -0
- package/dist/error-handler.d.ts +29 -0
- package/dist/error-handler.js +114 -0
- package/dist/http-server.d.ts +3 -0
- package/dist/http-server.js +150 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +253 -0
- package/dist/logging.d.ts +6 -0
- package/dist/logging.js +42 -0
- package/dist/proxy.d.ts +16 -0
- package/dist/proxy.js +97 -0
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +95 -0
- package/dist/search.d.ts +2 -0
- package/dist/search.js +130 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +79 -0
- package/dist/url-reader.d.ts +10 -0
- package/dist/url-reader.js +212 -0
- package/package.json +64 -0
package/dist/types.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export function isSearXNGWebSearchArgs(args) {
|
|
2
|
+
return (typeof args === "object" &&
|
|
3
|
+
args !== null &&
|
|
4
|
+
"query" in args &&
|
|
5
|
+
typeof args.query === "string");
|
|
6
|
+
}
|
|
7
|
+
export const WEB_SEARCH_TOOL = {
|
|
8
|
+
name: "searxng_web_search",
|
|
9
|
+
description: "Performs a web search using the SearXNG API, ideal for general queries, news, articles, and online content. " +
|
|
10
|
+
"Use this for broad information gathering, recent events, or when you need diverse web sources.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
query: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "The search query. This is the main input for the web search",
|
|
17
|
+
},
|
|
18
|
+
pageno: {
|
|
19
|
+
type: "number",
|
|
20
|
+
description: "Search page number (starts at 1)",
|
|
21
|
+
default: 1,
|
|
22
|
+
},
|
|
23
|
+
time_range: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Time range of search (day, month, year)",
|
|
26
|
+
enum: ["day", "month", "year"],
|
|
27
|
+
},
|
|
28
|
+
language: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Language code for search results (e.g., 'en', 'fr', 'de'). Default is instance-dependent.",
|
|
31
|
+
default: "all",
|
|
32
|
+
},
|
|
33
|
+
safesearch: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Safe search filter level (0: None, 1: Moderate, 2: Strict)",
|
|
36
|
+
enum: ["0", "1", "2"],
|
|
37
|
+
default: "0",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ["query"],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export const READ_URL_TOOL = {
|
|
44
|
+
name: "web_url_read",
|
|
45
|
+
description: "Read the content from an URL. " +
|
|
46
|
+
"Use this for further information retrieving to understand the content of each URL.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
url: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "URL",
|
|
53
|
+
},
|
|
54
|
+
startChar: {
|
|
55
|
+
type: "number",
|
|
56
|
+
description: "Starting character position for content extraction (default: 0)",
|
|
57
|
+
minimum: 0,
|
|
58
|
+
},
|
|
59
|
+
maxLength: {
|
|
60
|
+
type: "number",
|
|
61
|
+
description: "Maximum number of characters to return",
|
|
62
|
+
minimum: 1,
|
|
63
|
+
},
|
|
64
|
+
section: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Extract content under a specific heading (searches for heading text)",
|
|
67
|
+
},
|
|
68
|
+
paragraphRange: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Return specific paragraph ranges (e.g., '1-5', '3', '10-')",
|
|
71
|
+
},
|
|
72
|
+
readHeadings: {
|
|
73
|
+
type: "boolean",
|
|
74
|
+
description: "Return only a list of headings instead of full content",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
required: ["url"],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
interface PaginationOptions {
|
|
3
|
+
startChar?: number;
|
|
4
|
+
maxLength?: number;
|
|
5
|
+
section?: string;
|
|
6
|
+
paragraphRange?: string;
|
|
7
|
+
readHeadings?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function fetchAndConvertToMarkdown(server: Server, url: string, timeoutMs?: number, paginationOptions?: PaginationOptions): Promise<string>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { createProxyAgent } from "./proxy.js";
|
|
2
|
+
import { logMessage } from "./logging.js";
|
|
3
|
+
import { urlCache } from "./cache.js";
|
|
4
|
+
import { createURLFormatError, createNetworkError, createServerError, createContentError, createTimeoutError, createEmptyContentWarning, createUnexpectedError } from "./error-handler.js";
|
|
5
|
+
function applyCharacterPagination(content, startChar = 0, maxLength) {
|
|
6
|
+
if (startChar >= content.length) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
const start = Math.max(0, startChar);
|
|
10
|
+
const end = maxLength ? Math.min(content.length, start + maxLength) : content.length;
|
|
11
|
+
return content.slice(start, end);
|
|
12
|
+
}
|
|
13
|
+
function extractSection(markdownContent, sectionHeading) {
|
|
14
|
+
const lines = markdownContent.split('\n');
|
|
15
|
+
const sectionRegex = new RegExp(`^#{1,6}\s*.*${sectionHeading}.*$`, 'i');
|
|
16
|
+
let startIndex = -1;
|
|
17
|
+
let currentLevel = 0;
|
|
18
|
+
// Find the section start
|
|
19
|
+
for (let i = 0; i < lines.length; i++) {
|
|
20
|
+
const line = lines[i];
|
|
21
|
+
if (sectionRegex.test(line)) {
|
|
22
|
+
startIndex = i;
|
|
23
|
+
currentLevel = (line.match(/^#+/) || [''])[0].length;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (startIndex === -1) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
// Find the section end (next heading of same or higher level)
|
|
31
|
+
let endIndex = lines.length;
|
|
32
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
const match = line.match(/^#+/);
|
|
35
|
+
if (match && match[0].length <= currentLevel) {
|
|
36
|
+
endIndex = i;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return lines.slice(startIndex, endIndex).join('\n');
|
|
41
|
+
}
|
|
42
|
+
function extractParagraphRange(markdownContent, range) {
|
|
43
|
+
const paragraphs = markdownContent.split('\n\n').filter(p => p.trim().length > 0);
|
|
44
|
+
// Parse range (e.g., "1-5", "3", "10-")
|
|
45
|
+
const rangeMatch = range.match(/^(\d+)(?:-(\d*))?$/);
|
|
46
|
+
if (!rangeMatch) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
const start = parseInt(rangeMatch[1]) - 1; // Convert to 0-based index
|
|
50
|
+
const endStr = rangeMatch[2];
|
|
51
|
+
if (start < 0 || start >= paragraphs.length) {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
if (endStr === undefined) {
|
|
55
|
+
// Single paragraph (e.g., "3")
|
|
56
|
+
return paragraphs[start] || "";
|
|
57
|
+
}
|
|
58
|
+
else if (endStr === "") {
|
|
59
|
+
// Range to end (e.g., "10-")
|
|
60
|
+
return paragraphs.slice(start).join('\n\n');
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Specific range (e.g., "1-5")
|
|
64
|
+
const end = parseInt(endStr);
|
|
65
|
+
return paragraphs.slice(start, end).join('\n\n');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function extractHeadings(markdownContent) {
|
|
69
|
+
const lines = markdownContent.split('\n');
|
|
70
|
+
const headings = lines.filter(line => /^#{1,6}\s/.test(line));
|
|
71
|
+
if (headings.length === 0) {
|
|
72
|
+
return "No headings found in the content.";
|
|
73
|
+
}
|
|
74
|
+
return headings.join('\n');
|
|
75
|
+
}
|
|
76
|
+
function applyPaginationOptions(markdownContent, options) {
|
|
77
|
+
let result = markdownContent;
|
|
78
|
+
// Apply heading extraction first if requested
|
|
79
|
+
if (options.readHeadings) {
|
|
80
|
+
return extractHeadings(result);
|
|
81
|
+
}
|
|
82
|
+
// Apply section extraction
|
|
83
|
+
if (options.section) {
|
|
84
|
+
result = extractSection(result, options.section);
|
|
85
|
+
if (result === "") {
|
|
86
|
+
return `Section "${options.section}" not found in the content.`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Apply paragraph range filtering
|
|
90
|
+
if (options.paragraphRange) {
|
|
91
|
+
result = extractParagraphRange(result, options.paragraphRange);
|
|
92
|
+
if (result === "") {
|
|
93
|
+
return `Paragraph range "${options.paragraphRange}" is invalid or out of bounds.`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Apply character-based pagination last
|
|
97
|
+
if (options.startChar !== undefined || options.maxLength !== undefined) {
|
|
98
|
+
result = applyCharacterPagination(result, options.startChar, options.maxLength);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
export async function fetchAndConvertToMarkdown(server, url, timeoutMs = 10000, paginationOptions = {}) {
|
|
103
|
+
const startTime = Date.now();
|
|
104
|
+
logMessage(server, "info", `Fetching URL: ${url}`);
|
|
105
|
+
// Check cache first
|
|
106
|
+
const cachedEntry = urlCache.get(url);
|
|
107
|
+
if (cachedEntry) {
|
|
108
|
+
logMessage(server, "info", `Using cached content for URL: ${url}`);
|
|
109
|
+
const result = applyPaginationOptions(cachedEntry.markdownContent, paginationOptions);
|
|
110
|
+
const duration = Date.now() - startTime;
|
|
111
|
+
logMessage(server, "info", `Processed cached URL: ${url} (${result.length} chars in ${duration}ms)`);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
// Validate URL format
|
|
115
|
+
let parsedUrl;
|
|
116
|
+
try {
|
|
117
|
+
parsedUrl = new URL(url);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
logMessage(server, "error", `Invalid URL format: ${url}`);
|
|
121
|
+
throw createURLFormatError(url);
|
|
122
|
+
}
|
|
123
|
+
// Build gateway API URL
|
|
124
|
+
const gatewayUrl = process.env.GATEWAY_URL || "http://115.190.91.253:80";
|
|
125
|
+
const gatewayApiUrl = `${gatewayUrl}/api/read/${encodeURIComponent(url)}`;
|
|
126
|
+
// Create an AbortController instance
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
129
|
+
try {
|
|
130
|
+
// Prepare request options with proxy support
|
|
131
|
+
const requestOptions = {
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
};
|
|
134
|
+
// Add proxy dispatcher if proxy is configured
|
|
135
|
+
// Node.js fetch uses 'dispatcher' option for proxy, not 'agent'
|
|
136
|
+
const proxyAgent = createProxyAgent(gatewayApiUrl);
|
|
137
|
+
if (proxyAgent) {
|
|
138
|
+
requestOptions.dispatcher = proxyAgent;
|
|
139
|
+
}
|
|
140
|
+
let response;
|
|
141
|
+
try {
|
|
142
|
+
// Fetch the URL via gateway API with the abort signal
|
|
143
|
+
response = await fetch(gatewayApiUrl, requestOptions);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const context = {
|
|
147
|
+
url,
|
|
148
|
+
gatewayUrl,
|
|
149
|
+
proxyAgent: !!proxyAgent,
|
|
150
|
+
timeout: timeoutMs
|
|
151
|
+
};
|
|
152
|
+
throw createNetworkError(error, context);
|
|
153
|
+
}
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
let responseBody;
|
|
156
|
+
try {
|
|
157
|
+
responseBody = await response.text();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
responseBody = '[Could not read response body]';
|
|
161
|
+
}
|
|
162
|
+
const context = { url, gatewayUrl };
|
|
163
|
+
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
164
|
+
}
|
|
165
|
+
// Retrieve content from gateway API (JSON response)
|
|
166
|
+
let markdownContent;
|
|
167
|
+
try {
|
|
168
|
+
const jsonData = await response.json();
|
|
169
|
+
// Gateway API returns: { content: "...", title: "...", url: "...", wordCount: N }
|
|
170
|
+
if (!jsonData.content) {
|
|
171
|
+
throw createContentError("Gateway API returned empty content field.", url);
|
|
172
|
+
}
|
|
173
|
+
markdownContent = jsonData.content;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (error.name === 'MCPSearXNGError') {
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
throw createContentError(`Failed to read gateway response: ${error.message || 'Unknown error'}`, url);
|
|
180
|
+
}
|
|
181
|
+
if (!markdownContent || markdownContent.trim().length === 0) {
|
|
182
|
+
logMessage(server, "warning", `Empty content from gateway: ${url}`);
|
|
183
|
+
return createEmptyContentWarning(url, 0, "");
|
|
184
|
+
}
|
|
185
|
+
// Cache the markdown content from gateway
|
|
186
|
+
urlCache.set(url, "", markdownContent);
|
|
187
|
+
// Apply pagination options
|
|
188
|
+
const result = applyPaginationOptions(markdownContent, paginationOptions);
|
|
189
|
+
const duration = Date.now() - startTime;
|
|
190
|
+
logMessage(server, "info", `Successfully fetched and converted URL: ${url} (${result.length} chars in ${duration}ms)`);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.name === "AbortError") {
|
|
195
|
+
logMessage(server, "error", `Timeout fetching URL: ${url} (${timeoutMs}ms)`);
|
|
196
|
+
throw createTimeoutError(timeoutMs, url);
|
|
197
|
+
}
|
|
198
|
+
// Re-throw our enhanced errors
|
|
199
|
+
if (error.name === 'MCPSearXNGError') {
|
|
200
|
+
logMessage(server, "error", `Error fetching URL: ${url} - ${error.message}`);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
// Catch any unexpected errors
|
|
204
|
+
logMessage(server, "error", `Unexpected error fetching URL: ${url}`, error);
|
|
205
|
+
const context = { url };
|
|
206
|
+
throw createUnexpectedError(error, context);
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
// Clean up the timeout to prevent memory leaks
|
|
210
|
+
clearTimeout(timeoutId);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amplify-studio/open-mcp",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Open MCP server for web search and URL reading via Gateway API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Amplify Studio (https://github.com/amplify-studio)",
|
|
7
|
+
"homepage": "https://github.com/amplify-studio/open-mcp",
|
|
8
|
+
"bugs": "https://github.com/amplify-studio/open-mcp/issues",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/amplify-studio/open-mcp"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"modelcontextprotocol",
|
|
16
|
+
"searxng",
|
|
17
|
+
"search",
|
|
18
|
+
"web-search",
|
|
19
|
+
"claude",
|
|
20
|
+
"ai",
|
|
21
|
+
"pagination",
|
|
22
|
+
"smithery",
|
|
23
|
+
"url-reader"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"open-mcp": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"main": "dist/index.js",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc && shx chmod +x dist/*.js",
|
|
38
|
+
"watch": "tsc --watch",
|
|
39
|
+
"test": "SEARXNG_URL=https://test-searx.example.com tsx __tests__/run-all.ts",
|
|
40
|
+
"test:coverage": "SEARXNG_URL=https://test-searx.example.com c8 --reporter=text tsx __tests__/run-all.ts",
|
|
41
|
+
"bootstrap": "npm install && npm run build",
|
|
42
|
+
"inspector": "DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector node dist/index.js",
|
|
43
|
+
"postversion": "TAG=$(node scripts/update-version.js | tail -1) && git add src/index.ts && git commit --amend --no-edit && git tag -f $TAG"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "1.17.4",
|
|
47
|
+
"@types/cors": "^2.8.19",
|
|
48
|
+
"@types/express": "^5.0.3",
|
|
49
|
+
"cors": "^2.8.5",
|
|
50
|
+
"express": "^5.1.0",
|
|
51
|
+
"node-html-markdown": "^1.3.0",
|
|
52
|
+
"undici": "^6.20.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"mcp-evals": "^1.0.18",
|
|
56
|
+
"@types/node": "^22.17.2",
|
|
57
|
+
"@types/supertest": "^6.0.3",
|
|
58
|
+
"c8": "^10.1.3",
|
|
59
|
+
"shx": "^0.4.0",
|
|
60
|
+
"supertest": "^7.1.4",
|
|
61
|
+
"tsx": "^4.20.5",
|
|
62
|
+
"typescript": "^5.8.3"
|
|
63
|
+
}
|
|
64
|
+
}
|