@dotsetlabs/bellwether 2.1.2 → 2.1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -2
  3. package/dist/baseline/golden-output.d.ts +0 -4
  4. package/dist/baseline/golden-output.js +2 -47
  5. package/dist/cli/commands/baseline-accept.js +14 -45
  6. package/dist/cli/commands/baseline.js +23 -78
  7. package/dist/cli/commands/check-formatters.d.ts +10 -0
  8. package/dist/cli/commands/check-formatters.js +160 -0
  9. package/dist/cli/commands/check.js +33 -241
  10. package/dist/cli/commands/contract.js +1 -13
  11. package/dist/cli/commands/explore.js +19 -66
  12. package/dist/cli/commands/watch.js +2 -3
  13. package/dist/cli/output.d.ts +0 -42
  14. package/dist/cli/output.js +73 -110
  15. package/dist/cli/utils/config-loader.d.ts +6 -0
  16. package/dist/cli/utils/config-loader.js +19 -0
  17. package/dist/cli/utils/error-hints.d.ts +9 -0
  18. package/dist/cli/utils/error-hints.js +128 -0
  19. package/dist/cli/utils/headers.js +2 -25
  20. package/dist/cli/utils/path-resolution.d.ts +10 -0
  21. package/dist/cli/utils/path-resolution.js +27 -0
  22. package/dist/cli/utils/report-loader.d.ts +9 -0
  23. package/dist/cli/utils/report-loader.js +31 -0
  24. package/dist/cli/utils/server-runtime.d.ts +16 -0
  25. package/dist/cli/utils/server-runtime.js +31 -0
  26. package/dist/config/defaults.d.ts +0 -1
  27. package/dist/config/defaults.js +0 -1
  28. package/dist/constants/core.d.ts +0 -42
  29. package/dist/constants/core.js +0 -50
  30. package/dist/contract/validator.js +2 -47
  31. package/dist/interview/question-category.d.ts +5 -0
  32. package/dist/interview/question-category.js +2 -0
  33. package/dist/interview/question-types.d.ts +80 -0
  34. package/dist/interview/question-types.js +2 -0
  35. package/dist/interview/schema-test-generator.d.ts +3 -29
  36. package/dist/interview/schema-test-generator.js +11 -286
  37. package/dist/interview/test-fixtures.d.ts +19 -0
  38. package/dist/interview/test-fixtures.js +2 -0
  39. package/dist/interview/types.d.ts +5 -80
  40. package/dist/persona/types.d.ts +3 -5
  41. package/dist/scenarios/types.d.ts +1 -1
  42. package/dist/transport/auth-errors.d.ts +15 -0
  43. package/dist/transport/auth-errors.js +22 -0
  44. package/dist/transport/http-transport.js +7 -9
  45. package/dist/transport/mcp-client.d.ts +0 -4
  46. package/dist/transport/mcp-client.js +13 -37
  47. package/dist/transport/sse-transport.d.ts +0 -1
  48. package/dist/transport/sse-transport.js +13 -28
  49. package/dist/utils/content-type.d.ts +14 -0
  50. package/dist/utils/content-type.js +37 -0
  51. package/dist/utils/http-headers.d.ts +9 -0
  52. package/dist/utils/http-headers.js +34 -0
  53. package/dist/utils/smart-truncate.js +2 -23
  54. package/package.json +2 -2
@@ -5,11 +5,14 @@ import type { ResponseSchemaEvolution } from '../baseline/response-schema-tracke
5
5
  import type { ErrorAnalysisSummary } from '../baseline/error-analyzer.js';
6
6
  import type { DocumentationScore } from '../baseline/documentation-scorer.js';
7
7
  import type { SemanticInference } from '../validation/semantic-types.js';
8
- import type { Persona, QuestionCategory } from '../persona/types.js';
8
+ import type { Persona } from '../persona/types.js';
9
+ import type { QuestionCategory } from './question-category.js';
9
10
  import type { Workflow, WorkflowResult, WorkflowTimeoutConfig } from '../workflow/types.js';
