@agiflowai/one-mcp 0.2.0 → 0.2.1
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/dist/cli.cjs +4 -4
- package/dist/cli.mjs +4 -4
- package/dist/{http-B5WVqLzz.cjs → http-23nhycE8.cjs} +479 -12
- package/dist/{http-B1EDyxR_.mjs → http-CkWO35l-.mjs} +490 -23
- package/dist/index.cjs +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
package/dist/cli.cjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const require_http = require('./http-
|
|
2
|
+
const require_http = require('./http-23nhycE8.cjs');
|
|
3
3
|
let node_fs_promises = require("node:fs/promises");
|
|
4
|
-
let commander = require("commander");
|
|
5
4
|
let node_path = require("node:path");
|
|
5
|
+
let commander = require("commander");
|
|
6
6
|
let __agiflowai_aicode_utils = require("@agiflowai/aicode-utils");
|
|
7
7
|
|
|
8
8
|
//#region src/types/index.ts
|
|
@@ -396,7 +396,7 @@ const useToolCommand = new commander.Command("use-tool").description("Execute an
|
|
|
396
396
|
|
|
397
397
|
//#endregion
|
|
398
398
|
//#region src/templates/mcp-config.yaml?raw
|
|
399
|
-
var mcp_config_default = "# MCP Server Configuration\n# This file configures the MCP servers that one-mcp will connect to\n#\n# Environment Variable Interpolation:\n# Use ${VAR_NAME} syntax to reference environment variables\n# Example: ${HOME}, ${API_KEY}, ${DATABASE_URL}\n#\n# Instructions:\n# - config.instruction: Server's default instruction (from server documentation)\n# - instruction: User override (optional, takes precedence over config.instruction)\n# - config.toolBlacklist: Array of tool names to hide/block from this server\n# - config.omitToolDescription: Boolean to show only tool names without descriptions (saves tokens)\n\nmcpServers:\n # Example MCP server using stdio transport\n example-server:\n command: node\n args:\n - /path/to/mcp-server/build/index.js\n env:\n # Environment variables for the MCP server\n LOG_LEVEL: info\n # You can use environment variable interpolation:\n # DATABASE_URL: ${DATABASE_URL}\n # API_KEY: ${MY_API_KEY}\n config:\n # Server's default instruction (from server documentation)\n instruction: Use this server for...\n # Optional: Block specific tools from being listed or executed\n # toolBlacklist:\n # - dangerous_tool_name\n # - another_blocked_tool\n # Optional: Omit tool descriptions to save tokens (default: false)\n # omitToolDescription: true\n # instruction: Optional user override - takes precedence over config.instruction\n\n # Example MCP server using SSE transport with environment variables\n # remote-server:\n # url: https://example.com/mcp\n # type: sse\n # headers:\n # # Use ${VAR_NAME} to interpolate environment variables\n # Authorization: Bearer ${API_KEY}\n # config:\n # instruction: This server provides tools for...\n # # Optional: Block specific tools from being listed or executed\n # # toolBlacklist:\n # # - tool_to_block\n # # Optional: Omit tool descriptions to save tokens (default: false)\n # # omitToolDescription: true\n # # instruction: Optional user override\n";
|
|
399
|
+
var mcp_config_default = "# MCP Server Configuration\n# This file configures the MCP servers that one-mcp will connect to\n#\n# Environment Variable Interpolation:\n# Use ${VAR_NAME} syntax to reference environment variables\n# Example: ${HOME}, ${API_KEY}, ${DATABASE_URL}\n#\n# Instructions:\n# - config.instruction: Server's default instruction (from server documentation)\n# - instruction: User override (optional, takes precedence over config.instruction)\n# - config.toolBlacklist: Array of tool names to hide/block from this server\n# - config.omitToolDescription: Boolean to show only tool names without descriptions (saves tokens)\n\n# Remote Configuration Sources (OPTIONAL)\n# Fetch and merge configurations from remote URLs\n# Remote configs are merged with local configs based on merge strategy\n#\n# SECURITY: SSRF Protection is ENABLED by default\n# - Only HTTPS URLs are allowed (set security.enforceHttps: false to allow HTTP)\n# - Private IPs and localhost are blocked (set security.allowPrivateIPs: true for internal networks)\n# - Blocked ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16\nremoteConfigs:\n # Example 1: Basic remote config with default security\n # - url: ${AGIFLOW_URL}/api/v1/mcp-configs\n # headers:\n # Authorization: Bearer ${AGIFLOW_API_KEY}\n # mergeStrategy: local-priority # Options: local-priority (default), remote-priority, merge-deep\n #\n # Example 2: Remote config with custom security settings (for internal networks)\n # - url: ${INTERNAL_URL}/mcp-configs\n # headers:\n # Authorization: Bearer ${INTERNAL_TOKEN}\n # security:\n # allowPrivateIPs: true # Allow internal IPs (default: false)\n # enforceHttps: false # Allow HTTP (default: true, HTTPS only)\n # mergeStrategy: local-priority\n #\n # Example 3: Remote config with additional validation (OPTIONAL)\n # - url: ${AGIFLOW_URL}/api/v1/mcp-configs\n # headers:\n # Authorization: Bearer ${AGIFLOW_API_KEY}\n # X-API-Key: ${AGIFLOW_API_KEY}\n # security:\n # enforceHttps: true # Require HTTPS (default: true)\n # allowPrivateIPs: false # Block private IPs (default: false)\n # validation: # OPTIONAL: Additional regex validation on top of security checks\n # url: ^https://.*\\.agiflow\\.io/.* # OPTIONAL: Regex pattern to validate URL format\n # headers: # OPTIONAL: Regex patterns to validate header values\n # Authorization: ^Bearer [A-Za-z0-9_-]+$\n # X-API-Key: ^[A-Za-z0-9_-]{32,}$\n # mergeStrategy: local-priority\n\nmcpServers:\n # Example MCP server using stdio transport\n example-server:\n command: node\n args:\n - /path/to/mcp-server/build/index.js\n env:\n # Environment variables for the MCP server\n LOG_LEVEL: info\n # You can use environment variable interpolation:\n # DATABASE_URL: ${DATABASE_URL}\n # API_KEY: ${MY_API_KEY}\n config:\n # Server's default instruction (from server documentation)\n instruction: Use this server for...\n # Optional: Block specific tools from being listed or executed\n # toolBlacklist:\n # - dangerous_tool_name\n # - another_blocked_tool\n # Optional: Omit tool descriptions to save tokens (default: false)\n # omitToolDescription: true\n # instruction: Optional user override - takes precedence over config.instruction\n\n # Example MCP server using SSE transport with environment variables\n # remote-server:\n # url: https://example.com/mcp\n # type: sse\n # headers:\n # # Use ${VAR_NAME} to interpolate environment variables\n # Authorization: Bearer ${API_KEY}\n # config:\n # instruction: This server provides tools for...\n # # Optional: Block specific tools from being listed or executed\n # # toolBlacklist:\n # # - tool_to_block\n # # Optional: Omit tool descriptions to save tokens (default: false)\n # # omitToolDescription: true\n # # instruction: Optional user override\n";
|
|
400
400
|
|
|
401
401
|
//#endregion
|
|
402
402
|
//#region src/templates/mcp-config.json?raw
|
|
@@ -457,7 +457,7 @@ const initCommand = new commander.Command("init").description("Initialize MCP co
|
|
|
457
457
|
|
|
458
458
|
//#endregion
|
|
459
459
|
//#region package.json
|
|
460
|
-
var version = "0.
|
|
460
|
+
var version = "0.2.0";
|
|
461
461
|
|
|
462
462
|
//#endregion
|
|
463
463
|
//#region src/cli.ts
|
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as McpClientManagerService, i as createServer, n as SseTransportHandler, o as ConfigFetcherService, r as StdioTransportHandler, t as HttpTransportHandler } from "./http-
|
|
2
|
+
import { a as McpClientManagerService, i as createServer, n as SseTransportHandler, o as ConfigFetcherService, r as StdioTransportHandler, t as HttpTransportHandler } from "./http-CkWO35l-.mjs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { Command } from "commander";
|
|
5
4
|
import { resolve } from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
6
|
import { log } from "@agiflowai/aicode-utils";
|
|
7
7
|
|
|
8
8
|
//#region src/types/index.ts
|
|
@@ -396,7 +396,7 @@ const useToolCommand = new Command("use-tool").description("Execute an MCP tool
|
|
|
396
396
|
|
|
397
397
|
//#endregion
|
|
398
398
|
//#region src/templates/mcp-config.yaml?raw
|
|
399
|
-
var mcp_config_default = "# MCP Server Configuration\n# This file configures the MCP servers that one-mcp will connect to\n#\n# Environment Variable Interpolation:\n# Use ${VAR_NAME} syntax to reference environment variables\n# Example: ${HOME}, ${API_KEY}, ${DATABASE_URL}\n#\n# Instructions:\n# - config.instruction: Server's default instruction (from server documentation)\n# - instruction: User override (optional, takes precedence over config.instruction)\n# - config.toolBlacklist: Array of tool names to hide/block from this server\n# - config.omitToolDescription: Boolean to show only tool names without descriptions (saves tokens)\n\nmcpServers:\n # Example MCP server using stdio transport\n example-server:\n command: node\n args:\n - /path/to/mcp-server/build/index.js\n env:\n # Environment variables for the MCP server\n LOG_LEVEL: info\n # You can use environment variable interpolation:\n # DATABASE_URL: ${DATABASE_URL}\n # API_KEY: ${MY_API_KEY}\n config:\n # Server's default instruction (from server documentation)\n instruction: Use this server for...\n # Optional: Block specific tools from being listed or executed\n # toolBlacklist:\n # - dangerous_tool_name\n # - another_blocked_tool\n # Optional: Omit tool descriptions to save tokens (default: false)\n # omitToolDescription: true\n # instruction: Optional user override - takes precedence over config.instruction\n\n # Example MCP server using SSE transport with environment variables\n # remote-server:\n # url: https://example.com/mcp\n # type: sse\n # headers:\n # # Use ${VAR_NAME} to interpolate environment variables\n # Authorization: Bearer ${API_KEY}\n # config:\n # instruction: This server provides tools for...\n # # Optional: Block specific tools from being listed or executed\n # # toolBlacklist:\n # # - tool_to_block\n # # Optional: Omit tool descriptions to save tokens (default: false)\n # # omitToolDescription: true\n # # instruction: Optional user override\n";
|
|
399
|
+
var mcp_config_default = "# MCP Server Configuration\n# This file configures the MCP servers that one-mcp will connect to\n#\n# Environment Variable Interpolation:\n# Use ${VAR_NAME} syntax to reference environment variables\n# Example: ${HOME}, ${API_KEY}, ${DATABASE_URL}\n#\n# Instructions:\n# - config.instruction: Server's default instruction (from server documentation)\n# - instruction: User override (optional, takes precedence over config.instruction)\n# - config.toolBlacklist: Array of tool names to hide/block from this server\n# - config.omitToolDescription: Boolean to show only tool names without descriptions (saves tokens)\n\n# Remote Configuration Sources (OPTIONAL)\n# Fetch and merge configurations from remote URLs\n# Remote configs are merged with local configs based on merge strategy\n#\n# SECURITY: SSRF Protection is ENABLED by default\n# - Only HTTPS URLs are allowed (set security.enforceHttps: false to allow HTTP)\n# - Private IPs and localhost are blocked (set security.allowPrivateIPs: true for internal networks)\n# - Blocked ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16\nremoteConfigs:\n # Example 1: Basic remote config with default security\n # - url: ${AGIFLOW_URL}/api/v1/mcp-configs\n # headers:\n # Authorization: Bearer ${AGIFLOW_API_KEY}\n # mergeStrategy: local-priority # Options: local-priority (default), remote-priority, merge-deep\n #\n # Example 2: Remote config with custom security settings (for internal networks)\n # - url: ${INTERNAL_URL}/mcp-configs\n # headers:\n # Authorization: Bearer ${INTERNAL_TOKEN}\n # security:\n # allowPrivateIPs: true # Allow internal IPs (default: false)\n # enforceHttps: false # Allow HTTP (default: true, HTTPS only)\n # mergeStrategy: local-priority\n #\n # Example 3: Remote config with additional validation (OPTIONAL)\n # - url: ${AGIFLOW_URL}/api/v1/mcp-configs\n # headers:\n # Authorization: Bearer ${AGIFLOW_API_KEY}\n # X-API-Key: ${AGIFLOW_API_KEY}\n # security:\n # enforceHttps: true # Require HTTPS (default: true)\n # allowPrivateIPs: false # Block private IPs (default: false)\n # validation: # OPTIONAL: Additional regex validation on top of security checks\n # url: ^https://.*\\.agiflow\\.io/.* # OPTIONAL: Regex pattern to validate URL format\n # headers: # OPTIONAL: Regex patterns to validate header values\n # Authorization: ^Bearer [A-Za-z0-9_-]+$\n # X-API-Key: ^[A-Za-z0-9_-]{32,}$\n # mergeStrategy: local-priority\n\nmcpServers:\n # Example MCP server using stdio transport\n example-server:\n command: node\n args:\n - /path/to/mcp-server/build/index.js\n env:\n # Environment variables for the MCP server\n LOG_LEVEL: info\n # You can use environment variable interpolation:\n # DATABASE_URL: ${DATABASE_URL}\n # API_KEY: ${MY_API_KEY}\n config:\n # Server's default instruction (from server documentation)\n instruction: Use this server for...\n # Optional: Block specific tools from being listed or executed\n # toolBlacklist:\n # - dangerous_tool_name\n # - another_blocked_tool\n # Optional: Omit tool descriptions to save tokens (default: false)\n # omitToolDescription: true\n # instruction: Optional user override - takes precedence over config.instruction\n\n # Example MCP server using SSE transport with environment variables\n # remote-server:\n # url: https://example.com/mcp\n # type: sse\n # headers:\n # # Use ${VAR_NAME} to interpolate environment variables\n # Authorization: Bearer ${API_KEY}\n # config:\n # instruction: This server provides tools for...\n # # Optional: Block specific tools from being listed or executed\n # # toolBlacklist:\n # # - tool_to_block\n # # Optional: Omit tool descriptions to save tokens (default: false)\n # # omitToolDescription: true\n # # instruction: Optional user override\n";
|
|
400
400
|
|
|
401
401
|
//#endregion
|
|
402
402
|
//#region src/templates/mcp-config.json?raw
|
|
@@ -457,7 +457,7 @@ const initCommand = new Command("init").description("Initialize MCP configuratio
|
|
|
457
457
|
|
|
458
458
|
//#endregion
|
|
459
459
|
//#region package.json
|
|
460
|
-
var version = "0.
|
|
460
|
+
var version = "0.2.0";
|
|
461
461
|
|
|
462
462
|
//#endregion
|
|
463
463
|
//#region src/cli.ts
|
|
@@ -28,6 +28,9 @@ let node_fs = require("node:fs");
|
|
|
28
28
|
let js_yaml = require("js-yaml");
|
|
29
29
|
js_yaml = __toESM(js_yaml);
|
|
30
30
|
let zod = require("zod");
|
|
31
|
+
let node_crypto = require("node:crypto");
|
|
32
|
+
let node_path = require("node:path");
|
|
33
|
+
let node_os = require("node:os");
|
|
31
34
|
let __modelcontextprotocol_sdk_client_index_js = require("@modelcontextprotocol/sdk/client/index.js");
|
|
32
35
|
let __modelcontextprotocol_sdk_client_stdio_js = require("@modelcontextprotocol/sdk/client/stdio.js");
|
|
33
36
|
let __modelcontextprotocol_sdk_client_sse_js = require("@modelcontextprotocol/sdk/client/sse.js");
|
|
@@ -35,7 +38,6 @@ let __modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/
|
|
|
35
38
|
let __modelcontextprotocol_sdk_server_sse_js = require("@modelcontextprotocol/sdk/server/sse.js");
|
|
36
39
|
let express = require("express");
|
|
37
40
|
express = __toESM(express);
|
|
38
|
-
let node_crypto = require("node:crypto");
|
|
39
41
|
let __modelcontextprotocol_sdk_server_streamableHttp_js = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
40
42
|
|
|
41
43
|
//#region src/utils/mcpConfigSchema.ts
|
|
@@ -105,6 +107,95 @@ function interpolateEnvVarsInObject(obj) {
|
|
|
105
107
|
return obj;
|
|
106
108
|
}
|
|
107
109
|
/**
|
|
110
|
+
* Private IP range patterns for SSRF protection
|
|
111
|
+
* Covers both IPv4 and IPv6 loopback, private, and link-local ranges
|
|
112
|
+
*/
|
|
113
|
+
const PRIVATE_IP_PATTERNS = [
|
|
114
|
+
/^127\./,
|
|
115
|
+
/^10\./,
|
|
116
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
117
|
+
/^192\.168\./,
|
|
118
|
+
/^169\.254\./,
|
|
119
|
+
/^0\./,
|
|
120
|
+
/^224\./,
|
|
121
|
+
/^240\./,
|
|
122
|
+
/^localhost$/i,
|
|
123
|
+
/^.*\.localhost$/i,
|
|
124
|
+
/^\[::\]/,
|
|
125
|
+
/^\[::1\]/,
|
|
126
|
+
/^\[0:0:0:0:0:0:0:1\]/,
|
|
127
|
+
/^\[0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:1\]/i,
|
|
128
|
+
/^\[fe80:/i,
|
|
129
|
+
/^\[fc00:/i,
|
|
130
|
+
/^\[fd00:/i,
|
|
131
|
+
/^\[::ffff:127\./i,
|
|
132
|
+
/^\[::ffff:7f[0-9a-f]{2}:/i,
|
|
133
|
+
/^\[::ffff:10\./i,
|
|
134
|
+
/^\[::ffff:a[0-9a-f]{2}:/i,
|
|
135
|
+
/^\[::ffff:172\.(1[6-9]|2\d|3[01])\./i,
|
|
136
|
+
/^\[::ffff:ac1[0-9a-f]:/i,
|
|
137
|
+
/^\[::ffff:192\.168\./i,
|
|
138
|
+
/^\[::ffff:c0a8:/i,
|
|
139
|
+
/^\[::ffff:169\.254\./i,
|
|
140
|
+
/^\[::ffff:a9fe:/i,
|
|
141
|
+
/^\[::ffff:0\./i,
|
|
142
|
+
/^\[::127\./i,
|
|
143
|
+
/^\[::7f[0-9a-f]{2}:/i,
|
|
144
|
+
/^\[::10\./i,
|
|
145
|
+
/^\[::a[0-9a-f]{2}:/i,
|
|
146
|
+
/^\[::192\.168\./i,
|
|
147
|
+
/^\[::c0a8:/i
|
|
148
|
+
];
|
|
149
|
+
/**
|
|
150
|
+
* Validate URL for SSRF protection
|
|
151
|
+
*
|
|
152
|
+
* @param url - The URL to validate (after env var interpolation)
|
|
153
|
+
* @param security - Security settings
|
|
154
|
+
* @throws Error if URL is unsafe
|
|
155
|
+
*/
|
|
156
|
+
function validateUrlSecurity(url, security) {
|
|
157
|
+
const allowPrivateIPs = security?.allowPrivateIPs ?? false;
|
|
158
|
+
const enforceHttps = security?.enforceHttps ?? true;
|
|
159
|
+
let parsedUrl;
|
|
160
|
+
try {
|
|
161
|
+
parsedUrl = new URL(url);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new Error(`Invalid URL format: ${url}`);
|
|
164
|
+
}
|
|
165
|
+
const protocol = parsedUrl.protocol.replace(":", "");
|
|
166
|
+
if (enforceHttps && protocol !== "https") throw new Error(`HTTPS is required for security. URL uses '${protocol}://'. Set security.enforceHttps: false to allow HTTP.`);
|
|
167
|
+
if (protocol !== "http" && protocol !== "https") throw new Error(`Invalid URL protocol '${protocol}://'. Only http:// and https:// are allowed.`);
|
|
168
|
+
if (!allowPrivateIPs) {
|
|
169
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
170
|
+
if (PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(hostname))) throw new Error(`Private IP addresses and localhost are blocked for security (${hostname}). Set security.allowPrivateIPs: true to allow internal networks.`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Validate a remote config source against its validation rules
|
|
175
|
+
*
|
|
176
|
+
* @param source - Remote config source with validation rules
|
|
177
|
+
* @throws Error if validation fails
|
|
178
|
+
*/
|
|
179
|
+
function validateRemoteConfigSource(source) {
|
|
180
|
+
const interpolatedUrl = interpolateEnvVars(source.url);
|
|
181
|
+
validateUrlSecurity(interpolatedUrl, source.security);
|
|
182
|
+
if (!source.validation) return;
|
|
183
|
+
if (source.validation.url) {
|
|
184
|
+
if (!new RegExp(source.validation.url).test(interpolatedUrl)) throw new Error(`Remote config URL "${interpolatedUrl}" does not match validation pattern: ${source.validation.url}`);
|
|
185
|
+
}
|
|
186
|
+
if (source.validation.headers && Object.keys(source.validation.headers).length > 0) {
|
|
187
|
+
if (!source.headers) {
|
|
188
|
+
const requiredHeaders = Object.keys(source.validation.headers);
|
|
189
|
+
throw new Error(`Remote config is missing required headers: ${requiredHeaders.join(", ")}`);
|
|
190
|
+
}
|
|
191
|
+
for (const [headerName, pattern] of Object.entries(source.validation.headers)) {
|
|
192
|
+
if (!(headerName in source.headers)) throw new Error(`Remote config is missing required header: ${headerName}`);
|
|
193
|
+
const interpolatedHeaderValue = interpolateEnvVars(source.headers[headerName]);
|
|
194
|
+
if (!new RegExp(pattern).test(interpolatedHeaderValue)) throw new Error(`Remote config header "${headerName}" value "${interpolatedHeaderValue}" does not match validation pattern: ${pattern}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
108
199
|
* Claude Code / Claude Desktop standard MCP config format
|
|
109
200
|
* This is the format users write in their config files
|
|
110
201
|
*/
|
|
@@ -130,10 +221,32 @@ const ClaudeCodeHttpServerSchema = zod.z.object({
|
|
|
130
221
|
config: AdditionalConfigSchema
|
|
131
222
|
});
|
|
132
223
|
const ClaudeCodeServerConfigSchema = zod.z.union([ClaudeCodeStdioServerSchema, ClaudeCodeHttpServerSchema]);
|
|
224
|
+
const RemoteConfigValidationSchema = zod.z.object({
|
|
225
|
+
url: zod.z.string().optional(),
|
|
226
|
+
headers: zod.z.record(zod.z.string(), zod.z.string()).optional()
|
|
227
|
+
}).optional();
|
|
228
|
+
const RemoteConfigSecuritySchema = zod.z.object({
|
|
229
|
+
allowPrivateIPs: zod.z.boolean().optional(),
|
|
230
|
+
enforceHttps: zod.z.boolean().optional()
|
|
231
|
+
}).optional();
|
|
232
|
+
const RemoteConfigSourceSchema = zod.z.object({
|
|
233
|
+
url: zod.z.string(),
|
|
234
|
+
headers: zod.z.record(zod.z.string(), zod.z.string()).optional(),
|
|
235
|
+
validation: RemoteConfigValidationSchema,
|
|
236
|
+
security: RemoteConfigSecuritySchema,
|
|
237
|
+
mergeStrategy: zod.z.enum([
|
|
238
|
+
"local-priority",
|
|
239
|
+
"remote-priority",
|
|
240
|
+
"merge-deep"
|
|
241
|
+
]).optional()
|
|
242
|
+
});
|
|
133
243
|
/**
|
|
134
244
|
* Full Claude Code MCP configuration schema
|
|
135
245
|
*/
|
|
136
|
-
const ClaudeCodeMcpConfigSchema = zod.z.object({
|
|
246
|
+
const ClaudeCodeMcpConfigSchema = zod.z.object({
|
|
247
|
+
mcpServers: zod.z.record(zod.z.string(), ClaudeCodeServerConfigSchema),
|
|
248
|
+
remoteConfigs: zod.z.array(RemoteConfigSourceSchema).optional()
|
|
249
|
+
});
|
|
137
250
|
/**
|
|
138
251
|
* Internal MCP config format
|
|
139
252
|
* This is the normalized format used internally by the proxy
|
|
@@ -242,6 +355,226 @@ function parseMcpConfig(rawConfig) {
|
|
|
242
355
|
return InternalMcpConfigSchema.parse(internalConfig);
|
|
243
356
|
}
|
|
244
357
|
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/services/RemoteConfigCacheService.ts
|
|
360
|
+
/**
|
|
361
|
+
* RemoteConfigCacheService
|
|
362
|
+
*
|
|
363
|
+
* DESIGN PATTERNS:
|
|
364
|
+
* - Service pattern for cache management
|
|
365
|
+
* - Single responsibility principle
|
|
366
|
+
* - File-based caching with TTL support
|
|
367
|
+
*
|
|
368
|
+
* CODING STANDARDS:
|
|
369
|
+
* - Use async/await for asynchronous operations
|
|
370
|
+
* - Handle file system errors gracefully
|
|
371
|
+
* - Keep cache organized by URL hash
|
|
372
|
+
* - Implement automatic cache expiration
|
|
373
|
+
*
|
|
374
|
+
* AVOID:
|
|
375
|
+
* - Storing sensitive data in cache (headers with tokens)
|
|
376
|
+
* - Unbounded cache growth
|
|
377
|
+
* - Missing error handling for file operations
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* Service for caching remote MCP configurations
|
|
381
|
+
*/
|
|
382
|
+
var RemoteConfigCacheService = class {
|
|
383
|
+
cacheDir;
|
|
384
|
+
cacheTTL;
|
|
385
|
+
readEnabled;
|
|
386
|
+
writeEnabled;
|
|
387
|
+
constructor(options) {
|
|
388
|
+
this.cacheDir = (0, node_path.join)((0, node_os.tmpdir)(), "one-mcp-cache", "remote-configs");
|
|
389
|
+
this.cacheTTL = options?.ttl || 3600 * 1e3;
|
|
390
|
+
this.readEnabled = options?.readEnabled !== void 0 ? options.readEnabled : true;
|
|
391
|
+
this.writeEnabled = options?.writeEnabled !== void 0 ? options.writeEnabled : true;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Generate a hash key from remote config URL
|
|
395
|
+
* Only uses URL for hashing to avoid caching credentials in the key
|
|
396
|
+
*/
|
|
397
|
+
generateCacheKey(url) {
|
|
398
|
+
return (0, node_crypto.createHash)("sha256").update(url).digest("hex");
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Get the cache file path for a given cache key
|
|
402
|
+
*/
|
|
403
|
+
getCacheFilePath(cacheKey) {
|
|
404
|
+
return (0, node_path.join)(this.cacheDir, `${cacheKey}.json`);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Initialize cache directory
|
|
408
|
+
* Uses mkdir with recursive option which handles existing directories gracefully
|
|
409
|
+
* (no TOCTOU race condition from existsSync check)
|
|
410
|
+
*/
|
|
411
|
+
async ensureCacheDir() {
|
|
412
|
+
try {
|
|
413
|
+
await (0, node_fs_promises.mkdir)(this.cacheDir, { recursive: true });
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error?.code !== "EEXIST") throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Get cached data for a remote config URL
|
|
420
|
+
*/
|
|
421
|
+
async get(url) {
|
|
422
|
+
if (!this.readEnabled) return null;
|
|
423
|
+
try {
|
|
424
|
+
await this.ensureCacheDir();
|
|
425
|
+
const cacheKey = this.generateCacheKey(url);
|
|
426
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
427
|
+
if (!(0, node_fs.existsSync)(cacheFilePath)) return null;
|
|
428
|
+
const cacheContent = await (0, node_fs_promises.readFile)(cacheFilePath, "utf-8");
|
|
429
|
+
const cacheEntry = JSON.parse(cacheContent);
|
|
430
|
+
const now = Date.now();
|
|
431
|
+
if (now > cacheEntry.expiresAt) {
|
|
432
|
+
await (0, node_fs_promises.unlink)(cacheFilePath).catch(() => {});
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
const expiresInSeconds = Math.round((cacheEntry.expiresAt - now) / 1e3);
|
|
436
|
+
console.error(`Remote config cache hit for ${url} (expires in ${expiresInSeconds}s)`);
|
|
437
|
+
return cacheEntry.data;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error(`Failed to read remote config cache for ${url}:`, error);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Set cached data for a remote config URL
|
|
445
|
+
*/
|
|
446
|
+
async set(url, data) {
|
|
447
|
+
if (!this.writeEnabled) return;
|
|
448
|
+
try {
|
|
449
|
+
await this.ensureCacheDir();
|
|
450
|
+
const cacheKey = this.generateCacheKey(url);
|
|
451
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
const cacheEntry = {
|
|
454
|
+
data,
|
|
455
|
+
timestamp: now,
|
|
456
|
+
expiresAt: now + this.cacheTTL,
|
|
457
|
+
url
|
|
458
|
+
};
|
|
459
|
+
await (0, node_fs_promises.writeFile)(cacheFilePath, JSON.stringify(cacheEntry, null, 2), "utf-8");
|
|
460
|
+
console.error(`Cached remote config for ${url} (TTL: ${Math.round(this.cacheTTL / 1e3)}s)`);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.error(`Failed to write remote config cache for ${url}:`, error);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Clear cache for a specific URL
|
|
467
|
+
*/
|
|
468
|
+
async clear(url) {
|
|
469
|
+
try {
|
|
470
|
+
const cacheKey = this.generateCacheKey(url);
|
|
471
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
472
|
+
if ((0, node_fs.existsSync)(cacheFilePath)) {
|
|
473
|
+
await (0, node_fs_promises.unlink)(cacheFilePath);
|
|
474
|
+
console.error(`Cleared remote config cache for ${url}`);
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
console.error(`Failed to clear remote config cache for ${url}:`, error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Clear all cached remote configs
|
|
482
|
+
*/
|
|
483
|
+
async clearAll() {
|
|
484
|
+
try {
|
|
485
|
+
if (!(0, node_fs.existsSync)(this.cacheDir)) return;
|
|
486
|
+
const files = await (0, node_fs_promises.readdir)(this.cacheDir);
|
|
487
|
+
const deletePromises = files.filter((file) => file.endsWith(".json")).map((file) => (0, node_fs_promises.unlink)((0, node_path.join)(this.cacheDir, file)).catch(() => {}));
|
|
488
|
+
await Promise.all(deletePromises);
|
|
489
|
+
console.error(`Cleared all remote config cache entries (${files.length} files)`);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
console.error("Failed to clear all remote config cache:", error);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Clean up expired cache entries
|
|
496
|
+
*/
|
|
497
|
+
async cleanExpired() {
|
|
498
|
+
try {
|
|
499
|
+
if (!(0, node_fs.existsSync)(this.cacheDir)) return;
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
const files = await (0, node_fs_promises.readdir)(this.cacheDir);
|
|
502
|
+
let expiredCount = 0;
|
|
503
|
+
for (const file of files) {
|
|
504
|
+
if (!file.endsWith(".json")) continue;
|
|
505
|
+
const filePath = (0, node_path.join)(this.cacheDir, file);
|
|
506
|
+
try {
|
|
507
|
+
const content = await (0, node_fs_promises.readFile)(filePath, "utf-8");
|
|
508
|
+
if (now > JSON.parse(content).expiresAt) {
|
|
509
|
+
await (0, node_fs_promises.unlink)(filePath);
|
|
510
|
+
expiredCount++;
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
await (0, node_fs_promises.unlink)(filePath).catch(() => {});
|
|
514
|
+
expiredCount++;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (expiredCount > 0) console.error(`Cleaned up ${expiredCount} expired remote config cache entries`);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.error("Failed to clean expired remote config cache:", error);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Get cache statistics
|
|
524
|
+
*/
|
|
525
|
+
async getStats() {
|
|
526
|
+
try {
|
|
527
|
+
if (!(0, node_fs.existsSync)(this.cacheDir)) return {
|
|
528
|
+
totalEntries: 0,
|
|
529
|
+
totalSize: 0
|
|
530
|
+
};
|
|
531
|
+
const jsonFiles = (await (0, node_fs_promises.readdir)(this.cacheDir)).filter((file) => file.endsWith(".json"));
|
|
532
|
+
let totalSize = 0;
|
|
533
|
+
for (const file of jsonFiles) {
|
|
534
|
+
const filePath = (0, node_path.join)(this.cacheDir, file);
|
|
535
|
+
try {
|
|
536
|
+
const content = await (0, node_fs_promises.readFile)(filePath, "utf-8");
|
|
537
|
+
totalSize += Buffer.byteLength(content, "utf-8");
|
|
538
|
+
} catch {}
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
totalEntries: jsonFiles.length,
|
|
542
|
+
totalSize
|
|
543
|
+
};
|
|
544
|
+
} catch (error) {
|
|
545
|
+
console.error("Failed to get remote config cache stats:", error);
|
|
546
|
+
return {
|
|
547
|
+
totalEntries: 0,
|
|
548
|
+
totalSize: 0
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Check if read from cache is enabled
|
|
554
|
+
*/
|
|
555
|
+
isReadEnabled() {
|
|
556
|
+
return this.readEnabled;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Check if write to cache is enabled
|
|
560
|
+
*/
|
|
561
|
+
isWriteEnabled() {
|
|
562
|
+
return this.writeEnabled;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Set read enabled state
|
|
566
|
+
*/
|
|
567
|
+
setReadEnabled(enabled) {
|
|
568
|
+
this.readEnabled = enabled;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Set write enabled state
|
|
572
|
+
*/
|
|
573
|
+
setWriteEnabled(enabled) {
|
|
574
|
+
this.writeEnabled = enabled;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
245
578
|
//#endregion
|
|
246
579
|
//#region src/services/ConfigFetcherService.ts
|
|
247
580
|
/**
|
|
@@ -263,35 +596,62 @@ function parseMcpConfig(rawConfig) {
|
|
|
263
596
|
* - Direct tool implementation (services should be tool-agnostic)
|
|
264
597
|
*/
|
|
265
598
|
/**
|
|
266
|
-
* Service for fetching and caching MCP server configurations from local file
|
|
599
|
+
* Service for fetching and caching MCP server configurations from local file and remote sources
|
|
600
|
+
* Supports merging multiple remote configs with local config
|
|
267
601
|
*/
|
|
268
602
|
var ConfigFetcherService = class {
|
|
269
603
|
configFilePath;
|
|
270
604
|
cacheTtlMs;
|
|
271
605
|
cachedConfig = null;
|
|
272
606
|
lastFetchTime = 0;
|
|
607
|
+
remoteConfigCache;
|
|
273
608
|
constructor(options) {
|
|
274
609
|
this.configFilePath = options.configFilePath;
|
|
275
610
|
this.cacheTtlMs = options.cacheTtlMs || 6e4;
|
|
611
|
+
const useCache = options.useCache !== void 0 ? options.useCache : true;
|
|
612
|
+
this.remoteConfigCache = new RemoteConfigCacheService({
|
|
613
|
+
ttl: options.remoteCacheTtlMs || 3600 * 1e3,
|
|
614
|
+
readEnabled: useCache,
|
|
615
|
+
writeEnabled: true
|
|
616
|
+
});
|
|
276
617
|
if (!this.configFilePath) throw new Error("configFilePath must be provided");
|
|
277
618
|
}
|
|
278
619
|
/**
|
|
279
|
-
* Fetch MCP configuration from local file with caching
|
|
620
|
+
* Fetch MCP configuration from local file and remote sources with caching
|
|
621
|
+
* Merges remote configs with local config based on merge strategy
|
|
280
622
|
* @param forceRefresh - Force reload from source, bypassing cache
|
|
281
623
|
*/
|
|
282
624
|
async fetchConfiguration(forceRefresh = false) {
|
|
283
625
|
const now = Date.now();
|
|
284
626
|
if (!forceRefresh && this.cachedConfig && now - this.lastFetchTime < this.cacheTtlMs) return this.cachedConfig;
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
627
|
+
const localConfigData = await this.loadRawConfigFromFile();
|
|
628
|
+
const remoteConfigSources = localConfigData.remoteConfigs || [];
|
|
629
|
+
let mergedConfig = await this.parseConfig(localConfigData);
|
|
630
|
+
const remoteConfigPromises = remoteConfigSources.map(async (remoteSource) => {
|
|
631
|
+
try {
|
|
632
|
+
validateRemoteConfigSource(remoteSource);
|
|
633
|
+
return {
|
|
634
|
+
config: await this.loadFromUrl(remoteSource),
|
|
635
|
+
mergeStrategy: remoteSource.mergeStrategy || "local-priority",
|
|
636
|
+
url: remoteSource.url
|
|
637
|
+
};
|
|
638
|
+
} catch (error) {
|
|
639
|
+
if (error instanceof Error) console.error(`Failed to fetch remote config from ${remoteSource.url}: ${error.message}`);
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
const remoteConfigResults = await Promise.all(remoteConfigPromises);
|
|
644
|
+
for (const result of remoteConfigResults) if (result !== null) mergedConfig = this.mergeConfigurations(mergedConfig, result.config, result.mergeStrategy);
|
|
645
|
+
if (!mergedConfig.mcpServers || typeof mergedConfig.mcpServers !== "object") throw new Error("Invalid MCP configuration: missing or invalid mcpServers");
|
|
646
|
+
this.cachedConfig = mergedConfig;
|
|
288
647
|
this.lastFetchTime = now;
|
|
289
|
-
return
|
|
648
|
+
return mergedConfig;
|
|
290
649
|
}
|
|
291
650
|
/**
|
|
292
|
-
* Load configuration from a local file (supports JSON and YAML)
|
|
651
|
+
* Load raw configuration data from a local file (supports JSON and YAML)
|
|
652
|
+
* Returns unparsed config data to allow access to remoteConfigs
|
|
293
653
|
*/
|
|
294
|
-
async
|
|
654
|
+
async loadRawConfigFromFile() {
|
|
295
655
|
if (!this.configFilePath) throw new Error("No config file path provided");
|
|
296
656
|
if (!(0, node_fs.existsSync)(this.configFilePath)) throw new Error(`Config file not found: ${this.configFilePath}`);
|
|
297
657
|
try {
|
|
@@ -299,13 +659,117 @@ var ConfigFetcherService = class {
|
|
|
299
659
|
let rawConfig;
|
|
300
660
|
if (this.configFilePath.endsWith(".yaml") || this.configFilePath.endsWith(".yml")) rawConfig = js_yaml.default.load(content);
|
|
301
661
|
else rawConfig = JSON.parse(content);
|
|
302
|
-
return
|
|
662
|
+
return rawConfig;
|
|
303
663
|
} catch (error) {
|
|
304
664
|
if (error instanceof Error) throw new Error(`Failed to load config file: ${error.message}`);
|
|
305
665
|
throw new Error("Failed to load config file: Unknown error");
|
|
306
666
|
}
|
|
307
667
|
}
|
|
308
668
|
/**
|
|
669
|
+
* Parse raw config data using Zod schema
|
|
670
|
+
* Filters out remoteConfigs to avoid including them in the final config
|
|
671
|
+
*/
|
|
672
|
+
async parseConfig(rawConfig) {
|
|
673
|
+
try {
|
|
674
|
+
const { remoteConfigs, ...configWithoutRemote } = rawConfig;
|
|
675
|
+
return parseMcpConfig(configWithoutRemote);
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (error instanceof Error) throw new Error(`Failed to parse config: ${error.message}`);
|
|
678
|
+
throw new Error("Failed to parse config: Unknown error");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Load configuration from a remote URL with caching
|
|
683
|
+
*
|
|
684
|
+
* SECURITY NOTE: This method fetches remote configs based on URLs from the local config file.
|
|
685
|
+
* This is intentional and safe because:
|
|
686
|
+
* 1. URLs are user-controlled via their local config file (not external input)
|
|
687
|
+
* 2. SSRF protection validates URLs before fetching (blocks private IPs, enforces HTTPS)
|
|
688
|
+
* 3. Users explicitly opt-in to remote configs in their local configuration
|
|
689
|
+
* 4. This enables centralized config management (intended feature, not a vulnerability)
|
|
690
|
+
*
|
|
691
|
+
* CodeQL alert "file-access-to-http" is a false positive here - we're not leaking
|
|
692
|
+
* file contents to arbitrary URLs, we're fetching configs from user-specified sources.
|
|
693
|
+
*/
|
|
694
|
+
async loadFromUrl(source) {
|
|
695
|
+
try {
|
|
696
|
+
const interpolatedUrl = this.interpolateEnvVars(source.url);
|
|
697
|
+
const cachedConfig = await this.remoteConfigCache.get(interpolatedUrl);
|
|
698
|
+
if (cachedConfig) return cachedConfig;
|
|
699
|
+
const interpolatedHeaders = source.headers ? Object.fromEntries(Object.entries(source.headers).map(([key, value]) => [key, this.interpolateEnvVars(value)])) : {};
|
|
700
|
+
const response = await fetch(interpolatedUrl, { headers: interpolatedHeaders });
|
|
701
|
+
if (!response.ok) throw new Error(`Failed to fetch remote config: ${response.status} ${response.statusText}`);
|
|
702
|
+
const config = parseMcpConfig(await response.json());
|
|
703
|
+
await this.remoteConfigCache.set(interpolatedUrl, config);
|
|
704
|
+
return config;
|
|
705
|
+
} catch (error) {
|
|
706
|
+
if (error instanceof Error) throw new Error(`Failed to fetch remote config from ${source.url}: ${error.message}`);
|
|
707
|
+
throw new Error(`Failed to fetch remote config from ${source.url}: Unknown error`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Interpolate environment variables in a string
|
|
712
|
+
* Supports ${VAR_NAME} syntax
|
|
713
|
+
*/
|
|
714
|
+
interpolateEnvVars(value) {
|
|
715
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
716
|
+
const envValue = process.env[varName];
|
|
717
|
+
if (envValue === void 0) {
|
|
718
|
+
console.warn(`Environment variable ${varName} is not defined, keeping placeholder`);
|
|
719
|
+
return `\${${varName}}`;
|
|
720
|
+
}
|
|
721
|
+
return envValue;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Merge two MCP configurations based on the specified merge strategy
|
|
726
|
+
* @param localConfig Configuration loaded from local file
|
|
727
|
+
* @param remoteConfig Configuration loaded from remote URL
|
|
728
|
+
* @param mergeStrategy Strategy for merging configs
|
|
729
|
+
* @returns Merged configuration
|
|
730
|
+
*/
|
|
731
|
+
mergeConfigurations(localConfig, remoteConfig, mergeStrategy) {
|
|
732
|
+
switch (mergeStrategy) {
|
|
733
|
+
case "local-priority": return { mcpServers: {
|
|
734
|
+
...remoteConfig.mcpServers,
|
|
735
|
+
...localConfig.mcpServers
|
|
736
|
+
} };
|
|
737
|
+
case "remote-priority": return { mcpServers: {
|
|
738
|
+
...localConfig.mcpServers,
|
|
739
|
+
...remoteConfig.mcpServers
|
|
740
|
+
} };
|
|
741
|
+
case "merge-deep": {
|
|
742
|
+
const merged = { ...remoteConfig.mcpServers };
|
|
743
|
+
for (const [serverName, localServerConfig] of Object.entries(localConfig.mcpServers)) if (merged[serverName]) {
|
|
744
|
+
const remoteServer = merged[serverName];
|
|
745
|
+
const mergedConfig = {
|
|
746
|
+
...remoteServer.config,
|
|
747
|
+
...localServerConfig.config
|
|
748
|
+
};
|
|
749
|
+
const remoteEnv = "env" in remoteServer.config ? remoteServer.config.env : void 0;
|
|
750
|
+
const localEnv = "env" in localServerConfig.config ? localServerConfig.config.env : void 0;
|
|
751
|
+
if (remoteEnv || localEnv) mergedConfig.env = {
|
|
752
|
+
...remoteEnv || {},
|
|
753
|
+
...localEnv || {}
|
|
754
|
+
};
|
|
755
|
+
const remoteHeaders = "headers" in remoteServer.config ? remoteServer.config.headers : void 0;
|
|
756
|
+
const localHeaders = "headers" in localServerConfig.config ? localServerConfig.config.headers : void 0;
|
|
757
|
+
if (remoteHeaders || localHeaders) mergedConfig.headers = {
|
|
758
|
+
...remoteHeaders || {},
|
|
759
|
+
...localHeaders || {}
|
|
760
|
+
};
|
|
761
|
+
merged[serverName] = {
|
|
762
|
+
...remoteServer,
|
|
763
|
+
...localServerConfig,
|
|
764
|
+
config: mergedConfig
|
|
765
|
+
};
|
|
766
|
+
} else merged[serverName] = localServerConfig;
|
|
767
|
+
return { mcpServers: merged };
|
|
768
|
+
}
|
|
769
|
+
default: throw new Error(`Unknown merge strategy: ${mergeStrategy}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
309
773
|
* Clear the cached configuration
|
|
310
774
|
*/
|
|
311
775
|
clearCache() {
|
|
@@ -862,7 +1326,10 @@ async function createServer(options) {
|
|
|
862
1326
|
}, { capabilities: { tools: {} } });
|
|
863
1327
|
const clientManager = new McpClientManagerService();
|
|
864
1328
|
if (options?.configFilePath) try {
|
|
865
|
-
const config = await new ConfigFetcherService({
|
|
1329
|
+
const config = await new ConfigFetcherService({
|
|
1330
|
+
configFilePath: options.configFilePath,
|
|
1331
|
+
useCache: !options.noCache
|
|
1332
|
+
}).fetchConfiguration(options.noCache || false);
|
|
866
1333
|
const connectionPromises = Object.entries(config.mcpServers).map(async ([serverName, serverConfig]) => {
|
|
867
1334
|
try {
|
|
868
1335
|
await clientManager.connectToServer(serverName, serverConfig);
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { CallToolRequestSchema, ListToolsRequestSchema, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import yaml from "js-yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
7
10
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
11
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
12
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
10
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
14
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
12
15
|
import express from "express";
|
|
13
|
-
import { randomUUID } from "node:crypto";
|
|
14
16
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
17
|
|
|
16
18
|
//#region src/utils/mcpConfigSchema.ts
|
|
@@ -80,6 +82,95 @@ function interpolateEnvVarsInObject(obj) {
|
|
|
80
82
|
return obj;
|
|
81
83
|
}
|
|
82
84
|
/**
|
|
85
|
+
* Private IP range patterns for SSRF protection
|
|
86
|
+
* Covers both IPv4 and IPv6 loopback, private, and link-local ranges
|
|
87
|
+
*/
|
|
88
|
+
const PRIVATE_IP_PATTERNS = [
|
|
89
|
+
/^127\./,
|
|
90
|
+
/^10\./,
|
|
91
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
92
|
+
/^192\.168\./,
|
|
93
|
+
/^169\.254\./,
|
|
94
|
+
/^0\./,
|
|
95
|
+
/^224\./,
|
|
96
|
+
/^240\./,
|
|
97
|
+
/^localhost$/i,
|
|
98
|
+
/^.*\.localhost$/i,
|
|
99
|
+
/^\[::\]/,
|
|
100
|
+
/^\[::1\]/,
|
|
101
|
+
/^\[0:0:0:0:0:0:0:1\]/,
|
|
102
|
+
/^\[0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:0{1,4}:1\]/i,
|
|
103
|
+
/^\[fe80:/i,
|
|
104
|
+
/^\[fc00:/i,
|
|
105
|
+
/^\[fd00:/i,
|
|
106
|
+
/^\[::ffff:127\./i,
|
|
107
|
+
/^\[::ffff:7f[0-9a-f]{2}:/i,
|
|
108
|
+
/^\[::ffff:10\./i,
|
|
109
|
+
/^\[::ffff:a[0-9a-f]{2}:/i,
|
|
110
|
+
/^\[::ffff:172\.(1[6-9]|2\d|3[01])\./i,
|
|
111
|
+
/^\[::ffff:ac1[0-9a-f]:/i,
|
|
112
|
+
/^\[::ffff:192\.168\./i,
|
|
113
|
+
/^\[::ffff:c0a8:/i,
|
|
114
|
+
/^\[::ffff:169\.254\./i,
|
|
115
|
+
/^\[::ffff:a9fe:/i,
|
|
116
|
+
/^\[::ffff:0\./i,
|
|
117
|
+
/^\[::127\./i,
|
|
118
|
+
/^\[::7f[0-9a-f]{2}:/i,
|
|
119
|
+
/^\[::10\./i,
|
|
120
|
+
/^\[::a[0-9a-f]{2}:/i,
|
|
121
|
+
/^\[::192\.168\./i,
|
|
122
|
+
/^\[::c0a8:/i
|
|
123
|
+
];
|
|
124
|
+
/**
|
|
125
|
+
* Validate URL for SSRF protection
|
|
126
|
+
*
|
|
127
|
+
* @param url - The URL to validate (after env var interpolation)
|
|
128
|
+
* @param security - Security settings
|
|
129
|
+
* @throws Error if URL is unsafe
|
|
130
|
+
*/
|
|
131
|
+
function validateUrlSecurity(url, security) {
|
|
132
|
+
const allowPrivateIPs = security?.allowPrivateIPs ?? false;
|
|
133
|
+
const enforceHttps = security?.enforceHttps ?? true;
|
|
134
|
+
let parsedUrl;
|
|
135
|
+
try {
|
|
136
|
+
parsedUrl = new URL(url);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(`Invalid URL format: ${url}`);
|
|
139
|
+
}
|
|
140
|
+
const protocol = parsedUrl.protocol.replace(":", "");
|
|
141
|
+
if (enforceHttps && protocol !== "https") throw new Error(`HTTPS is required for security. URL uses '${protocol}://'. Set security.enforceHttps: false to allow HTTP.`);
|
|
142
|
+
if (protocol !== "http" && protocol !== "https") throw new Error(`Invalid URL protocol '${protocol}://'. Only http:// and https:// are allowed.`);
|
|
143
|
+
if (!allowPrivateIPs) {
|
|
144
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
145
|
+
if (PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(hostname))) throw new Error(`Private IP addresses and localhost are blocked for security (${hostname}). Set security.allowPrivateIPs: true to allow internal networks.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Validate a remote config source against its validation rules
|
|
150
|
+
*
|
|
151
|
+
* @param source - Remote config source with validation rules
|
|
152
|
+
* @throws Error if validation fails
|
|
153
|
+
*/
|
|
154
|
+
function validateRemoteConfigSource(source) {
|
|
155
|
+
const interpolatedUrl = interpolateEnvVars(source.url);
|
|
156
|
+
validateUrlSecurity(interpolatedUrl, source.security);
|
|
157
|
+
if (!source.validation) return;
|
|
158
|
+
if (source.validation.url) {
|
|
159
|
+
if (!new RegExp(source.validation.url).test(interpolatedUrl)) throw new Error(`Remote config URL "${interpolatedUrl}" does not match validation pattern: ${source.validation.url}`);
|
|
160
|
+
}
|
|
161
|
+
if (source.validation.headers && Object.keys(source.validation.headers).length > 0) {
|
|
162
|
+
if (!source.headers) {
|
|
163
|
+
const requiredHeaders = Object.keys(source.validation.headers);
|
|
164
|
+
throw new Error(`Remote config is missing required headers: ${requiredHeaders.join(", ")}`);
|
|
165
|
+
}
|
|
166
|
+
for (const [headerName, pattern] of Object.entries(source.validation.headers)) {
|
|
167
|
+
if (!(headerName in source.headers)) throw new Error(`Remote config is missing required header: ${headerName}`);
|
|
168
|
+
const interpolatedHeaderValue = interpolateEnvVars(source.headers[headerName]);
|
|
169
|
+
if (!new RegExp(pattern).test(interpolatedHeaderValue)) throw new Error(`Remote config header "${headerName}" value "${interpolatedHeaderValue}" does not match validation pattern: ${pattern}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
83
174
|
* Claude Code / Claude Desktop standard MCP config format
|
|
84
175
|
* This is the format users write in their config files
|
|
85
176
|
*/
|
|
@@ -105,10 +196,32 @@ const ClaudeCodeHttpServerSchema = z.object({
|
|
|
105
196
|
config: AdditionalConfigSchema
|
|
106
197
|
});
|
|
107
198
|
const ClaudeCodeServerConfigSchema = z.union([ClaudeCodeStdioServerSchema, ClaudeCodeHttpServerSchema]);
|
|
199
|
+
const RemoteConfigValidationSchema = z.object({
|
|
200
|
+
url: z.string().optional(),
|
|
201
|
+
headers: z.record(z.string(), z.string()).optional()
|
|
202
|
+
}).optional();
|
|
203
|
+
const RemoteConfigSecuritySchema = z.object({
|
|
204
|
+
allowPrivateIPs: z.boolean().optional(),
|
|
205
|
+
enforceHttps: z.boolean().optional()
|
|
206
|
+
}).optional();
|
|
207
|
+
const RemoteConfigSourceSchema = z.object({
|
|
208
|
+
url: z.string(),
|
|
209
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
210
|
+
validation: RemoteConfigValidationSchema,
|
|
211
|
+
security: RemoteConfigSecuritySchema,
|
|
212
|
+
mergeStrategy: z.enum([
|
|
213
|
+
"local-priority",
|
|
214
|
+
"remote-priority",
|
|
215
|
+
"merge-deep"
|
|
216
|
+
]).optional()
|
|
217
|
+
});
|
|
108
218
|
/**
|
|
109
219
|
* Full Claude Code MCP configuration schema
|
|
110
220
|
*/
|
|
111
|
-
const ClaudeCodeMcpConfigSchema = z.object({
|
|
221
|
+
const ClaudeCodeMcpConfigSchema = z.object({
|
|
222
|
+
mcpServers: z.record(z.string(), ClaudeCodeServerConfigSchema),
|
|
223
|
+
remoteConfigs: z.array(RemoteConfigSourceSchema).optional()
|
|
224
|
+
});
|
|
112
225
|
/**
|
|
113
226
|
* Internal MCP config format
|
|
114
227
|
* This is the normalized format used internally by the proxy
|
|
@@ -217,6 +330,226 @@ function parseMcpConfig(rawConfig) {
|
|
|
217
330
|
return InternalMcpConfigSchema.parse(internalConfig);
|
|
218
331
|
}
|
|
219
332
|
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/services/RemoteConfigCacheService.ts
|
|
335
|
+
/**
|
|
336
|
+
* RemoteConfigCacheService
|
|
337
|
+
*
|
|
338
|
+
* DESIGN PATTERNS:
|
|
339
|
+
* - Service pattern for cache management
|
|
340
|
+
* - Single responsibility principle
|
|
341
|
+
* - File-based caching with TTL support
|
|
342
|
+
*
|
|
343
|
+
* CODING STANDARDS:
|
|
344
|
+
* - Use async/await for asynchronous operations
|
|
345
|
+
* - Handle file system errors gracefully
|
|
346
|
+
* - Keep cache organized by URL hash
|
|
347
|
+
* - Implement automatic cache expiration
|
|
348
|
+
*
|
|
349
|
+
* AVOID:
|
|
350
|
+
* - Storing sensitive data in cache (headers with tokens)
|
|
351
|
+
* - Unbounded cache growth
|
|
352
|
+
* - Missing error handling for file operations
|
|
353
|
+
*/
|
|
354
|
+
/**
|
|
355
|
+
* Service for caching remote MCP configurations
|
|
356
|
+
*/
|
|
357
|
+
var RemoteConfigCacheService = class {
|
|
358
|
+
cacheDir;
|
|
359
|
+
cacheTTL;
|
|
360
|
+
readEnabled;
|
|
361
|
+
writeEnabled;
|
|
362
|
+
constructor(options) {
|
|
363
|
+
this.cacheDir = join(tmpdir(), "one-mcp-cache", "remote-configs");
|
|
364
|
+
this.cacheTTL = options?.ttl || 3600 * 1e3;
|
|
365
|
+
this.readEnabled = options?.readEnabled !== void 0 ? options.readEnabled : true;
|
|
366
|
+
this.writeEnabled = options?.writeEnabled !== void 0 ? options.writeEnabled : true;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Generate a hash key from remote config URL
|
|
370
|
+
* Only uses URL for hashing to avoid caching credentials in the key
|
|
371
|
+
*/
|
|
372
|
+
generateCacheKey(url) {
|
|
373
|
+
return createHash("sha256").update(url).digest("hex");
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Get the cache file path for a given cache key
|
|
377
|
+
*/
|
|
378
|
+
getCacheFilePath(cacheKey) {
|
|
379
|
+
return join(this.cacheDir, `${cacheKey}.json`);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Initialize cache directory
|
|
383
|
+
* Uses mkdir with recursive option which handles existing directories gracefully
|
|
384
|
+
* (no TOCTOU race condition from existsSync check)
|
|
385
|
+
*/
|
|
386
|
+
async ensureCacheDir() {
|
|
387
|
+
try {
|
|
388
|
+
await mkdir(this.cacheDir, { recursive: true });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error?.code !== "EEXIST") throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Get cached data for a remote config URL
|
|
395
|
+
*/
|
|
396
|
+
async get(url) {
|
|
397
|
+
if (!this.readEnabled) return null;
|
|
398
|
+
try {
|
|
399
|
+
await this.ensureCacheDir();
|
|
400
|
+
const cacheKey = this.generateCacheKey(url);
|
|
401
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
402
|
+
if (!existsSync(cacheFilePath)) return null;
|
|
403
|
+
const cacheContent = await readFile(cacheFilePath, "utf-8");
|
|
404
|
+
const cacheEntry = JSON.parse(cacheContent);
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
if (now > cacheEntry.expiresAt) {
|
|
407
|
+
await unlink(cacheFilePath).catch(() => {});
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
const expiresInSeconds = Math.round((cacheEntry.expiresAt - now) / 1e3);
|
|
411
|
+
console.error(`Remote config cache hit for ${url} (expires in ${expiresInSeconds}s)`);
|
|
412
|
+
return cacheEntry.data;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
console.error(`Failed to read remote config cache for ${url}:`, error);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Set cached data for a remote config URL
|
|
420
|
+
*/
|
|
421
|
+
async set(url, data) {
|
|
422
|
+
if (!this.writeEnabled) return;
|
|
423
|
+
try {
|
|
424
|
+
await this.ensureCacheDir();
|
|
425
|
+
const cacheKey = this.generateCacheKey(url);
|
|
426
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
const cacheEntry = {
|
|
429
|
+
data,
|
|
430
|
+
timestamp: now,
|
|
431
|
+
expiresAt: now + this.cacheTTL,
|
|
432
|
+
url
|
|
433
|
+
};
|
|
434
|
+
await writeFile(cacheFilePath, JSON.stringify(cacheEntry, null, 2), "utf-8");
|
|
435
|
+
console.error(`Cached remote config for ${url} (TTL: ${Math.round(this.cacheTTL / 1e3)}s)`);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
console.error(`Failed to write remote config cache for ${url}:`, error);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Clear cache for a specific URL
|
|
442
|
+
*/
|
|
443
|
+
async clear(url) {
|
|
444
|
+
try {
|
|
445
|
+
const cacheKey = this.generateCacheKey(url);
|
|
446
|
+
const cacheFilePath = this.getCacheFilePath(cacheKey);
|
|
447
|
+
if (existsSync(cacheFilePath)) {
|
|
448
|
+
await unlink(cacheFilePath);
|
|
449
|
+
console.error(`Cleared remote config cache for ${url}`);
|
|
450
|
+
}
|
|
451
|
+
} catch (error) {
|
|
452
|
+
console.error(`Failed to clear remote config cache for ${url}:`, error);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Clear all cached remote configs
|
|
457
|
+
*/
|
|
458
|
+
async clearAll() {
|
|
459
|
+
try {
|
|
460
|
+
if (!existsSync(this.cacheDir)) return;
|
|
461
|
+
const files = await readdir(this.cacheDir);
|
|
462
|
+
const deletePromises = files.filter((file) => file.endsWith(".json")).map((file) => unlink(join(this.cacheDir, file)).catch(() => {}));
|
|
463
|
+
await Promise.all(deletePromises);
|
|
464
|
+
console.error(`Cleared all remote config cache entries (${files.length} files)`);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
console.error("Failed to clear all remote config cache:", error);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Clean up expired cache entries
|
|
471
|
+
*/
|
|
472
|
+
async cleanExpired() {
|
|
473
|
+
try {
|
|
474
|
+
if (!existsSync(this.cacheDir)) return;
|
|
475
|
+
const now = Date.now();
|
|
476
|
+
const files = await readdir(this.cacheDir);
|
|
477
|
+
let expiredCount = 0;
|
|
478
|
+
for (const file of files) {
|
|
479
|
+
if (!file.endsWith(".json")) continue;
|
|
480
|
+
const filePath = join(this.cacheDir, file);
|
|
481
|
+
try {
|
|
482
|
+
const content = await readFile(filePath, "utf-8");
|
|
483
|
+
if (now > JSON.parse(content).expiresAt) {
|
|
484
|
+
await unlink(filePath);
|
|
485
|
+
expiredCount++;
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
await unlink(filePath).catch(() => {});
|
|
489
|
+
expiredCount++;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (expiredCount > 0) console.error(`Cleaned up ${expiredCount} expired remote config cache entries`);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
console.error("Failed to clean expired remote config cache:", error);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Get cache statistics
|
|
499
|
+
*/
|
|
500
|
+
async getStats() {
|
|
501
|
+
try {
|
|
502
|
+
if (!existsSync(this.cacheDir)) return {
|
|
503
|
+
totalEntries: 0,
|
|
504
|
+
totalSize: 0
|
|
505
|
+
};
|
|
506
|
+
const jsonFiles = (await readdir(this.cacheDir)).filter((file) => file.endsWith(".json"));
|
|
507
|
+
let totalSize = 0;
|
|
508
|
+
for (const file of jsonFiles) {
|
|
509
|
+
const filePath = join(this.cacheDir, file);
|
|
510
|
+
try {
|
|
511
|
+
const content = await readFile(filePath, "utf-8");
|
|
512
|
+
totalSize += Buffer.byteLength(content, "utf-8");
|
|
513
|
+
} catch {}
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
totalEntries: jsonFiles.length,
|
|
517
|
+
totalSize
|
|
518
|
+
};
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.error("Failed to get remote config cache stats:", error);
|
|
521
|
+
return {
|
|
522
|
+
totalEntries: 0,
|
|
523
|
+
totalSize: 0
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Check if read from cache is enabled
|
|
529
|
+
*/
|
|
530
|
+
isReadEnabled() {
|
|
531
|
+
return this.readEnabled;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Check if write to cache is enabled
|
|
535
|
+
*/
|
|
536
|
+
isWriteEnabled() {
|
|
537
|
+
return this.writeEnabled;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Set read enabled state
|
|
541
|
+
*/
|
|
542
|
+
setReadEnabled(enabled) {
|
|
543
|
+
this.readEnabled = enabled;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Set write enabled state
|
|
547
|
+
*/
|
|
548
|
+
setWriteEnabled(enabled) {
|
|
549
|
+
this.writeEnabled = enabled;
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
220
553
|
//#endregion
|
|
221
554
|
//#region src/services/ConfigFetcherService.ts
|
|
222
555
|
/**
|
|
@@ -238,35 +571,62 @@ function parseMcpConfig(rawConfig) {
|
|
|
238
571
|
* - Direct tool implementation (services should be tool-agnostic)
|
|
239
572
|
*/
|
|
240
573
|
/**
|
|
241
|
-
* Service for fetching and caching MCP server configurations from local file
|
|
574
|
+
* Service for fetching and caching MCP server configurations from local file and remote sources
|
|
575
|
+
* Supports merging multiple remote configs with local config
|
|
242
576
|
*/
|
|
243
577
|
var ConfigFetcherService = class {
|
|
244
578
|
configFilePath;
|
|
245
579
|
cacheTtlMs;
|
|
246
580
|
cachedConfig = null;
|
|
247
581
|
lastFetchTime = 0;
|
|
582
|
+
remoteConfigCache;
|
|
248
583
|
constructor(options) {
|
|
249
584
|
this.configFilePath = options.configFilePath;
|
|
250
585
|
this.cacheTtlMs = options.cacheTtlMs || 6e4;
|
|
586
|
+
const useCache = options.useCache !== void 0 ? options.useCache : true;
|
|
587
|
+
this.remoteConfigCache = new RemoteConfigCacheService({
|
|
588
|
+
ttl: options.remoteCacheTtlMs || 3600 * 1e3,
|
|
589
|
+
readEnabled: useCache,
|
|
590
|
+
writeEnabled: true
|
|
591
|
+
});
|
|
251
592
|
if (!this.configFilePath) throw new Error("configFilePath must be provided");
|
|
252
593
|
}
|
|
253
594
|
/**
|
|
254
|
-
* Fetch MCP configuration from local file with caching
|
|
595
|
+
* Fetch MCP configuration from local file and remote sources with caching
|
|
596
|
+
* Merges remote configs with local config based on merge strategy
|
|
255
597
|
* @param forceRefresh - Force reload from source, bypassing cache
|
|
256
598
|
*/
|
|
257
599
|
async fetchConfiguration(forceRefresh = false) {
|
|
258
600
|
const now = Date.now();
|
|
259
601
|
if (!forceRefresh && this.cachedConfig && now - this.lastFetchTime < this.cacheTtlMs) return this.cachedConfig;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
602
|
+
const localConfigData = await this.loadRawConfigFromFile();
|
|
603
|
+
const remoteConfigSources = localConfigData.remoteConfigs || [];
|
|
604
|
+
let mergedConfig = await this.parseConfig(localConfigData);
|
|
605
|
+
const remoteConfigPromises = remoteConfigSources.map(async (remoteSource) => {
|
|
606
|
+
try {
|
|
607
|
+
validateRemoteConfigSource(remoteSource);
|
|
608
|
+
return {
|
|
609
|
+
config: await this.loadFromUrl(remoteSource),
|
|
610
|
+
mergeStrategy: remoteSource.mergeStrategy || "local-priority",
|
|
611
|
+
url: remoteSource.url
|
|
612
|
+
};
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (error instanceof Error) console.error(`Failed to fetch remote config from ${remoteSource.url}: ${error.message}`);
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
const remoteConfigResults = await Promise.all(remoteConfigPromises);
|
|
619
|
+
for (const result of remoteConfigResults) if (result !== null) mergedConfig = this.mergeConfigurations(mergedConfig, result.config, result.mergeStrategy);
|
|
620
|
+
if (!mergedConfig.mcpServers || typeof mergedConfig.mcpServers !== "object") throw new Error("Invalid MCP configuration: missing or invalid mcpServers");
|
|
621
|
+
this.cachedConfig = mergedConfig;
|
|
263
622
|
this.lastFetchTime = now;
|
|
264
|
-
return
|
|
623
|
+
return mergedConfig;
|
|
265
624
|
}
|
|
266
625
|
/**
|
|
267
|
-
* Load configuration from a local file (supports JSON and YAML)
|
|
626
|
+
* Load raw configuration data from a local file (supports JSON and YAML)
|
|
627
|
+
* Returns unparsed config data to allow access to remoteConfigs
|
|
268
628
|
*/
|
|
269
|
-
async
|
|
629
|
+
async loadRawConfigFromFile() {
|
|
270
630
|
if (!this.configFilePath) throw new Error("No config file path provided");
|
|
271
631
|
if (!existsSync(this.configFilePath)) throw new Error(`Config file not found: ${this.configFilePath}`);
|
|
272
632
|
try {
|
|
@@ -274,13 +634,117 @@ var ConfigFetcherService = class {
|
|
|
274
634
|
let rawConfig;
|
|
275
635
|
if (this.configFilePath.endsWith(".yaml") || this.configFilePath.endsWith(".yml")) rawConfig = yaml.load(content);
|
|
276
636
|
else rawConfig = JSON.parse(content);
|
|
277
|
-
return
|
|
637
|
+
return rawConfig;
|
|
278
638
|
} catch (error) {
|
|
279
639
|
if (error instanceof Error) throw new Error(`Failed to load config file: ${error.message}`);
|
|
280
640
|
throw new Error("Failed to load config file: Unknown error");
|
|
281
641
|
}
|
|
282
642
|
}
|
|
283
643
|
/**
|
|
644
|
+
* Parse raw config data using Zod schema
|
|
645
|
+
* Filters out remoteConfigs to avoid including them in the final config
|
|
646
|
+
*/
|
|
647
|
+
async parseConfig(rawConfig) {
|
|
648
|
+
try {
|
|
649
|
+
const { remoteConfigs, ...configWithoutRemote } = rawConfig;
|
|
650
|
+
return parseMcpConfig(configWithoutRemote);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (error instanceof Error) throw new Error(`Failed to parse config: ${error.message}`);
|
|
653
|
+
throw new Error("Failed to parse config: Unknown error");
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Load configuration from a remote URL with caching
|
|
658
|
+
*
|
|
659
|
+
* SECURITY NOTE: This method fetches remote configs based on URLs from the local config file.
|
|
660
|
+
* This is intentional and safe because:
|
|
661
|
+
* 1. URLs are user-controlled via their local config file (not external input)
|
|
662
|
+
* 2. SSRF protection validates URLs before fetching (blocks private IPs, enforces HTTPS)
|
|
663
|
+
* 3. Users explicitly opt-in to remote configs in their local configuration
|
|
664
|
+
* 4. This enables centralized config management (intended feature, not a vulnerability)
|
|
665
|
+
*
|
|
666
|
+
* CodeQL alert "file-access-to-http" is a false positive here - we're not leaking
|
|
667
|
+
* file contents to arbitrary URLs, we're fetching configs from user-specified sources.
|
|
668
|
+
*/
|
|
669
|
+
async loadFromUrl(source) {
|
|
670
|
+
try {
|
|
671
|
+
const interpolatedUrl = this.interpolateEnvVars(source.url);
|
|
672
|
+
const cachedConfig = await this.remoteConfigCache.get(interpolatedUrl);
|
|
673
|
+
if (cachedConfig) return cachedConfig;
|
|
674
|
+
const interpolatedHeaders = source.headers ? Object.fromEntries(Object.entries(source.headers).map(([key, value]) => [key, this.interpolateEnvVars(value)])) : {};
|
|
675
|
+
const response = await fetch(interpolatedUrl, { headers: interpolatedHeaders });
|
|
676
|
+
if (!response.ok) throw new Error(`Failed to fetch remote config: ${response.status} ${response.statusText}`);
|
|
677
|
+
const config = parseMcpConfig(await response.json());
|
|
678
|
+
await this.remoteConfigCache.set(interpolatedUrl, config);
|
|
679
|
+
return config;
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (error instanceof Error) throw new Error(`Failed to fetch remote config from ${source.url}: ${error.message}`);
|
|
682
|
+
throw new Error(`Failed to fetch remote config from ${source.url}: Unknown error`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Interpolate environment variables in a string
|
|
687
|
+
* Supports ${VAR_NAME} syntax
|
|
688
|
+
*/
|
|
689
|
+
interpolateEnvVars(value) {
|
|
690
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
691
|
+
const envValue = process.env[varName];
|
|
692
|
+
if (envValue === void 0) {
|
|
693
|
+
console.warn(`Environment variable ${varName} is not defined, keeping placeholder`);
|
|
694
|
+
return `\${${varName}}`;
|
|
695
|
+
}
|
|
696
|
+
return envValue;
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Merge two MCP configurations based on the specified merge strategy
|
|
701
|
+
* @param localConfig Configuration loaded from local file
|
|
702
|
+
* @param remoteConfig Configuration loaded from remote URL
|
|
703
|
+
* @param mergeStrategy Strategy for merging configs
|
|
704
|
+
* @returns Merged configuration
|
|
705
|
+
*/
|
|
706
|
+
mergeConfigurations(localConfig, remoteConfig, mergeStrategy) {
|
|
707
|
+
switch (mergeStrategy) {
|
|
708
|
+
case "local-priority": return { mcpServers: {
|
|
709
|
+
...remoteConfig.mcpServers,
|
|
710
|
+
...localConfig.mcpServers
|
|
711
|
+
} };
|
|
712
|
+
case "remote-priority": return { mcpServers: {
|
|
713
|
+
...localConfig.mcpServers,
|
|
714
|
+
...remoteConfig.mcpServers
|
|
715
|
+
} };
|
|
716
|
+
case "merge-deep": {
|
|
717
|
+
const merged = { ...remoteConfig.mcpServers };
|
|
718
|
+
for (const [serverName, localServerConfig] of Object.entries(localConfig.mcpServers)) if (merged[serverName]) {
|
|
719
|
+
const remoteServer = merged[serverName];
|
|
720
|
+
const mergedConfig = {
|
|
721
|
+
...remoteServer.config,
|
|
722
|
+
...localServerConfig.config
|
|
723
|
+
};
|
|
724
|
+
const remoteEnv = "env" in remoteServer.config ? remoteServer.config.env : void 0;
|
|
725
|
+
const localEnv = "env" in localServerConfig.config ? localServerConfig.config.env : void 0;
|
|
726
|
+
if (remoteEnv || localEnv) mergedConfig.env = {
|
|
727
|
+
...remoteEnv || {},
|
|
728
|
+
...localEnv || {}
|
|
729
|
+
};
|
|
730
|
+
const remoteHeaders = "headers" in remoteServer.config ? remoteServer.config.headers : void 0;
|
|
731
|
+
const localHeaders = "headers" in localServerConfig.config ? localServerConfig.config.headers : void 0;
|
|
732
|
+
if (remoteHeaders || localHeaders) mergedConfig.headers = {
|
|
733
|
+
...remoteHeaders || {},
|
|
734
|
+
...localHeaders || {}
|
|
735
|
+
};
|
|
736
|
+
merged[serverName] = {
|
|
737
|
+
...remoteServer,
|
|
738
|
+
...localServerConfig,
|
|
739
|
+
config: mergedConfig
|
|
740
|
+
};
|
|
741
|
+
} else merged[serverName] = localServerConfig;
|
|
742
|
+
return { mcpServers: merged };
|
|
743
|
+
}
|
|
744
|
+
default: throw new Error(`Unknown merge strategy: ${mergeStrategy}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
284
748
|
* Clear the cached configuration
|
|
285
749
|
*/
|
|
286
750
|
clearCache() {
|
|
@@ -837,7 +1301,10 @@ async function createServer(options) {
|
|
|
837
1301
|
}, { capabilities: { tools: {} } });
|
|
838
1302
|
const clientManager = new McpClientManagerService();
|
|
839
1303
|
if (options?.configFilePath) try {
|
|
840
|
-
const config = await new ConfigFetcherService({
|
|
1304
|
+
const config = await new ConfigFetcherService({
|
|
1305
|
+
configFilePath: options.configFilePath,
|
|
1306
|
+
useCache: !options.noCache
|
|
1307
|
+
}).fetchConfiguration(options.noCache || false);
|
|
841
1308
|
const connectionPromises = Object.entries(config.mcpServers).map(async ([serverName, serverConfig]) => {
|
|
842
1309
|
try {
|
|
843
1310
|
await clientManager.connectToServer(serverName, serverConfig);
|
|
@@ -990,14 +1457,14 @@ var SseTransportHandler = class {
|
|
|
990
1457
|
}
|
|
991
1458
|
}
|
|
992
1459
|
async start() {
|
|
993
|
-
return new Promise((resolve, reject) => {
|
|
1460
|
+
return new Promise((resolve$1, reject) => {
|
|
994
1461
|
try {
|
|
995
1462
|
this.server = this.app.listen(this.config.port, this.config.host, () => {
|
|
996
1463
|
console.error(`@agiflowai/one-mcp MCP server started with SSE transport on http://${this.config.host}:${this.config.port}`);
|
|
997
1464
|
console.error(`SSE endpoint: http://${this.config.host}:${this.config.port}/sse`);
|
|
998
1465
|
console.error(`Messages endpoint: http://${this.config.host}:${this.config.port}/messages`);
|
|
999
1466
|
console.error(`Health check: http://${this.config.host}:${this.config.port}/health`);
|
|
1000
|
-
resolve();
|
|
1467
|
+
resolve$1();
|
|
1001
1468
|
});
|
|
1002
1469
|
this.server.on("error", (error) => {
|
|
1003
1470
|
reject(error);
|
|
@@ -1008,17 +1475,17 @@ var SseTransportHandler = class {
|
|
|
1008
1475
|
});
|
|
1009
1476
|
}
|
|
1010
1477
|
async stop() {
|
|
1011
|
-
return new Promise((resolve, reject) => {
|
|
1478
|
+
return new Promise((resolve$1, reject) => {
|
|
1012
1479
|
if (this.server) {
|
|
1013
1480
|
this.sessionManager.clear();
|
|
1014
1481
|
this.server.close((err) => {
|
|
1015
1482
|
if (err) reject(err);
|
|
1016
1483
|
else {
|
|
1017
1484
|
this.server = null;
|
|
1018
|
-
resolve();
|
|
1485
|
+
resolve$1();
|
|
1019
1486
|
}
|
|
1020
1487
|
});
|
|
1021
|
-
} else resolve();
|
|
1488
|
+
} else resolve$1();
|
|
1022
1489
|
});
|
|
1023
1490
|
}
|
|
1024
1491
|
getPort() {
|
|
@@ -1170,12 +1637,12 @@ var HttpTransportHandler = class {
|
|
|
1170
1637
|
this.sessionManager.deleteSession(sessionId);
|
|
1171
1638
|
}
|
|
1172
1639
|
async start() {
|
|
1173
|
-
return new Promise((resolve, reject) => {
|
|
1640
|
+
return new Promise((resolve$1, reject) => {
|
|
1174
1641
|
try {
|
|
1175
1642
|
this.server = this.app.listen(this.config.port, this.config.host, () => {
|
|
1176
1643
|
console.error(`@agiflowai/one-mcp MCP server started on http://${this.config.host}:${this.config.port}/mcp`);
|
|
1177
1644
|
console.error(`Health check: http://${this.config.host}:${this.config.port}/health`);
|
|
1178
|
-
resolve();
|
|
1645
|
+
resolve$1();
|
|
1179
1646
|
});
|
|
1180
1647
|
this.server.on("error", (error) => {
|
|
1181
1648
|
reject(error);
|
|
@@ -1186,17 +1653,17 @@ var HttpTransportHandler = class {
|
|
|
1186
1653
|
});
|
|
1187
1654
|
}
|
|
1188
1655
|
async stop() {
|
|
1189
|
-
return new Promise((resolve, reject) => {
|
|
1656
|
+
return new Promise((resolve$1, reject) => {
|
|
1190
1657
|
if (this.server) {
|
|
1191
1658
|
this.sessionManager.clear();
|
|
1192
1659
|
this.server.close((err) => {
|
|
1193
1660
|
if (err) reject(err);
|
|
1194
1661
|
else {
|
|
1195
1662
|
this.server = null;
|
|
1196
|
-
resolve();
|
|
1663
|
+
resolve$1();
|
|
1197
1664
|
}
|
|
1198
1665
|
});
|
|
1199
|
-
} else resolve();
|
|
1666
|
+
} else resolve$1();
|
|
1200
1667
|
});
|
|
1201
1668
|
}
|
|
1202
1669
|
getPort() {
|
package/dist/index.cjs
CHANGED
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { i as createServer, n as SseTransportHandler, r as StdioTransportHandler, t as HttpTransportHandler } from "./http-
|
|
1
|
+
import { i as createServer, n as SseTransportHandler, r as StdioTransportHandler, t as HttpTransportHandler } from "./http-CkWO35l-.mjs";
|
|
2
2
|
|
|
3
3
|
export { HttpTransportHandler, SseTransportHandler, StdioTransportHandler, createServer };
|