@dotsetlabs/bellwether 1.0.2 → 1.0.3
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 +23 -0
- package/README.md +3 -2
- package/dist/cache/response-cache.d.ts +4 -2
- package/dist/cache/response-cache.js +68 -30
- package/dist/cli/commands/check.js +78 -49
- package/dist/cli/index.js +5 -3
- package/dist/interview/interviewer.js +70 -50
- package/dist/interview/orchestrator.js +49 -22
- package/dist/llm/anthropic.js +49 -16
- package/dist/llm/client.d.ts +2 -0
- package/dist/llm/client.js +61 -0
- package/dist/llm/ollama.js +9 -4
- package/dist/llm/openai.js +34 -23
- package/dist/transport/base-transport.d.ts +1 -1
- package/dist/transport/http-transport.d.ts +2 -2
- package/dist/transport/http-transport.js +26 -6
- package/dist/transport/mcp-client.d.ts +18 -6
- package/dist/transport/mcp-client.js +49 -19
- package/dist/transport/sse-transport.d.ts +1 -1
- package/dist/transport/sse-transport.js +4 -2
- package/dist/transport/stdio-transport.d.ts +1 -1
- package/dist/transport/stdio-transport.js +1 -1
- package/dist/utils/timeout.d.ts +10 -2
- package/dist/utils/timeout.js +9 -5
- package/dist/version.js +1 -1
- package/dist/workflow/executor.js +18 -13
- package/dist/workflow/loader.js +4 -1
- package/dist/workflow/state-tracker.js +22 -18
- package/man/bellwether.1 +204 -0
- package/man/bellwether.1.md +148 -0
- package/package.json +6 -7
package/dist/llm/anthropic.js
CHANGED
|
@@ -13,6 +13,22 @@ import { getLogger } from '../logging/logger.js';
|
|
|
13
13
|
*/
|
|
14
14
|
const PLACEHOLDER_CONTINUE = 'Continue.';
|
|
15
15
|
const PLACEHOLDER_GREETING = 'Hello.';
|
|
16
|
+
function getErrorStatus(error) {
|
|
17
|
+
const err = error;
|
|
18
|
+
return err.status ?? err.statusCode;
|
|
19
|
+
}
|
|
20
|
+
function getErrorCode(error) {
|
|
21
|
+
const err = error;
|
|
22
|
+
return err.code ?? err.error?.code;
|
|
23
|
+
}
|
|
24
|
+
function getErrorType(error) {
|
|
25
|
+
const err = error;
|
|
26
|
+
return err.type ?? err.error?.type;
|
|
27
|
+
}
|
|
28
|
+
function getErrorMessage(error) {
|
|
29
|
+
const err = error;
|
|
30
|
+
return err.error?.message ?? err.message ?? '';
|
|
31
|
+
}
|
|
16
32
|
/**
|
|
17
33
|
* Anthropic Claude LLM client implementation.
|
|
18
34
|
*/
|
|
@@ -48,7 +64,7 @@ export class AnthropicClient {
|
|
|
48
64
|
// Separate system message from conversation messages
|
|
49
65
|
const systemPrompt = options?.systemPrompt;
|
|
50
66
|
// Convert messages to Anthropic format
|
|
51
|
-
const anthropicMessages = messages.map(m => ({
|
|
67
|
+
const anthropicMessages = messages.map((m) => ({
|
|
52
68
|
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
53
69
|
content: m.content,
|
|
54
70
|
}));
|
|
@@ -72,7 +88,7 @@ export class AnthropicClient {
|
|
|
72
88
|
max_tokens: options?.maxTokens ?? LLM_DEFAULTS.MAX_TOKENS,
|
|
73
89
|
system: system,
|
|
74
90
|
messages: normalizedMessages,
|
|
75
|
-
});
|
|
91
|
+
}, { signal: options?.signal });
|
|
76
92
|
// Track actual token usage from API response
|
|
77
93
|
if (this.onUsage && response.usage) {
|
|
78
94
|
this.onUsage(response.usage.input_tokens, response.usage.output_tokens);
|
|
@@ -80,26 +96,34 @@ export class AnthropicClient {
|
|
|
80
96
|
// Check for content filtering refusal
|
|
81
97
|
this.checkForRefusal(response, model);
|
|
82
98
|
// Extract text content from response
|
|
83
|
-
const textBlocks = response.content.filter(block => block.type === 'text');
|
|
99
|
+
const textBlocks = response.content.filter((block) => block.type === 'text');
|
|
84
100
|
if (textBlocks.length === 0) {
|
|
85
101
|
throw new Error('No text content in Claude response');
|
|
86
102
|
}
|
|
87
|
-
return textBlocks.map(block => block.text).join('');
|
|
103
|
+
return textBlocks.map((block) => block.text).join('');
|
|
88
104
|
}
|
|
89
105
|
catch (error) {
|
|
90
106
|
// Don't re-process errors that are already typed LLM errors
|
|
91
|
-
if (error instanceof LLMRefusalError ||
|
|
92
|
-
error instanceof
|
|
107
|
+
if (error instanceof LLMRefusalError ||
|
|
108
|
+
error instanceof LLMAuthError ||
|
|
109
|
+
error instanceof LLMRateLimitError ||
|
|
110
|
+
error instanceof LLMQuotaError ||
|
|
93
111
|
error instanceof LLMConnectionError) {
|
|
94
112
|
throw error;
|
|
95
113
|
}
|
|
96
114
|
// Convert to typed errors for retry logic
|
|
97
115
|
if (error instanceof Error) {
|
|
98
|
-
const
|
|
99
|
-
|
|
116
|
+
const status = getErrorStatus(error);
|
|
117
|
+
const code = (getErrorCode(error) ?? '').toLowerCase();
|
|
118
|
+
const type = (getErrorType(error) ?? '').toLowerCase();
|
|
119
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
120
|
+
if (status === 401 || status === 403 || message.includes('authentication')) {
|
|
100
121
|
throw new LLMAuthError('anthropic', model);
|
|
101
122
|
}
|
|
102
|
-
if (
|
|
123
|
+
if (status === 429 ||
|
|
124
|
+
code.includes('rate_limit') ||
|
|
125
|
+
type.includes('rate_limit') ||
|
|
126
|
+
message.includes('rate limit')) {
|
|
103
127
|
// Extract retry-after-ms header if available from Anthropic API error
|
|
104
128
|
let retryAfterMs;
|
|
105
129
|
const apiError = error;
|
|
@@ -127,7 +151,11 @@ export class AnthropicClient {
|
|
|
127
151
|
}
|
|
128
152
|
throw new LLMRateLimitError('anthropic', retryAfterMs, model);
|
|
129
153
|
}
|
|
130
|
-
if (
|
|
154
|
+
if (status === 402 ||
|
|
155
|
+
code.includes('insufficient') ||
|
|
156
|
+
type.includes('insufficient') ||
|
|
157
|
+
message.includes('insufficient') ||
|
|
158
|
+
message.includes('credit')) {
|
|
131
159
|
throw new LLMQuotaError('anthropic', model);
|
|
132
160
|
}
|
|
133
161
|
if (message.includes('econnrefused') || message.includes('fetch failed')) {
|
|
@@ -164,9 +192,12 @@ export class AnthropicClient {
|
|
|
164
192
|
throw new LLMRefusalError('anthropic', 'Response blocked due to safety concerns', model);
|
|
165
193
|
}
|
|
166
194
|
// Check content for refusal indicators
|
|
167
|
-
const textBlocks = response.content.filter(block => block.type === 'text');
|
|
195
|
+
const textBlocks = response.content.filter((block) => block.type === 'text');
|
|
168
196
|
if (textBlocks.length > 0) {
|
|
169
|
-
const fullText = textBlocks
|
|
197
|
+
const fullText = textBlocks
|
|
198
|
+
.map((block) => block.text ?? '')
|
|
199
|
+
.join('')
|
|
200
|
+
.toLowerCase();
|
|
170
201
|
// Check for common refusal patterns in Claude's responses
|
|
171
202
|
const refusalPatterns = [
|
|
172
203
|
'i cannot help with',
|
|
@@ -244,7 +275,7 @@ export class AnthropicClient {
|
|
|
244
275
|
// Separate system message from conversation messages
|
|
245
276
|
const systemPrompt = options?.systemPrompt;
|
|
246
277
|
// Convert messages to Anthropic format
|
|
247
|
-
const anthropicMessages = messages.map(m => ({
|
|
278
|
+
const anthropicMessages = messages.map((m) => ({
|
|
248
279
|
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
249
280
|
content: m.content,
|
|
250
281
|
}));
|
|
@@ -266,7 +297,7 @@ export class AnthropicClient {
|
|
|
266
297
|
max_tokens: options?.maxTokens ?? LLM_DEFAULTS.MAX_TOKENS,
|
|
267
298
|
system: system,
|
|
268
299
|
messages: normalizedMessages,
|
|
269
|
-
});
|
|
300
|
+
}, { signal: options?.signal });
|
|
270
301
|
let fullText = '';
|
|
271
302
|
for await (const event of stream) {
|
|
272
303
|
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
|
|
@@ -294,8 +325,10 @@ export class AnthropicClient {
|
|
|
294
325
|
catch (error) {
|
|
295
326
|
options?.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
296
327
|
// Don't re-process errors that are already typed LLM errors
|
|
297
|
-
if (error instanceof LLMRefusalError ||
|
|
298
|
-
error instanceof
|
|
328
|
+
if (error instanceof LLMRefusalError ||
|
|
329
|
+
error instanceof LLMAuthError ||
|
|
330
|
+
error instanceof LLMRateLimitError ||
|
|
331
|
+
error instanceof LLMQuotaError ||
|
|
299
332
|
error instanceof LLMConnectionError) {
|
|
300
333
|
throw error;
|
|
301
334
|
}
|
package/dist/llm/client.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface CompletionOptions {
|
|
|
16
16
|
responseFormat?: 'text' | 'json';
|
|
17
17
|
/** System prompt to set context */
|
|
18
18
|
systemPrompt?: string;
|
|
19
|
+
/** Optional abort signal to cancel the request */
|
|
20
|
+
signal?: AbortSignal;
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Options for streaming completions.
|
package/dist/llm/client.js
CHANGED
|
@@ -36,7 +36,68 @@ export function parseJSONResponse(response) {
|
|
|
36
36
|
return JSON.parse(cleaned);
|
|
37
37
|
}
|
|
38
38
|
catch (error) {
|
|
39
|
+
// Try to extract a JSON object/array from mixed content
|
|
40
|
+
const extracted = extractFirstJsonBlock(cleaned);
|
|
41
|
+
if (extracted) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(extracted);
|
|
44
|
+
}
|
|
45
|
+
catch (extractedError) {
|
|
46
|
+
throw new Error(`Failed to parse extracted JSON from LLM response: ${extractedError}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
39
49
|
throw new Error(`Failed to parse JSON from LLM response: ${error}`);
|
|
40
50
|
}
|
|
41
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Extract the first valid JSON object or array from a string.
|
|
54
|
+
* Handles mixed content like "Here is the JSON: {...}".
|
|
55
|
+
*/
|
|
56
|
+
function extractFirstJsonBlock(text) {
|
|
57
|
+
const startIndexes = [];
|
|
58
|
+
for (let i = 0; i < text.length; i++) {
|
|
59
|
+
const char = text[i];
|
|
60
|
+
if (char === '{' || char === '[') {
|
|
61
|
+
startIndexes.push(i);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const start of startIndexes) {
|
|
65
|
+
const opening = text[start];
|
|
66
|
+
const closing = opening === '{' ? '}' : ']';
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let inString = false;
|
|
69
|
+
let escape = false;
|
|
70
|
+
for (let i = start; i < text.length; i++) {
|
|
71
|
+
const char = text[i];
|
|
72
|
+
if (inString) {
|
|
73
|
+
if (escape) {
|
|
74
|
+
escape = false;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (char === '\\') {
|
|
78
|
+
escape = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === '"') {
|
|
82
|
+
inString = false;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === '"') {
|
|
87
|
+
inString = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === opening) {
|
|
91
|
+
depth += 1;
|
|
92
|
+
}
|
|
93
|
+
else if (char === closing) {
|
|
94
|
+
depth -= 1;
|
|
95
|
+
if (depth === 0) {
|
|
96
|
+
return text.slice(start, i + 1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
42
103
|
//# sourceMappingURL=client.js.map
|
package/dist/llm/ollama.js
CHANGED
|
@@ -65,12 +65,13 @@ export class OllamaClient {
|
|
|
65
65
|
'Content-Type': 'application/json',
|
|
66
66
|
},
|
|
67
67
|
body: JSON.stringify(request),
|
|
68
|
+
signal: options?.signal,
|
|
68
69
|
});
|
|
69
70
|
if (!response.ok) {
|
|
70
71
|
const errorText = await response.text();
|
|
71
72
|
throw new Error(`Ollama API error (${response.status}): ${errorText}`);
|
|
72
73
|
}
|
|
73
|
-
const result = await response.json();
|
|
74
|
+
const result = (await response.json());
|
|
74
75
|
if (!result.message?.content) {
|
|
75
76
|
throw new Error('No content in Ollama response');
|
|
76
77
|
}
|
|
@@ -170,6 +171,7 @@ export class OllamaClient {
|
|
|
170
171
|
'Content-Type': 'application/json',
|
|
171
172
|
},
|
|
172
173
|
body: JSON.stringify(request),
|
|
174
|
+
signal: options?.signal,
|
|
173
175
|
});
|
|
174
176
|
if (!response.ok) {
|
|
175
177
|
const errorText = await response.text();
|
|
@@ -234,7 +236,10 @@ export class OllamaClient {
|
|
|
234
236
|
}
|
|
235
237
|
catch (parseError) {
|
|
236
238
|
// Log parse error for debugging
|
|
237
|
-
this.logger.debug({
|
|
239
|
+
this.logger.debug({
|
|
240
|
+
buffer,
|
|
241
|
+
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
242
|
+
}, 'Failed to parse final buffer chunk');
|
|
238
243
|
}
|
|
239
244
|
}
|
|
240
245
|
}
|
|
@@ -318,8 +323,8 @@ export class OllamaClient {
|
|
|
318
323
|
if (!response.ok) {
|
|
319
324
|
return [];
|
|
320
325
|
}
|
|
321
|
-
const result = await response.json();
|
|
322
|
-
return result.models?.map(m => m.name) ?? [];
|
|
326
|
+
const result = (await response.json());
|
|
327
|
+
return result.models?.map((m) => m.name) ?? [];
|
|
323
328
|
}
|
|
324
329
|
catch (error) {
|
|
325
330
|
this.logger.debug({ error: error instanceof Error ? error.message : String(error) }, 'Failed to list Ollama models');
|
package/dist/llm/openai.js
CHANGED
|
@@ -15,26 +15,35 @@ import { LLM_DEFAULTS } from '../constants.js';
|
|
|
15
15
|
* - Don't support custom temperature (only default of 1)
|
|
16
16
|
* - Use reasoning tokens that come out of the completion token budget
|
|
17
17
|
*/
|
|
18
|
-
const MODELS_WITH_RESTRICTED_PARAMS = [
|
|
19
|
-
'o1',
|
|
20
|
-
'o1-mini',
|
|
21
|
-
'o1-preview',
|
|
22
|
-
'o3',
|
|
23
|
-
'o3-mini',
|
|
24
|
-
'gpt-5',
|
|
25
|
-
];
|
|
18
|
+
const MODELS_WITH_RESTRICTED_PARAMS = ['o1', 'o1-mini', 'o1-preview', 'o3', 'o3-mini', 'gpt-5'];
|
|
26
19
|
/**
|
|
27
20
|
* Minimum max_completion_tokens for reasoning models.
|
|
28
21
|
* Reasoning models use tokens for internal "thinking" before producing output.
|
|
29
22
|
* We need a high minimum to ensure there are enough tokens left for actual output.
|
|
30
23
|
*/
|
|
31
24
|
const REASONING_MODEL_MIN_TOKENS = 8192;
|
|
25
|
+
function getErrorStatus(error) {
|
|
26
|
+
const err = error;
|
|
27
|
+
return err.status ?? err.statusCode;
|
|
28
|
+
}
|
|
29
|
+
function getErrorCode(error) {
|
|
30
|
+
const err = error;
|
|
31
|
+
return err.code ?? err.error?.code;
|
|
32
|
+
}
|
|
33
|
+
function getErrorType(error) {
|
|
34
|
+
const err = error;
|
|
35
|
+
return err.type ?? err.error?.type;
|
|
36
|
+
}
|
|
37
|
+
function getErrorMessage(error) {
|
|
38
|
+
const err = error;
|
|
39
|
+
return err.error?.message ?? err.message ?? '';
|
|
40
|
+
}
|
|
32
41
|
/**
|
|
33
42
|
* Check if a model has restricted parameters (newer reasoning/GPT-5 models).
|
|
34
43
|
*/
|
|
35
44
|
function hasRestrictedParams(model) {
|
|
36
45
|
const modelLower = model.toLowerCase();
|
|
37
|
-
return MODELS_WITH_RESTRICTED_PARAMS.some(prefix => modelLower.startsWith(prefix));
|
|
46
|
+
return MODELS_WITH_RESTRICTED_PARAMS.some((prefix) => modelLower.startsWith(prefix));
|
|
38
47
|
}
|
|
39
48
|
/**
|
|
40
49
|
* Get the effective max tokens for a model, accounting for reasoning overhead.
|
|
@@ -88,7 +97,7 @@ export class OpenAIClient {
|
|
|
88
97
|
const maxTokensValue = getEffectiveMaxTokens(model, requestedMaxTokens);
|
|
89
98
|
const response = await this.client.chat.completions.create({
|
|
90
99
|
model,
|
|
91
|
-
messages: allMessages.map(m => ({
|
|
100
|
+
messages: allMessages.map((m) => ({
|
|
92
101
|
role: m.role,
|
|
93
102
|
content: m.content,
|
|
94
103
|
})),
|
|
@@ -102,10 +111,8 @@ export class OpenAIClient {
|
|
|
102
111
|
...(restrictedParams
|
|
103
112
|
? {}
|
|
104
113
|
: { temperature: options?.temperature ?? LLM_DEFAULTS.TEMPERATURE }),
|
|
105
|
-
response_format: options?.responseFormat === 'json'
|
|
106
|
-
|
|
107
|
-
: undefined,
|
|
108
|
-
});
|
|
114
|
+
response_format: options?.responseFormat === 'json' ? { type: 'json_object' } : undefined,
|
|
115
|
+
}, { signal: options?.signal });
|
|
109
116
|
// Track actual token usage from API response
|
|
110
117
|
if (this.onUsage && response.usage) {
|
|
111
118
|
this.onUsage(response.usage.prompt_tokens, response.usage.completion_tokens);
|
|
@@ -138,11 +145,14 @@ export class OpenAIClient {
|
|
|
138
145
|
catch (error) {
|
|
139
146
|
// Convert to typed errors for retry logic
|
|
140
147
|
if (error instanceof Error) {
|
|
141
|
-
const
|
|
142
|
-
|
|
148
|
+
const status = getErrorStatus(error);
|
|
149
|
+
const code = (getErrorCode(error) ?? '').toLowerCase();
|
|
150
|
+
const type = (getErrorType(error) ?? '').toLowerCase();
|
|
151
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
152
|
+
if (status === 401 || status === 403) {
|
|
143
153
|
throw new LLMAuthError('openai', model);
|
|
144
154
|
}
|
|
145
|
-
if (
|
|
155
|
+
if (status === 429 || code.includes('rate_limit') || type.includes('rate_limit')) {
|
|
146
156
|
// Extract Retry-After header if available from OpenAI API error
|
|
147
157
|
let retryAfterMs;
|
|
148
158
|
const apiError = error;
|
|
@@ -172,7 +182,10 @@ export class OpenAIClient {
|
|
|
172
182
|
}
|
|
173
183
|
throw new LLMRateLimitError('openai', retryAfterMs, model);
|
|
174
184
|
}
|
|
175
|
-
if (
|
|
185
|
+
if (status === 402 ||
|
|
186
|
+
code.includes('insufficient_quota') ||
|
|
187
|
+
type.includes('insufficient_quota') ||
|
|
188
|
+
message.includes('insufficient_quota')) {
|
|
176
189
|
throw new LLMQuotaError('openai', model);
|
|
177
190
|
}
|
|
178
191
|
if (message.includes('econnrefused') || message.includes('fetch failed')) {
|
|
@@ -219,7 +232,7 @@ export class OpenAIClient {
|
|
|
219
232
|
const maxTokensValue = getEffectiveMaxTokens(model, requestedMaxTokens);
|
|
220
233
|
const stream = await this.client.chat.completions.create({
|
|
221
234
|
model,
|
|
222
|
-
messages: allMessages.map(m => ({
|
|
235
|
+
messages: allMessages.map((m) => ({
|
|
223
236
|
role: m.role,
|
|
224
237
|
content: m.content,
|
|
225
238
|
})),
|
|
@@ -233,11 +246,9 @@ export class OpenAIClient {
|
|
|
233
246
|
...(restrictedParams
|
|
234
247
|
? {}
|
|
235
248
|
: { temperature: options?.temperature ?? LLM_DEFAULTS.TEMPERATURE }),
|
|
236
|
-
response_format: options?.responseFormat === 'json'
|
|
237
|
-
? { type: 'json_object' }
|
|
238
|
-
: undefined,
|
|
249
|
+
response_format: options?.responseFormat === 'json' ? { type: 'json_object' } : undefined,
|
|
239
250
|
stream: true,
|
|
240
|
-
});
|
|
251
|
+
}, { signal: options?.signal });
|
|
241
252
|
let fullText = '';
|
|
242
253
|
let inputTokens = 0;
|
|
243
254
|
let outputTokens = 0;
|
|
@@ -36,7 +36,7 @@ export declare abstract class BaseTransport extends EventEmitter {
|
|
|
36
36
|
/**
|
|
37
37
|
* Send a JSON-RPC message to the server.
|
|
38
38
|
*/
|
|
39
|
-
abstract send(message: JSONRPCMessage): void;
|
|
39
|
+
abstract send(message: JSONRPCMessage, signal?: AbortSignal): void;
|
|
40
40
|
/**
|
|
41
41
|
* Close the transport connection.
|
|
42
42
|
*/
|
|
@@ -49,11 +49,11 @@ export declare class HTTPTransport extends BaseTransport {
|
|
|
49
49
|
* Unlike SSE transport, this method handles the response synchronously
|
|
50
50
|
* and emits a 'message' event with the response.
|
|
51
51
|
*/
|
|
52
|
-
send(message: JSONRPCMessage): void;
|
|
52
|
+
send(message: JSONRPCMessage, signal?: AbortSignal): void;
|
|
53
53
|
/**
|
|
54
54
|
* Send a JSON-RPC message and wait for the response.
|
|
55
55
|
*/
|
|
56
|
-
sendAsync(message: JSONRPCMessage): Promise<JSONRPCResponse | null>;
|
|
56
|
+
sendAsync(message: JSONRPCMessage, signal?: AbortSignal): Promise<JSONRPCResponse | null>;
|
|
57
57
|
/**
|
|
58
58
|
* Handle a streaming HTTP response (text/event-stream).
|
|
59
59
|
* Includes timeout handling to prevent indefinite hangs.
|
|
@@ -59,21 +59,32 @@ export class HTTPTransport extends BaseTransport {
|
|
|
59
59
|
* Unlike SSE transport, this method handles the response synchronously
|
|
60
60
|
* and emits a 'message' event with the response.
|
|
61
61
|
*/
|
|
62
|
-
send(message) {
|
|
62
|
+
send(message, signal) {
|
|
63
63
|
if (!this.connected) {
|
|
64
64
|
this.emit('error', new Error('Transport not connected'));
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
|
-
this.sendAsync(message).catch((error) => {
|
|
67
|
+
this.sendAsync(message, signal).catch((error) => {
|
|
68
68
|
this.emit('error', error);
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* Send a JSON-RPC message and wait for the response.
|
|
73
73
|
*/
|
|
74
|
-
async sendAsync(message) {
|
|
74
|
+
async sendAsync(message, signal) {
|
|
75
75
|
this.log('Sending message', { message });
|
|
76
76
|
this.abortController = new AbortController();
|
|
77
|
+
const controller = this.abortController;
|
|
78
|
+
let abortListener;
|
|
79
|
+
if (signal) {
|
|
80
|
+
if (signal.aborted) {
|
|
81
|
+
controller.abort();
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
abortListener = () => controller.abort();
|
|
85
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
77
88
|
const timeoutId = setTimeout(() => {
|
|
78
89
|
this.abortController?.abort();
|
|
79
90
|
}, this.timeout);
|
|
@@ -82,7 +93,7 @@ export class HTTPTransport extends BaseTransport {
|
|
|
82
93
|
method: 'POST',
|
|
83
94
|
headers: this.buildHeaders(),
|
|
84
95
|
body: JSON.stringify(message),
|
|
85
|
-
signal:
|
|
96
|
+
signal: controller.signal,
|
|
86
97
|
});
|
|
87
98
|
clearTimeout(timeoutId);
|
|
88
99
|
if (!response.ok) {
|
|
@@ -117,6 +128,11 @@ export class HTTPTransport extends BaseTransport {
|
|
|
117
128
|
}
|
|
118
129
|
throw error;
|
|
119
130
|
}
|
|
131
|
+
finally {
|
|
132
|
+
if (signal && abortListener) {
|
|
133
|
+
signal.removeEventListener('abort', abortListener);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
120
136
|
}
|
|
121
137
|
/**
|
|
122
138
|
* Handle a streaming HTTP response (text/event-stream).
|
|
@@ -177,7 +193,9 @@ export class HTTPTransport extends BaseTransport {
|
|
|
177
193
|
}
|
|
178
194
|
catch (error) {
|
|
179
195
|
// Log streaming parse errors for visibility
|
|
180
|
-
const preview = data.length > DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW
|
|
196
|
+
const preview = data.length > DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW
|
|
197
|
+
? `${data.substring(0, DISPLAY_LIMITS.TRANSPORT_DATA_PREVIEW)}...`
|
|
198
|
+
: data;
|
|
181
199
|
this.logger.warn({ preview, error: error instanceof Error ? error.message : String(error) }, 'Failed to parse SSE message');
|
|
182
200
|
}
|
|
183
201
|
}
|
|
@@ -189,7 +207,9 @@ export class HTTPTransport extends BaseTransport {
|
|
|
189
207
|
}
|
|
190
208
|
catch {
|
|
191
209
|
// Not JSON - this is common for non-JSON lines in streams, log only in debug
|
|
192
|
-
this.log('Skipping non-JSON line', {
|
|
210
|
+
this.log('Skipping non-JSON line', {
|
|
211
|
+
preview: trimmedLine.substring(0, DISPLAY_LIMITS.RESPONSE_DATA_PREVIEW),
|
|
212
|
+
});
|
|
193
213
|
}
|
|
194
214
|
}
|
|
195
215
|
}
|
|
@@ -130,27 +130,39 @@ export declare class MCPClient {
|
|
|
130
130
|
/**
|
|
131
131
|
* List all tools available on the server.
|
|
132
132
|
*/
|
|
133
|
-
listTools(
|
|
133
|
+
listTools(options?: {
|
|
134
|
+
signal?: AbortSignal;
|
|
135
|
+
}): Promise<MCPTool[]>;
|
|
134
136
|
/**
|
|
135
137
|
* List all prompts available on the server.
|
|
136
138
|
*/
|
|
137
|
-
listPrompts(
|
|
139
|
+
listPrompts(options?: {
|
|
140
|
+
signal?: AbortSignal;
|
|
141
|
+
}): Promise<MCPPrompt[]>;
|
|
138
142
|
/**
|
|
139
143
|
* List all resources available on the server.
|
|
140
144
|
*/
|
|
141
|
-
listResources(
|
|
145
|
+
listResources(options?: {
|
|
146
|
+
signal?: AbortSignal;
|
|
147
|
+
}): Promise<MCPResource[]>;
|
|
142
148
|
/**
|
|
143
149
|
* Read a resource from the server by URI.
|
|
144
150
|
*/
|
|
145
|
-
readResource(uri: string
|
|
151
|
+
readResource(uri: string, options?: {
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
}): Promise<MCPResourceReadResult>;
|
|
146
154
|
/**
|
|
147
155
|
* Get a prompt from the server with the given arguments.
|
|
148
156
|
*/
|
|
149
|
-
getPrompt(name: string, args?: Record<string, string
|
|
157
|
+
getPrompt(name: string, args?: Record<string, string>, options?: {
|
|
158
|
+
signal?: AbortSignal;
|
|
159
|
+
}): Promise<MCPPromptGetResult>;
|
|
150
160
|
/**
|
|
151
161
|
* Call a tool on the server.
|
|
152
162
|
*/
|
|
153
|
-
callTool(name: string, args?: Record<string, unknown
|
|
163
|
+
callTool(name: string, args?: Record<string, unknown>, options?: {
|
|
164
|
+
signal?: AbortSignal;
|
|
165
|
+
}): Promise<MCPToolCallResult>;
|
|
154
166
|
/**
|
|
155
167
|
* Get server capabilities.
|
|
156
168
|
*/
|