@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +48 -31
  3. package/dist/cli/commands/check.js +49 -6
  4. package/dist/cli/commands/dashboard.d.ts +3 -0
  5. package/dist/cli/commands/dashboard.js +69 -0
  6. package/dist/cli/commands/discover.js +24 -2
  7. package/dist/cli/commands/explore.js +49 -6
  8. package/dist/cli/commands/watch.js +12 -1
  9. package/dist/cli/index.js +27 -34
  10. package/dist/cli/utils/headers.d.ts +12 -0
  11. package/dist/cli/utils/headers.js +63 -0
  12. package/dist/config/defaults.d.ts +2 -0
  13. package/dist/config/defaults.js +2 -0
  14. package/dist/config/template.js +12 -0
  15. package/dist/config/validator.d.ts +38 -18
  16. package/dist/config/validator.js +10 -0
  17. package/dist/constants/core.d.ts +4 -2
  18. package/dist/constants/core.js +13 -2
  19. package/dist/dashboard/index.d.ts +3 -0
  20. package/dist/dashboard/index.js +6 -0
  21. package/dist/dashboard/runtime/artifact-index.d.ts +45 -0
  22. package/dist/dashboard/runtime/artifact-index.js +238 -0
  23. package/dist/dashboard/runtime/command-profiles.d.ts +764 -0
  24. package/dist/dashboard/runtime/command-profiles.js +691 -0
  25. package/dist/dashboard/runtime/config-service.d.ts +21 -0
  26. package/dist/dashboard/runtime/config-service.js +73 -0
  27. package/dist/dashboard/runtime/job-runner.d.ts +26 -0
  28. package/dist/dashboard/runtime/job-runner.js +292 -0
  29. package/dist/dashboard/security/input-validation.d.ts +3 -0
  30. package/dist/dashboard/security/input-validation.js +27 -0
  31. package/dist/dashboard/security/localhost-guard.d.ts +5 -0
  32. package/dist/dashboard/security/localhost-guard.js +52 -0
  33. package/dist/dashboard/server.d.ts +14 -0
  34. package/dist/dashboard/server.js +293 -0
  35. package/dist/dashboard/types.d.ts +55 -0
  36. package/dist/dashboard/types.js +2 -0
  37. package/dist/dashboard/ui.d.ts +2 -0
  38. package/dist/dashboard/ui.js +2264 -0
  39. package/dist/discovery/discovery.js +20 -1
  40. package/dist/discovery/types.d.ts +1 -1
  41. package/dist/docs/contract.js +7 -1
  42. package/dist/errors/retry.js +15 -1
  43. package/dist/errors/types.d.ts +10 -0
  44. package/dist/errors/types.js +28 -0
  45. package/dist/logging/logger.js +5 -2
  46. package/dist/transport/env-filter.d.ts +6 -0
  47. package/dist/transport/env-filter.js +76 -0
  48. package/dist/transport/http-transport.js +10 -0
  49. package/dist/transport/mcp-client.d.ts +16 -9
  50. package/dist/transport/mcp-client.js +119 -88
  51. package/dist/transport/sse-transport.js +19 -0
  52. package/dist/version.js +2 -2
  53. package/package.json +5 -15
  54. package/man/bellwether.1 +0 -204
  55. 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 = generateDiscoveryWarnings(initResult.capabilities, tools, prompts, resources, transportErrors, initResult.protocolVersion);
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
  */
@@ -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
- if (hasInvalidJson || hasProtocolError) {
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':
@@ -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;
@@ -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
  */
@@ -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
  */
@@ -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: 'warn' });
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
- * Environment variables to filter out when spawning MCP server processes.
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 = this.filterEnv(process.env, env);
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: options?.headers ?? this.sseConfig?.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: options?.headers ?? this.httpConfig?.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
- // npx-based servers often need significant time to download and start
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));