10
11
  import type { LoadedScenarios, ScenarioResult } from '../scenarios/types.js';
11
12
  import type { ToolResponseCache } from '../cache/response-cache.js';
12
- import type { TestFixturesConfig } from './schema-test-generator.js';
13
+ import type { TestFixturesConfig } from './test-fixtures.js';
14
+ import type { InterviewQuestion, OutcomeAssessment } from './question-types.js';
15
+ export type { ExpectedOutcome, InterviewQuestion, OutcomeAssessment } from './question-types.js';
13
16
  /**
14
17
  * Server context extracted during discovery/initial probing.
15
18
  * Used to generate contextually appropriate test cases.
@@ -112,84 +115,6 @@ export interface InterviewConfig {
112
115
  /** Test fixtures for overriding default parameter values */
113
116
  testFixtures?: TestFixturesConfig;
114
117
  }
115
- /**
116
- * Expected outcome for a test question.
117
- * - 'success': Test expects the tool to execute successfully
118
- * - 'error': Test expects the tool to reject/fail (validation test)
119
- * - 'either': Test outcome is acceptable either way
120
- */
121
- export type ExpectedOutcome = 'success' | 'error' | 'either';
122
- /**
123
- * A question to ask about a tool's behavior.
124
- */
125
- export interface InterviewQuestion {
126
- /** Description of what this question tests */
127
- description: string;
128
- /** Category of question */
129
- category: QuestionCategory;
130
- /** Arguments to pass to the tool */
131
- args: Record<string, unknown>;
132
- /**
133
- * Expected outcome of this test.
134
- * Used to determine if the tool behaved correctly.
135
- * - 'success': Expects successful execution (happy path)
136
- * - 'error': Expects rejection/error (validation test)
137
- * - 'either': Either outcome is acceptable
138
- */
139
- expectedOutcome?: ExpectedOutcome;
140
- /** Semantic validation metadata (for tests generated from semantic type inference) */
141
- metadata?: {
142
- /** The inferred semantic type being tested */
143
- semanticType?: string;
144
- /** Expected behavior: 'reject' for invalid values, 'accept' for valid */
145
- expectedBehavior?: 'reject' | 'accept';
146
- /** Confidence level of the semantic type inference (0-1) */
147
- confidence?: number;
148
- /** Stateful testing metadata */
149
- stateful?: {
150
- /** Keys injected from prior tool outputs */
151
- usedKeys?: string[];
152
- /** Keys captured from this response */
153
- providedKeys?: string[];
154
- };
155
- /**
156
- * Whether this tool uses operation-based dispatch pattern.
157
- * Tools with this pattern have different required args per operation.
158
- */
159
- operationBased?: boolean;
160
- /** The parameter name that selects the operation (e.g., "operation", "action") */
161
- operationParam?: string;
162
- /** The parameter name that holds operation-specific args (e.g., "args", "params") */
163
- argsParam?: string;
164
- /**
165
- * Whether this tool requires prior state (session, chain, etc.).
166
- * These tools need an active session before they can work.
167
- */
168
- selfStateful?: boolean;
169
- /** Reason for self-stateful detection */
170
- selfStatefulReason?: string;
171
- /**
172
- * Whether this tool has complex array schemas requiring structured data.
173
- * Simple test data generation often fails for these tools.
174
- */
175
- hasComplexArrays?: boolean;
176
- /** Array parameters with complex item schemas */
177
- complexArrayParams?: string[];
178
- };
179
- }
180
- /**
181
- * Assessment of whether a tool interaction outcome matched expectations.
182
- */
183
- export interface OutcomeAssessment {
184
- /** What outcome was expected */
185
- expected: ExpectedOutcome;
186
- /** What actually happened */
187
- actual: 'success' | 'error';
188
- /** Whether the tool behaved correctly (matches expectation) */
189
- correct: boolean;
190
- /** True if this was a validation test that correctly rejected invalid input */
191
- isValidationSuccess?: boolean;
192
- }
193
118
  /**
194
119
  * Result of asking a single question.
195
120
  */
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Persona types for configurable interviewer personalities.
3
3
  */
