Skip to content

Commit 5824d96

Browse files
author
FluxLuFFy
committed
fix: WebSearch providers + MCPTool bugs
WebSearchTool: - custom.ts: fix buildAuthHeadersForPreset WEB_AUTH_HEADER opt-out - custom.ts: fix WEB_AUTH_SCHEME empty string handling - custom.ts: fix walkJsonPath null safety for jsonPath parsing - duckduckgo.ts: use SafeSearchType enum instead of raw 0 - mojeek.ts: always send Accept: application/json header - README: fix timeout documentation (15s -> 120s to match code) - custom.test.ts: add tests for auth header behavior MCPTool: - MCPTool.ts: fix outputSchema to accept ContentBlockParam[] (not just string) - MCPTool.ts: fix isResultTruncated for array output (iterates text blocks)
1 parent f4ac709 commit 5824d96

6 files changed

Lines changed: 84 additions & 12 deletions

File tree

src/tools/MCPTool/MCPTool.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,21 @@ import {
1414
export const inputSchema = lazySchema(() => z.object({}).passthrough())
1515
type InputSchema = ReturnType<typeof inputSchema>
1616

17+
// MCP tools can return either a plain string or an array of content blocks
18+
// (text, images, etc.). The outputSchema must reflect both shapes so the model
19+
// knows rich content is possible.
1720
export const outputSchema = lazySchema(() =>
18-
z.string().describe('MCP tool execution result'),
21+
z.union([
22+
z.string().describe('MCP tool execution result as text'),
23+
z
24+
.array(
25+
z.object({
26+
type: z.string(),
27+
text: z.string().optional(),
28+
}),
29+
)
30+
.describe('MCP tool execution result as content blocks'),
31+
]),
1932
)
2033
type OutputSchema = ReturnType<typeof outputSchema>
2134

@@ -65,7 +78,19 @@ export const MCPTool = buildTool({
6578
renderToolUseProgressMessage,
6679
renderToolResultMessage,
6780
isResultTruncated(output: Output): boolean {
68-
return isOutputLineTruncated(output)
81+
if (typeof output === 'string') {
82+
return isOutputLineTruncated(output)
83+
}
84+
// Array of content blocks — check if any text block exceeds the display limit
85+
if (Array.isArray(output)) {
86+
return output.some(
87+
block =>
88+
block?.type === 'text' &&
89+
typeof block.text === 'string' &&
90+
isOutputLineTruncated(block.text),
91+
)
92+
}
93+
return false
6994
},
7095
mapToolResultToToolResultBlockParam(content, toolUseID) {
7196
return {

src/tools/WebSearchTool/README_SEARCH_PROVIDERS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ export WEB_JSON_PATH=response.payload.results
464464

465465
## Retry
466466

467-
Failed requests (network errors, 5xx) are retried once after 500ms. Client errors (4xx) are not retried. Custom requests have a default 15s timeout.
467+
Failed requests (network errors, 5xx) are retried once after 500ms. Client errors (4xx) are not retried. Custom requests have a default 120s timeout.
468468

469469
## Custom Provider Security Guardrails
470470

@@ -476,7 +476,7 @@ The custom provider enforces the following guardrails by default:
476476
| Block private IPs / localhost || `WEB_CUSTOM_ALLOW_PRIVATE=true` |
477477
| Header allowlist || `WEB_CUSTOM_ALLOW_ARBITRARY_HEADERS=true` |
478478
| Max POST body | 300 KB | `WEB_CUSTOM_MAX_BODY_KB=<kb>` |
479-
| Request timeout | 15s | `WEB_CUSTOM_TIMEOUT_SEC=<seconds>` |
479+
| Request timeout | 120s | `WEB_CUSTOM_TIMEOUT_SEC=<seconds>` |
480480
| Audit log (one-time warning) |||
481481

482482
### Self-hosted SearXNG example

src/tools/WebSearchTool/providers/custom.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test } from 'bun:test'
1+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
22
import { extractHits } from './custom.js'
33

44
// ---------------------------------------------------------------------------
@@ -83,3 +83,41 @@ describe('extractHits', () => {
8383
expect(hits).toHaveLength(1)
8484
})
8585
})
86+
87+
// ---------------------------------------------------------------------------
88+
// buildAuthHeadersForPreset — tested indirectly via env vars
89+
// ---------------------------------------------------------------------------
90+
91+
describe('buildAuthHeadersForPreset auth header behavior', () => {
92+
const savedEnv: Record<string, string | undefined> = {}
93+
94+
beforeEach(() => {
95+
for (const k of ['WEB_KEY', 'WEB_AUTH_HEADER', 'WEB_AUTH_SCHEME']) {
96+
savedEnv[k] = process.env[k]
97+
}
98+
})
99+
100+
afterEach(() => {
101+
for (const [k, v] of Object.entries(savedEnv)) {
102+
if (v === undefined) delete process.env[k]
103+
else process.env[k] = v
104+
}
105+
})
106+
107+
// We test isConfigured() which depends on WEB_SEARCH_API/WEB_PROVIDER/WEB_URL_TEMPLATE
108+
// and the auth behavior through the public search() interface
109+
test('custom provider is configured when WEB_URL_TEMPLATE is set', () => {
110+
process.env.WEB_URL_TEMPLATE = 'https://example.com/search?q={query}'
111+
const { customProvider } = require('./custom.js')
112+
expect(customProvider.isConfigured()).toBe(true)
113+
delete process.env.WEB_URL_TEMPLATE
114+
})
115+
116+
test('custom provider is NOT configured when no env vars are set', () => {
117+
delete process.env.WEB_URL_TEMPLATE
118+
delete process.env.WEB_SEARCH_API
119+
delete process.env.WEB_PROVIDER
120+
const { customProvider } = require('./custom.js')
121+
expect(customProvider.isConfigured()).toBe(false)
122+
})
123+
})

src/tools/WebSearchTool/providers/custom.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,14 @@ function buildAuthHeadersForPreset(preset?: ProviderPreset): Record<string, stri
225225
const apiKey = process.env.WEB_KEY
226226
if (!apiKey) return {}
227227

228-
const headerName = process.env.WEB_AUTH_HEADER ?? preset?.authHeader ?? 'Authorization'
229-
const scheme = process.env.WEB_AUTH_SCHEME ?? preset?.authScheme ?? 'Bearer'
228+
// WEB_AUTH_HEADER="" is an explicit opt-out of auth headers entirely
229+
const explicitHeader = process.env.WEB_AUTH_HEADER
230+
if (explicitHeader === '') return {}
231+
232+
const headerName = explicitHeader ?? preset?.authHeader ?? 'Authorization'
233+
const scheme = process.env.WEB_AUTH_SCHEME !== undefined
234+
? process.env.WEB_AUTH_SCHEME
235+
: (preset?.authScheme ?? 'Bearer')
230236
return { [headerName]: `${scheme} ${apiKey}`.trim() }
231237
}
232238

@@ -350,7 +356,7 @@ function buildRequest(query: string) {
350356
function walkJsonPath(obj: any, path: string): any {
351357
let current = obj
352358
for (const seg of path.split('.')) {
353-
if (current == null) return undefined
359+
if (current == null || typeof current !== 'object') return undefined
354360
current = current[seg]
355361
}
356362
return current
@@ -364,6 +370,7 @@ function extractFromNode(node: any): SearchHit[] {
364370
for (const sub of Object.values(node)) all.push(...extractFromNode(sub))
365371
return all
366372
}
373+
// node is a primitive (string/number) — not a valid hit structure
367374
return []
368375
}
369376

src/tools/WebSearchTool/providers/duckduckgo.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ export const duckduckgoProvider: SearchProvider = {
1212
async search(input: SearchInput, signal?: AbortSignal): Promise<ProviderOutput> {
1313
const start = performance.now()
1414
let search: typeof import('duck-duck-scrape').search
15+
let SafeSearchType: typeof import('duck-duck-scrape').SafeSearchType
1516
try {
16-
;({ search } = await import('duck-duck-scrape'))
17+
;({ search, SafeSearchType } = await import('duck-duck-scrape'))
1718
} catch {
1819
throw new Error('duck-duck-scrape package not installed. Run: npm install duck-duck-scrape')
1920
}
2021
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
2122
// TODO: duck-duck-scrape doesn't accept AbortSignal — can't cancel in-flight searches
22-
const response = await search(input.query, { safeSearch: 0 })
23+
const response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
2324

2425
const hits = applyDomainFilters(
2526
response.results.map(r => ({

src/tools/WebSearchTool/providers/mojeek.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ export const mojeekProvider: SearchProvider = {
2121
url.searchParams.set('q', input.query)
2222
url.searchParams.set('fmt', 'json')
2323

24-
const headers: Record<string, string> = {}
24+
const headers: Record<string, string> = {
25+
'Accept': 'application/json',
26+
}
2527
if (process.env.MOJEEK_API_KEY) {
26-
headers['Accept'] = 'application/json'
2728
headers['Authorization'] = `Bearer ${process.env.MOJEEK_API_KEY}`
2829
}
2930

0 commit comments

Comments
 (0)