@dotsetlabs/bellwether 2.1.0 → 2.1.2
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/CHANGELOG.md +35 -0
- package/README.md +48 -31
- package/dist/cli/commands/check.js +49 -6
- package/dist/cli/commands/dashboard.d.ts +3 -0
- package/dist/cli/commands/dashboard.js +69 -0
- package/dist/cli/commands/discover.js +24 -2
- package/dist/cli/commands/explore.js +49 -6
- package/dist/cli/commands/watch.js +12 -1
- package/dist/cli/index.js +27 -34
- package/dist/cli/utils/headers.d.ts +12 -0
- package/dist/cli/utils/headers.js +63 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +2 -0
- package/dist/config/template.js +12 -0
- package/dist/config/validator.d.ts +38 -18
- package/dist/config/validator.js +10 -0
- package/dist/constants/core.d.ts +4 -2
- package/dist/constants/core.js +13 -2
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +6 -0
- package/dist/dashboard/runtime/artifact-index.d.ts +45 -0
- package/dist/dashboard/runtime/artifact-index.js +238 -0
- package/dist/dashboard/runtime/command-profiles.d.ts +764 -0
- package/dist/dashboard/runtime/command-profiles.js +691 -0
- package/dist/dashboard/runtime/config-service.d.ts +21 -0
- package/dist/dashboard/runtime/config-service.js +73 -0
- package/dist/dashboard/runtime/job-runner.d.ts +26 -0
- package/dist/dashboard/runtime/job-runner.js +292 -0
- package/dist/dashboard/security/input-validation.d.ts +3 -0
- package/dist/dashboard/security/input-validation.js +27 -0
- package/dist/dashboard/security/localhost-guard.d.ts +5 -0
- package/dist/dashboard/security/localhost-guard.js +52 -0
- package/dist/dashboard/server.d.ts +14 -0
- package/dist/dashboard/server.js +293 -0
- package/dist/dashboard/types.d.ts +55 -0
- package/dist/dashboard/types.js +2 -0
- package/dist/dashboard/ui.d.ts +2 -0
- package/dist/dashboard/ui.js +2264 -0
- package/dist/discovery/discovery.js +20 -1
- package/dist/discovery/types.d.ts +1 -1
- package/dist/docs/contract.js +7 -1
- package/dist/errors/retry.js +15 -1
- package/dist/errors/types.d.ts +10 -0
- package/dist/errors/types.js +28 -0
- package/dist/logging/logger.js +5 -2
- package/dist/transport/env-filter.d.ts +6 -0
- package/dist/transport/env-filter.js +76 -0
- package/dist/transport/http-transport.js +10 -0
- package/dist/transport/mcp-client.d.ts +16 -9
- package/dist/transport/mcp-client.js +119 -88
- package/dist/transport/sse-transport.js +19 -0
- package/dist/version.js +2 -2
- package/package.json +5 -15
- package/man/bellwether.1 +0 -204
- package/man/bellwether.1.md +0 -148
|
@@ -8,6 +8,7 @@ const logger = getLogger('discovery');
|
|
|
8
8
|
export async function discover(client, command, args) {
|
|
9
9
|
// Initialize connection
|
|
10
10
|
const initResult = await client.initialize();
|
|
11
|
+
const capabilityWarnings = [];
|
|
11
12
|
// Discover tools
|
|
12
13
|
let tools = [];
|
|
13
14
|
if (initResult.capabilities.tools) {
|
|
@@ -16,6 +17,7 @@ export async function discover(client, command, args) {
|
|
|
16
17
|
}
|
|
17
18
|
catch (error) {
|
|
18
19
|
logger.error({ error }, 'Failed to list tools');
|
|
20
|
+
throw new Error(`Failed to list tools despite advertised tools capability: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
// Discover prompts
|
|
@@ -26,6 +28,11 @@ export async function discover(client, command, args) {
|
|
|
26
28
|
}
|
|
27
29
|
catch (error) {
|
|
28
30
|
logger.error({ error }, 'Failed to list prompts');
|
|
31
|
+
capabilityWarnings.push({
|
|
32
|
+
level: 'warning',
|
|
33
|
+
message: `Failed to list prompts: ${error instanceof Error ? error.message : String(error)}`,
|
|
34
|
+
recommendation: 'Check server prompt implementation and transport health.',
|
|
35
|
+
});
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
// Discover resources
|
|
@@ -37,18 +44,30 @@ export async function discover(client, command, args) {
|
|
|
37
44
|
}
|
|
38
45
|
catch (error) {
|
|
39
46
|
logger.error({ error }, 'Failed to list resources');
|
|
47
|
+
capabilityWarnings.push({
|
|
48
|
+
level: 'warning',
|
|
49
|
+
message: `Failed to list resources: ${error instanceof Error ? error.message : String(error)}`,
|
|
50
|
+
recommendation: 'Check server resource implementation and transport health.',
|
|
51
|
+
});
|
|
40
52
|
}
|
|
41
53
|
try {
|
|
42
54
|
resourceTemplates = await client.listResourceTemplates();
|
|
43
55
|
}
|
|
44
56
|
catch (error) {
|
|
45
57
|
logger.debug({ error }, 'Failed to list resource templates (server may not support them)');
|
|
58
|
+
capabilityWarnings.push({
|
|
59
|
+
level: 'info',
|
|
60
|
+
message: `Failed to list resource templates: ${error instanceof Error ? error.message : String(error)}`,
|
|
61
|
+
});
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
// Collect transport errors from the client
|
|
49
65
|
const transportErrors = client.getTransportErrors();
|
|
50
66
|
// Generate warnings based on discovery results
|
|
51
|
-
const warnings =
|
|
67
|
+
const warnings = [
|
|
68
|
+
...capabilityWarnings,
|
|
69
|
+
...generateDiscoveryWarnings(initResult.capabilities, tools, prompts, resources, transportErrors, initResult.protocolVersion),
|
|
70
|
+
];
|
|
52
71
|
return {
|
|
53
72
|
serverInfo: initResult.serverInfo,
|
|
54
73
|
protocolVersion: initResult.protocolVersion,
|
|
@@ -60,7 +60,7 @@ export interface PropertySchema {
|
|
|
60
60
|
* Classification of transport-level errors.
|
|
61
61
|
* Used to differentiate between server bugs, protocol issues, and environment problems.
|
|
62
62
|
*/
|
|
63
|
-
export type TransportErrorCategory = 'invalid_json' | 'buffer_overflow' | 'connection_refused' | 'connection_lost' | 'protocol_violation' | 'timeout' | 'shutdown_error' | 'unknown';
|
|
63
|
+
export type TransportErrorCategory = 'invalid_json' | 'buffer_overflow' | 'connection_refused' | 'auth_failed' | 'connection_lost' | 'protocol_violation' | 'timeout' | 'shutdown_error' | 'unknown';
|
|
64
64
|
/**
|
|
65
65
|
* Record of a transport-level error that occurred during MCP communication.
|
|
66
66
|
*/
|
package/dist/docs/contract.js
CHANGED
|
@@ -624,9 +624,13 @@ function generateTransportIssuesSection(transportErrors, warnings) {
|
|
|
624
624
|
// Recommendations
|
|
625
625
|
const hasInvalidJson = transportErrors.some((e) => e.category === 'invalid_json');
|
|
626
626
|
const hasProtocolError = transportErrors.some((e) => e.category === 'protocol_violation');
|
|
627
|
-
|
|
627
|
+
const hasAuthError = transportErrors.some((e) => e.category === 'auth_failed');
|
|
628
|
+
if (hasInvalidJson || hasProtocolError || hasAuthError) {
|
|
628
629
|
lines.push('### Recommendations');
|
|
629
630
|
lines.push('');
|
|
631
|
+
if (hasAuthError) {
|
|
632
|
+
lines.push('- **Auth Failed**: Configure remote authentication headers (for example `Authorization: Bearer <token>`) and verify credentials are valid.');
|
|
633
|
+
}
|
|
630
634
|
if (hasInvalidJson) {
|
|
631
635
|
lines.push('- **Invalid JSON**: The server may be writing debug output to stdout. Ensure all non-JSON-RPC output goes to stderr.');
|
|
632
636
|
}
|
|
@@ -649,6 +653,8 @@ function formatTransportErrorCategory(category) {
|
|
|
649
653
|
return 'Buffer Overflow';
|
|
650
654
|
case 'connection_refused':
|
|
651
655
|
return 'Connection Refused';
|
|
656
|
+
case 'auth_failed':
|
|
657
|
+
return 'Auth Failed';
|
|
652
658
|
case 'connection_lost':
|
|
653
659
|
return 'Connection Lost';
|
|
654
660
|
case 'protocol_violation':
|
package/dist/errors/retry.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Retry logic with exponential backoff.
|
|
3
3
|
*/
|
|
4
4
|
import { getLogger } from '../logging/logger.js';
|
|
5
|
-
import { BellwetherError, LLMRateLimitError, isRetryable, wrapError, createTimingContext, } from './types.js';
|
|
5
|
+
import { BellwetherError, LLMRateLimitError, ServerAuthError, isRetryable, wrapError, createTimingContext, } from './types.js';
|
|
6
6
|
import { RETRY_STRATEGIES, CIRCUIT_BREAKER, MATH_FACTORS } from '../constants.js';
|
|
7
7
|
const DEFAULT_OPTIONS = {
|
|
8
8
|
maxAttempts: RETRY_STRATEGIES.DEFAULT.maxAttempts,
|
|
@@ -191,7 +191,21 @@ export const TRANSPORT_RETRY_OPTIONS = {
|
|
|
191
191
|
backoffMultiplier: RETRY_STRATEGIES.TRANSPORT.backoffMultiplier,
|
|
192
192
|
jitter: RETRY_STRATEGIES.TRANSPORT.jitter,
|
|
193
193
|
shouldRetry: (error) => {
|
|
194
|
+
if (error instanceof ServerAuthError) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
194
197
|
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
198
|
+
// Authentication/authorization failures are terminal until credentials change
|
|
199
|
+
if (message.includes('401') ||
|
|
200
|
+
message.includes('403') ||
|
|
201
|
+
message.includes('407') ||
|
|
202
|
+
message.includes('unauthorized') ||
|
|
203
|
+
message.includes('forbidden') ||
|
|
204
|
+
message.includes('authentication') ||
|
|
205
|
+
message.includes('authorization') ||
|
|
206
|
+
message.includes('access denied')) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
195
209
|
// Timeouts might be transient
|
|
196
210
|
if (message.includes('timeout')) {
|
|
197
211
|
return true;
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -120,6 +120,16 @@ export declare class ServerExitError extends TransportError {
|
|
|
120
120
|
export declare class ProtocolError extends TransportError {
|
|
121
121
|
constructor(message: string, context?: ErrorContext, cause?: Error);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Remote server authentication/authorization failed.
|
|
125
|
+
*/
|
|
126
|
+
export declare class ServerAuthError extends TransportError {
|
|
127
|
+
/** HTTP status code if available */
|
|
128
|
+
readonly statusCode?: number;
|
|
129
|
+
/** Suggested remediation hint */
|
|
130
|
+
readonly hint?: string;
|
|
131
|
+
constructor(message: string, statusCode?: number, hint?: string, context?: ErrorContext, cause?: Error);
|
|
132
|
+
}
|
|
123
133
|
/**
|
|
124
134
|
* Buffer overflow during message processing.
|
|
125
135
|
*/
|
package/dist/errors/types.js
CHANGED
|
@@ -146,6 +146,34 @@ export class ProtocolError extends TransportError {
|
|
|
146
146
|
this.name = 'ProtocolError';
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Remote server authentication/authorization failed.
|
|
151
|
+
*/
|
|
152
|
+
export class ServerAuthError extends TransportError {
|
|
153
|
+
/** HTTP status code if available */
|
|
154
|
+
statusCode;
|
|
155
|
+
/** Suggested remediation hint */
|
|
156
|
+
hint;
|
|
157
|
+
constructor(message, statusCode, hint, context, cause) {
|
|
158
|
+
super(message, {
|
|
159
|
+
code: 'TRANSPORT_AUTH_FAILED',
|
|
160
|
+
severity: 'high',
|
|
161
|
+
retryable: 'terminal',
|
|
162
|
+
context: {
|
|
163
|
+
...context,
|
|
164
|
+
metadata: {
|
|
165
|
+
...context?.metadata,
|
|
166
|
+
statusCode,
|
|
167
|
+
hint,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
cause,
|
|
171
|
+
});
|
|
172
|
+
this.name = 'ServerAuthError';
|
|
173
|
+
this.statusCode = statusCode;
|
|
174
|
+
this.hint = hint;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
149
177
|
/**
|
|
150
178
|
* Buffer overflow during message processing.
|
|
151
179
|
*/
|
package/dist/logging/logger.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
|
+
const IS_TEST_ENV = process.env.NODE_ENV === 'test' ||
|
|
3
|
+
process.env.VITEST === 'true' ||
|
|
4
|
+
process.env.VITEST_WORKER_ID !== undefined;
|
|
2
5
|
/**
|
|
3
6
|
* Default configuration.
|
|
4
7
|
* Default level is 'warn' to keep CLI output clean.
|
|
5
8
|
* Users can enable verbose output with --log-level info or --log-level debug.
|
|
6
9
|
*/
|
|
7
10
|
const DEFAULT_CONFIG = {
|
|
8
|
-
level: 'warn',
|
|
11
|
+
level: IS_TEST_ENV ? 'silent' : 'warn',
|
|
9
12
|
pretty: false,
|
|
10
13
|
timestamp: true,
|
|
11
14
|
};
|
|
@@ -50,7 +53,7 @@ export function createLogger(config = {}) {
|
|
|
50
53
|
*/
|
|
51
54
|
export function getLogger(name) {
|
|
52
55
|
if (!globalLogger) {
|
|
53
|
-
globalLogger = createLogger({ level:
|
|
56
|
+
globalLogger = createLogger({ level: DEFAULT_CONFIG.level });
|
|
54
57
|
}
|
|
55
58
|
if (name) {
|
|
56
59
|
return globalLogger.child({ component: name });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter sensitive variables from process.env before spawning subprocesses.
|
|
3
|
+
* Explicitly provided additional environment variables are still allowed.
|
|
4
|
+
*/
|
|
5
|
+
export declare function filterSpawnEnv(baseEnv: NodeJS.ProcessEnv, additionalEnv?: Record<string, string>): Record<string, string>;
|
|
6
|
+
//# sourceMappingURL=env-filter.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variables to filter out when spawning MCP server processes.
|
|
3
|
+
* These may contain sensitive credentials that should not be exposed.
|
|
4
|
+
*/
|
|
5
|
+
const FILTERED_ENV_VARS = new Set([
|
|
6
|
+
// LLM API keys
|
|
7
|
+
'OPENAI_API_KEY',
|
|
8
|
+
'ANTHROPIC_API_KEY',
|
|
9
|
+
'GOOGLE_API_KEY',
|
|
10
|
+
'AZURE_OPENAI_API_KEY',
|
|
11
|
+
'COHERE_API_KEY',
|
|
12
|
+
'HUGGINGFACE_API_KEY',
|
|
13
|
+
'REPLICATE_API_TOKEN',
|
|
14
|
+
// Provider credentials
|
|
15
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
16
|
+
'AWS_SESSION_TOKEN',
|
|
17
|
+
'AZURE_CLIENT_SECRET',
|
|
18
|
+
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
19
|
+
// SCM/CI tokens
|
|
20
|
+
'GITHUB_TOKEN',
|
|
21
|
+
'GH_TOKEN',
|
|
22
|
+
'GITLAB_TOKEN',
|
|
23
|
+
'BITBUCKET_TOKEN',
|
|
24
|
+
'NPM_TOKEN',
|
|
25
|
+
'PYPI_TOKEN',
|
|
26
|
+
// Database credentials
|
|
27
|
+
'DATABASE_URL',
|
|
28
|
+
'DATABASE_PASSWORD',
|
|
29
|
+
'POSTGRES_PASSWORD',
|
|
30
|
+
'MYSQL_PASSWORD',
|
|
31
|
+
'REDIS_PASSWORD',
|
|
32
|
+
'MONGODB_URI',
|
|
33
|
+
// Application secrets
|
|
34
|
+
'COOKIE_SECRET',
|
|
35
|
+
'SESSION_SECRET',
|
|
36
|
+
'JWT_SECRET',
|
|
37
|
+
'ENCRYPTION_KEY',
|
|
38
|
+
'PRIVATE_KEY',
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* Patterns for environment variable names that should be filtered.
|
|
42
|
+
* Matches common naming conventions for secrets.
|
|
43
|
+
*/
|
|
44
|
+
const FILTERED_ENV_PATTERNS = [
|
|
45
|
+
/_API_KEY$/i,
|
|
46
|
+
/_SECRET$/i,
|
|
47
|
+
/_TOKEN$/i,
|
|
48
|
+
/_PASSWORD$/i,
|
|
49
|
+
/_PRIVATE_KEY$/i,
|
|
50
|
+
/_CREDENTIALS$/i,
|
|
51
|
+
/^SECRET_/i,
|
|
52
|
+
/^PRIVATE_/i,
|
|
53
|
+
];
|
|
54
|
+
function isSensitiveEnvVar(name) {
|
|
55
|
+
if (FILTERED_ENV_VARS.has(name)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return FILTERED_ENV_PATTERNS.some((pattern) => pattern.test(name));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Filter sensitive variables from process.env before spawning subprocesses.
|
|
62
|
+
* Explicitly provided additional environment variables are still allowed.
|
|
63
|
+
*/
|
|
64
|
+
export function filterSpawnEnv(baseEnv, additionalEnv) {
|
|
65
|
+
const filtered = {};
|
|
66
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
67
|
+
if (value !== undefined && !isSensitiveEnvVar(key)) {
|
|
68
|
+
filtered[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (additionalEnv) {
|
|
72
|
+
Object.assign(filtered, additionalEnv);
|
|
73
|
+
}
|
|
74
|
+
return filtered;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=env-filter.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BaseTransport } from './base-transport.js';
|
|
2
2
|
import { TIMEOUTS, DISPLAY_LIMITS, MCP } from '../constants.js';
|
|
3
|
+
import { ServerAuthError } from '../errors/types.js';
|
|
3
4
|
/**
|
|
4
5
|
* HTTPTransport connects to MCP servers over HTTP using POST requests.
|
|
5
6
|
*
|
|
@@ -111,6 +112,15 @@ export class HTTPTransport extends BaseTransport {
|
|
|
111
112
|
});
|
|
112
113
|
clearTimeout(timeoutId);
|
|
113
114
|
if (!response.ok) {
|
|
115
|
+
if (response.status === 401) {
|
|
116
|
+
throw new ServerAuthError('Remote MCP server authentication failed (401 Unauthorized)', 401, 'Add server.headers.Authorization (for example: Bearer token) in bellwether.yaml or pass --header.');
|
|
117
|
+
}
|
|
118
|
+
if (response.status === 403) {
|
|
119
|
+
throw new ServerAuthError('Remote MCP server authorization failed (403 Forbidden)', 403, 'Credentials are recognized but lack required permissions. Verify token scopes/roles.');
|
|
120
|
+
}
|
|
121
|
+
if (response.status === 407) {
|
|
122
|
+
throw new ServerAuthError('Proxy authentication required (407)', 407, 'Configure proxy credentials and retry.');
|
|
123
|
+
}
|
|
114
124
|
// MCP 2025-11-25: 404 means session expired, clear session ID
|
|
115
125
|
if (response.status === 404 && this.sessionId) {
|
|
116
126
|
this.log('Session expired (404), clearing session ID');
|
|
@@ -19,6 +19,8 @@ export interface MCPClientOptions {
|
|
|
19
19
|
sseConfig?: Omit<SSETransportConfig, 'debug'>;
|
|
20
20
|
/** Configuration for HTTP transport */
|
|
21
21
|
httpConfig?: Omit<HTTPTransportConfig, 'debug'>;
|
|
22
|
+
/** Optional preflight check for remote transports (default: true) */
|
|
23
|
+
remotePreflight?: boolean;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* MCPClient connects to an MCP server via various transports and provides
|
|
@@ -55,7 +57,21 @@ export declare class MCPClient {
|
|
|
55
57
|
private connectionState;
|
|
56
58
|
/** Protocol version negotiated with the server during initialization */
|
|
57
59
|
private negotiatedProtocolVersion;
|
|
60
|
+
/** Whether to run an explicit preflight before remote connection */
|
|
61
|
+
private remotePreflight;
|
|
58
62
|
constructor(options?: MCPClientOptions);
|
|
63
|
+
/**
|
|
64
|
+
* Merge two header maps, giving precedence to override values.
|
|
65
|
+
*/
|
|
66
|
+
private mergeHeaders;
|
|
67
|
+
/**
|
|
68
|
+
* Optional remote connectivity/auth preflight (enabled by default).
|
|
69
|
+
* Uses GET (not HEAD/OPTIONS) for broader compatibility.
|
|
70
|
+
*
|
|
71
|
+
* Any successful HTTP response (including 404/405/etc.) confirms network reachability.
|
|
72
|
+
* Only auth failures and hard network/timeouts are treated as terminal preflight failures.
|
|
73
|
+
*/
|
|
74
|
+
private preflightRemote;
|
|
59
75
|
/**
|
|
60
76
|
* Classify a transport error based on its message.
|
|
61
77
|
*/
|
|
@@ -86,15 +102,6 @@ export declare class MCPClient {
|
|
|
86
102
|
*/
|
|
87
103
|
getTransportType(): TransportType;
|
|
88
104
|
private log;
|
|
89
|
-
/**
|
|
90
|
-
* Check if an environment variable name looks like a secret.
|
|
91
|
-
*/
|
|
92
|
-
private isSensitiveEnvVar;
|
|
93
|
-
/**
|
|
94
|
-
* Filter sensitive environment variables before passing to subprocess.
|
|
95
|
-
* Uses both explicit list and pattern matching to catch common secret naming conventions.
|
|
96
|
-
*/
|
|
97
|
-
private filterEnv;
|
|
98
105
|
/**
|
|
99
106
|
* Connect to an MCP server by spawning it as a subprocess.
|
|
100
107
|
*/
|
|
@@ -6,59 +6,8 @@ import { getLogger, startTiming } from '../logging/logger.js';
|
|
|
6
6
|
import { TIMEOUTS, MCP, TRANSPORT_ERRORS } from '../constants.js';
|
|
7
7
|
import { VERSION } from '../version.js';
|
|
8
8
|
import { getFeatureFlags, isKnownProtocolVersion, } from '../protocol/index.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* These may contain sensitive credentials that should not be exposed.
|
|
12
|
-
*/
|
|
13
|
-
const FILTERED_ENV_VARS = new Set([
|
|
14
|
-
// LLM API keys
|
|
15
|
-
'OPENAI_API_KEY',
|
|
16
|
-
'ANTHROPIC_API_KEY',
|
|
17
|
-
'GOOGLE_API_KEY',
|
|
18
|
-
'AZURE_OPENAI_API_KEY',
|
|
19
|
-
'COHERE_API_KEY',
|
|
20
|
-
'HUGGINGFACE_API_KEY',
|
|
21
|
-
'REPLICATE_API_TOKEN',
|
|
22
|
-
// Provider credentials
|
|
23
|
-
'AWS_SECRET_ACCESS_KEY',
|
|
24
|
-
'AWS_SESSION_TOKEN',
|
|
25
|
-
'AZURE_CLIENT_SECRET',
|
|
26
|
-
'GOOGLE_APPLICATION_CREDENTIALS',
|
|
27
|
-
// SCM/CI tokens
|
|
28
|
-
'GITHUB_TOKEN',
|
|
29
|
-
'GH_TOKEN',
|
|
30
|
-
'GITLAB_TOKEN',
|
|
31
|
-
'BITBUCKET_TOKEN',
|
|
32
|
-
'NPM_TOKEN',
|
|
33
|
-
'PYPI_TOKEN',
|
|
34
|
-
// Database credentials
|
|
35
|
-
'DATABASE_URL',
|
|
36
|
-
'DATABASE_PASSWORD',
|
|
37
|
-
'POSTGRES_PASSWORD',
|
|
38
|
-
'MYSQL_PASSWORD',
|
|
39
|
-
'REDIS_PASSWORD',
|
|
40
|
-
'MONGODB_URI',
|
|
41
|
-
// Application secrets
|
|
42
|
-
'COOKIE_SECRET',
|
|
43
|
-
'SESSION_SECRET',
|
|
44
|
-
'JWT_SECRET',
|
|
45
|
-
'ENCRYPTION_KEY',
|
|
46
|
-
'PRIVATE_KEY',
|
|
47
|
-
]);
|
|
48
|
-
/**
|
|
49
|
-
* Patterns for environment variable names that should be filtered.
|
|
50
|
-
* Matches common naming conventions for secrets.
|
|
51
|
-
*/
|
|
52
|
-
const FILTERED_ENV_PATTERNS = [
|
|
53
|
-
/_API_KEY$/i,
|
|
54
|
-
/_SECRET$/i,
|
|
55
|
-
/_TOKEN$/i,
|
|
56
|
-
/_PASSWORD$/i,
|
|
57
|
-
/_PRIVATE_KEY$/i,
|
|
58
|
-
/_CREDENTIALS$/i,
|
|
59
|
-
/^SECRET_/i,
|
|
60
|
-
/^PRIVATE_/i,
|
|
61
|
-
];
|
|
9
|
+
import { filterSpawnEnv } from './env-filter.js';
|
|
10
|
+
import { ConnectionError, ServerAuthError } from '../errors/types.js';
|
|
62
11
|
const DEFAULT_TIMEOUT = TIMEOUTS.DEFAULT;
|
|
63
12
|
const DEFAULT_STARTUP_DELAY = TIMEOUTS.SERVER_STARTUP;
|
|
64
13
|
/**
|
|
@@ -96,6 +45,8 @@ export class MCPClient {
|
|
|
96
45
|
connectionState = { attempted: false };
|
|
97
46
|
/** Protocol version negotiated with the server during initialization */
|
|
98
47
|
negotiatedProtocolVersion = null;
|
|
48
|
+
/** Whether to run an explicit preflight before remote connection */
|
|
49
|
+
remotePreflight;
|
|
99
50
|
constructor(options) {
|
|
100
51
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
101
52
|
this.startupDelay = options?.startupDelay ?? DEFAULT_STARTUP_DELAY;
|
|
@@ -103,6 +54,96 @@ export class MCPClient {
|
|
|
103
54
|
this.transportType = options?.transport ?? 'stdio';
|
|
104
55
|
this.sseConfig = options?.sseConfig;
|
|
105
56
|
this.httpConfig = options?.httpConfig;
|
|
57
|
+
this.remotePreflight = options?.remotePreflight ?? true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Merge two header maps, giving precedence to override values.
|
|
61
|
+
*/
|
|
62
|
+
mergeHeaders(base, override) {
|
|
63
|
+
if (!base && !override)
|
|
64
|
+
return undefined;
|
|
65
|
+
const merged = {};
|
|
66
|
+
const apply = (headers) => {
|
|
67
|
+
if (!headers)
|
|
68
|
+
return;
|
|
69
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
70
|
+
const normalized = name.toLowerCase();
|
|
71
|
+
for (const existing of Object.keys(merged)) {
|
|
72
|
+
if (existing.toLowerCase() === normalized) {
|
|
73
|
+
delete merged[existing];
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
merged[name] = value;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
apply(base);
|
|
81
|
+
apply(override);
|
|
82
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Optional remote connectivity/auth preflight (enabled by default).
|
|
86
|
+
* Uses GET (not HEAD/OPTIONS) for broader compatibility.
|
|
87
|
+
*
|
|
88
|
+
* Any successful HTTP response (including 404/405/etc.) confirms network reachability.
|
|
89
|
+
* Only auth failures and hard network/timeouts are treated as terminal preflight failures.
|
|
90
|
+
*/
|
|
91
|
+
async preflightRemote(url, transport, headers) {
|
|
92
|
+
if (!this.remotePreflight) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const endpoint = transport === 'sse' ? `${url.replace(/\/$/, '')}/sse` : url;
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const timeoutId = setTimeout(() => controller.abort(), Math.min(this.timeout, 5000));
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(endpoint, {
|
|
100
|
+
method: 'GET',
|
|
101
|
+
headers: {
|
|
102
|
+
Accept: transport === 'sse' ? 'text/event-stream' : 'application/json, text/event-stream',
|
|
103
|
+
...(headers ?? {}),
|
|
104
|
+
},
|
|
105
|
+
signal: controller.signal,
|
|
106
|
+
});
|
|
107
|
+
const closePreflightBody = async () => {
|
|
108
|
+
try {
|
|
109
|
+
await response.body?.cancel();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Ignore body cancellation failures; preflight status handling is primary.
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (response.status === 401) {
|
|
116
|
+
await closePreflightBody();
|
|
117
|
+
throw new ServerAuthError(`Remote MCP preflight failed: unauthorized (${response.status})`, response.status, 'Add authentication headers (for example Authorization) and retry.');
|
|
118
|
+
}
|
|
119
|
+
if (response.status === 403) {
|
|
120
|
+
await closePreflightBody();
|
|
121
|
+
throw new ServerAuthError(`Remote MCP preflight failed: forbidden (${response.status})`, response.status, 'Credentials are valid but lack required permissions/scopes.');
|
|
122
|
+
}
|
|
123
|
+
if (response.status === 407) {
|
|
124
|
+
await closePreflightBody();
|
|
125
|
+
throw new ServerAuthError(`Remote MCP preflight failed: proxy authentication required (${response.status})`, response.status, 'Configure proxy credentials and retry.');
|
|
126
|
+
}
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
// Non-auth HTTP statuses are compatibility-safe to continue:
|
|
129
|
+
// some MCP servers reject generic GET preflight paths but still
|
|
130
|
+
// support their actual protocol endpoints for normal operation.
|
|
131
|
+
this.logger.debug({ endpoint, status: response.status }, 'Remote preflight received non-auth HTTP status; continuing to transport connect');
|
|
132
|
+
}
|
|
133
|
+
await closePreflightBody();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (error instanceof ServerAuthError || error instanceof ConnectionError) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
140
|
+
throw new ConnectionError(`Remote MCP preflight timed out for ${endpoint}`);
|
|
141
|
+
}
|
|
142
|
+
throw new ConnectionError(`Remote MCP preflight failed for ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
clearTimeout(timeoutId);
|
|
146
|
+
}
|
|
106
147
|
}
|
|
107
148
|
/**
|
|
108
149
|
* Classify a transport error based on its message.
|
|
@@ -127,6 +168,12 @@ export class MCPClient {
|
|
|
127
168
|
return { category: 'connection_refused', likelyServerBug: false };
|
|
128
169
|
}
|
|
129
170
|
}
|
|
171
|
+
// Check for authentication failures (environment/config issue)
|
|
172
|
+
for (const pattern of TRANSPORT_ERRORS.AUTH_FAILURE_PATTERNS) {
|
|
173
|
+
if (pattern.test(msg)) {
|
|
174
|
+
return { category: 'auth_failed', likelyServerBug: false };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
130
177
|
// Check for connection lost
|
|
131
178
|
for (const pattern of TRANSPORT_ERRORS.CONNECTION_LOST_PATTERNS) {
|
|
132
179
|
if (pattern.test(msg)) {
|
|
@@ -190,6 +237,8 @@ export class MCPClient {
|
|
|
190
237
|
return 'Server response exceeded buffer limits';
|
|
191
238
|
case 'connection_refused':
|
|
192
239
|
return 'Failed to connect to server process';
|
|
240
|
+
case 'auth_failed':
|
|
241
|
+
return 'Authentication failed when connecting to remote MCP server';
|
|
193
242
|
case 'connection_lost':
|
|
194
243
|
return 'Connection to server was lost unexpectedly';
|
|
195
244
|
case 'protocol_violation':
|
|
@@ -247,35 +296,6 @@ export class MCPClient {
|
|
|
247
296
|
this.logger.debug({ args }, 'MCP Debug');
|
|
248
297
|
}
|
|
249
298
|
}
|
|
250
|
-
/**
|
|
251
|
-
* Check if an environment variable name looks like a secret.
|
|
252
|
-
*/
|
|
253
|
-
isSensitiveEnvVar(name) {
|
|
254
|
-
// Check explicit list
|
|
255
|
-
if (FILTERED_ENV_VARS.has(name)) {
|
|
256
|
-
return true;
|
|
257
|
-
}
|
|
258
|
-
// Check patterns
|
|
259
|
-
return FILTERED_ENV_PATTERNS.some((pattern) => pattern.test(name));
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Filter sensitive environment variables before passing to subprocess.
|
|
263
|
-
* Uses both explicit list and pattern matching to catch common secret naming conventions.
|
|
264
|
-
*/
|
|
265
|
-
filterEnv(baseEnv, additionalEnv) {
|
|
266
|
-
const filtered = {};
|
|
267
|
-
// Copy process.env, filtering out sensitive variables
|
|
268
|
-
for (const [key, value] of Object.entries(baseEnv)) {
|
|
269
|
-
if (value !== undefined && !this.isSensitiveEnvVar(key)) {
|
|
270
|
-
filtered[key] = value;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// Add additional env vars (these are explicitly provided, so allow them)
|
|
274
|
-
if (additionalEnv) {
|
|
275
|
-
Object.assign(filtered, additionalEnv);
|
|
276
|
-
}
|
|
277
|
-
return filtered;
|
|
278
|
-
}
|
|
279
299
|
/**
|
|
280
300
|
* Connect to an MCP server by spawning it as a subprocess.
|
|
281
301
|
*/
|
|
@@ -291,7 +311,7 @@ export class MCPClient {
|
|
|
291
311
|
args,
|
|
292
312
|
};
|
|
293
313
|
// Filter out sensitive environment variables before spawning subprocess
|
|
294
|
-
const filteredEnv =
|
|
314
|
+
const filteredEnv = filterSpawnEnv(process.env, env);
|
|
295
315
|
this.process = spawn(command, args, {
|
|
296
316
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
297
317
|
env: filteredEnv,
|
|
@@ -309,6 +329,7 @@ export class MCPClient {
|
|
|
309
329
|
this.transport.on('error', (error) => {
|
|
310
330
|
this.logger.error({ error: error.message }, 'Transport error');
|
|
311
331
|
this.recordTransportError(error, 'stdio_transport');
|
|
332
|
+
this.clearPendingRequests(error.message);
|
|
312
333
|
});
|
|
313
334
|
this.transport.on('close', () => {
|
|
314
335
|
this.logger.debug('Transport closed');
|
|
@@ -360,27 +381,36 @@ export class MCPClient {
|
|
|
360
381
|
// Reset flags for new connection
|
|
361
382
|
this.cleaningUp = false;
|
|
362
383
|
this.disconnecting = false;
|
|
384
|
+
// Track connection state for diagnostics
|
|
385
|
+
this.connectionState = {
|
|
386
|
+
attempted: true,
|
|
387
|
+
url,
|
|
388
|
+
};
|
|
363
389
|
this.logger.info({ url, transport }, 'Connecting to remote MCP server');
|
|
390
|
+
const mergedHeaders = transport === 'sse'
|
|
391
|
+
? this.mergeHeaders(this.sseConfig?.headers, options?.headers)
|
|
392
|
+
: this.mergeHeaders(this.httpConfig?.headers, options?.headers);
|
|
393
|
+
await this.preflightRemote(url, transport, mergedHeaders);
|
|
364
394
|
if (transport === 'sse') {
|
|
365
395
|
const sseTransport = new SSETransport({
|
|
396
|
+
...this.sseConfig,
|
|
366
397
|
baseUrl: url,
|
|
367
398
|
sessionId: options?.sessionId ?? this.sseConfig?.sessionId,
|
|
368
|
-
headers:
|
|
399
|
+
headers: mergedHeaders,
|
|
369
400
|
timeout: this.timeout,
|
|
370
401
|
debug: this.debug,
|
|
371
|
-
...this.sseConfig,
|
|
372
402
|
});
|
|
373
403
|
await sseTransport.connect();
|
|
374
404
|
this.transport = sseTransport;
|
|
375
405
|
}
|
|
376
406
|
else if (transport === 'streamable-http') {
|
|
377
407
|
const httpTransport = new HTTPTransport({
|
|
408
|
+
...this.httpConfig,
|
|
378
409
|
baseUrl: url,
|
|
379
410
|
sessionId: options?.sessionId ?? this.httpConfig?.sessionId,
|
|
380
|
-
headers:
|
|
411
|
+
headers: mergedHeaders,
|
|
381
412
|
timeout: this.timeout,
|
|
382
413
|
debug: this.debug,
|
|
383
|
-
...this.httpConfig,
|
|
384
414
|
});
|
|
385
415
|
await httpTransport.connect();
|
|
386
416
|
this.transport = httpTransport;
|
|
@@ -405,6 +435,7 @@ export class MCPClient {
|
|
|
405
435
|
this.transport.on('error', (error) => {
|
|
406
436
|
this.logger.error({ error: error.message }, 'Transport error');
|
|
407
437
|
this.recordTransportError(error, 'remote_transport');
|
|
438
|
+
this.clearPendingRequests(error.message);
|
|
408
439
|
});
|
|
409
440
|
this.transport.on('close', () => {
|
|
410
441
|
this.logger.debug('Transport closed');
|
|
@@ -418,7 +449,7 @@ export class MCPClient {
|
|
|
418
449
|
*/
|
|
419
450
|
async waitForStartup() {
|
|
420
451
|
// Enforce minimum startup delay to allow server to fully start
|
|
421
|
-
//
|
|
452
|
+
// while still honoring explicit higher startupDelay values from config/tests.
|
|
422
453
|
const delay = Math.max(this.startupDelay, TIMEOUTS.MIN_SERVER_STARTUP_WAIT);
|
|
423
454
|
this.logger.debug({ delay }, 'Waiting for server startup');
|
|
424
455
|
await new Promise((resolve) => setTimeout(resolve, delay));
|