4
- import type { InterviewQuestion } from '../interview/types.js';
5
- /**
6
- * Question categories that can be weighted by personas.
7
- */
8
- export type QuestionCategory = 'happy_path' | 'edge_case' | 'error_handling' | 'boundary' | 'security';
4
+ import type { InterviewQuestion } from '../interview/question-types.js';
5
+ import type { QuestionCategory } from '../interview/question-category.js';
6
+ export type { QuestionCategory } from '../interview/question-category.js';
9
7
  /**
10
8
  * Weight distribution for question categories.
11
9
  * Values should be 0-1 and represent relative likelihood.
@@ -4,7 +4,7 @@
4
4
  * These types define the schema for user-defined test cases
5
5
  * that can be provided via bellwether-tests.yaml files.
6
6
  */
7
- import type { QuestionCategory } from '../persona/types.js';
7
+ import type { QuestionCategory } from '../interview/question-category.js';
8
8
  /**
9
9
  * Valid assertion conditions for scenario expectations.
10
10
  */
@@ -0,0 +1,15 @@
1
+ import { ServerAuthError } from '../errors/types.js';
2
+ interface AuthErrorConfig {
3
+ unauthorizedMessage: string;
4
+ forbiddenMessage: string;
5
+ proxyMessage?: string;
6
+ unauthorizedHint?: string;
7
+ forbiddenHint?: string;
8
+ proxyHint?: string;
9
+ }
10
+ /**
11
+ * Map HTTP auth-related status codes to typed transport auth errors.
12
+ */
13
+ export declare function createServerAuthError(status: number, config: AuthErrorConfig): ServerAuthError | null;
14
+ export {};
15
+ //# sourceMappingURL=auth-errors.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { ServerAuthError } from '../errors/types.js';
2
+ const DEFAULT_HINTS = {
3
+ unauthorized: 'Add server.headers.Authorization (for example: Bearer token) in bellwether.yaml or pass --header.',
4
+ forbidden: 'Credentials are recognized but lack required permissions. Verify token scopes/roles.',
5
+ proxy: 'Configure proxy credentials and retry.',
6
+ };
7
+ /**
8
+ * Map HTTP auth-related status codes to typed transport auth errors.
9
+ */
10
+ export function createServerAuthError(status, config) {
11
+ if (status === 401) {
12
+ return new ServerAuthError(config.unauthorizedMessage, 401, config.unauthorizedHint ?? DEFAULT_HINTS.unauthorized);
13
+ }
14
+ if (status === 403) {
15
+ return new ServerAuthError(config.forbiddenMessage, 403, config.forbiddenHint ?? DEFAULT_HINTS.forbidden);
16
+ }
17
+ if (status === 407) {
18
+ return new ServerAuthError(config.proxyMessage ?? 'Proxy authentication required (407)', 407, config.proxyHint ?? DEFAULT_HINTS.proxy);
19
+ }
20
+ return null;
21
+ }
22
+ //# sourceMappingURL=auth-errors.js.map
@@ -1,6 +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
+ import { createServerAuthError } from './auth-errors.js';
4
4
  /**
5
5
  * HTTPTransport connects to MCP servers over HTTP using POST requests.
6
6
  *
@@ -112,14 +112,12 @@ export class HTTPTransport extends BaseTransport {
112
112
  });
113
113
  clearTimeout(timeoutId);
114
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.');
115
+ const authError = createServerAuthError(response.status, {
116
+ unauthorizedMessage: 'Remote MCP server authentication failed (401 Unauthorized)',
117
+ forbiddenMessage: 'Remote MCP server authorization failed (403 Forbidden)',
118
+ });
119
+ if (authError) {
120
+ throw authError;
123
121
  }
124
122
  // MCP 2025-11-25: 404 means session expired, clear session ID
125
123
  if (response.status === 404 && this.sessionId) {
@@ -60,10 +60,6 @@ export declare class MCPClient {
60
60
  /** Whether to run an explicit preflight before remote connection */
