@open-mercato/ai-assistant 0.6.6-develop.5675.1.d585628349 → 0.6.6-develop.5706.1.dbef29f570
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/AGENTS.md +13 -0
- package/dist/modules/ai_assistant/lib/mcp-server-config.js +82 -6
- package/dist/modules/ai_assistant/lib/mcp-server-config.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/lib/__tests__/mcp-server-config-hardening.test.ts +148 -0
- package/src/modules/ai_assistant/lib/mcp-server-config.ts +107 -5
package/AGENTS.md
CHANGED
|
@@ -1464,6 +1464,19 @@ Agents that need multi-step tool loops configure the `loop` block on `AiAgentDef
|
|
|
1464
1464
|
|
|
1465
1465
|
## Changelog
|
|
1466
1466
|
|
|
1467
|
+
### 2026-06-11 - Harden latent MCP server-config module (#2672)
|
|
1468
|
+
|
|
1469
|
+
**What changed** (`lib/mcp-server-config.ts`, currently dead code — no callers):
|
|
1470
|
+
- Added `validateMcpServerUrl()` (exported): restricts external MCP server URLs to `http:`/`https:` (blocks `file:`/`gopher:`/`data:` local-file disclosure) and rejects literal loopback, link-local (`169.254.0.0/16`, `fe80::/10`), and RFC1918 private hosts plus `localhost`/`0.0.0.0`/IPv4-mapped IPv6 — reducing SSRF exposure if a management route is ever wired up. `validateMcpServerConfig` now uses it for HTTP configs.
|
|
1471
|
+
- `saveMcpServerConfig` / `updateMcpServerConfig` now call `validateMcpServerConfig` and throw on invalid input, so persistence is **fail-closed**.
|
|
1472
|
+
- `generateId()` now uses `randomUUID()` (CSPRNG) instead of `Date.now()` + `Math.random()`.
|
|
1473
|
+
|
|
1474
|
+
Note: the guard is intentionally NOT added to `mcp-client.ts` `connectHttp`, which legitimately connects to the app's own loopback MCP server (`localhost:3001`). DNS-rebinding (resolution-time checks) is out of scope for this dead-code hardening.
|
|
1475
|
+
|
|
1476
|
+
**Files**: `lib/mcp-server-config.ts`. Regression test: `lib/__tests__/mcp-server-config-hardening.test.ts`.
|
|
1477
|
+
|
|
1478
|
+
**Backward compatibility**: `validateMcpServerUrl` is additive. The module has no callers, so the tightened HTTP validation + fail-closed persistence change no live behavior.
|
|
1479
|
+
|
|
1467
1480
|
### 2026-06-11 - MCP stdio server fails closed without auth (#2673)
|
|
1468
1481
|
|
|
1469
1482
|
**What changed**:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
const MCP_SERVERS_CONFIG_KEY = "mcp_servers";
|
|
2
3
|
async function getMcpServerConfigs(resolver) {
|
|
3
4
|
let service;
|
|
@@ -61,6 +62,10 @@ async function saveMcpServerConfig(resolver, config) {
|
|
|
61
62
|
};
|
|
62
63
|
updatedConfigs = [...configs, savedConfig];
|
|
63
64
|
}
|
|
65
|
+
const validation = validateMcpServerConfig(savedConfig);
|
|
66
|
+
if (!validation.valid) {
|
|
67
|
+
throw new Error(`[internal] Invalid MCP server config: ${validation.error}`);
|
|
68
|
+
}
|
|
64
69
|
await service.setValue("ai_assistant", MCP_SERVERS_CONFIG_KEY, updatedConfigs);
|
|
65
70
|
return savedConfig;
|
|
66
71
|
}
|
|
@@ -83,6 +88,10 @@ async function updateMcpServerConfig(resolver, serverId, updates) {
|
|
|
83
88
|
// Ensure ID is preserved
|
|
84
89
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
85
90
|
};
|
|
91
|
+
const validation = validateMcpServerConfig(updatedConfig);
|
|
92
|
+
if (!validation.valid) {
|
|
93
|
+
throw new Error(`[internal] Invalid MCP server config: ${validation.error}`);
|
|
94
|
+
}
|
|
86
95
|
const updatedConfigs = [
|
|
87
96
|
...configs.slice(0, existingIndex),
|
|
88
97
|
updatedConfig,
|
|
@@ -120,7 +129,74 @@ async function toggleMcpServerEnabled(resolver, serverId) {
|
|
|
120
129
|
});
|
|
121
130
|
}
|
|
122
131
|
function generateId() {
|
|
123
|
-
return `mcp_${
|
|
132
|
+
return `mcp_${randomUUID()}`;
|
|
133
|
+
}
|
|
134
|
+
const ALLOWED_MCP_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
135
|
+
const BLOCKED_MCP_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "0.0.0.0", "::", "::1"]);
|
|
136
|
+
function parseIpv4Octets(hostname) {
|
|
137
|
+
const parts = hostname.split(".");
|
|
138
|
+
if (parts.length !== 4) return null;
|
|
139
|
+
const octets = parts.map((part) => /^\d{1,3}$/.test(part) ? Number(part) : NaN);
|
|
140
|
+
if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return octets;
|
|
144
|
+
}
|
|
145
|
+
function isPrivateOrLocalIpv4(octets) {
|
|
146
|
+
const [first, second] = octets;
|
|
147
|
+
if (first === 0) return true;
|
|
148
|
+
if (first === 127) return true;
|
|
149
|
+
if (first === 10) return true;
|
|
150
|
+
if (first === 172 && second >= 16 && second <= 31) return true;
|
|
151
|
+
if (first === 192 && second === 168) return true;
|
|
152
|
+
if (first === 169 && second === 254) return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
function isPrivateOrLocalIpv6(hostname) {
|
|
156
|
+
const host = hostname.toLowerCase();
|
|
157
|
+
if (host === "::1" || host === "::") return true;
|
|
158
|
+
if (host.startsWith("fe80")) return true;
|
|
159
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true;
|
|
160
|
+
const ipv4MappedDotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
161
|
+
if (ipv4MappedDotted) {
|
|
162
|
+
const octets = parseIpv4Octets(ipv4MappedDotted[1]);
|
|
163
|
+
if (octets && isPrivateOrLocalIpv4(octets)) return true;
|
|
164
|
+
}
|
|
165
|
+
const ipv4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
166
|
+
if (ipv4MappedHex) {
|
|
167
|
+
const high = parseInt(ipv4MappedHex[1], 16);
|
|
168
|
+
const low = parseInt(ipv4MappedHex[2], 16);
|
|
169
|
+
const octets = [high >> 8 & 255, high & 255, low >> 8 & 255, low & 255];
|
|
170
|
+
if (isPrivateOrLocalIpv4(octets)) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
function validateMcpServerUrl(rawUrl) {
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = new URL(rawUrl);
|
|
178
|
+
} catch {
|
|
179
|
+
return { valid: false, error: "Invalid URL format" };
|
|
180
|
+
}
|
|
181
|
+
if (!ALLOWED_MCP_URL_PROTOCOLS.has(parsed.protocol)) {
|
|
182
|
+
return { valid: false, error: "URL must use the http or https protocol" };
|
|
183
|
+
}
|
|
184
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
185
|
+
if (!hostname) {
|
|
186
|
+
return { valid: false, error: "URL host is required" };
|
|
187
|
+
}
|
|
188
|
+
if (BLOCKED_MCP_HOSTNAMES.has(hostname) || hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
189
|
+
return { valid: false, error: "URL host is not allowed" };
|
|
190
|
+
}
|
|
191
|
+
const ipv4 = parseIpv4Octets(hostname);
|
|
192
|
+
if (ipv4) {
|
|
193
|
+
if (isPrivateOrLocalIpv4(ipv4)) {
|
|
194
|
+
return { valid: false, error: "URL host resolves to a private or loopback address" };
|
|
195
|
+
}
|
|
196
|
+
} else if (hostname.includes(":") && isPrivateOrLocalIpv6(hostname)) {
|
|
197
|
+
return { valid: false, error: "URL host resolves to a private or loopback address" };
|
|
198
|
+
}
|
|
199
|
+
return { valid: true };
|
|
124
200
|
}
|
|
125
201
|
function validateMcpServerConfig(config) {
|
|
126
202
|
if (!config.name?.trim()) {
|
|
@@ -133,10 +209,9 @@ function validateMcpServerConfig(config) {
|
|
|
133
209
|
if (!config.url?.trim()) {
|
|
134
210
|
return { valid: false, error: "URL is required for HTTP servers" };
|
|
135
211
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return { valid: false, error: "Invalid URL format" };
|
|
212
|
+
const urlCheck = validateMcpServerUrl(config.url);
|
|
213
|
+
if (!urlCheck.valid) {
|
|
214
|
+
return urlCheck;
|
|
140
215
|
}
|
|
141
216
|
}
|
|
142
217
|
if (config.type === "stdio") {
|
|
@@ -155,6 +230,7 @@ export {
|
|
|
155
230
|
saveMcpServerConfig,
|
|
156
231
|
toggleMcpServerEnabled,
|
|
157
232
|
updateMcpServerConfig,
|
|
158
|
-
validateMcpServerConfig
|
|
233
|
+
validateMcpServerConfig,
|
|
234
|
+
validateMcpServerUrl
|
|
159
235
|
};
|
|
160
236
|
//# sourceMappingURL=mcp-server-config.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/mcp-server-config.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\n\n// Types\n\n/**\n * MCP server connection type.\n */\nexport type McpServerType = 'http' | 'stdio'\n\n/**\n * Configuration for an external MCP server.\n */\nexport interface McpServerConfig {\n /** Unique identifier */\n id: string\n /** User-defined name */\n name: string\n /** Connection type */\n type: McpServerType\n /** Server URL (for HTTP type) */\n url?: string\n /** Command to run (for stdio type) */\n command?: string\n /** Command arguments (for stdio type) */\n args?: string[]\n /** API key for authentication (stored as reference, not the actual secret) */\n apiKeyId?: string\n /** Whether the server is enabled */\n enabled: boolean\n /** When the config was created */\n createdAt: string\n /** When the config was last updated */\n updatedAt: string\n}\n\n/**\n * Input for creating a new MCP server config.\n */\nexport type McpServerConfigInput = Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>\n\n/**\n * Input for updating an MCP server config.\n */\nexport type McpServerConfigUpdate = Partial<Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>>\n\n// Constants\n\nexport const MCP_SERVERS_CONFIG_KEY = 'mcp_servers'\n\n// Resolver type\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// Config functions\n\n/**\n * Get all MCP server configurations.\n */\nexport async function getMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return []\n }\n\n try {\n const value = await service.getValue<McpServerConfig[]>(\n 'ai_assistant',\n MCP_SERVERS_CONFIG_KEY,\n { defaultValue: [] }\n )\n return value ?? []\n } catch {\n return []\n }\n}\n\n/**\n * Get a single MCP server configuration by ID.\n */\nexport async function getMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig | null> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.find((c) => c.id === serverId) ?? null\n}\n\n/**\n * Get only enabled MCP server configurations.\n */\nexport async function getEnabledMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.filter((c) => c.enabled)\n}\n\n/**\n * Save an MCP server configuration (create or update).\n */\nexport async function saveMcpServerConfig(\n resolver: Resolver,\n config: McpServerConfigInput & { id?: string }\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const now = new Date().toISOString()\n\n let updatedConfigs: McpServerConfig[]\n let savedConfig: McpServerConfig\n\n if (config.id) {\n // Update existing\n const existingIndex = configs.findIndex((c) => c.id === config.id)\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${config.id}`)\n }\n\n savedConfig = {\n ...configs[existingIndex],\n ...config,\n id: config.id,\n updatedAt: now,\n }\n\n updatedConfigs = [\n ...configs.slice(0, existingIndex),\n savedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n } else {\n // Create new\n savedConfig = {\n ...config,\n id: generateId(),\n createdAt: now,\n updatedAt: now,\n }\n\n updatedConfigs = [...configs, savedConfig]\n }\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return savedConfig\n}\n\n/**\n * Update an existing MCP server configuration.\n */\nexport async function updateMcpServerConfig(\n resolver: Resolver,\n serverId: string,\n updates: McpServerConfigUpdate\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n const updatedConfig: McpServerConfig = {\n ...configs[existingIndex],\n ...updates,\n id: serverId, // Ensure ID is preserved\n updatedAt: new Date().toISOString(),\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n updatedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return updatedConfig\n}\n\n/**\n * Delete an MCP server configuration.\n */\nexport async function deleteMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<boolean> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n return false\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return true\n}\n\n/**\n * Toggle the enabled state of an MCP server configuration.\n */\nexport async function toggleMcpServerEnabled(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig> {\n const config = await getMcpServerConfig(resolver, serverId)\n if (!config) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n return updateMcpServerConfig(resolver, serverId, {\n enabled: !config.enabled,\n })\n}\n\n// Helpers\n\n/**\n * Generate a unique ID for a new MCP server config.\n */\nfunction generateId(): string {\n return `mcp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`\n}\n\n/**\n * Validate an MCP server configuration.\n */\nexport function validateMcpServerConfig(\n config: McpServerConfigInput\n): { valid: boolean; error?: string } {\n if (!config.name?.trim()) {\n return { valid: false, error: 'Name is required' }\n }\n\n if (!['http', 'stdio'].includes(config.type)) {\n return { valid: false, error: 'Type must be \"http\" or \"stdio\"' }\n }\n\n if (config.type === 'http') {\n if (!config.url?.trim()) {\n return { valid: false, error: 'URL is required for HTTP servers' }\n }\n\n try {\n new URL(config.url)\n } catch {\n return { valid: false, error: 'Invalid URL format' }\n }\n }\n\n if (config.type === 'stdio') {\n if (!config.command?.trim()) {\n return { valid: false, error: 'Command is required for stdio servers' }\n }\n }\n\n return { valid: true }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\n\n// Types\n\n/**\n * MCP server connection type.\n */\nexport type McpServerType = 'http' | 'stdio'\n\n/**\n * Configuration for an external MCP server.\n */\nexport interface McpServerConfig {\n /** Unique identifier */\n id: string\n /** User-defined name */\n name: string\n /** Connection type */\n type: McpServerType\n /** Server URL (for HTTP type) */\n url?: string\n /** Command to run (for stdio type) */\n command?: string\n /** Command arguments (for stdio type) */\n args?: string[]\n /** API key for authentication (stored as reference, not the actual secret) */\n apiKeyId?: string\n /** Whether the server is enabled */\n enabled: boolean\n /** When the config was created */\n createdAt: string\n /** When the config was last updated */\n updatedAt: string\n}\n\n/**\n * Input for creating a new MCP server config.\n */\nexport type McpServerConfigInput = Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>\n\n/**\n * Input for updating an MCP server config.\n */\nexport type McpServerConfigUpdate = Partial<Omit<McpServerConfig, 'id' | 'createdAt' | 'updatedAt'>>\n\n// Constants\n\nexport const MCP_SERVERS_CONFIG_KEY = 'mcp_servers'\n\n// Resolver type\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// Config functions\n\n/**\n * Get all MCP server configurations.\n */\nexport async function getMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return []\n }\n\n try {\n const value = await service.getValue<McpServerConfig[]>(\n 'ai_assistant',\n MCP_SERVERS_CONFIG_KEY,\n { defaultValue: [] }\n )\n return value ?? []\n } catch {\n return []\n }\n}\n\n/**\n * Get a single MCP server configuration by ID.\n */\nexport async function getMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig | null> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.find((c) => c.id === serverId) ?? null\n}\n\n/**\n * Get only enabled MCP server configurations.\n */\nexport async function getEnabledMcpServerConfigs(\n resolver: Resolver\n): Promise<McpServerConfig[]> {\n const configs = await getMcpServerConfigs(resolver)\n return configs.filter((c) => c.enabled)\n}\n\n/**\n * Save an MCP server configuration (create or update).\n */\nexport async function saveMcpServerConfig(\n resolver: Resolver,\n config: McpServerConfigInput & { id?: string }\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const now = new Date().toISOString()\n\n let updatedConfigs: McpServerConfig[]\n let savedConfig: McpServerConfig\n\n if (config.id) {\n // Update existing\n const existingIndex = configs.findIndex((c) => c.id === config.id)\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${config.id}`)\n }\n\n savedConfig = {\n ...configs[existingIndex],\n ...config,\n id: config.id,\n updatedAt: now,\n }\n\n updatedConfigs = [\n ...configs.slice(0, existingIndex),\n savedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n } else {\n // Create new\n savedConfig = {\n ...config,\n id: generateId(),\n createdAt: now,\n updatedAt: now,\n }\n\n updatedConfigs = [...configs, savedConfig]\n }\n\n const validation = validateMcpServerConfig(savedConfig)\n if (!validation.valid) {\n throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)\n }\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return savedConfig\n}\n\n/**\n * Update an existing MCP server configuration.\n */\nexport async function updateMcpServerConfig(\n resolver: Resolver,\n serverId: string,\n updates: McpServerConfigUpdate\n): Promise<McpServerConfig> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n const updatedConfig: McpServerConfig = {\n ...configs[existingIndex],\n ...updates,\n id: serverId, // Ensure ID is preserved\n updatedAt: new Date().toISOString(),\n }\n\n const validation = validateMcpServerConfig(updatedConfig)\n if (!validation.valid) {\n throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n updatedConfig,\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return updatedConfig\n}\n\n/**\n * Delete an MCP server configuration.\n */\nexport async function deleteMcpServerConfig(\n resolver: Resolver,\n serverId: string\n): Promise<boolean> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Configuration service unavailable')\n }\n\n const configs = await getMcpServerConfigs(resolver)\n const existingIndex = configs.findIndex((c) => c.id === serverId)\n\n if (existingIndex === -1) {\n return false\n }\n\n const updatedConfigs = [\n ...configs.slice(0, existingIndex),\n ...configs.slice(existingIndex + 1),\n ]\n\n await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)\n return true\n}\n\n/**\n * Toggle the enabled state of an MCP server configuration.\n */\nexport async function toggleMcpServerEnabled(\n resolver: Resolver,\n serverId: string\n): Promise<McpServerConfig> {\n const config = await getMcpServerConfig(resolver, serverId)\n if (!config) {\n throw new Error(`MCP server config not found: ${serverId}`)\n }\n\n return updateMcpServerConfig(resolver, serverId, {\n enabled: !config.enabled,\n })\n}\n\n// Helpers\n\n/**\n * Generate a unique ID for a new MCP server config.\n *\n * Uses a CSPRNG (`randomUUID`) instead of `Math.random()` so the suffix is not\n * predictable if the id ever surfaces in URLs, logs, or cache keys.\n */\nfunction generateId(): string {\n return `mcp_${randomUUID()}`\n}\n\nconst ALLOWED_MCP_URL_PROTOCOLS = new Set(['http:', 'https:'])\nconst BLOCKED_MCP_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::', '::1'])\n\nfunction parseIpv4Octets(hostname: string): number[] | null {\n const parts = hostname.split('.')\n if (parts.length !== 4) return null\n const octets = parts.map((part) => (/^\\d{1,3}$/.test(part) ? Number(part) : NaN))\n if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {\n return null\n }\n return octets\n}\n\nfunction isPrivateOrLocalIpv4(octets: number[]): boolean {\n const [first, second] = octets\n if (first === 0) return true // \"this\" network / 0.0.0.0\n if (first === 127) return true // loopback 127.0.0.0/8\n if (first === 10) return true // RFC1918 10.0.0.0/8\n if (first === 172 && second >= 16 && second <= 31) return true // RFC1918 172.16.0.0/12\n if (first === 192 && second === 168) return true // RFC1918 192.168.0.0/16\n if (first === 169 && second === 254) return true // link-local 169.254.0.0/16\n return false\n}\n\nfunction isPrivateOrLocalIpv6(hostname: string): boolean {\n const host = hostname.toLowerCase()\n if (host === '::1' || host === '::') return true // loopback / unspecified\n if (host.startsWith('fe80')) return true // link-local fe80::/10\n if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7\n const ipv4MappedDotted = host.match(/^::ffff:(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})$/)\n if (ipv4MappedDotted) {\n const octets = parseIpv4Octets(ipv4MappedDotted[1]!)\n if (octets && isPrivateOrLocalIpv4(octets)) return true\n }\n // Node normalizes ::ffff:127.0.0.1 to its hex form (::ffff:7f00:1).\n const ipv4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)\n if (ipv4MappedHex) {\n const high = parseInt(ipv4MappedHex[1]!, 16)\n const low = parseInt(ipv4MappedHex[2]!, 16)\n const octets = [(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff]\n if (isPrivateOrLocalIpv4(octets)) return true\n }\n return false\n}\n\n/**\n * Validate that an external MCP server URL is safe to connect to.\n *\n * Rejects non-http(s) protocols (blocking `file:`/`gopher:`/`data:` local-file\n * disclosure) and literal loopback, link-local, and RFC1918 private hosts to\n * reduce SSRF exposure. Hostnames that are not IP literals are allowed here\n * (DNS-rebinding to a private address would still need a resolution-time guard,\n * which is out of scope for this dead-code hardening).\n */\nexport function validateMcpServerUrl(\n rawUrl: string\n): { valid: boolean; error?: string } {\n let parsed: URL\n try {\n parsed = new URL(rawUrl)\n } catch {\n return { valid: false, error: 'Invalid URL format' }\n }\n\n if (!ALLOWED_MCP_URL_PROTOCOLS.has(parsed.protocol)) {\n return { valid: false, error: 'URL must use the http or https protocol' }\n }\n\n const hostname = parsed.hostname.toLowerCase().replace(/^\\[/, '').replace(/\\]$/, '')\n if (!hostname) {\n return { valid: false, error: 'URL host is required' }\n }\n\n if (BLOCKED_MCP_HOSTNAMES.has(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {\n return { valid: false, error: 'URL host is not allowed' }\n }\n\n const ipv4 = parseIpv4Octets(hostname)\n if (ipv4) {\n if (isPrivateOrLocalIpv4(ipv4)) {\n return { valid: false, error: 'URL host resolves to a private or loopback address' }\n }\n } else if (hostname.includes(':') && isPrivateOrLocalIpv6(hostname)) {\n return { valid: false, error: 'URL host resolves to a private or loopback address' }\n }\n\n return { valid: true }\n}\n\n/**\n * Validate an MCP server configuration.\n */\nexport function validateMcpServerConfig(\n config: McpServerConfigInput\n): { valid: boolean; error?: string } {\n if (!config.name?.trim()) {\n return { valid: false, error: 'Name is required' }\n }\n\n if (!['http', 'stdio'].includes(config.type)) {\n return { valid: false, error: 'Type must be \"http\" or \"stdio\"' }\n }\n\n if (config.type === 'http') {\n if (!config.url?.trim()) {\n return { valid: false, error: 'URL is required for HTTP servers' }\n }\n\n const urlCheck = validateMcpServerUrl(config.url)\n if (!urlCheck.valid) {\n return urlCheck\n }\n }\n\n if (config.type === 'stdio') {\n if (!config.command?.trim()) {\n return { valid: false, error: 'Command is required for stdio servers' }\n }\n }\n\n return { valid: true }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5706.1.dbef29f570",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -98,16 +98,16 @@
|
|
|
98
98
|
"zod-to-json-schema": "^3.25.2"
|
|
99
99
|
},
|
|
100
100
|
"peerDependencies": {
|
|
101
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
102
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
101
|
+
"@open-mercato/shared": "0.6.6-develop.5706.1.dbef29f570",
|
|
102
|
+
"@open-mercato/ui": "0.6.6-develop.5706.1.dbef29f570",
|
|
103
103
|
"react": "^19.0.0",
|
|
104
104
|
"react-dom": "^19.0.0",
|
|
105
105
|
"zod": ">=3.23.0"
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@open-mercato/cli": "0.6.6-develop.
|
|
109
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
110
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
108
|
+
"@open-mercato/cli": "0.6.6-develop.5706.1.dbef29f570",
|
|
109
|
+
"@open-mercato/shared": "0.6.6-develop.5706.1.dbef29f570",
|
|
110
|
+
"@open-mercato/ui": "0.6.6-develop.5706.1.dbef29f570",
|
|
111
111
|
"@types/react": "^19.2.17",
|
|
112
112
|
"@types/react-dom": "^19.2.3",
|
|
113
113
|
"react": "19.2.7",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateMcpServerUrl,
|
|
3
|
+
validateMcpServerConfig,
|
|
4
|
+
saveMcpServerConfig,
|
|
5
|
+
updateMcpServerConfig,
|
|
6
|
+
type McpServerConfig,
|
|
7
|
+
} from '../mcp-server-config'
|
|
8
|
+
|
|
9
|
+
function makeResolver(initial: McpServerConfig[] = []) {
|
|
10
|
+
const store: { value: McpServerConfig[] } = { value: initial }
|
|
11
|
+
const service = {
|
|
12
|
+
async getValue<T>(_module: string, _key: string, opts?: { defaultValue?: T }) {
|
|
13
|
+
return (store.value as unknown as T) ?? (opts?.defaultValue as T)
|
|
14
|
+
},
|
|
15
|
+
async setValue(_module: string, _key: string, value: McpServerConfig[]) {
|
|
16
|
+
store.value = value
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
const resolver = {
|
|
20
|
+
resolve: <T = unknown>(name: string): T => {
|
|
21
|
+
if (name === 'moduleConfigService') return service as unknown as T
|
|
22
|
+
throw new Error(`unexpected resolve: ${name}`)
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
return { resolver, store }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('issue #2672 — MCP server-config hardening', () => {
|
|
29
|
+
describe('validateMcpServerUrl — protocol allowlist', () => {
|
|
30
|
+
it.each(['file:///etc/passwd', 'gopher://example.com', 'data:text/plain,hi', 'ftp://example.com'])(
|
|
31
|
+
'rejects non-http(s) protocol: %s',
|
|
32
|
+
(url) => {
|
|
33
|
+
expect(validateMcpServerUrl(url).valid).toBe(false)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
it('accepts a public https URL', () => {
|
|
38
|
+
expect(validateMcpServerUrl('https://mcp.example.com/sse')).toEqual({ valid: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('accepts a public http URL', () => {
|
|
42
|
+
expect(validateMcpServerUrl('http://203.0.113.5:3001/mcp')).toEqual({ valid: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('rejects a malformed URL', () => {
|
|
46
|
+
expect(validateMcpServerUrl('not a url').valid).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('validateMcpServerUrl — private / loopback / link-local hosts', () => {
|
|
51
|
+
it.each([
|
|
52
|
+
'http://localhost:3001/mcp',
|
|
53
|
+
'http://sub.localhost/mcp',
|
|
54
|
+
'http://127.0.0.1/mcp',
|
|
55
|
+
'http://127.5.6.7/mcp',
|
|
56
|
+
'http://0.0.0.0/mcp',
|
|
57
|
+
'http://10.0.0.5/mcp',
|
|
58
|
+
'http://172.16.0.9/mcp',
|
|
59
|
+
'http://172.31.255.1/mcp',
|
|
60
|
+
'http://192.168.1.1/mcp',
|
|
61
|
+
'http://169.254.169.254/latest/meta-data',
|
|
62
|
+
'http://[::1]/mcp',
|
|
63
|
+
'http://[fe80::1]/mcp',
|
|
64
|
+
'http://[fc00::1]/mcp',
|
|
65
|
+
'http://[::ffff:127.0.0.1]/mcp',
|
|
66
|
+
])('rejects blocked host: %s', (url) => {
|
|
67
|
+
expect(validateMcpServerUrl(url).valid).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not block a public host in the 172.x range outside RFC1918', () => {
|
|
71
|
+
expect(validateMcpServerUrl('http://172.32.0.1/mcp')).toEqual({ valid: true })
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('validateMcpServerConfig', () => {
|
|
76
|
+
it('rejects an http config whose URL is a loopback address', () => {
|
|
77
|
+
const result = validateMcpServerConfig({
|
|
78
|
+
name: 'evil',
|
|
79
|
+
type: 'http',
|
|
80
|
+
url: 'http://127.0.0.1/mcp',
|
|
81
|
+
enabled: true,
|
|
82
|
+
})
|
|
83
|
+
expect(result.valid).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('accepts an http config with a public URL', () => {
|
|
87
|
+
const result = validateMcpServerConfig({
|
|
88
|
+
name: 'good',
|
|
89
|
+
type: 'http',
|
|
90
|
+
url: 'https://mcp.example.com/sse',
|
|
91
|
+
enabled: true,
|
|
92
|
+
})
|
|
93
|
+
expect(result).toEqual({ valid: true })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('requires a command for stdio configs', () => {
|
|
97
|
+
expect(validateMcpServerConfig({ name: 'x', type: 'stdio', enabled: true }).valid).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('saveMcpServerConfig — fail-closed + CSPRNG id', () => {
|
|
102
|
+
it('generates a UUID-based id and persists a valid config', async () => {
|
|
103
|
+
const { resolver, store } = makeResolver()
|
|
104
|
+
const saved = await saveMcpServerConfig(resolver, {
|
|
105
|
+
name: 'good',
|
|
106
|
+
type: 'http',
|
|
107
|
+
url: 'https://mcp.example.com/sse',
|
|
108
|
+
enabled: true,
|
|
109
|
+
})
|
|
110
|
+
expect(saved.id).toMatch(
|
|
111
|
+
/^mcp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
112
|
+
)
|
|
113
|
+
expect(store.value).toHaveLength(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses to persist a config with an SSRF-prone URL', async () => {
|
|
117
|
+
const { resolver, store } = makeResolver()
|
|
118
|
+
await expect(
|
|
119
|
+
saveMcpServerConfig(resolver, {
|
|
120
|
+
name: 'evil',
|
|
121
|
+
type: 'http',
|
|
122
|
+
url: 'http://169.254.169.254/latest/meta-data',
|
|
123
|
+
enabled: true,
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow(/Invalid MCP server config/i)
|
|
126
|
+
expect(store.value).toHaveLength(0)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('updateMcpServerConfig — fail-closed', () => {
|
|
131
|
+
it('refuses an update that introduces a blocked URL', async () => {
|
|
132
|
+
const existing: McpServerConfig = {
|
|
133
|
+
id: 'mcp_existing',
|
|
134
|
+
name: 'good',
|
|
135
|
+
type: 'http',
|
|
136
|
+
url: 'https://mcp.example.com/sse',
|
|
137
|
+
enabled: true,
|
|
138
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
139
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
140
|
+
}
|
|
141
|
+
const { resolver, store } = makeResolver([existing])
|
|
142
|
+
await expect(
|
|
143
|
+
updateMcpServerConfig(resolver, 'mcp_existing', { url: 'file:///etc/passwd' }),
|
|
144
|
+
).rejects.toThrow(/Invalid MCP server config/i)
|
|
145
|
+
expect(store.value[0]!.url).toBe('https://mcp.example.com/sse')
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
3
|
|
|
3
4
|
// Types
|
|
@@ -152,6 +153,11 @@ export async function saveMcpServerConfig(
|
|
|
152
153
|
updatedConfigs = [...configs, savedConfig]
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
const validation = validateMcpServerConfig(savedConfig)
|
|
157
|
+
if (!validation.valid) {
|
|
158
|
+
throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
155
161
|
await service.setValue('ai_assistant', MCP_SERVERS_CONFIG_KEY, updatedConfigs)
|
|
156
162
|
return savedConfig
|
|
157
163
|
}
|
|
@@ -185,6 +191,11 @@ export async function updateMcpServerConfig(
|
|
|
185
191
|
updatedAt: new Date().toISOString(),
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
const validation = validateMcpServerConfig(updatedConfig)
|
|
195
|
+
if (!validation.valid) {
|
|
196
|
+
throw new Error(`[internal] Invalid MCP server config: ${validation.error}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
188
199
|
const updatedConfigs = [
|
|
189
200
|
...configs.slice(0, existingIndex),
|
|
190
201
|
updatedConfig,
|
|
@@ -246,9 +257,101 @@ export async function toggleMcpServerEnabled(
|
|
|
246
257
|
|
|
247
258
|
/**
|
|
248
259
|
* Generate a unique ID for a new MCP server config.
|
|
260
|
+
*
|
|
261
|
+
* Uses a CSPRNG (`randomUUID`) instead of `Math.random()` so the suffix is not
|
|
262
|
+
* predictable if the id ever surfaces in URLs, logs, or cache keys.
|
|
249
263
|
*/
|
|
250
264
|
function generateId(): string {
|
|
251
|
-
return `mcp_${
|
|
265
|
+
return `mcp_${randomUUID()}`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ALLOWED_MCP_URL_PROTOCOLS = new Set(['http:', 'https:'])
|
|
269
|
+
const BLOCKED_MCP_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::', '::1'])
|
|
270
|
+
|
|
271
|
+
function parseIpv4Octets(hostname: string): number[] | null {
|
|
272
|
+
const parts = hostname.split('.')
|
|
273
|
+
if (parts.length !== 4) return null
|
|
274
|
+
const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : NaN))
|
|
275
|
+
if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
return octets
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isPrivateOrLocalIpv4(octets: number[]): boolean {
|
|
282
|
+
const [first, second] = octets
|
|
283
|
+
if (first === 0) return true // "this" network / 0.0.0.0
|
|
284
|
+
if (first === 127) return true // loopback 127.0.0.0/8
|
|
285
|
+
if (first === 10) return true // RFC1918 10.0.0.0/8
|
|
286
|
+
if (first === 172 && second >= 16 && second <= 31) return true // RFC1918 172.16.0.0/12
|
|
287
|
+
if (first === 192 && second === 168) return true // RFC1918 192.168.0.0/16
|
|
288
|
+
if (first === 169 && second === 254) return true // link-local 169.254.0.0/16
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isPrivateOrLocalIpv6(hostname: string): boolean {
|
|
293
|
+
const host = hostname.toLowerCase()
|
|
294
|
+
if (host === '::1' || host === '::') return true // loopback / unspecified
|
|
295
|
+
if (host.startsWith('fe80')) return true // link-local fe80::/10
|
|
296
|
+
if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7
|
|
297
|
+
const ipv4MappedDotted = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
|
298
|
+
if (ipv4MappedDotted) {
|
|
299
|
+
const octets = parseIpv4Octets(ipv4MappedDotted[1]!)
|
|
300
|
+
if (octets && isPrivateOrLocalIpv4(octets)) return true
|
|
301
|
+
}
|
|
302
|
+
// Node normalizes ::ffff:127.0.0.1 to its hex form (::ffff:7f00:1).
|
|
303
|
+
const ipv4MappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
304
|
+
if (ipv4MappedHex) {
|
|
305
|
+
const high = parseInt(ipv4MappedHex[1]!, 16)
|
|
306
|
+
const low = parseInt(ipv4MappedHex[2]!, 16)
|
|
307
|
+
const octets = [(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff]
|
|
308
|
+
if (isPrivateOrLocalIpv4(octets)) return true
|
|
309
|
+
}
|
|
310
|
+
return false
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Validate that an external MCP server URL is safe to connect to.
|
|
315
|
+
*
|
|
316
|
+
* Rejects non-http(s) protocols (blocking `file:`/`gopher:`/`data:` local-file
|
|
317
|
+
* disclosure) and literal loopback, link-local, and RFC1918 private hosts to
|
|
318
|
+
* reduce SSRF exposure. Hostnames that are not IP literals are allowed here
|
|
319
|
+
* (DNS-rebinding to a private address would still need a resolution-time guard,
|
|
320
|
+
* which is out of scope for this dead-code hardening).
|
|
321
|
+
*/
|
|
322
|
+
export function validateMcpServerUrl(
|
|
323
|
+
rawUrl: string
|
|
324
|
+
): { valid: boolean; error?: string } {
|
|
325
|
+
let parsed: URL
|
|
326
|
+
try {
|
|
327
|
+
parsed = new URL(rawUrl)
|
|
328
|
+
} catch {
|
|
329
|
+
return { valid: false, error: 'Invalid URL format' }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!ALLOWED_MCP_URL_PROTOCOLS.has(parsed.protocol)) {
|
|
333
|
+
return { valid: false, error: 'URL must use the http or https protocol' }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '')
|
|
337
|
+
if (!hostname) {
|
|
338
|
+
return { valid: false, error: 'URL host is required' }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (BLOCKED_MCP_HOSTNAMES.has(hostname) || hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
342
|
+
return { valid: false, error: 'URL host is not allowed' }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const ipv4 = parseIpv4Octets(hostname)
|
|
346
|
+
if (ipv4) {
|
|
347
|
+
if (isPrivateOrLocalIpv4(ipv4)) {
|
|
348
|
+
return { valid: false, error: 'URL host resolves to a private or loopback address' }
|
|
349
|
+
}
|
|
350
|
+
} else if (hostname.includes(':') && isPrivateOrLocalIpv6(hostname)) {
|
|
351
|
+
return { valid: false, error: 'URL host resolves to a private or loopback address' }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { valid: true }
|
|
252
355
|
}
|
|
253
356
|
|
|
254
357
|
/**
|
|
@@ -270,10 +373,9 @@ export function validateMcpServerConfig(
|
|
|
270
373
|
return { valid: false, error: 'URL is required for HTTP servers' }
|
|
271
374
|
}
|
|
272
375
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return { valid: false, error: 'Invalid URL format' }
|
|
376
|
+
const urlCheck = validateMcpServerUrl(config.url)
|
|
377
|
+
if (!urlCheck.valid) {
|
|
378
|
+
return urlCheck
|
|
277
379
|
}
|
|
278
380
|
}
|
|
279
381
|
|