@sonoma-security/mcp-gateway 0.1.4 → 0.1.6
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 +91 -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 +203 -41
- 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 -6
- package/dist/sonoma-client.d.ts.map +1 -1
- package/dist/sonoma-client.js +45 -5
- 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;IAsErB;;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,81 @@ 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
|
-
if (!this.policy
|
|
86
|
+
if (!this.policy) {
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
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
|
|
88
106
|
return true;
|
|
89
107
|
}
|
|
108
|
+
// Tool-level rule - check if pattern matches the tool name
|
|
109
|
+
return matchesToolPattern(toolName, rule.toolPattern);
|
|
110
|
+
});
|
|
111
|
+
// No matching rules = not blocked (unlisted servers are allowed)
|
|
112
|
+
if (matchingRules.length === 0) {
|
|
90
113
|
return false;
|
|
91
114
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
// Sort rules by priority (higher first), then by pattern specificity (more specific first)
|
|
116
|
+
const sortedRules = [...matchingRules].sort((a, b) => {
|
|
117
|
+
// First sort by explicit priority (higher wins)
|
|
118
|
+
if (a.priority !== b.priority)
|
|
119
|
+
return b.priority - a.priority;
|
|
120
|
+
// Then by pattern specificity (more specific wins)
|
|
121
|
+
const specA = getPatternSpecificity(a.toolPattern);
|
|
122
|
+
const specB = getPatternSpecificity(b.toolPattern);
|
|
123
|
+
return specB - specA;
|
|
124
|
+
});
|
|
125
|
+
// Get the highest priority rule
|
|
126
|
+
const topRule = sortedRules[0];
|
|
127
|
+
// Find if there's a deny rule at the same priority level
|
|
128
|
+
const topPriority = topRule.priority;
|
|
129
|
+
const topSpecificity = getPatternSpecificity(topRule.toolPattern);
|
|
130
|
+
const denyAtTopLevel = sortedRules.find((r) => r.status === "blocked" &&
|
|
131
|
+
r.priority === topPriority &&
|
|
132
|
+
getPatternSpecificity(r.toolPattern) === topSpecificity);
|
|
133
|
+
// Block if any matching rule says "blocked" (deny wins at same priority)
|
|
134
|
+
if (denyAtTopLevel) {
|
|
135
|
+
this.log(`Tool blocked by policy: ${serverName}.${toolName} (pattern: ${denyAtTopLevel.toolPattern ?? "server-level"})`);
|
|
136
|
+
return true;
|
|
99
137
|
}
|
|
100
138
|
return false;
|
|
101
139
|
}
|
|
@@ -230,16 +268,12 @@ export class McpGateway {
|
|
|
230
268
|
});
|
|
231
269
|
}
|
|
232
270
|
/**
|
|
233
|
-
*
|
|
271
|
+
* Create a stdio transport for command-based servers.
|
|
234
272
|
*/
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
version: "0.1.0",
|
|
240
|
-
}, {
|
|
241
|
-
capabilities: {},
|
|
242
|
-
});
|
|
273
|
+
createStdioTransport(config) {
|
|
274
|
+
if (!config.command) {
|
|
275
|
+
throw new Error(`Server ${config.name} has neither url nor command`);
|
|
276
|
+
}
|
|
243
277
|
// Build env with only defined values (filter out undefined from process.env)
|
|
244
278
|
let env;
|
|
245
279
|
if (config.env) {
|
|
@@ -251,27 +285,121 @@ export class McpGateway {
|
|
|
251
285
|
}
|
|
252
286
|
Object.assign(env, config.env);
|
|
253
287
|
}
|
|
254
|
-
|
|
288
|
+
return new StdioClientTransport({
|
|
255
289
|
command: config.command,
|
|
256
290
|
args: config.args,
|
|
257
291
|
env,
|
|
258
292
|
cwd: config.cwd,
|
|
259
293
|
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Build HTTP request options from config headers.
|
|
297
|
+
*/
|
|
298
|
+
getHttpRequestInit(config) {
|
|
299
|
+
if (config.headers && Object.keys(config.headers).length > 0) {
|
|
300
|
+
return { headers: config.headers };
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Connect a client to a transport with a timeout.
|
|
306
|
+
*/
|
|
307
|
+
async connectWithTimeout(client, transport, name) {
|
|
308
|
+
const connectPromise = client.connect(transport);
|
|
309
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
310
|
+
setTimeout(() => reject(new Error(`Connection timeout after ${CONNECT_TIMEOUT_MS}ms: ${name}`)), CONNECT_TIMEOUT_MS);
|
|
311
|
+
});
|
|
312
|
+
await Promise.race([connectPromise, timeoutPromise]);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Connect to an upstream MCP server.
|
|
316
|
+
* For URL-based servers, tries StreamableHTTP first, then falls back to SSE.
|
|
317
|
+
*/
|
|
318
|
+
async connectUpstream(config, existingConnection) {
|
|
319
|
+
const label = config.url ?? config.command ?? config.name;
|
|
320
|
+
this.log(`Connecting to upstream: ${config.name} (${label})`);
|
|
321
|
+
const serverName = config.name;
|
|
322
|
+
const makeClient = () => new Client({
|
|
323
|
+
name: `gateway-client-${serverName}`,
|
|
324
|
+
version: "0.1.0",
|
|
325
|
+
}, {
|
|
326
|
+
capabilities: {},
|
|
327
|
+
listChanged: {
|
|
328
|
+
tools: {
|
|
329
|
+
autoRefresh: true,
|
|
330
|
+
debounceMs: 200,
|
|
331
|
+
onChanged: (error, tools) => {
|
|
332
|
+
if (error) {
|
|
333
|
+
this.log(`Failed to refresh tools from ${serverName}: ${error.message}`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const upstream = this.upstreams.get(serverName);
|
|
337
|
+
if (upstream) {
|
|
338
|
+
upstream.tools = tools || [];
|
|
339
|
+
this.log(`Tools updated from ${serverName}: ${upstream.tools.length} tools`);
|
|
340
|
+
}
|
|
341
|
+
this.server.sendToolListChanged().catch((err) => {
|
|
342
|
+
this.log(`Failed to notify client of tool list change: ${err}`);
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
let client;
|
|
349
|
+
let transport;
|
|
350
|
+
if (config.url) {
|
|
351
|
+
const url = new URL(config.url);
|
|
352
|
+
const requestInit = this.getHttpRequestInit(config);
|
|
353
|
+
// Try OAuth pre-auth for HTTP servers (discovers if server needs OAuth)
|
|
354
|
+
let authProvider;
|
|
355
|
+
try {
|
|
356
|
+
authProvider = await authenticateUpstream({
|
|
357
|
+
serverUrl: config.url,
|
|
358
|
+
serverName: config.name,
|
|
359
|
+
store: this.upstreamTokenStore,
|
|
360
|
+
debug: this.debug,
|
|
361
|
+
});
|
|
362
|
+
this.log(` ${config.name}: OAuth pre-auth succeeded`);
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
// Server may not require OAuth, or user declined; try without auth
|
|
366
|
+
this.log(` ${config.name}: OAuth pre-auth skipped (${err instanceof Error ? err.message : err})`);
|
|
367
|
+
}
|
|
368
|
+
// Try StreamableHTTP first, fall back to SSE
|
|
369
|
+
try {
|
|
370
|
+
client = makeClient();
|
|
371
|
+
transport = new StreamableHTTPClientTransport(url, { requestInit, authProvider });
|
|
372
|
+
await this.connectWithTimeout(client, transport, config.name);
|
|
373
|
+
this.log(` ${config.name}: connected via StreamableHTTP`);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
this.log(` ${config.name}: StreamableHTTP failed, trying SSE`);
|
|
377
|
+
client = makeClient();
|
|
378
|
+
transport = new SSEClientTransport(url, { requestInit, authProvider });
|
|
379
|
+
await this.connectWithTimeout(client, transport, config.name);
|
|
380
|
+
this.log(` ${config.name}: connected via SSE`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
client = makeClient();
|
|
385
|
+
transport = this.createStdioTransport(config);
|
|
386
|
+
await this.connectWithTimeout(client, transport, config.name);
|
|
387
|
+
}
|
|
260
388
|
// Set up close handler for auto-reconnect
|
|
261
389
|
transport.onclose = () => {
|
|
262
390
|
const upstream = this.upstreams.get(config.name);
|
|
263
391
|
if (upstream) {
|
|
264
392
|
upstream.connected = false;
|
|
265
393
|
this.log(`Upstream disconnected: ${config.name}`);
|
|
394
|
+
// Notify AI client that tools changed (disconnected server's tools are no longer available)
|
|
395
|
+
if (upstream.tools.length > 0) {
|
|
396
|
+
this.server.sendToolListChanged().catch((err) => {
|
|
397
|
+
this.log(`Failed to notify client of tool list change: ${err}`);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
266
400
|
this.scheduleReconnect(config.name);
|
|
267
401
|
}
|
|
268
402
|
};
|
|
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
403
|
// Fetch tools from this upstream (also with timeout)
|
|
276
404
|
const listToolsPromise = client.listTools();
|
|
277
405
|
const listToolsTimeout = new Promise((_, reject) => {
|
|
@@ -308,10 +436,21 @@ export class McpGateway {
|
|
|
308
436
|
if (!this.started)
|
|
309
437
|
return;
|
|
310
438
|
try {
|
|
439
|
+
const oldToolNames = new Set(upstream.tools.map((t) => t.name));
|
|
311
440
|
const newUpstream = await this.connectUpstream(upstream.config, upstream);
|
|
312
441
|
newUpstream.reconnectAttempts = 0; // Reset on successful reconnect
|
|
313
442
|
this.upstreams.set(serverName, newUpstream);
|
|
314
443
|
this.log(`Reconnected to ${serverName}`);
|
|
444
|
+
// Notify AI client if tools changed during disconnect
|
|
445
|
+
const newToolNames = new Set(newUpstream.tools.map((t) => t.name));
|
|
446
|
+
const toolsChanged = oldToolNames.size !== newToolNames.size ||
|
|
447
|
+
[...oldToolNames].some((name) => !newToolNames.has(name));
|
|
448
|
+
if (toolsChanged) {
|
|
449
|
+
this.log(`Tools changed on ${serverName} after reconnect`);
|
|
450
|
+
this.server.sendToolListChanged().catch((err) => {
|
|
451
|
+
this.log(`Failed to notify client of tool list change: ${err}`);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
315
454
|
}
|
|
316
455
|
catch (error) {
|
|
317
456
|
this.log(`Reconnect failed for ${serverName}: ${error}`);
|
|
@@ -338,7 +477,7 @@ export class McpGateway {
|
|
|
338
477
|
return;
|
|
339
478
|
try {
|
|
340
479
|
this.policy = await this.sonomaClient.fetchPolicy();
|
|
341
|
-
this.log(`Policy refreshed:
|
|
480
|
+
this.log(`Policy refreshed: ${this.policy.list.length} rules`);
|
|
342
481
|
}
|
|
343
482
|
catch (error) {
|
|
344
483
|
this.log(`Failed to refresh policy: ${error}`);
|
|
@@ -362,8 +501,11 @@ export class McpGateway {
|
|
|
362
501
|
this.log(`Policy refresh error: ${err}`);
|
|
363
502
|
});
|
|
364
503
|
}, 5 * 60 * 1000);
|
|
365
|
-
// Start periodic telemetry flush
|
|
366
|
-
|
|
504
|
+
// Start periodic telemetry flush
|
|
505
|
+
// Default: 30 seconds, configurable via env for testing
|
|
506
|
+
const parsedFlushMs = parseInt(process.env.SONOMA_TELEMETRY_FLUSH_MS ?? "30000", 10);
|
|
507
|
+
const flushIntervalMs = Number.isNaN(parsedFlushMs) ? 30000 : parsedFlushMs;
|
|
508
|
+
this.sonomaClient.startTelemetryFlush(flushIntervalMs);
|
|
367
509
|
}
|
|
368
510
|
// Connect to all upstream servers
|
|
369
511
|
for (const serverConfig of this.config.servers) {
|
|
@@ -372,11 +514,28 @@ export class McpGateway {
|
|
|
372
514
|
this.upstreams.set(serverConfig.name, upstream);
|
|
373
515
|
}
|
|
374
516
|
catch (error) {
|
|
375
|
-
|
|
517
|
+
// Security: serverConfig.name is from config, not user input
|
|
518
|
+
console.error(`Failed to connect to ${serverConfig.name}:`, error); // nosemgrep: unsafe-formatstring
|
|
376
519
|
// Continue with other servers
|
|
377
520
|
}
|
|
378
521
|
}
|
|
379
522
|
this.log(`Connected to ${this.upstreams.size}/${this.config.servers.length} upstreams`);
|
|
523
|
+
// Report discovered tools to Sonoma
|
|
524
|
+
if (this.sonomaClient) {
|
|
525
|
+
const serversWithTools = Array.from(this.upstreams.entries()).map(([serverName, upstream]) => ({
|
|
526
|
+
serverIdentifier: upstream.config.packageName || serverName,
|
|
527
|
+
serverName,
|
|
528
|
+
tools: upstream.tools.map((tool) => ({
|
|
529
|
+
name: tool.name,
|
|
530
|
+
description: tool.description,
|
|
531
|
+
inputSchema: tool.inputSchema,
|
|
532
|
+
})),
|
|
533
|
+
}));
|
|
534
|
+
// Fire and forget - don't block startup
|
|
535
|
+
this.sonomaClient.reportTools(serversWithTools).catch((err) => {
|
|
536
|
+
this.log(`Failed to report tools: ${err}`);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
380
539
|
// Start the server (stdio transport to AI client)
|
|
381
540
|
const transport = new StdioServerTransport();
|
|
382
541
|
await this.server.connect(transport);
|
|
@@ -408,6 +567,9 @@ export class McpGateway {
|
|
|
408
567
|
this.log(`Disconnected from ${name}`);
|
|
409
568
|
}
|
|
410
569
|
catch (error) {
|
|
570
|
+
// Security: `name` comes from this.upstreams Map keys, which are MCP server names
|
|
571
|
+
// from admin-controlled config (serverConfig.name), not user input.
|
|
572
|
+
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
|
|
411
573
|
console.error(`Error disconnecting from ${name}:`, error);
|
|
412
574
|
}
|
|
413
575
|
}
|