61
61
  private remotePreflight;
62
62
  constructor(options?: MCPClientOptions);
63
- /**
64
- * Merge two header maps, giving precedence to override values.
65
- */
66
- private mergeHeaders;
67
63
  /**
68
64
  * Optional remote connectivity/auth preflight (enabled by default).
69
65
  * Uses GET (not HEAD/OPTIONS) for broader compatibility.
@@ -8,6 +8,8 @@ import { VERSION } from '../version.js';
8
8
  import { getFeatureFlags, isKnownProtocolVersion, } from '../protocol/index.js';
9
9
  import { filterSpawnEnv } from './env-filter.js';
10
10
  import { ConnectionError, ServerAuthError } from '../errors/types.js';
11
+ import { mergeHeaderMaps } from '../utils/http-headers.js';
12
+ import { createServerAuthError } from './auth-errors.js';
11
13
  const DEFAULT_TIMEOUT = TIMEOUTS.DEFAULT;
12
14
  const DEFAULT_STARTUP_DELAY = TIMEOUTS.SERVER_STARTUP;
13
15
  /**
@@ -56,31 +58,6 @@ export class MCPClient {
56
58
  this.httpConfig = options?.httpConfig;
57
59
  this.remotePreflight = options?.remotePreflight ?? true;
58
60
  }
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
61
  /**
85
62
  * Optional remote connectivity/auth preflight (enabled by default).
86
63
  * Uses GET (not HEAD/OPTIONS) for broader compatibility.
@@ -112,17 +89,16 @@ export class MCPClient {
112
89
  // Ignore body cancellation failures; preflight status handling is primary.
113
90
  }
114
91
  };
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) {
92
+ const authError = createServerAuthError(response.status, {
93
+ unauthorizedMessage: `Remote MCP preflight failed: unauthorized (${response.status})`,
94
+ forbiddenMessage: `Remote MCP preflight failed: forbidden (${response.status})`,
95
+ proxyMessage: `Remote MCP preflight failed: proxy authentication required (${response.status})`,
96
+ unauthorizedHint: 'Add authentication headers (for example Authorization) and retry.',
97
+ forbiddenHint: 'Credentials are valid but lack required permissions/scopes.',
98
+ });
99
+ if (authError) {
124
100
  await closePreflightBody();
125
- throw new ServerAuthError(`Remote MCP preflight failed: proxy authentication required (${response.status})`, response.status, 'Configure proxy credentials and retry.');
101
+ throw authError;
126
102
  }
127
103
  if (!response.ok) {
128
104
  // Non-auth HTTP statuses are compatibility-safe to continue:
@@ -388,8 +364,8 @@ export class MCPClient {
388
364
  };
389
365
  this.logger.info({ url, transport }, 'Connecting to remote MCP server');
390
366
  const mergedHeaders = transport === 'sse'
391
- ? this.mergeHeaders(this.sseConfig?.headers, options?.headers)
392
- : this.mergeHeaders(this.httpConfig?.headers, options?.headers);
367
+ ? mergeHeaderMaps(this.sseConfig?.headers, options?.headers)
368
+ : mergeHeaderMaps(this.httpConfig?.headers, options?.headers);
393
369
  await this.preflightRemote(url, transport, mergedHeaders);
394
370
  if (transport === 'sse') {
395
371
  const sseTransport = new SSETransport({
@@ -30,7 +30,6 @@ export interface SSETransportConfig extends BaseTransportConfig {
30
30
  */
