openclaw-syncralis 2.1.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/.env.example ADDED
@@ -0,0 +1,6 @@
1
+ PUBLIC_TUNNEL_URL=https://your-domain.ngrok-free.app
2
+ URL_SIGNING_SECRET= "your_custom_32_character_secret_here",
3
+ TAVILY_API_KEY=your_tavily_key_here
4
+ BRAVE_API_KEY=your_brave_key_here
5
+ FILE_SERVER_HOST=127.0.0.1
6
+ FILE_SERVER_PORT=8080
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # OpenClaw Syncralis 🌐⚙️
2
+
3
+
4
+ An industry-grade, highly secure Model Context Protocol (MCP) server for OpenClaw.
5
+
6
+ Syncralis provides load-balanced web searching, secure file downloads, and mobile-ready external file sharing, built on a hardened, hybrid architecture.
7
+
8
+
9
+ ## 🚀 Key Features
10
+
11
+ ***Stateless File Sharing:** Securely generates public Ngrok download links for files inside your workspace.
12
+
13
+ ***Load-Balanced Web Search:** Intelligently alternates between Tavily and Brave Search APIs to prevent rate-limiting and ensure high availability.
14
+
15
+ ***Secure File Downloads:** Downloads files directly to your workspace with strict MIME-type enforcement and streaming size limits to prevent DoS attacks.
16
+
17
+ ***Path Boundary Enforcement:** Cryptographically verifies all file requests to prevent directory traversal attacks outside the designated workspace.
18
+
19
+
20
+ ## 🔑 Prerequisites & Free Tiers
21
+
22
+ Syncralis relies on three external services. Each of these providers offers a generous free tier for developers (subject to their respective Terms and Conditions):
23
+
24
+ ***Ngrok:** Provides the secure public tunnel for file downloads. Claim your free static domain at [https://ngrok.com](https://ngrok.com).
25
+
26
+ ***Tavily API:** Provides AI-optimized web search results. Get your API key at [https://tavily.com](https://tavily.com).
27
+
28
+ ***Brave Search API:** Provides the fallback web search index. Get your API key at [https://brave.com/search/api/](https://brave.com/search/api/).
29
+
30
+
31
+ ## 📦 Installation
32
+
33
+
34
+ Install the package globally via your terminal:
35
+
36
+ ```bash
37
+
38
+ npm install -g openclaw-syncralis
39
+
40
+ # OR via ClawHub: clawhub package install openclaw-syncralis
41
+
42
+ # OR via Openclaw: openclaw plugins install clawhub:openclaw-syncralis
43
+
44
+ ```
45
+
46
+ ### 🔑 Authentication
47
+ Before configuring the server, authenticate your local environment with the registry to ensure a secure handshake:
48
+
49
+ ```bash
50
+
51
+ clawhub login pslkk/openclaw-syncralis
52
+
53
+ ```
54
+
55
+
56
+ ## ⚙️ Configuration & Deployment
57
+
58
+
59
+ Syncralis is designed as a hybrid tool. It works perfectly on your native operating system (Windows/Mac/Linux) or securely inside a Dockerized environment.
60
+
61
+
62
+ Choose the deployment method that matches your OpenClaw setup below.
63
+
64
+
65
+ ### Option 1: Native NPM Setup (Without Docker)
66
+
67
+ When running OpenClaw natively on your host machine, Syncralis spins up a secure local HTTP server bound strictly to localhost.
68
+
69
+
70
+ 1. Open a new terminal window and run Ngrok to expose the default port:
71
+
72
+ ```bash
73
+
74
+ ngrok http 8080
75
+
76
+ ```
77
+
78
+ 2. Add the generated Ngrok URL to your OpenClaw configuration generally inside (/home/node/.openclaw/openclaw.json):
79
+
80
+ ```json
81
+
82
+ "mcp": {
83
+ "servers": {
84
+ "syncralis": {
85
+ "command": "openclaw-syncralis",
86
+ "env": {
87
+ "NODE_ENV": "production",
88
+ "FILE_SERVER_HOST": "127.0.0.1",
89
+ "PUBLIC_TUNNEL_URL": "https://your-ngrok-url.ngrok-free.app",
90
+ "URL_SIGNING_SECRET": "your_custom_32_character_secret_here",
91
+ "TAVILY_API_KEY": "your_tavily_key",
92
+ "BRAVE_API_KEY": "your_brave_key"
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ ```
99
+
100
+
101
+ ### Option 2: Docker Environment Setup (Recommended for Production)
102
+
103
+ OpenClaw often executes tools as ephemeral child processes. In a containerized setup, it is highly recommended to run openclaw alongside Ngrok to serve the workspace volume 24/7. This guarantees your download links remain active even after the MCP process shuts down.
104
+
105
+
106
+ 1. Configure your `openclaw.json` generally inside (/home/node/.openclaw/openclaw.json):
107
+
108
+ ```json
109
+
110
+ "mcp": {
111
+ "servers": {
112
+ "syncralis": {
113
+ "command": "openclaw-syncralis",
114
+ "env": {
115
+ "NODE_ENV": "production",
116
+ "FILE_SERVER_HOST": "0.0.0.0",
117
+ "PUBLIC_TUNNEL_URL": "https://your-static-domain.ngrok-free.app",
118
+ "URL_SIGNING_SECRET": "your_custom_32_character_secret_here",
119
+ "TAVILY_API_KEY": "your_tavily_key",
120
+ "BRAVE_API_KEY": "your_brave_key"
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ ```
127
+
128
+
129
+ 2. ### 🐳 Complete Docker Compose (Just an example only)
130
+
131
+
132
+ If you are running OpenClaw entirely inside Docker, here is a complete, production-ready `docker-compose.yml` template to get Syncralis and Ngrok running together seamlessly.
133
+
134
+
135
+ ```yaml
136
+
137
+ version: '3.8'
138
+
139
+ networks:
140
+ mcp_network:
141
+ driver: bridge
142
+
143
+ services:
144
+ # Your main OpenClaw instance
145
+ openclaw_gateway:
146
+ image: ghcr.io/openclaw/openclaw:latest # Replace with your actual OpenClaw image or version
147
+ container_name: openclaw_gateway
148
+ restart: unless-stopped
149
+ networks:
150
+ - mcp_network
151
+ ports:
152
+ - "127.0.0.1:18789:18789"
153
+ extra_hosts:
154
+ - "host.docker.internal:host-gateway"
155
+ volumes:
156
+ - ./claw_data:/home/node/.openclaw:rw
157
+ - # Your config file
158
+ environment:
159
+ - FILE_SERVER_HOST=0.0.0.0
160
+ - FILE_SERVER_PORT=8080
161
+ - PUBLIC_TUNNEL_URL=https://<your-custom-domain>.ngrok-free.app
162
+ - TAVILY_API_KEY=${TAVILY_API_KEY}
163
+ - BRAVE_API_KEY=${BRAVE_API_KEY}
164
+ deploy:
165
+ resources:
166
+ limits:
167
+ cpus: '2.0' # Hard cap: Cannot exceed 2 CPU cores
168
+ memory: 2G
169
+ reservations:
170
+ memory: 512M
171
+
172
+ logging:
173
+ driver: "json-file"
174
+ options:
175
+ max-size: "10m"
176
+ max-file: "5"
177
+ compress: "true"
178
+
179
+ healthcheck:
180
+ test: ["CMD", "curl", "-f", "http://localhost:18789"]
181
+ interval: 30s
182
+ timeout: 10s
183
+ retries: 3
184
+ start_period: 40s
185
+
186
+ # The Ngrok tunnel pointing to Syncralis's internal file server
187
+
188
+ ngrok_tunnel:
189
+
190
+ image: ngrok/ngrok:latest
191
+ container_name: ngrok_tunnel
192
+ restart: unless-stopped
193
+ networks:
194
+ - mcp_network
195
+ command: http openclaw_gateway:8080 --url=https://<your-custom-domain>.ngrok-free.app --log=stdout
196
+ environment:
197
+ - NGROK_AUTHTOKEN=${NGROK_AUTHTOKEN}
198
+ depends_on:
199
+ openclaw_gateway:
200
+ condition: service_healthy
201
+
202
+ ```
203
+
204
+
205
+ ## 🛡️ Security Parameters
206
+
207
+ * `MAX_QUERY_LENGTH`: Defaults to 2000 characters.
208
+
209
+ * `TIMEOUT_MS`: Defaults to 10000ms (10 seconds) to prevent hung API calls.
210
+
211
+ **Size Limits:** Syncralis enforces a hard limit of `50MB` for all file reads and downloads to prevent memory exhaustion.
212
+
213
+
214
+
215
+ ## 💬 Usage Examples (Prompts)
216
+
217
+ Once connected, you can ask your OpenClaw agent to perform complex I/O tasks:
218
+
219
+ **"Search the web for the latest advancements in solid-state batteries."*
220
+
221
+ **"Download the PDF from \[URL] and save it as `report.pdf`."*
222
+
223
+ **"Generate a mobile download link for `report.pdf`."*
224
+
225
+
226
+
227
+ ---
228
+
229
+ *Built for resilient, secure agentic workflows.*
230
+
@@ -0,0 +1,99 @@
1
+ {
2
+ "id": "openclaw-syncralis",
3
+ "name": "openclaw-syncralis",
4
+ "displayName": "Syncralis Gateway",
5
+ "version": "2.1.0",
6
+ "description": "An industry-grade file sharing, secure download, and load-balanced gateway designed for high-availability OpenClaw environments.",
7
+ "type": "gateway",
8
+ "main": "./server.js",
9
+ "extensions": [
10
+ "./server.js"
11
+ ],
12
+ "runtimeExtensions": [
13
+ "./server.js"
14
+ ],
15
+ "compat": {
16
+ "pluginApi": ">=2026.3.24-beta.2",
17
+ "minGatewayVersion": "2026.3.24-beta.2"
18
+ },
19
+ "build": {
20
+ "openclawVersion": "2026.3.24-beta.2",
21
+ "pluginSdkVersion": "2026.3.24-beta.2"
22
+ },
23
+ "configSchema": {
24
+ "type": "object",
25
+ "required": [],
26
+ "properties": {
27
+ "FILE_SERVER_PORT": {
28
+ "type": "number",
29
+ "default": 8080,
30
+ "description": "The port on which the Syncralis gateway will listen."
31
+ },
32
+ "FILE_SERVER_HOST": {
33
+ "type": "string",
34
+ "default": "127.0.0.1",
35
+ "description": "The host interface to bind the gateway to."
36
+ },
37
+ "NODE_ENV": {
38
+ "type": "string",
39
+ "enum": [
40
+ "development",
41
+ "production"
42
+ ],
43
+ "default": "production"
44
+ },
45
+ "TAVILY_API_KEY": {
46
+ "type": "string",
47
+ "description": "API key for Tavily web search integration."
48
+ },
49
+ "BRAVE_API_KEY": {
50
+ "type": "string",
51
+ "description": "API key for Brave web search integration."
52
+ },
53
+ "PUBLIC_TUNNEL_URL": {
54
+ "type": "string",
55
+ "description": "The public URL generated by the Ngrok tunnel."
56
+ },
57
+ "NGROK_AUTHTOKEN": {
58
+ "type": "string",
59
+ "description": "Authentication token for the Ngrok service."
60
+ },
61
+ "URL_SIGNING_SECRET": {
62
+ "type": "string",
63
+ "description": "Optional: 32-byte string for signing URLs."
64
+ }
65
+ },
66
+ "additionalProperties": true
67
+ },
68
+ "env": {
69
+ "required": [],
70
+ "optional": [
71
+ "TAVILY_API_KEY",
72
+ "BRAVE_API_KEY",
73
+ "PUBLIC_TUNNEL_URL",
74
+ "NGROK_AUTHTOKEN",
75
+ "FILE_SERVER_HOST",
76
+ "FILE_SERVER_PORT",
77
+ "URL_SIGNING_SECRET"
78
+ ]
79
+ },
80
+ "install": {
81
+ "method": "npm",
82
+ "bin": "openclaw-syncralis",
83
+ "global": true,
84
+ "dependencies": {
85
+ "@modelcontextprotocol/sdk": "^1.29.0",
86
+ "dotenv": "^17.4.2",
87
+ "mammoth": "^1.12.0",
88
+ "mime-types": "^3.0.2",
89
+ "pdf-parse": "^2.4.5"
90
+ },
91
+ "overrides": {
92
+ "@modelcontextprotocol/sdk": {
93
+ "express-rate-limit": {
94
+ "ip-address": "^10.1.1"
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
package/package.json ADDED
@@ -0,0 +1,115 @@
1
+ {
2
+ "name": "openclaw-syncralis",
3
+ "version": "2.1.0",
4
+ "description": "An industry-grade file sharing, secure download, and load-balanced gateway designed for high-availability OpenClaw environments.",
5
+ "type": "module",
6
+ "main": "./server.js",
7
+ "bin": {
8
+ "openclaw-syncralis": "./server.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node ./server.js",
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "openclaw",
17
+ "ai",
18
+ "tools",
19
+ "file-server",
20
+ "web-search",
21
+ "tavily",
22
+ "brave",
23
+ "ngrok"
24
+ ],
25
+ "author": "PSLKK <coregravity0.0.0@protonmail.com> (https://pslkk.space)",
26
+ "license": "ISC",
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.29.0",
29
+ "dotenv": "^17.4.2",
30
+ "mammoth": "^1.12.0",
31
+ "mime-types": "^3.0.2",
32
+ "pdf-parse": "^2.4.5"
33
+ },
34
+ "overrides": {
35
+ "@modelcontextprotocol/sdk": {
36
+ "express-rate-limit": {
37
+ "ip-address": "^10.1.1"
38
+ }
39
+ }
40
+ },
41
+ "openclaw": {
42
+ "type": "gateway",
43
+ "extensions": [
44
+ "./server.js"
45
+ ],
46
+ "runtimeExtensions": [
47
+ "./server.js"
48
+ ],
49
+ "compat": {
50
+ "pluginApi": ">=2026.3.24-beta.2",
51
+ "minGatewayVersion": "2026.3.24-beta.2"
52
+ },
53
+ "build": {
54
+ "openclawVersion": "2026.3.24-beta.2",
55
+ "pluginSdkVersion": "2026.3.24-beta.2"
56
+ },
57
+ "configSchema": {
58
+ "type": "object",
59
+ "required": [],
60
+ "properties": {
61
+ "FILE_SERVER_PORT": {
62
+ "type": "number",
63
+ "default": 8080,
64
+ "description": "The port on which the Syncralis gateway will listen."
65
+ },
66
+ "FILE_SERVER_HOST": {
67
+ "type": "string",
68
+ "default": "127.0.0.1",
69
+ "description": "The host interface to bind the gateway to."
70
+ },
71
+ "NODE_ENV": {
72
+ "type": "string",
73
+ "enum": [
74
+ "development",
75
+ "production"
76
+ ],
77
+ "default": "production"
78
+ },
79
+ "TAVILY_API_KEY": {
80
+ "type": "string",
81
+ "description": "API key for Tavily web search integration."
82
+ },
83
+ "BRAVE_API_KEY": {
84
+ "type": "string",
85
+ "description": "API key for Brave web search integration."
86
+ },
87
+ "PUBLIC_TUNNEL_URL": {
88
+ "type": "string",
89
+ "description": "The public URL generated by the Ngrok tunnel."
90
+ },
91
+ "NGROK_AUTHTOKEN": {
92
+ "type": "string",
93
+ "description": "Authentication token for the Ngrok service."
94
+ },
95
+ "URL_SIGNING_SECRET": {
96
+ "type": "string",
97
+ "description": "Optional: 32-byte string for signing URLs."
98
+ }
99
+ },
100
+ "additionalProperties": true
101
+ },
102
+ "env": {
103
+ "required": [],
104
+ "optional": [
105
+ "TAVILY_API_KEY",
106
+ "BRAVE_API_KEY",
107
+ "PUBLIC_TUNNEL_URL",
108
+ "NGROK_AUTHTOKEN",
109
+ "FILE_SERVER_HOST",
110
+ "FILE_SERVER_PORT",
111
+ "URL_SIGNING_SECRET"
112
+ ]
113
+ }
114
+ }
115
+ }
package/server.js ADDED
@@ -0,0 +1,434 @@
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
+ const GATEWAY_CONFIG = {
23
+ host: process.env.FILE_SERVER_HOST || '127.0.0.1',
24
+ port: parseInt(process.env.FILE_SERVER_PORT, 10) || 8080,
25
+ //workspace: path.join(os.homedir(), '.openclaw', 'workspace'),
26
+ tavilyKey: process.env.TAVILY_API_KEY,
27
+ braveKey: process.env.BRAVE_API_KEY,
28
+ tunnelUrl: process.env.PUBLIC_TUNNEL_URL,
29
+ ngrokToken: process.env.NGROK_AUTHTOKEN,
30
+ signingSecret: process.env.URL_SIGNING_SECRET || crypto.randomBytes(32).toString('hex')
31
+ };
32
+
33
+ const TIMEOUT_MS = 10000;
34
+ const MAX_QUERY_LENGTH = 2000;
35
+ let requestCount = 0;
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const pdf = require("pdf-parse");
39
+
40
+ const WORKSPACE_DIR = path.join(os.homedir(), '.openclaw', 'workspace');
41
+ const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
42
+
43
+ function generateSignedUrl(filename, expirationMinutes = 60) {
44
+ if (!GATEWAY_CONFIG.tunnelUrl) {
45
+ throw new Error("PUBLIC_TUNNEL_URL is not configured.");
46
+ }
47
+
48
+ const safeFilename = path.basename(filename);
49
+ const safeUrlName = encodeURIComponent(safeFilename);
50
+ const baseUrl = GATEWAY_CONFIG.tunnelUrl.replace(/\/$/, "");
51
+ const expires = Date.now() + (expirationMinutes * 60 * 1000);
52
+ const dataToSign = `${safeFilename}:${expires}`;
53
+
54
+ const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.signingSecret)
55
+ .update(dataToSign)
56
+ .digest('hex');
57
+
58
+ return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
59
+ }
60
+
61
+ async function getSecurePath(requestedPath) {
62
+ const isAbsolutePath = path.isAbsolute(requestedPath);
63
+ const targetPath = isAbsolutePath ? requestedPath : path.join(WORKSPACE_DIR, requestedPath);
64
+ const resolvedPath = path.resolve(targetPath);
65
+
66
+ // Mathematical boundary check to prevent partial directory matching traversal
67
+ const relativePath = path.relative(WORKSPACE_DIR, resolvedPath);
68
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
69
+ throw new Error(`SECURITY ALERT: Path traversal attempt blocked.`);
70
+ }
71
+ return resolvedPath;
72
+ }
73
+
74
+ const server = new Server(
75
+ { name: "openclaw-syncralis", version: "2.1.0" },
76
+ { capabilities: { tools: {} } }
77
+ );
78
+
79
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
80
+ return {
81
+ tools: [
82
+ {
83
+ name: "share_files",
84
+ description: "Handles reading and sharing files. Trigger this tool and set action to 'download' if a link or URL is requested.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ filePath: {
89
+ type: "string",
90
+ description: "The name of the file inside the workspace (e.g., invoice.pdf)"
91
+ },
92
+ action: {
93
+ type: "string",
94
+ enum: ["read", "download"],
95
+ description: "Use 'read' for text contents. Use 'download' for a URL link."
96
+ }
97
+ },
98
+ required: ["filePath"]
99
+ }
100
+ },
101
+ {
102
+ name: "download_from_url",
103
+ description: "Downloads a file directly from a public or authenticated HTTP/HTTPS URL and saves it to the workspace.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ url: {
108
+ type: "string",
109
+ description: "The direct HTTP/HTTPS URL of the file to download."
110
+ },
111
+ fileName: {
112
+ type: "string",
113
+ description: "The name to save the downloaded file as (e.g., report.pdf)."
114
+ },
115
+ headers: {
116
+ type: "object",
117
+ description: "OPTIONAL: JSON object of HTTP headers for authenticated/secure URLs."
118
+ }
119
+ },
120
+ required: ["url", "fileName"]
121
+ }
122
+ },
123
+ {
124
+ name: "web_search",
125
+ description: "Searches the live internet for accurate, up-to-date information. Use for current events.",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ query: {
130
+ type: "string",
131
+ description: "The highly specific search query to look up."
132
+ }
133
+ },
134
+ required: ["query"]
135
+ }
136
+ }
137
+ ]
138
+ };
139
+ });
140
+
141
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
142
+ const { name, arguments: args } = request.params;
143
+
144
+ if (name === "share_files") {
145
+ try {
146
+ const { filePath, action = "read" } = args;
147
+ const securePath = await getSecurePath(filePath);
148
+ const fileName = path.basename(securePath);
149
+
150
+ const stats = await fsPromises.stat(securePath);
151
+ if (!stats.isFile()) throw new Error(`Requested path is a directory.`);
152
+ if (stats.size > MAX_FILE_SIZE_BYTES) throw new Error(`File exceeds max allowed size.`);
153
+
154
+ const mimeType = mime.lookup(securePath) || 'application/octet-stream';
155
+
156
+ if (action === "download") {
157
+ const signedLink = generateSignedUrl(fileName);
158
+ return {
159
+ content: [{
160
+ type: "text",
161
+ text: `SUCCESS. Tell the user their file is ready and output exactly this URL: ${signedLink}`
162
+ }]
163
+ };
164
+ }
165
+
166
+ const fileBuffer = await fsPromises.readFile(securePath);
167
+ if (mimeType.startsWith('image/')) {
168
+ return { content: [{ type: "image", data: fileBuffer.toString('base64'), mimeType: mimeType }] };
169
+ }
170
+ if (mimeType === 'application/pdf') {
171
+ const pdfData = await pdf(fileBuffer);
172
+ return { content: [{ type: "text", text: pdfData.text }] };
173
+ }
174
+ if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
175
+ const docxData = await mammoth.extractRawText({ buffer: fileBuffer });
176
+ return { content: [{ type: "text", text: docxData.value }] };
177
+ }
178
+
179
+ return { content: [{ type: "text", text: fileBuffer.toString('utf-8') }] };
180
+
181
+ } catch (error) {
182
+ return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
183
+ }
184
+ }
185
+
186
+ else if (name === "download_from_url") {
187
+ let targetPath;
188
+ try {
189
+ const { url, fileName, headers = {} } = args;
190
+ const safeFileName = path.basename(fileName);
191
+ targetPath = path.join(WORKSPACE_DIR, safeFileName);
192
+
193
+ const response = await fetch(url, { method: 'GET', headers: headers });
194
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
195
+
196
+ const serverContentType = response.headers.get('content-type');
197
+ const expectedMimeType = mime.lookup(safeFileName);
198
+
199
+ if (serverContentType && expectedMimeType) {
200
+ const cleanServerType = serverContentType.split(';')[0].trim().toLowerCase();
201
+ if (cleanServerType !== expectedMimeType && cleanServerType !== 'application/octet-stream') {
202
+ throw new Error(`SECURITY ALERT: MIME mismatch. Expected ${expectedMimeType}, received ${cleanServerType}. Download aborted.`);
203
+ }
204
+ }
205
+
206
+ await fsPromises.mkdir(WORKSPACE_DIR, { recursive: true });
207
+
208
+ const fileStream = createWriteStream(targetPath);
209
+ const webStream = Readable.fromWeb(response.body);
210
+ let downloadedBytes = 0;
211
+
212
+ await new Promise((resolve, reject) => {
213
+ webStream.on('data', (chunk) => {
214
+ downloadedBytes += chunk.length;
215
+ if (downloadedBytes > MAX_FILE_SIZE_BYTES) {
216
+ webStream.destroy();
217
+ fileStream.destroy();
218
+ reject(new Error(`SECURITY ALERT: Payload exceeds maximum size. Download aborted.`));
219
+ }
220
+ });
221
+
222
+ webStream.pipe(fileStream);
223
+ fileStream.on('finish', resolve);
224
+ fileStream.on('error', reject);
225
+ webStream.on('error', reject);
226
+ });
227
+
228
+ return {
229
+ content: [{
230
+ type: "text",
231
+ text: `SUCCESS: Securely downloaded from URL and saved to ${targetPath}.`
232
+ }]
233
+ };
234
+ } catch (error) {
235
+ if (targetPath) await fsPromises.unlink(targetPath).catch(() => {});
236
+ return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
237
+ }
238
+ }
239
+
240
+ else if (name === "web_search") {
241
+ let rawQuery = request.params.arguments?.query;
242
+ const tavilyKey = GATEWAY_CONFIG.tavilyKey;
243
+ const braveKey = GATEWAY_CONFIG.braveKey;
244
+
245
+ if (!rawQuery || typeof rawQuery !== 'string') {
246
+ return { isError: true, content: [{ type: "text", text: "Search failed: Query must be a valid string." }] };
247
+ }
248
+ if (!tavilyKey || !braveKey) {
249
+ return { isError: true, content: [{ type: "text", text: "Search failed: Server configuration error (Missing API Keys)." }] };
250
+ }
251
+
252
+ const query = rawQuery.trim().substring(0, MAX_QUERY_LENGTH);
253
+ if (query === '') {
254
+ return { isError: true, content: [{ type: "text", text: "Search failed: Query cannot be empty." }] };
255
+ }
256
+
257
+ const isBraveTurn = requestCount % 2 === 0;
258
+ requestCount++;
259
+
260
+ try {
261
+ let resultText = "";
262
+ if (isBraveTurn) {
263
+ try {
264
+ resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
265
+ } catch (err) {
266
+ resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
267
+ }
268
+ } else {
269
+ try {
270
+ resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
271
+ } catch (err) {
272
+ resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
273
+ }
274
+ }
275
+ return { content: [{ type: "text", text: resultText }] };
276
+
277
+ } catch (error) {
278
+ return { isError: true, content: [{ type: "text", text: `Search Error: ${error.message}` }] };
279
+ }
280
+ }
281
+
282
+ throw new Error(`Tool not found: ${name}`);
283
+ });
284
+
285
+ async function executeSearchAttempt(query, apiKey, fetchFn) {
286
+ const controller = new AbortController();
287
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
288
+ try {
289
+ const result = await fetchFn(query, apiKey, controller.signal);
290
+ clearTimeout(timeoutId);
291
+ return result;
292
+ } catch (error) {
293
+ clearTimeout(timeoutId);
294
+ if (error.name === 'AbortError') {
295
+ throw new Error(`Network timeout (${TIMEOUT_MS / 1000}s).`);
296
+ }
297
+ throw error;
298
+ }
299
+ }
300
+
301
+ async function fetchTavily(query, apiKey, signal) {
302
+ const response = await fetch("https://api.tavily.com/search", {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify({
306
+ api_key: apiKey,
307
+ query: query,
308
+ search_depth: "basic",
309
+ include_answer: true,
310
+ max_results: 4
311
+ }),
312
+ signal: signal
313
+ });
314
+
315
+ if (!response.ok) throw new Error(`Tavily HTTP ${response.status}`);
316
+ const data = await response.json();
317
+
318
+ let resultText = "";
319
+ if (data.answer) resultText += `[DIRECT ANSWER]\n${data.answer}\n\n`;
320
+ if (data.results?.length > 0) {
321
+ resultText += "[SOURCE RESULTS]\n";
322
+ resultText += data.results.map(r => `Title: ${r.title}\nSnippet: ${r.content}\nURL: ${r.url}`).join('\n\n---\n\n');
323
+ } else {
324
+ resultText += "No relevant results were found on Tavily.";
325
+ }
326
+ return resultText;
327
+ }
328
+
329
+ async function fetchBrave(query, apiKey, signal) {
330
+ const response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=4`, {
331
+ headers: {
332
+ "Accept": "application/json",
333
+ "X-Subscription-Token": apiKey,
334
+ },
335
+ signal: signal
336
+ });
337
+
338
+ if (!response.ok) throw new Error(`Brave HTTP ${response.status}`);
339
+ const data = await response.json();
340
+
341
+ let resultText = "";
342
+ if (data.web?.results?.length > 0) {
343
+ resultText += "[SOURCE RESULTS]\n";
344
+ resultText += data.web.results.map(r => `Title: ${r.title}\nSnippet: ${r.description}\nURL: ${r.url}`).join('\n\n---\n\n');
345
+ } else {
346
+ resultText += "No relevant results were found on Brave.";
347
+ }
348
+ return resultText;
349
+ }
350
+
351
+ function startSecureFileServer() {
352
+ const PORT = GATEWAY_CONFIG.port;
353
+ const HOST = GATEWAY_CONFIG.host;
354
+
355
+ const fileServer = http.createServer(async (req, res) => {
356
+ try {
357
+ // Strictly enforce GET requests
358
+ if (req.method !== 'GET') {
359
+ res.writeHead(405);
360
+ return res.end('Method Not Allowed');
361
+ }
362
+
363
+ //const requestedFile = decodeURIComponent(req.url.slice(1).split('?')[0]);
364
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
365
+ const requestedFile = decodeURIComponent(reqUrl.pathname.slice(1));
366
+ if (!requestedFile) {
367
+ res.writeHead(400);
368
+ return res.end('Bad Request');
369
+ }
370
+
371
+ const expires = reqUrl.searchParams.get('expires');
372
+ const providedSig = reqUrl.searchParams.get('sig');
373
+
374
+ if (!expires || !providedSig) {
375
+ throw new Error("Missing cryptographic signature.");
376
+ }
377
+
378
+ if (Date.now() > parseInt(expires, 10)) {
379
+ throw new Error("This secure link has expired.");
380
+ }
381
+
382
+ const safeFilename = path.basename(requestedFile);
383
+ const dataToVerify = `${safeFilename}:${expires}`;
384
+ const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.signingSecret)
385
+ .update(dataToVerify)
386
+ .digest('hex');
387
+
388
+ const providedSigBuffer = Buffer.from(providedSig);
389
+ const expectedSigBuffer = Buffer.from(expectedSig);
390
+
391
+ if (providedSigBuffer.length !== expectedSigBuffer.length || !crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer)) {
392
+ throw new Error("Cryptographic signature mismatch.");
393
+ }
394
+
395
+ const securePath = await getSecurePath(requestedFile);
396
+
397
+ const stats = await fsPromises.stat(securePath);
398
+ if (!stats.isFile()) throw new Error("Requested path is not a valid file");
399
+
400
+ const mimeType = mime.lookup(securePath) || 'application/octet-stream';
401
+
402
+ res.writeHead(200, {
403
+ 'Content-Type': mimeType,
404
+ 'Content-Length': stats.size,
405
+ 'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
406
+ });
407
+
408
+ createReadStream(securePath).pipe(res);
409
+
410
+ } catch (error) {
411
+ console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
412
+ res.writeHead(404);
413
+ res.end('File not found or access securely blocked.');
414
+ }
415
+ });
416
+
417
+ fileServer.listen(PORT, HOST, () => {
418
+ console.error(`[System] Native Secure File Server bound internally to ${HOST}:${PORT}`);
419
+ });
420
+ }
421
+
422
+ async function main() {
423
+ try {
424
+ startSecureFileServer();
425
+ const transport = new StdioServerTransport();
426
+ await server.connect(transport);
427
+ console.error("[System] OpenClaw Enterprise File Ops MCP running securely via stdio");
428
+ } catch (error) {
429
+ console.error("[Fatal] Server connection failed:", error);
430
+ process.exit(1);
431
+ }
432
+ }
433
+
434
+ main();