openclaw-syncralis 2.2.0 → 2.3.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/server.js CHANGED
@@ -1,477 +1,444 @@
1
- #!/usr/bin/env node
2
-
3
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
- import fsPromises from "fs/promises";
7
- import { createWriteStream, createReadStream } from "fs";
8
- import { Readable } from "stream";
9
- import path from "path";
10
- import os from "os";
11
- import http from "http";
12
- import mime from "mime-types";
13
- import mammoth from "mammoth";
14
- import { createRequire } from "module";
15
- import dotenv from "dotenv";
16
- import { fileURLToPath } from 'url';
17
- import crypto from 'crypto';
18
-
19
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
- dotenv.config({ path: path.resolve(__dirname, '.env') });
21
-
22
- let activeTunnelUrl = process.env.PUBLIC_TUNNEL_URL;
23
- if (!activeTunnelUrl) {
24
- const ngrokApiPort = parseInt(process.env.NGROK_API_PORT, 10) || 4040;
25
- const controller = new AbortController();
26
- const timeoutId = setTimeout(() => controller.abort(), 2000);
27
-
28
- try {
29
- const response = await fetch(`http://127.0.0.1:${ngrokApiPort}/api/tunnels`, {
30
- signal: controller.signal
31
- });
32
-
33
- clearTimeout(timeoutId);
34
-
35
- if (response.ok) {
36
- const data = await response.json();
37
- if (Array.isArray(data?.tunnels)) {
38
- const httpsTunnel = data.tunnels.find(t =>
39
- typeof t.public_url === 'string' && t.public_url.startsWith('https://')
40
- );
41
- if (httpsTunnel) {
42
- activeTunnelUrl = httpsTunnel.public_url;
43
- console.log(`\n\x1b[32m[System]\x1b[0m Auto-discovered active Ngrok tunnel: ${activeTunnelUrl}\n`);
44
- }
45
- }
46
- }
47
- } catch (error) {
48
- clearTimeout(timeoutId);
49
- if (error.name === 'AbortError') {
50
- console.error(`\n\x1b[33m[Warning]\x1b[0m Ngrok auto-discovery timed out on port ${ngrokApiPort}.`);
51
- } else {
52
- console.error(`\n\x1b[33m[Warning]\x1b[0m PUBLIC_TUNNEL_URL is empty and local Ngrok was not detected.`);
53
- }
54
- console.error(`External download links will fail. Operating in Local-Only Mode.\n`);
55
- }
56
- }
57
-
58
- const GATEWAY_CONFIG = {
59
- host: process.env.FILE_SERVER_HOST || '127.0.0.1',
60
- port: parseInt(process.env.FILE_SERVER_PORT, 10) || 8080,
61
- //workspace: path.join(os.homedir(), '.openclaw', 'workspace'),
62
- tavilyKey: process.env.TAVILY_API_KEY,
63
- braveKey: process.env.BRAVE_API_KEY,
64
- tunnelUrl: activeTunnelUrl,
65
- //ngrokToken: process.env.NGROK_AUTHTOKEN,
66
- signingSecret: process.env.URL_SIGNING_SECRET || crypto.randomBytes(32).toString('hex')
67
- };
68
-
69
- const TIMEOUT_MS = 10000;
70
- const MAX_QUERY_LENGTH = 2000;
71
- let requestCount = 0;
72
-
73
- const require = createRequire(import.meta.url);
74
- const pdf = require("pdf-parse");
75
- const pkg = require("./package.json");
76
-
77
- // Check for version flags before starting the server
78
- if (process.argv.includes('--version') || process.argv.includes('-v')) {
79
- console.log(`openclaw-syncralis v${pkg.version}`);
80
- process.exit(0);
81
- }
82
-
83
- const WORKSPACE_DIR = process.env.WORKSPACE_DIR || path.join(os.homedir(), '.openclaw', 'workspace');
84
- const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
85
-
86
- function generateSignedUrl(filename, expirationMinutes = 60) {
87
- if (!GATEWAY_CONFIG.tunnelUrl) {
88
- throw new Error("PUBLIC_TUNNEL_URL is not configured.");
89
- }
90
-
91
- const safeFilename = path.basename(filename);
92
- const safeUrlName = encodeURIComponent(safeFilename);
93
- const baseUrl = GATEWAY_CONFIG.tunnelUrl.replace(/\/$/, "");
94
- const expires = Date.now() + (expirationMinutes * 60 * 1000);
95
- const dataToSign = `${safeFilename}:${expires}`;
96
-
97
- const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.signingSecret)
98
- .update(dataToSign)
99
- .digest('hex');
100
-
101
- return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
102
- }
103
-
104
- async function getSecurePath(requestedPath) {
105
- const isAbsolutePath = path.isAbsolute(requestedPath);
106
- const targetPath = isAbsolutePath ? requestedPath : path.join(WORKSPACE_DIR, requestedPath);
107
- const resolvedPath = path.resolve(targetPath);
108
-
109
- // Mathematical boundary check to prevent partial directory matching traversal
110
- const relativePath = path.relative(WORKSPACE_DIR, resolvedPath);
111
- if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
112
- throw new Error(`SECURITY ALERT: Path traversal attempt blocked.`);
113
- }
114
- return resolvedPath;
115
- }
116
-
117
- const server = new Server(
118
- { name: "openclaw-syncralis", version: "2.2.0" },
119
- { capabilities: { tools: {} } }
120
- );
121
-
122
- server.setRequestHandler(ListToolsRequestSchema, async () => {
123
- return {
124
- tools: [
125
- {
126
- name: "share_files",
127
- description: "Handles reading and sharing files. Trigger this tool and set action to 'download' if a link or URL is requested.",
128
- inputSchema: {
129
- type: "object",
130
- properties: {
131
- filePath: {
132
- type: "string",
133
- description: "The name of the file inside the workspace (e.g., invoice.pdf)"
134
- },
135
- action: {
136
- type: "string",
137
- enum: ["read", "download"],
138
- description: "Use 'read' for text contents. Use 'download' for a URL link."
139
- }
140
- },
141
- required: ["filePath"]
142
- }
143
- },
144
- {
145
- name: "download_from_url",
146
- description: "Downloads a file directly from a public or authenticated HTTP/HTTPS URL and saves it to the workspace.",
147
- inputSchema: {
148
- type: "object",
149
- properties: {
150
- url: {
151
- type: "string",
152
- description: "The direct HTTP/HTTPS URL of the file to download."
153
- },
154
- fileName: {
155
- type: "string",
156
- description: "The name to save the downloaded file as (e.g., report.pdf)."
157
- },
158
- headers: {
159
- type: "object",
160
- description: "OPTIONAL: JSON object of HTTP headers for authenticated/secure URLs."
161
- }
162
- },
163
- required: ["url", "fileName"]
164
- }
165
- },
166
- {
167
- name: "web_search",
168
- description: "Searches the live internet for accurate, up-to-date information. Use for current events.",
169
- inputSchema: {
170
- type: "object",
171
- properties: {
172
- query: {
173
- type: "string",
174
- description: "The highly specific search query to look up."
175
- }
176
- },
177
- required: ["query"]
178
- }
179
- }
180
- ]
181
- };
182
- });
183
-
184
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
185
- const { name, arguments: args } = request.params;
186
-
187
- if (name === "share_files") {
188
- try {
189
- const { filePath, action = "read" } = args;
190
- const securePath = await getSecurePath(filePath);
191
- const fileName = path.basename(securePath);
192
-
193
- const stats = await fsPromises.stat(securePath);
194
- if (!stats.isFile()) throw new Error(`Requested path is a directory.`);
195
- if (stats.size > MAX_FILE_SIZE_BYTES) throw new Error(`File exceeds max allowed size.`);
196
-
197
- const mimeType = mime.lookup(securePath) || 'application/octet-stream';
198
-
199
- if (action === "download") {
200
- const signedLink = generateSignedUrl(fileName);
201
- return {
202
- content: [{
203
- type: "text",
204
- text: `SUCCESS. Tell the user their file is ready and output exactly this URL: ${signedLink}`
205
- }]
206
- };
207
- }
208
-
209
- const fileBuffer = await fsPromises.readFile(securePath);
210
- if (mimeType.startsWith('image/')) {
211
- return { content: [{ type: "image", data: fileBuffer.toString('base64'), mimeType: mimeType }] };
212
- }
213
- if (mimeType === 'application/pdf') {
214
- const pdfData = await pdf(fileBuffer);
215
- return { content: [{ type: "text", text: pdfData.text }] };
216
- }
217
- if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
218
- const docxData = await mammoth.extractRawText({ buffer: fileBuffer });
219
- return { content: [{ type: "text", text: docxData.value }] };
220
- }
221
-
222
- return { content: [{ type: "text", text: fileBuffer.toString('utf-8') }] };
223
-
224
- } catch (error) {
225
- return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
226
- }
227
- }
228
-
229
- else if (name === "download_from_url") {
230
- let targetPath;
231
- try {
232
- const { url, fileName, headers = {} } = args;
233
- const safeFileName = path.basename(fileName);
234
- targetPath = path.join(WORKSPACE_DIR, safeFileName);
235
-
236
- const response = await fetch(url, { method: 'GET', headers: headers });
237
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
238
-
239
- const serverContentType = response.headers.get('content-type');
240
- const expectedMimeType = mime.lookup(safeFileName);
241
-
242
- if (serverContentType && expectedMimeType) {
243
- const cleanServerType = serverContentType.split(';')[0].trim().toLowerCase();
244
- if (cleanServerType !== expectedMimeType && cleanServerType !== 'application/octet-stream') {
245
- throw new Error(`SECURITY ALERT: MIME mismatch. Expected ${expectedMimeType}, received ${cleanServerType}. Download aborted.`);
246
- }
247
- }
248
-
249
- await fsPromises.mkdir(WORKSPACE_DIR, { recursive: true });
250
-
251
- const fileStream = createWriteStream(targetPath);
252
- const webStream = Readable.fromWeb(response.body);
253
- let downloadedBytes = 0;
254
-
255
- await new Promise((resolve, reject) => {
256
- webStream.on('data', (chunk) => {
257
- downloadedBytes += chunk.length;
258
- if (downloadedBytes > MAX_FILE_SIZE_BYTES) {
259
- webStream.destroy();
260
- fileStream.destroy();
261
- reject(new Error(`SECURITY ALERT: Payload exceeds maximum size. Download aborted.`));
262
- }
263
- });
264
-
265
- webStream.pipe(fileStream);
266
- fileStream.on('finish', resolve);
267
- fileStream.on('error', reject);
268
- webStream.on('error', reject);
269
- });
270
-
271
- return {
272
- content: [{
273
- type: "text",
274
- text: `SUCCESS: Securely downloaded from URL and saved to ${targetPath}.`
275
- }]
276
- };
277
- } catch (error) {
278
- if (targetPath) await fsPromises.unlink(targetPath).catch(() => {});
279
- return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
280
- }
281
- }
282
-
283
- else if (name === "web_search") {
284
- let rawQuery = request.params.arguments?.query;
285
- const tavilyKey = GATEWAY_CONFIG.tavilyKey;
286
- const braveKey = GATEWAY_CONFIG.braveKey;
287
-
288
- if (!rawQuery || typeof rawQuery !== 'string') {
289
- return { isError: true, content: [{ type: "text", text: "Search failed: Query must be a valid string." }] };
290
- }
291
- if (!tavilyKey || !braveKey) {
292
- return { isError: true, content: [{ type: "text", text: "Search failed: Server configuration error (Missing API Keys)." }] };
293
- }
294
-
295
- const query = rawQuery.trim().substring(0, MAX_QUERY_LENGTH);
296
- if (query === '') {
297
- return { isError: true, content: [{ type: "text", text: "Search failed: Query cannot be empty." }] };
298
- }
299
-
300
- const isBraveTurn = requestCount % 2 === 0;
301
- requestCount++;
302
-
303
- try {
304
- let resultText = "";
305
- if (isBraveTurn) {
306
- try {
307
- resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
308
- } catch (err) {
309
- resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
310
- }
311
- } else {
312
- try {
313
- resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
314
- } catch (err) {
315
- resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
316
- }
317
- }
318
- return { content: [{ type: "text", text: resultText }] };
319
-
320
- } catch (error) {
321
- return { isError: true, content: [{ type: "text", text: `Search Error: ${error.message}` }] };
322
- }
323
- }
324
-
325
- throw new Error(`Tool not found: ${name}`);
326
- });
327
-
328
- async function executeSearchAttempt(query, apiKey, fetchFn) {
329
- const controller = new AbortController();
330
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
331
- try {
332
- const result = await fetchFn(query, apiKey, controller.signal);
333
- clearTimeout(timeoutId);
334
- return result;
335
- } catch (error) {
336
- clearTimeout(timeoutId);
337
- if (error.name === 'AbortError') {
338
- throw new Error(`Network timeout (${TIMEOUT_MS / 1000}s).`);
339
- }
340
- throw error;
341
- }
342
- }
343
-
344
- async function fetchTavily(query, apiKey, signal) {
345
- const response = await fetch("https://api.tavily.com/search", {
346
- method: "POST",
347
- headers: { "Content-Type": "application/json" },
348
- body: JSON.stringify({
349
- api_key: apiKey,
350
- query: query,
351
- search_depth: "basic",
352
- include_answer: true,
353
- max_results: 4
354
- }),
355
- signal: signal
356
- });
357
-
358
- if (!response.ok) throw new Error(`Tavily HTTP ${response.status}`);
359
- const data = await response.json();
360
-
361
- let resultText = "";
362
- if (data.answer) resultText += `[DIRECT ANSWER]\n${data.answer}\n\n`;
363
- if (data.results?.length > 0) {
364
- resultText += "[SOURCE RESULTS]\n";
365
- resultText += data.results.map(r => `Title: ${r.title}\nSnippet: ${r.content}\nURL: ${r.url}`).join('\n\n---\n\n');
366
- } else {
367
- resultText += "No relevant results were found on Tavily.";
368
- }
369
- return resultText;
370
- }
371
-
372
- async function fetchBrave(query, apiKey, signal) {
373
- const response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=4`, {
374
- headers: {
375
- "Accept": "application/json",
376
- "X-Subscription-Token": apiKey,
377
- },
378
- signal: signal
379
- });
380
-
381
- if (!response.ok) throw new Error(`Brave HTTP ${response.status}`);
382
- const data = await response.json();
383
-
384
- let resultText = "";
385
- if (data.web?.results?.length > 0) {
386
- resultText += "[SOURCE RESULTS]\n";
387
- resultText += data.web.results.map(r => `Title: ${r.title}\nSnippet: ${r.description}\nURL: ${r.url}`).join('\n\n---\n\n');
388
- } else {
389
- resultText += "No relevant results were found on Brave.";
390
- }
391
- return resultText;
392
- }
393
-
394
- function startSecureFileServer() {
395
- const PORT = GATEWAY_CONFIG.port;
396
- const HOST = GATEWAY_CONFIG.host;
397
-
398
- const fileServer = http.createServer(async (req, res) => {
399
- try {
400
- // Strictly enforce GET requests
401
- if (req.method !== 'GET') {
402
- res.writeHead(405);
403
- return res.end('Method Not Allowed');
404
- }
405
-
406
- //const requestedFile = decodeURIComponent(req.url.slice(1).split('?')[0]);
407
- const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
408
- const requestedFile = decodeURIComponent(reqUrl.pathname.slice(1));
409
- if (!requestedFile) {
410
- res.writeHead(400);
411
- return res.end('Bad Request');
412
- }
413
-
414
- const expires = reqUrl.searchParams.get('expires');
415
- const providedSig = reqUrl.searchParams.get('sig');
416
-
417
- if (!expires || !providedSig) {
418
- throw new Error("Missing cryptographic signature.");
419
- }
420
-
421
- if (Date.now() > parseInt(expires, 10)) {
422
- throw new Error("This secure link has expired.");
423
- }
424
-
425
- const safeFilename = path.basename(requestedFile);
426
- const dataToVerify = `${safeFilename}:${expires}`;
427
- const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.signingSecret)
428
- .update(dataToVerify)
429
- .digest('hex');
430
-
431
- const providedSigBuffer = Buffer.from(providedSig);
432
- const expectedSigBuffer = Buffer.from(expectedSig);
433
-
434
- if (providedSigBuffer.length !== expectedSigBuffer.length || !crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer)) {
435
- throw new Error("Cryptographic signature mismatch.");
436
- }
437
-
438
- const securePath = await getSecurePath(requestedFile);
439
-
440
- const stats = await fsPromises.stat(securePath);
441
- if (!stats.isFile()) throw new Error("Requested path is not a valid file");
442
-
443
- const mimeType = mime.lookup(securePath) || 'application/octet-stream';
444
-
445
- res.writeHead(200, {
446
- 'Content-Type': mimeType,
447
- 'Content-Length': stats.size,
448
- 'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
449
- });
450
-
451
- createReadStream(securePath).pipe(res);
452
-
453
- } catch (error) {
454
- console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
455
- res.writeHead(404);
456
- res.end('File not found or access securely blocked.');
457
- }
458
- });
459
-
460
- fileServer.listen(PORT, HOST, () => {
461
- console.error(`[System] Native Secure File Server bound internally to ${HOST}:${PORT}`);
462
- });
463
- }
464
-
465
- async function main() {
466
- try {
467
- startSecureFileServer();
468
- const transport = new StdioServerTransport();
469
- await server.connect(transport);
470
- console.error("[System] openclaw-syncralis MCP running securely");
471
- } catch (error) {
472
- console.error("[Fatal] Server connection failed:", error);
473
- process.exit(1);
474
- }
475
- }
476
-
477
- main();
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
+ import { Readable } from "stream";
7
+ import path from "path";
8
+ import http from "http";
9
+ import mime from "mime-types";
10
+ import mammoth from "mammoth";
11
+ import { createRequire } from "module";
12
+ import crypto from 'crypto';
13
+
14
+ import { GATEWAY_CONFIG } from './config.js';
15
+ import {
16
+ getWorkspaceDir,
17
+ ensureWorkspaceExists,
18
+ getSecurePath,
19
+ readSafeFile,
20
+ createSafeWriteStream,
21
+ deleteSafeFile,
22
+ MAX_FILE_SIZE_BYTES
23
+ } from './fileOps.js';
24
+
25
+ let activeTunnelUrl = GATEWAY_CONFIG.tunnelUrlFallback;
26
+ if (!activeTunnelUrl) {
27
+ const controller = new AbortController();
28
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
29
+
30
+ try {
31
+ const response = await fetch(`http://127.0.0.1:${GATEWAY_CONFIG.discoveryPort}/api/tunnels`, {
32
+ signal: controller.signal
33
+ });
34
+
35
+ clearTimeout(timeoutId);
36
+
37
+ if (response.ok) {
38
+ const data = await response.json();
39
+ if (Array.isArray(data?.tunnels)) {
40
+ const httpsTunnel = data.tunnels.find(t =>
41
+ typeof t.public_url === 'string' && t.public_url.startsWith('https://')
42
+ );
43
+ if (httpsTunnel) {
44
+ activeTunnelUrl = httpsTunnel.public_url;
45
+ console.log(`\n\x1b[32m[System]\x1b[0m Auto-discovered active Ngrok tunnel: ${activeTunnelUrl}\n`);
46
+ }
47
+ }
48
+ }
49
+ } catch (error) {
50
+ clearTimeout(timeoutId);
51
+ if (error.name === 'AbortError') {
52
+ console.error(`\n\x1b[33m[Warning]\x1b[0m Ngrok auto-discovery timed out on port ${GATEWAY_CONFIG.discoveryPort}.`);
53
+ } else {
54
+ console.error(`\n\x1b[33m[Warning]\x1b[0m PUBLIC_TUNNEL_URL is empty and local Ngrok was not detected.`);
55
+ }
56
+ console.error(`External download links will fail. Operating in Local-Only Mode.\n`);
57
+ }
58
+ }
59
+
60
+ const TIMEOUT_MS = 10000;
61
+ const MAX_QUERY_LENGTH = 2000;
62
+ let requestCount = 0;
63
+
64
+ const require = createRequire(import.meta.url);
65
+ const pdf = require("pdf-parse");
66
+ const pkg = require("./package.json");
67
+
68
+ // Check for version flags before starting the server
69
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
70
+ console.log(`openclaw-syncralis v${pkg.version}`);
71
+ process.exit(0);
72
+ }
73
+
74
+ const WORKSPACE_DIR = getWorkspaceDir(GATEWAY_CONFIG.workspaceOverride);
75
+
76
+ function generateSignedUrl(filename, expirationMinutes = 60) {
77
+ if (!activeTunnelUrl) {
78
+ throw new Error("PUBLIC_TUNNEL_URL is not configured.");
79
+ }
80
+
81
+ const safeFilename = path.basename(filename);
82
+ const safeUrlName = encodeURIComponent(safeFilename);
83
+ const baseUrl = activeTunnelUrl.replace(/\/$/, "");
84
+ const expires = Date.now() + (expirationMinutes * 60 * 1000);
85
+ const dataToSign = `${safeFilename}:${expires}`;
86
+
87
+ const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
88
+ .update(dataToSign)
89
+ .digest('hex');
90
+
91
+ return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
92
+ }
93
+
94
+ const server = new Server(
95
+ { name: "openclaw-syncralis", version: pkg.version },
96
+ { capabilities: { tools: {} } }
97
+ );
98
+
99
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
100
+ return {
101
+ tools: [
102
+ {
103
+ name: "share_files",
104
+ description: "Handles reading and sharing files. Trigger this tool and set action to 'download' if a link or URL is requested.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ filePath: {
109
+ type: "string",
110
+ description: "The name of the file inside the workspace (e.g., invoice.pdf)"
111
+ },
112
+ action: {
113
+ type: "string",
114
+ enum: ["read", "download"],
115
+ description: "Use 'read' for text contents. Use 'download' for a URL link."
116
+ }
117
+ },
118
+ required: ["filePath"]
119
+ }
120
+ },
121
+ {
122
+ name: "download_from_url",
123
+ description: "Downloads a file directly from a public or authenticated HTTP/HTTPS URL and saves it to the workspace.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ url: {
128
+ type: "string",
129
+ description: "The direct HTTP/HTTPS URL of the file to download."
130
+ },
131
+ fileName: {
132
+ type: "string",
133
+ description: "The name to save the downloaded file as (e.g., report.pdf)."
134
+ },
135
+ headers: {
136
+ type: "object",
137
+ description: "OPTIONAL: JSON object of HTTP headers for authenticated/secure URLs."
138
+ }
139
+ },
140
+ required: ["url", "fileName"]
141
+ }
142
+ },
143
+ {
144
+ name: "web_search",
145
+ description: "Searches the live internet for accurate, up-to-date information. Use for current events.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ query: {
150
+ type: "string",
151
+ description: "The highly specific search query to look up."
152
+ }
153
+ },
154
+ required: ["query"]
155
+ }
156
+ }
157
+ ]
158
+ };
159
+ });
160
+
161
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
162
+ const { name, arguments: args } = request.params;
163
+
164
+ if (name === "share_files") {
165
+ try {
166
+ const { filePath, action = "read" } = args;
167
+ const securePath = await getSecurePath(WORKSPACE_DIR, filePath);
168
+ const fileName = path.basename(securePath);
169
+
170
+ if (action === "download") {
171
+ const signedLink = generateSignedUrl(fileName);
172
+ return {
173
+ content: [{
174
+ type: "text",
175
+ text: `SUCCESS. Tell the user their file is ready and output exactly this URL: ${signedLink}`
176
+ }]
177
+ };
178
+ }
179
+
180
+ const { buffer, mimeType } = await readSafeFile(securePath);
181
+ if (mimeType.startsWith('image/')) {
182
+ return { content: [{ type: "image", data: buffer.toString('base64'), mimeType }] };
183
+ }
184
+ if (mimeType === 'application/pdf') {
185
+ const pdfData = await pdf(buffer);
186
+ return { content: [{ type: "text", text: pdfData.text }] };
187
+ }
188
+ if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
189
+ const docxData = await mammoth.extractRawText({ buffer: buffer });
190
+ return { content: [{ type: "text", text: docxData.value }] };
191
+ }
192
+
193
+ return { content: [{ type: "text", text: buffer.toString('utf-8') }] };
194
+
195
+ } catch (error) {
196
+ return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
197
+ }
198
+ }
199
+
200
+ else if (name === "download_from_url") {
201
+ let targetPath;
202
+ try {
203
+ const { url, fileName, headers = {} } = args;
204
+ const safeFileName = path.basename(fileName);
205
+ targetPath = await getSecurePath(WORKSPACE_DIR, safeFileName);
206
+
207
+ const response = await fetch(url, { method: 'GET', headers: headers });
208
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
209
+
210
+ const serverContentType = response.headers.get('content-type');
211
+ const expectedMimeType = mime.lookup(safeFileName);
212
+
213
+ if (serverContentType && expectedMimeType) {
214
+ const cleanServerType = serverContentType.split(';')[0].trim().toLowerCase();
215
+ if (cleanServerType !== expectedMimeType && cleanServerType !== 'application/octet-stream') {
216
+ throw new Error(`SECURITY ALERT: MIME mismatch. Expected ${expectedMimeType}, received ${cleanServerType}. Download aborted.`);
217
+ }
218
+ }
219
+
220
+ await ensureWorkspaceExists(WORKSPACE_DIR);
221
+
222
+ const fileStream = createSafeWriteStream(targetPath);
223
+ const webStream = Readable.fromWeb(response.body);
224
+ let downloadedBytes = 0;
225
+
226
+ await new Promise((resolve, reject) => {
227
+ webStream.on('data', (chunk) => {
228
+ downloadedBytes += chunk.length;
229
+ if (downloadedBytes > MAX_FILE_SIZE_BYTES) {
230
+ webStream.destroy();
231
+ fileStream.destroy();
232
+ reject(new Error(`SECURITY ALERT: Payload exceeds maximum size. Download aborted.`));
233
+ }
234
+ });
235
+
236
+ webStream.pipe(fileStream);
237
+ fileStream.on('finish', resolve);
238
+ fileStream.on('error', reject);
239
+ webStream.on('error', reject);
240
+ });
241
+
242
+ return {
243
+ content: [{
244
+ type: "text",
245
+ text: `SUCCESS: Securely downloaded from URL and saved to ${targetPath}.`
246
+ }]
247
+ };
248
+ } catch (error) {
249
+ if (targetPath) await deleteSafeFile(targetPath);
250
+ return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
251
+ }
252
+ }
253
+
254
+ else if (name === "web_search") {
255
+ let rawQuery = request.params.arguments?.query;
256
+ const tavilyKey = GATEWAY_CONFIG.tavilyKey;
257
+ const braveKey = GATEWAY_CONFIG.braveKey;
258
+
259
+ if (!rawQuery || typeof rawQuery !== 'string') {
260
+ return { isError: true, content: [{ type: "text", text: "Search failed: Query must be a valid string." }] };
261
+ }
262
+ if (!tavilyKey || !braveKey) {
263
+ return { isError: true, content: [{ type: "text", text: "Search failed: Server configuration error (Missing API Keys)." }] };
264
+ }
265
+
266
+ const query = rawQuery.trim().substring(0, MAX_QUERY_LENGTH);
267
+ if (query === '') {
268
+ return { isError: true, content: [{ type: "text", text: "Search failed: Query cannot be empty." }] };
269
+ }
270
+
271
+ const isBraveTurn = requestCount % 2 === 0;
272
+ requestCount++;
273
+
274
+ try {
275
+ let resultText = "";
276
+ if (isBraveTurn) {
277
+ try {
278
+ resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
279
+ } catch (err) {
280
+ resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
281
+ }
282
+ } else {
283
+ try {
284
+ resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
285
+ } catch (err) {
286
+ resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
287
+ }
288
+ }
289
+ return { content: [{ type: "text", text: resultText }] };
290
+
291
+ } catch (error) {
292
+ return { isError: true, content: [{ type: "text", text: `Search Error: ${error.message}` }] };
293
+ }
294
+ }
295
+
296
+ throw new Error(`Tool not found: ${name}`);
297
+ });
298
+
299
+ async function executeSearchAttempt(query, apiKey, fetchFn) {
300
+ const controller = new AbortController();
301
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
302
+ try {
303
+ const result = await fetchFn(query, apiKey, controller.signal);
304
+ clearTimeout(timeoutId);
305
+ return result;
306
+ } catch (error) {
307
+ clearTimeout(timeoutId);
308
+ if (error.name === 'AbortError') {
309
+ throw new Error(`Network timeout (${TIMEOUT_MS / 1000}s).`);
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ async function fetchTavily(query, apiKey, signal) {
316
+ const response = await fetch("https://api.tavily.com/search", {
317
+ method: "POST",
318
+ headers: { "Content-Type": "application/json" },
319
+ body: JSON.stringify({
320
+ api_key: apiKey,
321
+ query: query,
322
+ search_depth: "basic",
323
+ include_answer: true,
324
+ max_results: 4
325
+ }),
326
+ signal: signal
327
+ });
328
+
329
+ if (!response.ok) throw new Error(`Tavily HTTP ${response.status}`);
330
+ const data = await response.json();
331
+
332
+ let resultText = "";
333
+ if (data.answer) resultText += `[DIRECT ANSWER]\n${data.answer}\n\n`;
334
+ if (data.results?.length > 0) {
335
+ resultText += "[SOURCE RESULTS]\n";
336
+ resultText += data.results.map(r => `Title: ${r.title}\nSnippet: ${r.content}\nURL: ${r.url}`).join('\n\n---\n\n');
337
+ } else {
338
+ resultText += "No relevant results were found on Tavily.";
339
+ }
340
+ return resultText;
341
+ }
342
+
343
+ async function fetchBrave(query, apiKey, signal) {
344
+ const response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=4`, {
345
+ headers: {
346
+ "Accept": "application/json",
347
+ "X-Subscription-Token": apiKey,
348
+ },
349
+ signal: signal
350
+ });
351
+
352
+ if (!response.ok) throw new Error(`Brave HTTP ${response.status}`);
353
+ const data = await response.json();
354
+
355
+ let resultText = "";
356
+ if (data.web?.results?.length > 0) {
357
+ resultText += "[SOURCE RESULTS]\n";
358
+ resultText += data.web.results.map(r => `Title: ${r.title}\nSnippet: ${r.description}\nURL: ${r.url}`).join('\n\n---\n\n');
359
+ } else {
360
+ resultText += "No relevant results were found on Brave.";
361
+ }
362
+ return resultText;
363
+ }
364
+
365
+ function startSecureFileServer() {
366
+ const PORT = GATEWAY_CONFIG.port;
367
+ const HOST = GATEWAY_CONFIG.host || '127.0.0.1';
368
+
369
+ const fileServer = http.createServer(async (req, res) => {
370
+ try {
371
+ // Strictly enforce GET requests
372
+ if (req.method !== 'GET') {
373
+ res.writeHead(405);
374
+ return res.end('Method Not Allowed');
375
+ }
376
+
377
+ //const requestedFile = decodeURIComponent(req.url.slice(1).split('?')[0]);
378
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
379
+ const requestedFile = decodeURIComponent(reqUrl.pathname.slice(1));
380
+ if (!requestedFile) {
381
+ res.writeHead(400);
382
+ return res.end('Bad Request');
383
+ }
384
+
385
+ const expires = reqUrl.searchParams.get('expires');
386
+ const providedSig = reqUrl.searchParams.get('sig');
387
+
388
+ if (!expires || !providedSig) {
389
+ throw new Error("Missing cryptographic signature.");
390
+ }
391
+
392
+ if (Date.now() > parseInt(expires, 10)) {
393
+ throw new Error("This secure link has expired.");
394
+ }
395
+
396
+ const safeFilename = path.basename(requestedFile);
397
+ const dataToVerify = `${safeFilename}:${expires}`;
398
+ const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
399
+ .update(dataToVerify)
400
+ .digest('hex');
401
+
402
+ const providedSigBuffer = Buffer.from(providedSig);
403
+ const expectedSigBuffer = Buffer.from(expectedSig);
404
+
405
+ if (providedSigBuffer.length !== expectedSigBuffer.length || !crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer)) {
406
+ throw new Error("Cryptographic signature mismatch.");
407
+ }
408
+
409
+ const securePath = await getSecurePath(WORKSPACE_DIR, requestedFile);
410
+ const { buffer, mimeType, size } = await readSafeFile(securePath);
411
+
412
+ res.writeHead(200, {
413
+ 'Content-Type': mimeType,
414
+ 'Content-Length': size,
415
+ 'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
416
+ });
417
+ res.end(buffer);
418
+
419
+ } catch (error) {
420
+ console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
421
+ res.writeHead(404);
422
+ res.end('File not found or access securely blocked.');
423
+ }
424
+ });
425
+
426
+ fileServer.listen(PORT, HOST, () => {
427
+ console.error(`[System] Native Secure File Server bound internally to ${HOST}:${PORT}`);
428
+ });
429
+ }
430
+
431
+ async function main() {
432
+ try {
433
+ await ensureWorkspaceExists(WORKSPACE_DIR);
434
+ startSecureFileServer();
435
+ const transport = new StdioServerTransport();
436
+ await server.connect(transport);
437
+ console.error("[System] openclaw-syncralis MCP running securely");
438
+ } catch (error) {
439
+ console.error("[Fatal] Server connection failed:", error);
440
+ process.exit(1);
441
+ }
442
+ }
443
+
444
+ main();