31
31
  export declare class SSETransport extends BaseTransport {
32
32
  private streamAbortController;
33
- private abortController;
34
33
  private connected;
35
34
  private reconnectAttempts;
36
35
  private readonly baseUrl;
@@ -1,7 +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
+ import { createServerAuthError } from './auth-errors.js';
5
5
  /**
6
6
  * Validate that a URL uses HTTPS in production contexts.
7
7
  * Allows HTTP only for localhost/127.0.0.1 for local development.
@@ -103,7 +103,6 @@ class SSEParser {
103
103
  */
104
104
  export class SSETransport extends BaseTransport {
105
105
  streamAbortController = null;
106
- abortController = null;
107
106
  connected = false;
108
107
  reconnectAttempts = 0;
109
108
  baseUrl;
@@ -162,14 +161,12 @@ export class SSETransport extends BaseTransport {
162
161
  signal: this.streamAbortController.signal,
163
162
  });
164
163
  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.');
164
+ const authError = createServerAuthError(response.status, {
165
+ unauthorizedMessage: 'Remote MCP SSE authentication failed (401 Unauthorized)',
166
+ forbiddenMessage: 'Remote MCP SSE authorization failed (403 Forbidden)',
167
+ });
168
+ if (authError) {
169
+ throw authError;
173
170
  }
174
171
  throw new Error(`Failed to connect to SSE endpoint: HTTP ${response.status}`);
175
172
  }
