@dotsetlabs/bellwether 2.1.1 → 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 +19 -4
- 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/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 +2 -0
- package/dist/constants/core.js +11 -0
- 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/transport/http-transport.js +10 -0
- package/dist/transport/mcp-client.d.ts +16 -0
- package/dist/transport/mcp-client.js +116 -4
- package/dist/transport/sse-transport.js +19 -0
- package/package.json +3 -14
- 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
|
*/
|
|
@@ -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
|
*/
|
|
@@ -7,6 +7,7 @@ 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
9
|
import { filterSpawnEnv } from './env-filter.js';
|
|
10
|
+
import { ConnectionError, ServerAuthError } from '../errors/types.js';
|
|
10
11
|
const DEFAULT_TIMEOUT = TIMEOUTS.DEFAULT;
|
|
11
12
|
const DEFAULT_STARTUP_DELAY = TIMEOUTS.SERVER_STARTUP;
|
|
12
13
|
/**
|
|
@@ -44,6 +45,8 @@ export class MCPClient {
|
|
|
44
45
|
connectionState = { attempted: false };
|
|
45
46
|
/** Protocol version negotiated with the server during initialization */
|
|
46
47
|
negotiatedProtocolVersion = null;
|
|
48
|
+
/** Whether to run an explicit preflight before remote connection */
|
|
49
|
+
remotePreflight;
|
|
47
50
|
constructor(options) {
|
|
48
51
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
49
52
|
this.startupDelay = options?.startupDelay ?? DEFAULT_STARTUP_DELAY;
|
|
@@ -51,6 +54,96 @@ export class MCPClient {
|
|
|
51
54
|
this.transportType = options?.transport ?? 'stdio';
|
|
52
55
|
this.sseConfig = options?.sseConfig;
|
|
53
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
|
+
}
|
|
54
147
|
}
|
|
55
148
|
/**
|
|
56
149
|
* Classify a transport error based on its message.
|
|
@@ -75,6 +168,12 @@ export class MCPClient {
|
|
|
75
168
|
return { category: 'connection_refused', likelyServerBug: false };
|
|
76
169
|
}
|
|
77
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
|
+
}
|
|
78
177
|
// Check for connection lost
|
|
79
178
|
for (const pattern of TRANSPORT_ERRORS.CONNECTION_LOST_PATTERNS) {
|
|
80
179
|
if (pattern.test(msg)) {
|
|
@@ -138,6 +237,8 @@ export class MCPClient {
|
|
|
138
237
|
return 'Server response exceeded buffer limits';
|
|
139
238
|
case 'connection_refused':
|
|
140
239
|
return 'Failed to connect to server process';
|
|
240
|
+
case 'auth_failed':
|
|
241
|
+
return 'Authentication failed when connecting to remote MCP server';
|
|
141
242
|
case 'connection_lost':
|
|
142
243
|
return 'Connection to server was lost unexpectedly';
|
|
143
244
|
case 'protocol_violation':
|
|
@@ -228,6 +329,7 @@ export class MCPClient {
|
|
|
228
329
|
this.transport.on('error', (error) => {
|
|
229
330
|
this.logger.error({ error: error.message }, 'Transport error');
|
|
230
331
|
this.recordTransportError(error, 'stdio_transport');
|
|
332
|
+
this.clearPendingRequests(error.message);
|
|
231
333
|
});
|
|
232
334
|
this.transport.on('close', () => {
|
|
233
335
|
this.logger.debug('Transport closed');
|
|
@@ -279,27 +381,36 @@ export class MCPClient {
|
|
|
279
381
|
// Reset flags for new connection
|
|
280
382
|
this.cleaningUp = false;
|
|
281
383
|
this.disconnecting = false;
|
|
384
|
+
// Track connection state for diagnostics
|
|
385
|
+
this.connectionState = {
|
|
386
|
+
attempted: true,
|
|
387
|
+
url,
|
|
388
|
+
};
|
|
282
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);
|
|
283
394
|
if (transport === 'sse') {
|
|
284
395
|
const sseTransport = new SSETransport({
|
|
396
|
+
...this.sseConfig,
|
|
285
397
|
baseUrl: url,
|
|
286
398
|
sessionId: options?.sessionId ?? this.sseConfig?.sessionId,
|
|
287
|
-
headers:
|
|
399
|
+
headers: mergedHeaders,
|
|
288
400
|
timeout: this.timeout,
|
|
289
401
|
debug: this.debug,
|
|
290
|
-
...this.sseConfig,
|
|
291
402
|
});
|
|
292
403
|
await sseTransport.connect();
|
|
293
404
|
this.transport = sseTransport;
|
|
294
405
|
}
|
|
295
406
|
else if (transport === 'streamable-http') {
|
|
296
407
|
const httpTransport = new HTTPTransport({
|
|
408
|
+
...this.httpConfig,
|
|
297
409
|
baseUrl: url,
|
|
298
410
|
sessionId: options?.sessionId ?? this.httpConfig?.sessionId,
|
|
299
|
-
headers:
|
|
411
|
+
headers: mergedHeaders,
|
|
300
412
|
timeout: this.timeout,
|
|
301
413
|
debug: this.debug,
|
|
302
|
-
...this.httpConfig,
|
|
303
414
|
});
|
|
304
415
|
await httpTransport.connect();
|
|
305
416
|
this.transport = httpTransport;
|
|
@@ -324,6 +435,7 @@ export class MCPClient {
|
|
|
324
435
|
this.transport.on('error', (error) => {
|
|
325
436
|
this.logger.error({ error: error.message }, 'Transport error');
|
|
326
437
|
this.recordTransportError(error, 'remote_transport');
|
|
438
|
+
this.clearPendingRequests(error.message);
|
|
327
439
|
});
|
|
328
440
|
this.transport.on('close', () => {
|
|
329
441
|
this.logger.debug('Transport closed');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseTransport } from './base-transport.js';
|
|
2
2
|
import { TIME_CONSTANTS, TIMEOUTS } from '../constants.js';
|
|
3
3
|
import { isLocalhost } from '../utils/index.js';
|
|
4
|
+
import { ServerAuthError } from '../errors/types.js';
|
|
4
5
|
/**
|
|
5
6
|
* Validate that a URL uses HTTPS in production contexts.
|
|
6
7
|
* Allows HTTP only for localhost/127.0.0.1 for local development.
|
|
@@ -161,6 +162,15 @@ export class SSETransport extends BaseTransport {
|
|
|
161
162
|
signal: this.streamAbortController.signal,
|
|
162
163
|
});
|
|
163
164
|
if (!response.ok) {
|
|
165
|
+
if (response.status === 401) {
|
|
166
|
+
throw new ServerAuthError('Remote MCP SSE authentication failed (401 Unauthorized)', 401, 'Add server.headers.Authorization (for example: Bearer token) in bellwether.yaml or pass --header.');
|
|
167
|
+
}
|
|
168
|
+
if (response.status === 403) {
|
|
169
|
+
throw new ServerAuthError('Remote MCP SSE authorization failed (403 Forbidden)', 403, 'Credentials are recognized but lack required permissions. Verify token scopes/roles.');
|
|
170
|
+
}
|
|
171
|
+
if (response.status === 407) {
|
|
172
|
+
throw new ServerAuthError('Proxy authentication required (407)', 407, 'Configure proxy credentials and retry.');
|
|
173
|
+
}
|
|
164
174
|
throw new Error(`Failed to connect to SSE endpoint: HTTP ${response.status}`);
|
|
165
175
|
}
|
|
166
176
|
if (!response.body) {
|
|
@@ -334,6 +344,15 @@ export class SSETransport extends BaseTransport {
|
|
|
334
344
|
.then(async (response) => {
|
|
335
345
|
clearTimeout(timeoutId);
|
|
336
346
|
if (!response.ok) {
|
|
347
|
+
if (response.status === 401) {
|
|
348
|
+
throw new ServerAuthError('Remote MCP message authentication failed (401 Unauthorized)', 401, 'Add server.headers.Authorization (for example: Bearer token) in bellwether.yaml or pass --header.');
|
|
349
|
+
}
|
|
350
|
+
if (response.status === 403) {
|
|
351
|
+
throw new ServerAuthError('Remote MCP message authorization failed (403 Forbidden)', 403, 'Credentials are recognized but lack required permissions. Verify token scopes/roles.');
|
|
352
|
+
}
|
|
353
|
+
if (response.status === 407) {
|
|
354
|
+
throw new ServerAuthError('Proxy authentication required (407)', 407, 'Configure proxy credentials and retry.');
|
|
355
|
+
}
|
|
337
356
|
const errorText = await response.text().catch(() => 'Unknown error');
|
|
338
357
|
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
339
358
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotsetlabs/bellwether",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "The open-source MCP testing tool. Structural drift detection and behavioral documentation for Model Context Protocol servers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,9 +36,7 @@
|
|
|
36
36
|
"clean": "rm -rf dist",
|
|
37
37
|
"docs:generate": "npm --prefix website run build",
|
|
38
38
|
"docs:dev": "npm --prefix website run start",
|
|
39
|
-
"
|
|
40
|
-
"prepare": "husky install || true",
|
|
41
|
-
"prepublishOnly": "npm run build && npm run man:generate"
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
42
40
|
},
|
|
43
41
|
"keywords": [
|
|
44
42
|
"mcp",
|
|
@@ -75,7 +73,7 @@
|
|
|
75
73
|
"node": ">=20.0.0"
|
|
76
74
|
},
|
|
77
75
|
"dependencies": {
|
|
78
|
-
"@anthropic-ai/sdk": "^0.
|
|
76
|
+
"@anthropic-ai/sdk": "^0.74.0",
|
|
79
77
|
"ajv": "^8.17.1",
|
|
80
78
|
"chalk": "^5.4.1",
|
|
81
79
|
"cli-progress": "^3.12.0",
|
|
@@ -98,25 +96,16 @@
|
|
|
98
96
|
"@typescript-eslint/parser": "^6.21.0",
|
|
99
97
|
"@vitest/coverage-v8": "^4.0.18",
|
|
100
98
|
"eslint": "^8.57.1",
|
|
101
|
-
"husky": "^9.1.0",
|
|
102
|
-
"lint-staged": "^16.2.7",
|
|
103
99
|
"prettier": "^3.3.0",
|
|
104
100
|
"tsx": "^4.21.0",
|
|
105
101
|
"typescript": "^5.3.0",
|
|
106
102
|
"vitest": "^4.0.17"
|
|
107
103
|
},
|
|
108
|
-
"lint-staged": {
|
|
109
|
-
"*.ts": [
|
|
110
|
-
"eslint --fix",
|
|
111
|
-
"prettier --write"
|
|
112
|
-
]
|
|
113
|
-
},
|
|
114
104
|
"files": [
|
|
115
105
|
"dist",
|
|
116
106
|
"!dist/**/*.map",
|
|
117
107
|
"schemas",
|
|
118
108
|
"scripts/completions",
|
|
119
|
-
"man",
|
|
120
109
|
"README.md",
|
|
121
110
|
"LICENSE",
|
|
122
111
|
"CHANGELOG.md",
|