@sonoma-security/mcp-gateway 0.1.4 → 0.1.5
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/README.md +104 -45
- package/dist/__tests__/config.test.js +28 -0
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/ssrf-protection.test.d.ts +2 -0
- package/dist/__tests__/ssrf-protection.test.d.ts.map +1 -0
- package/dist/__tests__/ssrf-protection.test.js +389 -0
- package/dist/__tests__/ssrf-protection.test.js.map +1 -0
- package/dist/auth/client.d.ts +2 -0
- package/dist/auth/client.d.ts.map +1 -1
- package/dist/auth/client.js +17 -15
- package/dist/auth/client.js.map +1 -1
- package/dist/auth/crypto.d.ts +23 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +78 -0
- package/dist/auth/crypto.js.map +1 -0
- package/dist/auth/index.d.ts +4 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +4 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/server.d.ts +2 -0
- package/dist/auth/server.d.ts.map +1 -1
- package/dist/auth/server.js +337 -59
- package/dist/auth/server.js.map +1 -1
- package/dist/auth/storage.d.ts.map +1 -1
- package/dist/auth/storage.js +2 -72
- package/dist/auth/storage.js.map +1 -1
- package/dist/auth/upstream-oauth-provider.d.ts +41 -0
- package/dist/auth/upstream-oauth-provider.d.ts.map +1 -0
- package/dist/auth/upstream-oauth-provider.js +88 -0
- package/dist/auth/upstream-oauth-provider.js.map +1 -0
- package/dist/auth/upstream-oauth.d.ts +31 -0
- package/dist/auth/upstream-oauth.d.ts.map +1 -0
- package/dist/auth/upstream-oauth.js +79 -0
- package/dist/auth/upstream-oauth.js.map +1 -0
- package/dist/auth/upstream-token-store.d.ts +27 -0
- package/dist/auth/upstream-token-store.d.ts.map +1 -0
- package/dist/auth/upstream-token-store.js +103 -0
- package/dist/auth/upstream-token-store.js.map +1 -0
- package/dist/cli.js +83 -63
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +94 -9
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +23 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +224 -35
- package/dist/gateway.js.map +1 -1
- package/dist/pattern-matcher.d.ts +47 -0
- package/dist/pattern-matcher.d.ts.map +1 -0
- package/dist/pattern-matcher.js +98 -0
- package/dist/pattern-matcher.js.map +1 -0
- package/dist/sonoma-client.d.ts +21 -5
- package/dist/sonoma-client.d.ts.map +1 -1
- package/dist/sonoma-client.js +42 -2
- package/dist/sonoma-client.js.map +1 -1
- package/dist/ssrf-protection.d.ts +59 -0
- package/dist/ssrf-protection.d.ts.map +1 -0
- package/dist/ssrf-protection.js +253 -0
- package/dist/ssrf-protection.js.map +1 -0
- package/dist/types.d.ts +6 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/config.js
CHANGED
|
@@ -2,18 +2,79 @@
|
|
|
2
2
|
* Config loader for MCP Gateway
|
|
3
3
|
* Parses Claude Desktop / Cursor style MCP configs
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
5
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { join, normalize } from "node:path";
|
|
8
|
+
/**
|
|
9
|
+
* Validate that a config path is safe to read.
|
|
10
|
+
* Prevents potential file inclusion attacks by ensuring paths:
|
|
11
|
+
* 1. End in .json (expected config format)
|
|
12
|
+
* 2. Don't contain path traversal sequences after normalization
|
|
13
|
+
* 3. Resolve to an expected location
|
|
14
|
+
*/
|
|
15
|
+
function validateConfigPath(configPath) {
|
|
16
|
+
// Normalize the path to resolve . and ..
|
|
17
|
+
const normalizedPath = normalize(configPath);
|
|
18
|
+
// Must end in .json
|
|
19
|
+
if (!normalizedPath.toLowerCase().endsWith(".json")) {
|
|
20
|
+
throw new Error(`Invalid config path: ${configPath}. Config files must have .json extension.`);
|
|
21
|
+
}
|
|
22
|
+
// Check for path traversal attempts that would escape after normalization
|
|
23
|
+
// This catches things like /etc/../../../passwd.json
|
|
24
|
+
if (existsSync(normalizedPath)) {
|
|
25
|
+
try {
|
|
26
|
+
const realPath = realpathSync(normalizedPath);
|
|
27
|
+
const home = homedir();
|
|
28
|
+
// Allow paths within:
|
|
29
|
+
// - User's home directory
|
|
30
|
+
// - /usr/local/etc/sonoma (MDM configs)
|
|
31
|
+
// - Current working directory
|
|
32
|
+
// - System temp directory (for tests and transient configs)
|
|
33
|
+
// Note: Use realpathSync on tmpdir to handle macOS /var -> /private/var symlink
|
|
34
|
+
const tempDir = tmpdir();
|
|
35
|
+
let resolvedTempDir = tempDir;
|
|
36
|
+
try {
|
|
37
|
+
resolvedTempDir = realpathSync(tempDir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Ignore if temp dir doesn't exist (unlikely)
|
|
41
|
+
}
|
|
42
|
+
const allowedPrefixes = [
|
|
43
|
+
home,
|
|
44
|
+
"/usr/local/etc/sonoma",
|
|
45
|
+
process.cwd(),
|
|
46
|
+
tempDir,
|
|
47
|
+
resolvedTempDir,
|
|
48
|
+
// Windows paths
|
|
49
|
+
process.env.APPDATA || "",
|
|
50
|
+
process.env.LOCALAPPDATA || "",
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
const isAllowed = allowedPrefixes.some((prefix) => realPath.startsWith(prefix) || realPath === prefix);
|
|
53
|
+
if (!isAllowed) {
|
|
54
|
+
throw new Error(`Config path not in allowed location: ${configPath}. ` +
|
|
55
|
+
`Allowed: home directory, /usr/local/etc/sonoma, or current directory.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
// realpathSync will throw if file doesn't exist - that's fine, existsSync handles that
|
|
60
|
+
if (e.code !== "ENOENT") {
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
8
66
|
/**
|
|
9
67
|
* Load gateway config from a JSON file
|
|
10
68
|
*/
|
|
11
69
|
export function loadConfig(configPath) {
|
|
70
|
+
validateConfigPath(configPath);
|
|
12
71
|
if (!existsSync(configPath)) {
|
|
13
72
|
throw new Error(`Config file not found: ${configPath}`);
|
|
14
73
|
}
|
|
15
74
|
const content = readFileSync(configPath, "utf-8");
|
|
16
|
-
|
|
75
|
+
// Strip UTF-8 BOM if present (Windows PowerShell adds this)
|
|
76
|
+
const cleanContent = content.replace(/^\uFEFF/, "");
|
|
77
|
+
const raw = JSON.parse(cleanContent);
|
|
17
78
|
// Check if this is a Claude Desktop style config
|
|
18
79
|
if ("mcpServers" in raw && raw.mcpServers) {
|
|
19
80
|
return convertClaudeConfig(raw);
|
|
@@ -28,11 +89,17 @@ function convertClaudeConfig(config) {
|
|
|
28
89
|
const servers = [];
|
|
29
90
|
if (config.mcpServers) {
|
|
30
91
|
for (const [name, server] of Object.entries(config.mcpServers)) {
|
|
92
|
+
if (server.disabled)
|
|
93
|
+
continue;
|
|
94
|
+
if (!server.command && !server.url)
|
|
95
|
+
continue;
|
|
31
96
|
servers.push({
|
|
32
97
|
name,
|
|
33
98
|
command: server.command,
|
|
34
99
|
args: server.args,
|
|
35
100
|
env: server.env,
|
|
101
|
+
url: server.url,
|
|
102
|
+
headers: server.headers,
|
|
36
103
|
});
|
|
37
104
|
}
|
|
38
105
|
}
|
|
@@ -58,20 +125,24 @@ function convertClaudeConfig(config) {
|
|
|
58
125
|
*/
|
|
59
126
|
export function loadFromParentConfig(configPath, gatewayName = "sonoma") {
|
|
60
127
|
const expandedPath = configPath.replace(/^~/, homedir());
|
|
128
|
+
validateConfigPath(expandedPath);
|
|
61
129
|
if (!existsSync(expandedPath)) {
|
|
62
130
|
throw new Error(`Parent config file not found: ${expandedPath}`);
|
|
63
131
|
}
|
|
64
132
|
const content = readFileSync(expandedPath, "utf-8");
|
|
65
|
-
|
|
133
|
+
// Strip UTF-8 BOM if present (Windows PowerShell adds this)
|
|
134
|
+
const cleanContent = content.replace(/^\uFEFF/, "");
|
|
135
|
+
const raw = JSON.parse(cleanContent);
|
|
66
136
|
if (!raw.mcpServers) {
|
|
67
137
|
throw new Error(`No mcpServers found in ${expandedPath}`);
|
|
68
138
|
}
|
|
69
|
-
// Find our gateway entry - try common names
|
|
139
|
+
// Find our gateway entry - try common names (only match entries with nested servers)
|
|
70
140
|
const gatewayNames = [gatewayName, "sonoma", "sonoma-gateway", "sonoma-local-gateway", "mcp-gateway"];
|
|
71
141
|
let gatewayEntry;
|
|
72
142
|
for (const name of gatewayNames) {
|
|
73
|
-
|
|
74
|
-
|
|
143
|
+
const entry = raw.mcpServers[name];
|
|
144
|
+
if (entry?.servers && Object.keys(entry.servers).length > 0) {
|
|
145
|
+
gatewayEntry = entry;
|
|
75
146
|
break;
|
|
76
147
|
}
|
|
77
148
|
}
|
|
@@ -79,6 +150,10 @@ export function loadFromParentConfig(configPath, gatewayName = "sonoma") {
|
|
|
79
150
|
// Fall back to loading all servers except our own gateway
|
|
80
151
|
const servers = [];
|
|
81
152
|
for (const [name, server] of Object.entries(raw.mcpServers)) {
|
|
153
|
+
if (server.disabled)
|
|
154
|
+
continue;
|
|
155
|
+
if (!server.command && !server.url)
|
|
156
|
+
continue;
|
|
82
157
|
// Skip gateway entries (they have --mcp-json-path in args)
|
|
83
158
|
const isGateway = server.args?.some(arg => arg.includes("mcp-gateway") || arg.includes("--mcp-json-path"));
|
|
84
159
|
if (!isGateway) {
|
|
@@ -87,6 +162,8 @@ export function loadFromParentConfig(configPath, gatewayName = "sonoma") {
|
|
|
87
162
|
command: server.command,
|
|
88
163
|
args: server.args,
|
|
89
164
|
env: server.env,
|
|
165
|
+
url: server.url,
|
|
166
|
+
headers: server.headers,
|
|
90
167
|
});
|
|
91
168
|
}
|
|
92
169
|
}
|
|
@@ -95,11 +172,17 @@ export function loadFromParentConfig(configPath, gatewayName = "sonoma") {
|
|
|
95
172
|
// Extract nested servers from gateway entry
|
|
96
173
|
const servers = [];
|
|
97
174
|
for (const [name, server] of Object.entries(gatewayEntry.servers)) {
|
|
175
|
+
if (server.disabled)
|
|
176
|
+
continue;
|
|
177
|
+
if (!server.command && !server.url)
|
|
178
|
+
continue;
|
|
98
179
|
servers.push({
|
|
99
180
|
name,
|
|
100
181
|
command: server.command,
|
|
101
182
|
args: server.args,
|
|
102
183
|
env: server.env,
|
|
184
|
+
url: server.url,
|
|
185
|
+
headers: server.headers,
|
|
103
186
|
});
|
|
104
187
|
}
|
|
105
188
|
return { servers };
|
|
@@ -200,7 +283,9 @@ export function autoDetectConfig(debug = false) {
|
|
|
200
283
|
}
|
|
201
284
|
try {
|
|
202
285
|
const content = readFileSync(path, "utf-8");
|
|
203
|
-
|
|
286
|
+
// Strip UTF-8 BOM if present (Windows PowerShell adds this)
|
|
287
|
+
const cleanContent = content.replace(/^\uFEFF/, "");
|
|
288
|
+
const config = JSON.parse(cleanContent);
|
|
204
289
|
if (!config.mcpServers)
|
|
205
290
|
continue;
|
|
206
291
|
// Look for a gateway entry with nested servers
|
package/dist/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/gateway.d.ts
CHANGED
|
@@ -23,11 +23,20 @@ export declare class McpGateway {
|
|
|
23
23
|
private sonomaClient;
|
|
24
24
|
private policy;
|
|
25
25
|
private policyRefreshInterval;
|
|
26
|
+
private upstreamTokenStore;
|
|
26
27
|
private started;
|
|
27
28
|
constructor(config: GatewayConfig);
|
|
28
29
|
private log;
|
|
29
30
|
/**
|
|
30
31
|
* Check if a tool is blocked by policy
|
|
32
|
+
*
|
|
33
|
+
* Supports both server-level and tool-level blocking:
|
|
34
|
+
* - Server-level: `toolPattern = null` blocks/allows entire server
|
|
35
|
+
* - Tool-level: `toolPattern = "read_*"` blocks/allows specific tools
|
|
36
|
+
*
|
|
37
|
+
* Priority ordering: higher priority wins, then more specific patterns
|
|
38
|
+
* Deny wins: in case of equal priority, blocked status takes precedence
|
|
39
|
+
*
|
|
31
40
|
* @returns true if blocked, false if allowed
|
|
32
41
|
*/
|
|
33
42
|
private isToolBlocked;
|
|
@@ -36,7 +45,20 @@ export declare class McpGateway {
|
|
|
36
45
|
*/
|
|
37
46
|
private setupHandlers;
|
|
38
47
|
/**
|
|
39
|
-
*
|
|
48
|
+
* Create a stdio transport for command-based servers.
|
|
49
|
+
*/
|
|
50
|
+
private createStdioTransport;
|
|
51
|
+
/**
|
|
52
|
+
* Build HTTP request options from config headers.
|
|
53
|
+
*/
|
|
54
|
+
private getHttpRequestInit;
|
|
55
|
+
/**
|
|
56
|
+
* Connect a client to a transport with a timeout.
|
|
57
|
+
*/
|
|
58
|
+
private connectWithTimeout;
|
|
59
|
+
/**
|
|
60
|
+
* Connect to an upstream MCP server.
|
|
61
|
+
* For URL-based servers, tries StreamableHTTP first, then falls back to SSE.
|
|
40
62
|
*/
|
|
41
63
|
private connectUpstream;
|
|
42
64
|
/**
|
package/dist/gateway.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;
|
|
1
|
+
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAiBH,OAAO,KAAK,EACV,aAAa,EAEb,aAAa,EACd,MAAM,YAAY,CAAC;AA0BpB,qBAAa,UAAU;IACrB,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,SAAS,CAA8C;IAC/D,OAAO,CAAC,YAAY,CAAkC;IACtD,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,YAAY,CAA6B;IACjD,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,qBAAqB,CAA+C;IAC5E,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,OAAO,CAAS;gBAEZ,MAAM,EAAE,aAAa;IA8BjC,OAAO,CAAC,GAAG;IAOX;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,aAAa;IAoGrB;;OAEG;IACH,OAAO,CAAC,aAAa;IAuJrB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAyB5B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAO1B;;OAEG;YACW,kBAAkB;IAehC;;;OAGG;YACW,eAAe;IA0H7B;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA8CzB;;OAEG;IACH,OAAO,CAAC,WAAW;IAUnB;;OAEG;YACW,aAAa;IAW3B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkE5B;;OAEG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAmC3B;;OAEG;IACH,SAAS,IAAI,aAAa,EAAE;IAI5B;;OAEG;IACH,WAAW,IAAI,IAAI;CAGpB"}
|
package/dist/gateway.js
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
16
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
17
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
17
19
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
21
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
20
22
|
import { SonomaClient } from "./sonoma-client.js";
|
|
23
|
+
import { matchesToolPattern, getPatternSpecificity } from "./pattern-matcher.js";
|
|
24
|
+
import { UpstreamTokenStore, authenticateUpstream } from "./auth/index.js";
|
|
21
25
|
// Timeout configuration
|
|
22
26
|
const CONNECT_TIMEOUT_MS = 10_000; // 10 seconds
|
|
23
27
|
const TOOL_CALL_TIMEOUT_MS = 30_000; // 30 seconds
|
|
@@ -35,10 +39,12 @@ export class McpGateway {
|
|
|
35
39
|
sonomaClient = null;
|
|
36
40
|
policy = null;
|
|
37
41
|
policyRefreshInterval = null;
|
|
42
|
+
upstreamTokenStore;
|
|
38
43
|
started = false;
|
|
39
44
|
constructor(config) {
|
|
40
45
|
this.config = config;
|
|
41
46
|
this.debug = config.debug ?? false;
|
|
47
|
+
this.upstreamTokenStore = new UpstreamTokenStore();
|
|
42
48
|
// Initialize Sonoma client if endpoint provided
|
|
43
49
|
// Auth priority: OAuth token (user-linked) > org API key (device-level)
|
|
44
50
|
if (config.sonomaEndpoint) {
|
|
@@ -53,49 +59,108 @@ export class McpGateway {
|
|
|
53
59
|
version: "0.1.0",
|
|
54
60
|
}, {
|
|
55
61
|
capabilities: {
|
|
56
|
-
tools: {},
|
|
62
|
+
tools: { listChanged: true },
|
|
57
63
|
},
|
|
58
64
|
});
|
|
59
65
|
this.setupHandlers();
|
|
60
66
|
}
|
|
61
67
|
log(message, ...args) {
|
|
62
68
|
if (this.debug) {
|
|
63
|
-
|
|
69
|
+
// Security: message is from developer code, not user input
|
|
70
|
+
console.error(`[gateway] ${message}`, ...args); // nosemgrep: unsafe-formatstring
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
/**
|
|
67
74
|
* Check if a tool is blocked by policy
|
|
75
|
+
*
|
|
76
|
+
* Supports both server-level and tool-level blocking:
|
|
77
|
+
* - Server-level: `toolPattern = null` blocks/allows entire server
|
|
78
|
+
* - Tool-level: `toolPattern = "read_*"` blocks/allows specific tools
|
|
79
|
+
*
|
|
80
|
+
* Priority ordering: higher priority wins, then more specific patterns
|
|
81
|
+
* Deny wins: in case of equal priority, blocked status takes precedence
|
|
82
|
+
*
|
|
68
83
|
* @returns true if blocked, false if allowed
|
|
69
84
|
*/
|
|
70
85
|
isToolBlocked(serverName, toolName) {
|
|
71
86
|
if (!this.policy || this.policy.mode === "disabled") {
|
|
72
87
|
return false;
|
|
73
88
|
}
|
|
74
|
-
// Get identifiers to check
|
|
89
|
+
// Get server identifiers to check
|
|
75
90
|
const upstream = this.upstreams.get(serverName);
|
|
76
91
|
const packageName = upstream?.config.packageName;
|
|
77
|
-
//
|
|
78
|
-
const
|
|
92
|
+
// Build set of identifiers: packageName (preferred) or server name
|
|
93
|
+
const serverIdentifiers = new Set();
|
|
79
94
|
if (packageName)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
const
|
|
95
|
+
serverIdentifiers.add(packageName);
|
|
96
|
+
serverIdentifiers.add(serverName);
|
|
97
|
+
// Find all matching rules (server-level and tool-level)
|
|
98
|
+
const matchingRules = this.policy.list.filter((rule) => {
|
|
99
|
+
// Check if server identifier matches (or rule is global with "*")
|
|
100
|
+
const serverMatches = serverIdentifiers.has(rule.identifier) || rule.identifier === "*";
|
|
101
|
+
if (!serverMatches)
|
|
102
|
+
return false;
|
|
103
|
+
// Check tool pattern match
|
|
104
|
+
if (!rule.toolPattern) {
|
|
105
|
+
// Server-level rule - applies to all tools on this server
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
// Tool-level rule - check if pattern matches the tool name
|
|
109
|
+
return matchesToolPattern(toolName, rule.toolPattern);
|
|
110
|
+
});
|
|
111
|
+
// No matching rules found
|
|
112
|
+
if (matchingRules.length === 0) {
|
|
113
|
+
if (this.policy.mode === "blocklist") {
|
|
114
|
+
// Blocklist with no matches = not blocked
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (this.policy.mode === "allowlist") {
|
|
118
|
+
// Allowlist with no matches = blocked (not explicitly allowed)
|
|
119
|
+
this.log(`Tool not in allowlist: ${serverName}.${toolName}`);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
// Sort rules by priority (higher first), then by pattern specificity (more specific first)
|
|
125
|
+
const sortedRules = [...matchingRules].sort((a, b) => {
|
|
126
|
+
// First sort by explicit priority (higher wins)
|
|
127
|
+
if (a.priority !== b.priority)
|
|
128
|
+
return b.priority - a.priority;
|
|
129
|
+
// Then by pattern specificity (more specific wins)
|
|
130
|
+
const specA = getPatternSpecificity(a.toolPattern);
|
|
131
|
+
const specB = getPatternSpecificity(b.toolPattern);
|
|
132
|
+
return specB - specA;
|
|
133
|
+
});
|
|
134
|
+
// Get the highest priority rule
|
|
135
|
+
const topRule = sortedRules[0];
|
|
136
|
+
// Find if there's a deny rule at the same priority level
|
|
137
|
+
const topPriority = topRule.priority;
|
|
138
|
+
const topSpecificity = getPatternSpecificity(topRule.toolPattern);
|
|
139
|
+
const denyAtTopLevel = sortedRules.find((r) => r.status === "blocked" &&
|
|
140
|
+
r.priority === topPriority &&
|
|
141
|
+
getPatternSpecificity(r.toolPattern) === topSpecificity);
|
|
84
142
|
if (this.policy.mode === "blocklist") {
|
|
85
|
-
// Blocklist: block if
|
|
86
|
-
|
|
87
|
-
|
|
143
|
+
// Blocklist: block if any matching rule says "blocked"
|
|
144
|
+
// Deny wins at same priority level
|
|
145
|
+
if (denyAtTopLevel) {
|
|
146
|
+
this.log(`Tool blocked by policy: ${serverName}.${toolName} (pattern: ${denyAtTopLevel.toolPattern ?? "server-level"})`);
|
|
88
147
|
return true;
|
|
89
148
|
}
|
|
90
149
|
return false;
|
|
91
150
|
}
|
|
92
151
|
if (this.policy.mode === "allowlist") {
|
|
93
|
-
// Allowlist:
|
|
94
|
-
|
|
95
|
-
|
|
152
|
+
// Allowlist: allow only if there's an "allowed" rule
|
|
153
|
+
// But deny wins at same priority level
|
|
154
|
+
if (denyAtTopLevel) {
|
|
155
|
+
this.log(`Tool blocked by deny rule: ${serverName}.${toolName} (pattern: ${denyAtTopLevel.toolPattern ?? "server-level"})`);
|
|
96
156
|
return true;
|
|
97
157
|
}
|
|
98
|
-
|
|
158
|
+
// Check if top rule allows
|
|
159
|
+
if (topRule.status === "allowed") {
|
|
160
|
+
return false; // Allowed
|
|
161
|
+
}
|
|
162
|
+
this.log(`Tool not in allowlist: ${serverName}.${toolName}`);
|
|
163
|
+
return true; // No allow rule = blocked
|
|
99
164
|
}
|
|
100
165
|
return false;
|
|
101
166
|
}
|
|
@@ -230,16 +295,12 @@ export class McpGateway {
|
|
|
230
295
|
});
|
|
231
296
|
}
|
|
232
297
|
/**
|
|
233
|
-
*
|
|
298
|
+
* Create a stdio transport for command-based servers.
|
|
234
299
|
*/
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
version: "0.1.0",
|
|
240
|
-
}, {
|
|
241
|
-
capabilities: {},
|
|
242
|
-
});
|
|
300
|
+
createStdioTransport(config) {
|
|
301
|
+
if (!config.command) {
|
|
302
|
+
throw new Error(`Server ${config.name} has neither url nor command`);
|
|
303
|
+
}
|
|
243
304
|
// Build env with only defined values (filter out undefined from process.env)
|
|
244
305
|
let env;
|
|
245
306
|
if (config.env) {
|
|
@@ -251,27 +312,121 @@ export class McpGateway {
|
|
|
251
312
|
}
|
|
252
313
|
Object.assign(env, config.env);
|
|
253
314
|
}
|
|
254
|
-
|
|
315
|
+
return new StdioClientTransport({
|
|
255
316
|
command: config.command,
|
|
256
317
|
args: config.args,
|
|
257
318
|
env,
|
|
258
319
|
cwd: config.cwd,
|
|
259
320
|
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Build HTTP request options from config headers.
|
|
324
|
+
*/
|
|
325
|
+
getHttpRequestInit(config) {
|
|
326
|
+
if (config.headers && Object.keys(config.headers).length > 0) {
|
|
327
|
+
return { headers: config.headers };
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Connect a client to a transport with a timeout.
|
|
333
|
+
*/
|
|
334
|
+
async connectWithTimeout(client, transport, name) {
|
|
335
|
+
const connectPromise = client.connect(transport);
|
|
336
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
337
|
+
setTimeout(() => reject(new Error(`Connection timeout after ${CONNECT_TIMEOUT_MS}ms: ${name}`)), CONNECT_TIMEOUT_MS);
|
|
338
|
+
});
|
|
339
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Connect to an upstream MCP server.
|
|
343
|
+
* For URL-based servers, tries StreamableHTTP first, then falls back to SSE.
|
|
344
|
+
*/
|
|
345
|
+
async connectUpstream(config, existingConnection) {
|
|
346
|
+
const label = config.url ?? config.command ?? config.name;
|
|
347
|
+
this.log(`Connecting to upstream: ${config.name} (${label})`);
|
|
348
|
+
const serverName = config.name;
|
|
349
|
+
const makeClient = () => new Client({
|
|
350
|
+
name: `gateway-client-${serverName}`,
|
|
351
|
+
version: "0.1.0",
|
|
352
|
+
}, {
|
|
353
|
+
capabilities: {},
|
|
354
|
+
listChanged: {
|
|
355
|
+
tools: {
|
|
356
|
+
autoRefresh: true,
|
|
357
|
+
debounceMs: 200,
|
|
358
|
+
onChanged: (error, tools) => {
|
|
359
|
+
if (error) {
|
|
360
|
+
this.log(`Failed to refresh tools from ${serverName}: ${error.message}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const upstream = this.upstreams.get(serverName);
|
|
364
|
+
if (upstream) {
|
|
365
|
+
upstream.tools = tools || [];
|
|
366
|
+
this.log(`Tools updated from ${serverName}: ${upstream.tools.length} tools`);
|
|
367
|
+
}
|
|
368
|
+
this.server.sendToolListChanged().catch((err) => {
|
|
369
|
+
this.log(`Failed to notify client of tool list change: ${err}`);
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
let client;
|
|
376
|
+
let transport;
|
|
377
|
+
if (config.url) {
|
|
378
|
+
const url = new URL(config.url);
|
|
379
|
+
const requestInit = this.getHttpRequestInit(config);
|
|
380
|
+
// Try OAuth pre-auth for HTTP servers (discovers if server needs OAuth)
|
|
381
|
+
let authProvider;
|
|
382
|
+
try {
|
|
383
|
+
authProvider = await authenticateUpstream({
|
|
384
|
+
serverUrl: config.url,
|
|
385
|
+
serverName: config.name,
|
|
386
|
+
store: this.upstreamTokenStore,
|
|
387
|
+
debug: this.debug,
|
|
388
|
+
});
|
|
389
|
+
this.log(` ${config.name}: OAuth pre-auth succeeded`);
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
// Server may not require OAuth, or user declined; try without auth
|
|
393
|
+
this.log(` ${config.name}: OAuth pre-auth skipped (${err instanceof Error ? err.message : err})`);
|
|
394
|
+
}
|
|
395
|
+
// Try StreamableHTTP first, fall back to SSE
|
|
396
|
+
try {
|
|
397
|
+
client = makeClient();
|
|
398
|
+
transport = new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
|
399
|
+
await this.connectWithTimeout(client, transport, config.name);
|
|
400
|
+
this.log(` ${config.name}: connected via StreamableHTTP`);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
this.log(` ${config.name}: StreamableHTTP failed, trying SSE`);
|
|
404
|
+
client = makeClient();
|
|
405
|
+
transport = new SSEClientTransport(url, { requestInit, authProvider });
|
|
406
|
+
await this.connectWithTimeout(client, transport, config.name);
|
|
407
|
+
this.log(` ${config.name}: connected via SSE`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
client = makeClient();
|
|
412
|
+
transport = this.createStdioTransport(config);
|
|
413
|
+
await this.connectWithTimeout(client, transport, config.name);
|
|
414
|
+
}
|
|
260
415
|
// Set up close handler for auto-reconnect
|
|
261
416
|
transport.onclose = () => {
|
|
262
417
|
const upstream = this.upstreams.get(config.name);
|
|
263
418
|
if (upstream) {
|
|
264
419
|
upstream.connected = false;
|
|
265
420
|
this.log(`Upstream disconnected: ${config.name}`);
|
|
421
|
+
// Notify AI client that tools changed (disconnected server's tools are no longer available)
|
|
422
|
+
if (upstream.tools.length > 0) {
|
|
423
|
+
this.server.sendToolListChanged().catch((err) => {
|
|
424
|
+
this.log(`Failed to notify client of tool list change: ${err}`);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
266
427
|
this.scheduleReconnect(config.name);
|
|
267
428
|
}
|
|
268
429
|
};
|
|
269
|
-
// Connect with timeout
|
|
270
|
-
const connectPromise = client.connect(transport);
|
|
271
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
272
|
-
setTimeout(() => reject(new Error(`Connection timeout after ${CONNECT_TIMEOUT_MS}ms: ${config.name}`)), CONNECT_TIMEOUT_MS);
|
|
273
|
-
});
|
|
274
|
-
await Promise.race([connectPromise, timeoutPromise]);
|
|
275
430
|
// Fetch tools from this upstream (also with timeout)
|
|
276
431
|
const listToolsPromise = client.listTools();
|
|
277
432
|
const listToolsTimeout = new Promise((_, reject) => {
|
|
@@ -308,10 +463,21 @@ export class McpGateway {
|
|
|
308
463
|
if (!this.started)
|
|
309
464
|
return;
|
|
310
465
|
try {
|
|
466
|
+
const oldToolNames = new Set(upstream.tools.map((t) => t.name));
|
|
311
467
|
const newUpstream = await this.connectUpstream(upstream.config, upstream);
|
|
312
468
|
newUpstream.reconnectAttempts = 0; // Reset on successful reconnect
|
|
313
469
|
this.upstreams.set(serverName, newUpstream);
|
|
314
470
|
this.log(`Reconnected to ${serverName}`);
|
|
471
|
+
// Notify AI client if tools changed during disconnect
|
|
472
|
+
const newToolNames = new Set(newUpstream.tools.map((t) => t.name));
|
|
473
|
+
const toolsChanged = oldToolNames.size !== newToolNames.size ||
|
|
474
|
+
[...oldToolNames].some((name) => !newToolNames.has(name));
|
|
475
|
+
if (toolsChanged) {
|
|
476
|
+
this.log(`Tools changed on ${serverName} after reconnect`);
|
|
477
|
+
this.server.sendToolListChanged().catch((err) => {
|
|
478
|
+
this.log(`Failed to notify client of tool list change: ${err}`);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
315
481
|
}
|
|
316
482
|
catch (error) {
|
|
317
483
|
this.log(`Reconnect failed for ${serverName}: ${error}`);
|
|
@@ -362,8 +528,11 @@ export class McpGateway {
|
|
|
362
528
|
this.log(`Policy refresh error: ${err}`);
|
|
363
529
|
});
|
|
364
530
|
}, 5 * 60 * 1000);
|
|
365
|
-
// Start periodic telemetry flush
|
|
366
|
-
|
|
531
|
+
// Start periodic telemetry flush
|
|
532
|
+
// Default: 30 seconds, configurable via env for testing
|
|
533
|
+
const parsedFlushMs = parseInt(process.env.SONOMA_TELEMETRY_FLUSH_MS ?? "30000", 10);
|
|
534
|
+
const flushIntervalMs = Number.isNaN(parsedFlushMs) ? 30000 : parsedFlushMs;
|
|
535
|
+
this.sonomaClient.startTelemetryFlush(flushIntervalMs);
|
|
367
536
|
}
|
|
368
537
|
// Connect to all upstream servers
|
|
369
538
|
for (const serverConfig of this.config.servers) {
|
|
@@ -372,11 +541,28 @@ export class McpGateway {
|
|
|
372
541
|
this.upstreams.set(serverConfig.name, upstream);
|
|
373
542
|
}
|
|
374
543
|
catch (error) {
|
|
375
|
-
|
|
544
|
+
// Security: serverConfig.name is from config, not user input
|
|
545
|
+
console.error(`Failed to connect to ${serverConfig.name}:`, error); // nosemgrep: unsafe-formatstring
|
|
376
546
|
// Continue with other servers
|
|
377
547
|
}
|
|
378
548
|
}
|
|
379
549
|
this.log(`Connected to ${this.upstreams.size}/${this.config.servers.length} upstreams`);
|
|
550
|
+
// Report discovered tools to Sonoma
|
|
551
|
+
if (this.sonomaClient) {
|
|
552
|
+
const serversWithTools = Array.from(this.upstreams.entries()).map(([serverName, upstream]) => ({
|
|
553
|
+
serverIdentifier: upstream.config.packageName || serverName,
|
|
554
|
+
serverName,
|
|
555
|
+
tools: upstream.tools.map((tool) => ({
|
|
556
|
+
name: tool.name,
|
|
557
|
+
description: tool.description,
|
|
558
|
+
inputSchema: tool.inputSchema,
|
|
559
|
+
})),
|
|
560
|
+
}));
|
|
561
|
+
// Fire and forget - don't block startup
|
|
562
|
+
this.sonomaClient.reportTools(serversWithTools).catch((err) => {
|
|
563
|
+
this.log(`Failed to report tools: ${err}`);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
380
566
|
// Start the server (stdio transport to AI client)
|
|
381
567
|
const transport = new StdioServerTransport();
|
|
382
568
|
await this.server.connect(transport);
|
|
@@ -408,6 +594,9 @@ export class McpGateway {
|
|
|
408
594
|
this.log(`Disconnected from ${name}`);
|
|
409
595
|
}
|
|
410
596
|
catch (error) {
|
|
597
|
+
// Security: `name` comes from this.upstreams Map keys, which are MCP server names
|
|
598
|
+
// from admin-controlled config (serverConfig.name), not user input.
|
|
599
|
+
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
411
600
|
console.error(`Error disconnecting from ${name}:`, error);
|
|
412
601
|
}
|
|
413
602
|
}
|