@@ -344,14 +341,12 @@ export class SSETransport extends BaseTransport {
344
341
  .then(async (response) => {
345
342
  clearTimeout(timeoutId);
346
343
  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.');
344
+ const authError = createServerAuthError(response.status, {
345
+ unauthorizedMessage: 'Remote MCP message authentication failed (401 Unauthorized)',
346
+ forbiddenMessage: 'Remote MCP message authorization failed (403 Forbidden)',
347
+ });
348
+ if (authError) {
349
+ throw authError;
355
350
  }
356
351
  const errorText = await response.text().catch(() => 'Unknown error');
357
352
  throw new Error(`HTTP ${response.status}: ${errorText}`);
@@ -404,16 +399,6 @@ export class SSETransport extends BaseTransport {
404
399
  }
405
400
  this.streamAbortController = null;
406
401
  }
407
- // Abort any in-flight HTTP requests
408
- if (this.abortController) {
409
- try {
410
- this.abortController.abort();
411
- }
412
- catch {
413
- // Ignore abort errors
414
- }
415
- this.abortController = null;
416
- }
417
402
  this.messageEndpoint = null;
418
403
  this.reconnectAttempts = 0;
419
404
  this.emit('close');
@@ -0,0 +1,14 @@
1
+ export type DetectedContentType = 'json' | 'markdown' | 'text';
2
+ export interface DetectContentTypeOptions {
3
+ /**
4
+ * Markdown detection mode:
5
+ * - `strict`: conservative heading/link/fence patterns (legacy contract/golden behavior)
6
+ * - `lenient`: broader markdown indicators (legacy smart-truncate behavior)
7
+ */
8
+ markdownHeuristics?: 'strict' | 'lenient';
9
+ }
10
+ /**
11
+ * Detect whether content is JSON, Markdown, or plain text.
12
+ */
13
+ export declare function detectContentType(content: string, options?: DetectContentTypeOptions): DetectedContentType;
14
+ //# sourceMappingURL=content-type.d.ts.map
@@ -0,0 +1,37 @@
1
+ function looksLikeMarkdownStrict(trimmed) {
2
+ return /^#|^\*{1,3}[^*]|\[.*\]\(.*\)|^```/.test(trimmed);
3
+ }
4
+ function looksLikeMarkdownLenient(trimmed) {
5
+ if (looksLikeMarkdownStrict(trimmed)) {
6
+ return true;
7
+ }
8
+ return (trimmed.includes('\n#') ||
9
+ /^[-*]\s/.test(trimmed) ||
10
+ /^\d+\.\s/.test(trimmed) ||
11
+ trimmed.includes('```') ||
12
+ trimmed.includes('**') ||
13
+ trimmed.includes('__'));
14
+ }
15
+ /**
16
+ * Detect whether content is JSON, Markdown, or plain text.
17
+ */
18
+ export function detectContentType(content, options = {}) {
19
+ const mode = options.markdownHeuristics ?? 'strict';
20
+ const trimmed = content.trim();
21
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
22
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
23
+ try {
24
+ JSON.parse(trimmed);
25
+ return 'json';
26
+ }
27
+ catch {
28
+ // Not valid JSON
29
+ }
30
+ }
31
+ const isMarkdown = mode === 'lenient' ? looksLikeMarkdownLenient(trimmed) : looksLikeMarkdownStrict(trimmed);
32
+ if (isMarkdown) {
33
+ return 'markdown';
34
+ }
35
+ return 'text';
36
+ }
37
+ //# sourceMappingURL=content-type.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Merge HTTP header maps case-insensitively, preserving latest key casing.
3
+ */
4
+ export declare function mergeHeaderMaps(base?: Record<string, string>, override?: Record<string, string>): Record<string, string> | undefined;
5
+ /**
6
+ * Set a header while treating header names as case-insensitive.
7
+ */
8
+ export declare function setHeaderCaseInsensitive(headers: Record<string, string>, name: string, value: string): void;
9
+ //# sourceMappingURL=http-headers.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Merge HTTP header maps case-insensitively, preserving latest key casing.
3
+ */
4
+ export function mergeHeaderMaps(base, override) {
5
+ if (!base && !override) {
6
+ return undefined;
7
+ }
8
+ const merged = {};
9
+ if (base) {
10
+ for (const [name, value] of Object.entries(base)) {
11
+ setHeaderCaseInsensitive(merged, name, value);
12
+ }
13
+ }
14
+ if (override) {
15
+ for (const [name, value] of Object.entries(override)) {
16
+ setHeaderCaseInsensitive(merged, name, value);
17
+ }
18
+ }
19
+ return Object.keys(merged).length > 0 ? merged : undefined;
20
+ }
21
+ /**
22
+ * Set a header while treating header names as case-insensitive.
23
+ */
24
+ export function setHeaderCaseInsensitive(headers, name, value) {
25
+ const normalized = name.toLowerCase();
26
+ for (const existing of Object.keys(headers)) {
27
+ if (existing.toLowerCase() === normalized) {
28
+ delete headers[existing];
29
+ break;
30
+ }
31
+ }
32
+ headers[name] = value;
33
+ }
34
+ //# sourceMappingURL=http-headers.js.map
@@ -11,6 +11,7 @@
11
11
  * - Provide helpful truncation indicators
12
12
  */
13
13
  import { EXAMPLE_OUTPUT } from '../constants.js';
14
+ import { detectContentType as detectGeneralContentType } from './content-type.js';
14
15
  // ==================== Main Functions ====================
15
16
  /**
16
17
  * Smart truncate content based on type.
@@ -321,29 +322,7 @@ function truncateAtSentence(text, maxLength) {
321
322
  * @returns Detected content type
322
323
  */
323
324
  export function detectContentType(content) {
324
- const trimmed = content.trim();
325
- // Check for JSON
326
- if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
327
- (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
328
- try {
329
- JSON.parse(trimmed);
330
- return 'json';
331
- }
332
- catch {
333
- // Not valid JSON
334
- }
335
- }
336
- // Check for Markdown indicators
337
- if (trimmed.includes('\n#') ||
338
- trimmed.startsWith('#') ||
339
- /^[-*]\s/.test(trimmed) ||
340
- /^\d+\.\s/.test(trimmed) ||
341
- trimmed.includes('```') ||
342
- trimmed.includes('**') ||
343
- trimmed.includes('__')) {
344
- return 'markdown';
345
- }
346
- return 'text';
325
+ return detectGeneralContentType(content, { markdownHeuristics: 'lenient' });
347
326
  }
348
327
  /**
349
328
  * Get the appropriate example length based on options.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotsetlabs/bellwether",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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",
@@ -34,7 +34,7 @@
34
34
  "format:check": "prettier --check \"src/**/*.ts\"",
35
35
  "check:consistency": "node ./scripts/validate-consistency.mjs",
36
36
  "clean": "rm -rf dist",
37
- "docs:generate": "npm --prefix website run build",
37
+ "docs:generate": "node ./scripts/build-docs.mjs",
38
38
  "docs:dev": "npm --prefix website run start",
39
39
  "prepublishOnly": "npm run build"
40
40
  },