mcp-researchpowerpack 6.0.10 → 6.0.11

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/README.md CHANGED
@@ -8,9 +8,9 @@ Built on [mcp-use](https://github.com/nicepkg/mcp-use). No stdio, HTTP only.
8
8
 
9
9
  | tool | what it does | needs |
10
10
  |------|-------------|-------|
11
- | `start-research` | returns a goal-tailored brief: `primary_branch` (reddit / web / both), exact `first_call_sequence`, 25–50 keyword seeds, iteration hints, gaps to watch, stop criteria. Call FIRST every session. | `LLM_API_KEY` (brief generation) |
11
+ | `start-research` | returns a goal-tailored brief: `primary_branch` (reddit / web / both), exact `first_call_sequence`, 25–50 keyword seeds, iteration hints, gaps to watch, stop criteria. Call FIRST every session. | `LLM_API_KEY` + `LLM_BASE_URL` + `LLM_MODEL` for non-degraded brief generation (optional) |
12
12
  | `web-search` | parallel Google search, up to 50 queries per call, parallel-callable across turns. `scope: "web" \| "reddit" \| "both"` — reddit mode filters to post permalinks. Returns tiered markdown (HIGHLY_RELEVANT / MAYBE_RELEVANT / OTHER) + grounded synthesis + gaps + refine suggestions. | `SERPER_API_KEY` |
13
- | `scrape-links` | fetch URLs in parallel with per-URL LLM extraction. Auto-detects `reddit.com/r/.../comments/` permalinks and routes them through the Reddit API (threaded post + comments); every other URL flows through the HTTP scraper. Parallel-callable. | `SCRAPEDO_API_KEY` (+ `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` for reddit URLs) |
13
+ | `scrape-links` | fetch URLs in parallel with per-URL LLM extraction. Auto-detects `reddit.com/r/.../comments/` permalinks and routes them through the Reddit API (threaded post + comments); PDF / DOCX / PPTX / XLSX URLs route through Jina Reader; non-reddit, non-document web URLs flow through Scrape.do. Parallel-callable. | `SCRAPEDO_API_KEY` for web URLs (+ `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` for reddit URLs; optional `JINA_API_KEY` for higher document limits) |
14
14
 
15
15
  Also exposes `/health`, `health://status`, and two optional MCP prompts: `deep-research` and `reddit-sentiment`.
16
16
 
@@ -21,7 +21,7 @@ Call `start-research` once at the beginning of each session with your goal. The
21
21
  Pair the server with the [`run-research`](https://github.com/yigitkonur/skills-by-yigitkonur/tree/main/skills/run-research) skill for the full agentic playbook:
22
22
 
23
23
  ```bash
24
- npx -y skills add -y -g yigitkonur/skills-by-yigitkonur/skills/run-research
24
+ npx -y skills add -y -g https://github.com/yigitkonur/skills-by-yigitkonur --skill /run-research
25
25
  ```
26
26
 
27
27
  ## quickstart
@@ -66,9 +66,10 @@ Copy `.env.example`, set only what you need. Missing keys don't crash the server
66
66
  | var | enables |
67
67
  |-----|---------|
68
68
  | `SERPER_API_KEY` | `web-search` (all scopes) |
69
- | `SCRAPEDO_API_KEY` | `scrape-links` for non-reddit URLs |
69
+ | `SCRAPEDO_API_KEY` | `scrape-links` for non-reddit, non-document web URLs |
70
70
  | `REDDIT_CLIENT_ID` + `REDDIT_CLIENT_SECRET` | `scrape-links` for reddit.com permalinks (threaded post + comments) |
71
- | `LLM_API_KEY` | goal-tailored brief, AI extraction, search classification, raw-mode refine suggestions |
71
+ | `JINA_API_KEY` | optional higher-rate `scrape-links` document conversion for PDF / DOCX / PPTX / XLSX URLs via Jina Reader |
72
+ | `LLM_API_KEY` + `LLM_BASE_URL` + `LLM_MODEL` | goal-tailored brief, AI extraction, search classification, raw-mode refine suggestions |
72
73
 
73
74
  ### llm (AI extraction + classification)
74
75
 
@@ -111,7 +112,7 @@ pnpm inspect # mcp-use inspector
111
112
  Deploy to Manufact Cloud via the `mcp-use` CLI (GitHub-backed):
112
113
 
113
114
  ```bash
114
- pnpm deploy # runs: mcp-use deploy --org <your-org>
115
+ pnpm deploy # runs the package script: mcp-use deploy
115
116
  ```
116
117
 
117
118
  Or self-host anywhere with Node 20.19+ / 22.12+:
@@ -126,13 +127,13 @@ HOST=0.0.0.0 ALLOWED_ORIGINS=https://app.example.com pnpm start
126
127
  index.ts server startup, cors, health, shutdown
127
128
  src/
128
129
  config/ env parsing, capability detection, lazy proxy config
129
- clients/ provider API clients (serper, reddit, scrapedo)
130
+ clients/ provider API clients (serper, reddit, scrapedo, jina)
130
131
  prompts/ optional MCP prompts for deep-research and reddit-sentiment
131
132
  tools/
132
133
  registry.ts registerAllTools() — wires 3 tools + 2 prompts
133
134
  start-research.ts goal-tailored brief + static playbook
134
135
  search.ts web-search handler (with CTR-weighted URL aggregation + LLM classification)
135
- scrape.ts scrape-links handler (reddit + web branches in parallel)
136
+ scrape.ts scrape-links handler (reddit + web + document branches in parallel)
136
137
  mcp-helpers.ts response builders (markdown + structured MCP output)
137
138
  utils.ts shared formatters
138
139
  services/
@@ -149,7 +150,7 @@ src/
149
150
  logger.ts mcpLog() — stderr-only (MCP-safe)
150
151
  ```
151
152
 
152
- Key patterns: capability detection at startup, description-led tool routing (no bootstrap gate), always-on structured MCP tool output, tiered classified output in `web-search`, parallel reddit + web branches in `scrape-links`, bounded concurrency via `p-map`, CTR-based URL ranking, tools never throw (always return `toolFailure`), and structured errors with retry classification.
153
+ Key patterns: capability detection at startup, description-led tool routing (no bootstrap gate), always-on structured MCP tool output, tiered classified output in `web-search`, parallel reddit + web + document branches in `scrape-links`, Jina fallback for binary/document content, bounded concurrency via `p-map`, CTR-based URL ranking, tools never throw (always return `toolFailure`), and structured errors with retry classification.
153
154
 
154
155
  ## license
155
156
 
package/dist/mcp-use.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "includeInspector": false,
3
- "buildTime": "2026-04-28T08:35:39.704Z",
4
- "buildId": "4783158b494ab6e4",
3
+ "buildTime": "2026-04-29T11:17:03.370Z",
4
+ "buildId": "40a7197a31e9fb29",
5
5
  "entryPoint": "dist/index.js",
6
6
  "widgets": {}
7
7
  }
@@ -137,7 +137,7 @@ class SearchClient {
137
137
  searches: [],
138
138
  totalQueries: queries.length,
139
139
  executionTime: Date.now() - startTime,
140
- error
140
+ error: error ?? { code: ErrorCode.UNKNOWN_ERROR, message: "Search provider returned no data", retryable: false }
141
141
  };
142
142
  }
143
143
  const responses = Array.isArray(data) ? data : [data];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/clients/search.ts"],
4
- "sourcesContent": ["/**\n * Web Search Client\n * Generic interface for web search via Google (Serper implementation)\n * Implements robust error handling that NEVER crashes\n */\n\nimport { parseEnv, CONCURRENCY } from '../config/index.js';\nimport {\n classifyError,\n fetchWithTimeout,\n sleep,\n ErrorCode,\n type StructuredError,\n} from '../utils/errors.js';\nimport { calculateBackoff } from '../utils/retry.js';\nimport { pMap } from '../utils/concurrency.js';\nimport { mcpLog } from '../utils/logger.js';\n\n// \u2500\u2500 Constants \u2500\u2500\n\nconst SERPER_API_URL = 'https://google.serper.dev/search' as const;\nconst DEFAULT_RESULTS_PER_QUERY = 10 as const;\nconst MAX_RETRIES = 3 as const;\n\n// \u2500\u2500 Data Interfaces \u2500\u2500\n\ninterface SearchResult {\n readonly title: string;\n readonly link: string;\n readonly snippet: string;\n readonly date?: string;\n readonly position: number;\n}\n\nexport interface QuerySearchResult {\n readonly query: string;\n readonly results: SearchResult[];\n readonly totalResults: number;\n readonly related: string[];\n readonly error?: StructuredError;\n}\n\ninterface MultipleSearchResponse {\n readonly searches: QuerySearchResult[];\n readonly totalQueries: number;\n readonly executionTime: number;\n readonly error?: StructuredError;\n}\n\nexport interface RedditSearchResult {\n readonly title: string;\n readonly url: string;\n readonly snippet: string;\n readonly date?: string;\n}\n\n// \u2500\u2500 Retry Configuration \u2500\u2500\n\nconst SEARCH_RETRY_CONFIG = {\n maxRetries: MAX_RETRIES,\n baseDelayMs: 1000,\n maxDelayMs: 10000,\n timeoutMs: 30000,\n} as const;\n\nconst RETRYABLE_SEARCH_CODES = new Set([429, 500, 502, 503, 504]);\n\n// Pre-compiled regex patterns for Reddit search\nconst REDDIT_SITE_REGEX = /site:\\s*reddit\\.com/i;\nconst REDDIT_SUBREDDIT_SUFFIX_REGEX = / : r\\/\\w+$/;\nconst REDDIT_SUFFIX_REGEX = / - Reddit$/;\n\n// \u2500\u2500 Helper: Parse Serper search responses into structured results \u2500\u2500\n\nfunction parseSearchResponses(\n responses: Array<Record<string, unknown>>,\n queries: string[],\n): QuerySearchResult[] {\n return responses.map((resp, index) => {\n try {\n const organic = (resp.organic || []) as Array<Record<string, unknown>>;\n const results: SearchResult[] = organic.map((item, idx) => ({\n title: (item.title as string) || 'No title',\n link: (item.link as string) || '#',\n snippet: (item.snippet as string) || '',\n date: item.date as string | undefined,\n position: (item.position as number) || idx + 1,\n }));\n\n const searchInfo = resp.searchInformation as Record<string, unknown> | undefined;\n const totalResults = searchInfo?.totalResults\n ? parseInt(String(searchInfo.totalResults).replace(/,/g, ''), 10)\n : results.length;\n\n const relatedSearches = (resp.relatedSearches || []) as Array<Record<string, unknown>>;\n const related = relatedSearches.map((r) => (r.query as string) || '');\n\n return { query: queries[index] || '', results, totalResults, related };\n } catch {\n return { query: queries[index] || '', results: [], totalResults: 0, related: [] };\n }\n });\n}\n\n// \u2500\u2500 Helper: Execute search API call with retry \u2500\u2500\n\nasync function executeSearchWithRetry(\n apiKey: string,\n body: unknown,\n isRetryable: (status?: number, error?: unknown) => boolean,\n): Promise<{ data: unknown; error?: StructuredError }> {\n let lastError: StructuredError | undefined;\n\n for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {\n try {\n if (attempt > 0) {\n mcpLog('warning', `Retry attempt ${attempt}/${SEARCH_RETRY_CONFIG.maxRetries}`, 'search');\n }\n\n const response = await fetchWithTimeout(SERPER_API_URL, {\n method: 'POST',\n headers: {\n 'X-API-KEY': apiKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs,\n });\n\n if (!response.ok) {\n const errorText = await response.text().catch(() => '');\n lastError = classifyError({ status: response.status, message: errorText });\n\n if (isRetryable(response.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `API returned ${response.status}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n\n return { data: undefined, error: lastError };\n }\n\n try {\n const data = await response.json();\n return { data };\n } catch {\n return {\n data: undefined,\n error: { code: ErrorCode.PARSE_ERROR, message: 'Failed to parse search response', retryable: false },\n };\n }\n } catch (error) {\n lastError = classifyError(error);\n\n if (isRetryable(undefined, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `${lastError.code}: ${lastError.message}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n\n return { data: undefined, error: lastError };\n }\n }\n\n return {\n data: undefined,\n error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: 'Search failed', retryable: false },\n };\n}\n\n// \u2500\u2500 SearchClient \u2500\u2500\n\nexport class SearchClient {\n private apiKey: string;\n\n constructor(apiKey?: string) {\n const env = parseEnv();\n this.apiKey = apiKey || env.SEARCH_API_KEY || '';\n\n if (!this.apiKey) {\n throw new Error('Web search capability is not configured. Please set up the required API credentials.');\n }\n }\n\n /**\n * Check if error is retryable\n */\n private isRetryable(status?: number, error?: unknown): boolean {\n if (status && RETRYABLE_SEARCH_CODES.has(status)) return true;\n\n if (error == null) return false;\n const message = (typeof error === 'object' && 'message' in error && typeof (error as { message?: string }).message === 'string')\n ? (error as { message: string }).message.toLowerCase()\n : '';\n return message.includes('timeout') || message.includes('rate limit') || message.includes('connection');\n }\n\n /**\n * Search multiple queries in parallel\n * NEVER throws - always returns a valid response\n */\n async searchMultiple(queries: string[]): Promise<MultipleSearchResponse> {\n const startTime = Date.now();\n\n if (queries.length === 0) {\n return {\n searches: [],\n totalQueries: 0,\n executionTime: 0,\n error: { code: ErrorCode.INVALID_INPUT, message: 'No queries provided', retryable: false },\n };\n }\n\n const searchQueries = queries.map(query => ({ q: query }));\n const { data, error } = await executeSearchWithRetry(\n this.apiKey,\n searchQueries,\n (status, err) => this.isRetryable(status, err),\n );\n\n if (error || data === undefined) {\n return {\n searches: [],\n totalQueries: queries.length,\n executionTime: Date.now() - startTime,\n error,\n };\n }\n\n const responses = Array.isArray(data) ? data : [data];\n const searches = parseSearchResponses(responses as Array<Record<string, unknown>>, queries);\n\n return { searches, totalQueries: queries.length, executionTime: Date.now() - startTime };\n }\n\n /**\n * Search Reddit via Google (adds site:reddit.com automatically)\n * NEVER throws - returns empty array on failure\n */\n async searchReddit(query: string, dateAfter?: string): Promise<RedditSearchResult[]> {\n if (!query?.trim()) {\n return [];\n }\n\n let q = query.replace(REDDIT_SITE_REGEX, '').trim() + ' site:reddit.com';\n\n if (dateAfter) {\n q += ` after:${dateAfter}`;\n }\n\n for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {\n try {\n const res = await fetchWithTimeout(SERPER_API_URL, {\n method: 'POST',\n headers: { 'X-API-KEY': this.apiKey, 'Content-Type': 'application/json' },\n body: JSON.stringify({ q, num: DEFAULT_RESULTS_PER_QUERY }),\n timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs,\n });\n\n if (!res.ok) {\n if (this.isRetryable(res.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `Reddit search ${res.status}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n mcpLog('error', `Reddit search failed with status ${res.status}`, 'search');\n return [];\n }\n\n const data = await res.json() as { organic?: Array<{ title: string; link: string; snippet: string; date?: string }> };\n return (data.organic || []).map((r) => ({\n title: (r.title || '').replace(REDDIT_SUBREDDIT_SUFFIX_REGEX, '').replace(REDDIT_SUFFIX_REGEX, ''),\n url: r.link || '',\n snippet: r.snippet || '',\n date: r.date,\n }));\n\n } catch (error) {\n const err = classifyError(error);\n if (this.isRetryable(undefined, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `Reddit search ${err.code}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n mcpLog('error', `Reddit search failed: ${err.message}`, 'search');\n return [];\n }\n }\n\n return [];\n }\n\n /**\n * Search Reddit with multiple queries (bounded concurrency)\n * NEVER throws - searchReddit never throws, pMap preserves order\n */\n async searchRedditMultiple(queries: string[], dateAfter?: string): Promise<Map<string, RedditSearchResult[]>> {\n if (queries.length === 0) {\n return new Map();\n }\n\n const results = await pMap(\n queries,\n q => this.searchReddit(q, dateAfter),\n CONCURRENCY.SEARCH\n );\n\n return new Map(queries.map((q, i) => [q, results[i] || []]));\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Web Search Client\n * Generic interface for web search via Google (Serper implementation)\n * Implements robust error handling that NEVER crashes\n */\n\nimport { parseEnv, CONCURRENCY } from '../config/index.js';\nimport {\n classifyError,\n fetchWithTimeout,\n sleep,\n ErrorCode,\n type StructuredError,\n} from '../utils/errors.js';\nimport { calculateBackoff } from '../utils/retry.js';\nimport { pMap } from '../utils/concurrency.js';\nimport { mcpLog } from '../utils/logger.js';\n\n// \u2500\u2500 Constants \u2500\u2500\n\nconst SERPER_API_URL = 'https://google.serper.dev/search' as const;\nconst DEFAULT_RESULTS_PER_QUERY = 10 as const;\nconst MAX_RETRIES = 3 as const;\n\n// \u2500\u2500 Data Interfaces \u2500\u2500\n\ninterface SearchResult {\n readonly title: string;\n readonly link: string;\n readonly snippet: string;\n readonly date?: string;\n readonly position: number;\n}\n\nexport interface QuerySearchResult {\n readonly query: string;\n readonly results: SearchResult[];\n readonly totalResults: number;\n readonly related: string[];\n readonly error?: StructuredError;\n}\n\nexport interface MultipleSearchResponse {\n readonly searches: QuerySearchResult[];\n readonly totalQueries: number;\n readonly executionTime: number;\n readonly error?: StructuredError;\n}\n\nexport interface RedditSearchResult {\n readonly title: string;\n readonly url: string;\n readonly snippet: string;\n readonly date?: string;\n}\n\n// \u2500\u2500 Retry Configuration \u2500\u2500\n\nconst SEARCH_RETRY_CONFIG = {\n maxRetries: MAX_RETRIES,\n baseDelayMs: 1000,\n maxDelayMs: 10000,\n timeoutMs: 30000,\n} as const;\n\nconst RETRYABLE_SEARCH_CODES = new Set([429, 500, 502, 503, 504]);\n\n// Pre-compiled regex patterns for Reddit search\nconst REDDIT_SITE_REGEX = /site:\\s*reddit\\.com/i;\nconst REDDIT_SUBREDDIT_SUFFIX_REGEX = / : r\\/\\w+$/;\nconst REDDIT_SUFFIX_REGEX = / - Reddit$/;\n\n// \u2500\u2500 Helper: Parse Serper search responses into structured results \u2500\u2500\n\nfunction parseSearchResponses(\n responses: Array<Record<string, unknown>>,\n queries: string[],\n): QuerySearchResult[] {\n return responses.map((resp, index) => {\n try {\n const organic = (resp.organic || []) as Array<Record<string, unknown>>;\n const results: SearchResult[] = organic.map((item, idx) => ({\n title: (item.title as string) || 'No title',\n link: (item.link as string) || '#',\n snippet: (item.snippet as string) || '',\n date: item.date as string | undefined,\n position: (item.position as number) || idx + 1,\n }));\n\n const searchInfo = resp.searchInformation as Record<string, unknown> | undefined;\n const totalResults = searchInfo?.totalResults\n ? parseInt(String(searchInfo.totalResults).replace(/,/g, ''), 10)\n : results.length;\n\n const relatedSearches = (resp.relatedSearches || []) as Array<Record<string, unknown>>;\n const related = relatedSearches.map((r) => (r.query as string) || '');\n\n return { query: queries[index] || '', results, totalResults, related };\n } catch {\n return { query: queries[index] || '', results: [], totalResults: 0, related: [] };\n }\n });\n}\n\n// \u2500\u2500 Helper: Execute search API call with retry \u2500\u2500\n\nasync function executeSearchWithRetry(\n apiKey: string,\n body: unknown,\n isRetryable: (status?: number, error?: unknown) => boolean,\n): Promise<{ data: unknown; error?: StructuredError }> {\n let lastError: StructuredError | undefined;\n\n for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {\n try {\n if (attempt > 0) {\n mcpLog('warning', `Retry attempt ${attempt}/${SEARCH_RETRY_CONFIG.maxRetries}`, 'search');\n }\n\n const response = await fetchWithTimeout(SERPER_API_URL, {\n method: 'POST',\n headers: {\n 'X-API-KEY': apiKey,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs,\n });\n\n if (!response.ok) {\n const errorText = await response.text().catch(() => '');\n lastError = classifyError({ status: response.status, message: errorText });\n\n if (isRetryable(response.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `API returned ${response.status}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n\n return { data: undefined, error: lastError };\n }\n\n try {\n const data = await response.json();\n return { data };\n } catch {\n return {\n data: undefined,\n error: { code: ErrorCode.PARSE_ERROR, message: 'Failed to parse search response', retryable: false },\n };\n }\n } catch (error) {\n lastError = classifyError(error);\n\n if (isRetryable(undefined, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `${lastError.code}: ${lastError.message}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n\n return { data: undefined, error: lastError };\n }\n }\n\n return {\n data: undefined,\n error: lastError || { code: ErrorCode.UNKNOWN_ERROR, message: 'Search failed', retryable: false },\n };\n}\n\n// \u2500\u2500 SearchClient \u2500\u2500\n\nexport class SearchClient {\n private apiKey: string;\n\n constructor(apiKey?: string) {\n const env = parseEnv();\n this.apiKey = apiKey || env.SEARCH_API_KEY || '';\n\n if (!this.apiKey) {\n throw new Error('Web search capability is not configured. Please set up the required API credentials.');\n }\n }\n\n /**\n * Check if error is retryable\n */\n private isRetryable(status?: number, error?: unknown): boolean {\n if (status && RETRYABLE_SEARCH_CODES.has(status)) return true;\n\n if (error == null) return false;\n const message = (typeof error === 'object' && 'message' in error && typeof (error as { message?: string }).message === 'string')\n ? (error as { message: string }).message.toLowerCase()\n : '';\n return message.includes('timeout') || message.includes('rate limit') || message.includes('connection');\n }\n\n /**\n * Search multiple queries in parallel\n * NEVER throws - always returns a valid response\n */\n async searchMultiple(queries: string[]): Promise<MultipleSearchResponse> {\n const startTime = Date.now();\n\n if (queries.length === 0) {\n return {\n searches: [],\n totalQueries: 0,\n executionTime: 0,\n error: { code: ErrorCode.INVALID_INPUT, message: 'No queries provided', retryable: false },\n };\n }\n\n const searchQueries = queries.map(query => ({ q: query }));\n const { data, error } = await executeSearchWithRetry(\n this.apiKey,\n searchQueries,\n (status, err) => this.isRetryable(status, err),\n );\n\n if (error || data === undefined) {\n return {\n searches: [],\n totalQueries: queries.length,\n executionTime: Date.now() - startTime,\n error: error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Search provider returned no data', retryable: false },\n };\n }\n\n const responses = Array.isArray(data) ? data : [data];\n const searches = parseSearchResponses(responses as Array<Record<string, unknown>>, queries);\n\n return { searches, totalQueries: queries.length, executionTime: Date.now() - startTime };\n }\n\n /**\n * Search Reddit via Google (adds site:reddit.com automatically)\n * NEVER throws - returns empty array on failure\n */\n async searchReddit(query: string, dateAfter?: string): Promise<RedditSearchResult[]> {\n if (!query?.trim()) {\n return [];\n }\n\n let q = query.replace(REDDIT_SITE_REGEX, '').trim() + ' site:reddit.com';\n\n if (dateAfter) {\n q += ` after:${dateAfter}`;\n }\n\n for (let attempt = 0; attempt <= SEARCH_RETRY_CONFIG.maxRetries; attempt++) {\n try {\n const res = await fetchWithTimeout(SERPER_API_URL, {\n method: 'POST',\n headers: { 'X-API-KEY': this.apiKey, 'Content-Type': 'application/json' },\n body: JSON.stringify({ q, num: DEFAULT_RESULTS_PER_QUERY }),\n timeoutMs: SEARCH_RETRY_CONFIG.timeoutMs,\n });\n\n if (!res.ok) {\n if (this.isRetryable(res.status) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `Reddit search ${res.status}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n mcpLog('error', `Reddit search failed with status ${res.status}`, 'search');\n return [];\n }\n\n const data = await res.json() as { organic?: Array<{ title: string; link: string; snippet: string; date?: string }> };\n return (data.organic || []).map((r) => ({\n title: (r.title || '').replace(REDDIT_SUBREDDIT_SUFFIX_REGEX, '').replace(REDDIT_SUFFIX_REGEX, ''),\n url: r.link || '',\n snippet: r.snippet || '',\n date: r.date,\n }));\n\n } catch (error) {\n const err = classifyError(error);\n if (this.isRetryable(undefined, error) && attempt < SEARCH_RETRY_CONFIG.maxRetries) {\n const delayMs = calculateBackoff(attempt, SEARCH_RETRY_CONFIG.baseDelayMs, SEARCH_RETRY_CONFIG.maxDelayMs);\n mcpLog('warning', `Reddit search ${err.code}, retrying in ${delayMs}ms...`, 'search');\n await sleep(delayMs);\n continue;\n }\n mcpLog('error', `Reddit search failed: ${err.message}`, 'search');\n return [];\n }\n }\n\n return [];\n }\n\n /**\n * Search Reddit with multiple queries (bounded concurrency)\n * NEVER throws - searchReddit never throws, pMap preserves order\n */\n async searchRedditMultiple(queries: string[], dateAfter?: string): Promise<Map<string, RedditSearchResult[]>> {\n if (queries.length === 0) {\n return new Map();\n }\n\n const results = await pMap(\n queries,\n q => this.searchReddit(q, dateAfter),\n CONCURRENCY.SEARCH\n );\n\n return new Map(queries.map((q, i) => [q, results[i] || []]));\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -43,7 +43,7 @@ function getCapabilities() {
43
43
  reddit: !!(env.REDDIT_CLIENT_ID && env.REDDIT_CLIENT_SECRET),
44
44
  search: !!env.SEARCH_API_KEY,
45
45
  scraping: !!env.SCRAPER_API_KEY,
46
- llmExtraction: !!LLM_EXTRACTION.API_KEY
46
+ llmExtraction: getLLMConfigStatus().configured
47
47
  };
48
48
  }
49
49
  function getMissingEnvMessage(capability) {
@@ -87,6 +87,24 @@ const CTR_WEIGHTS = {
87
87
  9: 13.33,
88
88
  10: 12.56
89
89
  };
90
+ function getLLMConfigStatus() {
91
+ const apiKeyPresent = !!process.env.LLM_API_KEY?.trim();
92
+ const baseUrlPresent = !!process.env.LLM_BASE_URL?.trim();
93
+ const modelPresent = !!process.env.LLM_MODEL?.trim();
94
+ const missingVars = [];
95
+ if (!apiKeyPresent) missingVars.push("LLM_API_KEY");
96
+ if (!baseUrlPresent) missingVars.push("LLM_BASE_URL");
97
+ if (!modelPresent) missingVars.push("LLM_MODEL");
98
+ const configured = missingVars.length === 0;
99
+ return {
100
+ configured,
101
+ apiKeyPresent,
102
+ baseUrlPresent,
103
+ modelPresent,
104
+ missingVars,
105
+ error: configured ? null : `LLM disabled: missing ${missingVars.join(", ")}`
106
+ };
107
+ }
90
108
  let cachedLlmExtraction = null;
91
109
  function getLlmExtraction() {
92
110
  if (cachedLlmExtraction) return cachedLlmExtraction;
@@ -125,6 +143,7 @@ export {
125
143
  SCRAPER,
126
144
  SERVER,
127
145
  getCapabilities,
146
+ getLLMConfigStatus,
128
147
  getMissingEnvMessage,
129
148
  parseEnv
130
149
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/config/index.ts"],
4
- "sourcesContent": ["/**\n * Consolidated configuration\n * All environment variables, constants, and LLM config in one place\n */\n\nimport { Logger } from 'mcp-use';\n\nimport { VERSION, PACKAGE_NAME, PACKAGE_DESCRIPTION } from '../version.js';\n\n// ============================================================================\n// Safe Integer Parsing Helper\n// ============================================================================\n\n/**\n * Safely parse an integer from environment variable with bounds checking\n */\nfunction safeParseInt(\n value: string | undefined,\n defaultVal: number,\n min: number,\n max: number\n): number {\n const logger = Logger.get('config');\n\n if (!value) {\n return defaultVal;\n }\n\n const parsed = parseInt(value, 10);\n\n if (isNaN(parsed)) {\n logger.warn(`Invalid number \"${value}\", using default ${defaultVal}`);\n return defaultVal;\n }\n\n if (parsed < min) {\n logger.warn(`Value ${parsed} below minimum ${min}, clamping to ${min}`);\n return min;\n }\n\n if (parsed > max) {\n logger.warn(`Value ${parsed} above maximum ${max}, clamping to ${max}`);\n return max;\n }\n\n return parsed;\n}\n\n\n// ============================================================================\n// Environment Parsing\n// ============================================================================\n\ninterface EnvConfig {\n SCRAPER_API_KEY: string;\n SEARCH_API_KEY: string | undefined;\n REDDIT_CLIENT_ID: string | undefined;\n REDDIT_CLIENT_SECRET: string | undefined;\n JINA_API_KEY: string | undefined;\n}\n\nlet cachedEnv: EnvConfig | null = null;\n\nexport function parseEnv(): EnvConfig {\n if (cachedEnv) return cachedEnv;\n cachedEnv = {\n SCRAPER_API_KEY: process.env.SCRAPEDO_API_KEY || '',\n SEARCH_API_KEY: process.env.SERPER_API_KEY || undefined,\n REDDIT_CLIENT_ID: process.env.REDDIT_CLIENT_ID || undefined,\n REDDIT_CLIENT_SECRET: process.env.REDDIT_CLIENT_SECRET || undefined,\n JINA_API_KEY: process.env.JINA_API_KEY || undefined,\n };\n return cachedEnv;\n}\n\n// ============================================================================\n// MCP Server Configuration\n// ============================================================================\n\nexport const SERVER = {\n NAME: PACKAGE_NAME,\n VERSION: VERSION,\n DESCRIPTION: PACKAGE_DESCRIPTION,\n} as const;\n\n// ============================================================================\n// Capability Detection (which features are available based on ENV)\n// ============================================================================\n\nexport interface Capabilities {\n reddit: boolean; // REDDIT_CLIENT_ID + REDDIT_CLIENT_SECRET\n search: boolean; // SERPER_API_KEY\n scraping: boolean; // SCRAPEDO_API_KEY\n llmExtraction: boolean; // LLM_API_KEY\n}\n\nexport function getCapabilities(): Capabilities {\n const env = parseEnv();\n return {\n reddit: !!(env.REDDIT_CLIENT_ID && env.REDDIT_CLIENT_SECRET),\n search: !!env.SEARCH_API_KEY,\n scraping: !!env.SCRAPER_API_KEY,\n llmExtraction: !!LLM_EXTRACTION.API_KEY,\n };\n}\n\nexport function getMissingEnvMessage(capability: keyof Capabilities): string {\n const messages: Record<keyof Capabilities, string> = {\n reddit: '\u274C **Reddit tools unavailable.** Set `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET` to enable `get-reddit-post`.\\n\\n\uD83D\uDC49 Create a Reddit app at: https://www.reddit.com/prefs/apps (select \"script\" type)',\n search: '\u274C **Search unavailable.** Set `SERPER_API_KEY` to enable `web-search` (including `scope: \"reddit\"`).\\n\\n\uD83D\uDC49 Get your free API key at: https://serper.dev (2,500 free queries)',\n scraping: '\u274C **Web scraping unavailable.** Set `SCRAPEDO_API_KEY` to enable `scrape-links`.\\n\\n\uD83D\uDC49 Sign up at: https://scrape.do (1,000 free credits)',\n llmExtraction: '\u26A0\uFE0F **AI extraction disabled.** Set `LLM_API_KEY`, `LLM_BASE_URL`, and `LLM_MODEL` to enable AI-powered content extraction and search classification.\\n\\nScraping will work but without intelligent content filtering.',\n };\n return messages[capability];\n}\n\n// ============================================================================\n// Concurrency Limits\n// ============================================================================\n\nexport const CONCURRENCY = {\n SEARCH: safeParseInt(process.env.CONCURRENCY_SEARCH, 50, 1, 200),\n SCRAPER: safeParseInt(process.env.CONCURRENCY_SCRAPER, 50, 1, 200),\n REDDIT: safeParseInt(process.env.CONCURRENCY_REDDIT, 50, 1, 200),\n LLM_EXTRACTION: safeParseInt(process.env.LLM_CONCURRENCY, 50, 1, 200),\n} as const;\n\nexport const SCRAPER = {\n BATCH_SIZE: 30,\n EXTRACTION_PREFIX: 'Extract from document only \u2014 never hallucinate or add external knowledge.',\n EXTRACTION_SUFFIX: 'First line = content, not preamble. No confirmation messages.',\n} as const;\n\n// ============================================================================\n// Reddit Configuration\n// ============================================================================\n\nexport const REDDIT = {\n BATCH_SIZE: 10,\n MAX_WORDS_PER_POST: 50_000,\n MAX_WORDS_TOTAL: 500_000,\n MIN_POSTS: 1,\n MAX_POSTS: 50,\n RETRY_COUNT: 5,\n RETRY_DELAYS: [2000, 4000, 8000, 16000, 32000] as const,\n} as const;\n\n// ============================================================================\n// CTR Weights for URL Ranking (inspired from CTR research)\n// ============================================================================\n\nexport const CTR_WEIGHTS: Record<number, number> = {\n 1: 100.00,\n 2: 60.00,\n 3: 48.89,\n 4: 33.33,\n 5: 28.89,\n 6: 26.44,\n 7: 24.44,\n 8: 17.78,\n 9: 13.33,\n 10: 12.56,\n} as const;\n\n// ============================================================================\n// LLM Configuration\n//\n// Required vars (all must be set together when LLM is enabled):\n// LLM_API_KEY \u2014 API key for the OpenAI-compatible endpoint\n// LLM_BASE_URL \u2014 endpoint base URL (e.g. https://server.up.railway.app/v1)\n// LLM_MODEL \u2014 primary model (e.g. gpt-5.4-mini)\n//\n// Optional:\n// LLM_FALLBACK_MODEL \u2014 model to use after primary exhausts all retries (e.g. gpt-5.4)\n// LLM_CONCURRENCY \u2014 parallel LLM calls (default: 50)\n//\n// Reasoning effort is always 'low' \u2014 not configurable.\n// ============================================================================\n\ninterface LlmExtractionConfig {\n readonly MODEL: string;\n readonly FALLBACK_MODEL: string;\n readonly BASE_URL: string;\n readonly API_KEY: string;\n}\n\nlet cachedLlmExtraction: LlmExtractionConfig | null = null;\n\nfunction getLlmExtraction(): LlmExtractionConfig {\n if (cachedLlmExtraction) return cachedLlmExtraction;\n\n const apiKey = process.env.LLM_API_KEY?.trim() || '';\n const baseUrl = process.env.LLM_BASE_URL?.trim();\n const model = process.env.LLM_MODEL?.trim();\n const fallbackModel = process.env.LLM_FALLBACK_MODEL?.trim() || '';\n\n if (apiKey && !baseUrl) {\n throw new Error(\n 'LLM_BASE_URL is required when LLM_API_KEY is set. ' +\n 'Set LLM_BASE_URL to your OpenAI-compatible endpoint.',\n );\n }\n if (apiKey && !model) {\n throw new Error(\n 'LLM_MODEL is required when LLM_API_KEY is set.',\n );\n }\n\n cachedLlmExtraction = {\n API_KEY: apiKey,\n BASE_URL: baseUrl || '',\n MODEL: model || '',\n FALLBACK_MODEL: fallbackModel,\n };\n return cachedLlmExtraction;\n}\n\nexport const LLM_EXTRACTION: LlmExtractionConfig = new Proxy({} as LlmExtractionConfig, {\n get(_target, prop: string) {\n return getLlmExtraction()[prop as keyof LlmExtractionConfig];\n },\n});\n"],
5
- "mappings": "AAKA,SAAS,cAAc;AAEvB,SAAS,SAAS,cAAc,2BAA2B;AAS3D,SAAS,aACP,OACA,YACA,KACA,KACQ;AACR,QAAM,SAAS,OAAO,IAAI,QAAQ;AAElC,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,EACT;AAEA,QAAM,SAAS,SAAS,OAAO,EAAE;AAEjC,MAAI,MAAM,MAAM,GAAG;AACjB,WAAO,KAAK,mBAAmB,KAAK,oBAAoB,UAAU,EAAE;AACpE,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,KAAK;AAChB,WAAO,KAAK,SAAS,MAAM,kBAAkB,GAAG,iBAAiB,GAAG,EAAE;AACtE,WAAO;AAAA,EACT;AAEA,MAAI,SAAS,KAAK;AAChB,WAAO,KAAK,SAAS,MAAM,kBAAkB,GAAG,iBAAiB,GAAG,EAAE;AACtE,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAeA,IAAI,YAA8B;AAE3B,SAAS,WAAsB;AACpC,MAAI,UAAW,QAAO;AACtB,cAAY;AAAA,IACV,iBAAiB,QAAQ,IAAI,oBAAoB;AAAA,IACjD,gBAAgB,QAAQ,IAAI,kBAAkB;AAAA,IAC9C,kBAAkB,QAAQ,IAAI,oBAAoB;AAAA,IAClD,sBAAsB,QAAQ,IAAI,wBAAwB;AAAA,IAC1D,cAAc,QAAQ,IAAI,gBAAgB;AAAA,EAC5C;AACA,SAAO;AACT;AAMO,MAAM,SAAS;AAAA,EACpB,MAAM;AAAA,EACN;AAAA,EACA,aAAa;AACf;AAaO,SAAS,kBAAgC;AAC9C,QAAM,MAAM,SAAS;AACrB,SAAO;AAAA,IACL,QAAQ,CAAC,EAAE,IAAI,oBAAoB,IAAI;AAAA,IACvC,QAAQ,CAAC,CAAC,IAAI;AAAA,IACd,UAAU,CAAC,CAAC,IAAI;AAAA,IAChB,eAAe,CAAC,CAAC,eAAe;AAAA,EAClC;AACF;AAEO,SAAS,qBAAqB,YAAwC;AAC3E,QAAM,WAA+C;AAAA,IACnD,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,eAAe;AAAA,EACjB;AACA,SAAO,SAAS,UAAU;AAC5B;AAMO,MAAM,cAAc;AAAA,EACzB,QAAQ,aAAa,QAAQ,IAAI,oBAAoB,IAAI,GAAG,GAAG;AAAA,EAC/D,SAAS,aAAa,QAAQ,IAAI,qBAAqB,IAAI,GAAG,GAAG;AAAA,EACjE,QAAQ,aAAa,QAAQ,IAAI,oBAAoB,IAAI,GAAG,GAAG;AAAA,EAC/D,gBAAgB,aAAa,QAAQ,IAAI,iBAAiB,IAAI,GAAG,GAAG;AACtE;AAEO,MAAM,UAAU;AAAA,EACrB,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,mBAAmB;AACrB;AAMO,MAAM,SAAS;AAAA,EACpB,YAAY;AAAA,EACZ,oBAAoB;AAAA,EACpB,iBAAiB;AAAA,EACjB,WAAW;AAAA,EACX,WAAW;AAAA,EACX,aAAa;AAAA,EACb,cAAc,CAAC,KAAM,KAAM,KAAM,MAAO,IAAK;AAC/C;AAMO,MAAM,cAAsC;AAAA,EACjD,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,IAAI;AACN;AAwBA,IAAI,sBAAkD;AAEtD,SAAS,mBAAwC;AAC/C,MAAI,oBAAqB,QAAO;AAEhC,QAAM,SAAS,QAAQ,IAAI,aAAa,KAAK,KAAK;AAClD,QAAM,UAAU,QAAQ,IAAI,cAAc,KAAK;AAC/C,QAAM,QAAQ,QAAQ,IAAI,WAAW,KAAK;AAC1C,QAAM,gBAAgB,QAAQ,IAAI,oBAAoB,KAAK,KAAK;AAEhE,MAAI,UAAU,CAAC,SAAS;AACtB,UAAM,IAAI;AAAA,MACR;AAAA,IAEF;AAAA,EACF;AACA,MAAI,UAAU,CAAC,OAAO;AACpB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,wBAAsB;AAAA,IACpB,SAAS;AAAA,IACT,UAAU,WAAW;AAAA,IACrB,OAAO,SAAS;AAAA,IAChB,gBAAgB;AAAA,EAClB;AACA,SAAO;AACT;AAEO,MAAM,iBAAsC,IAAI,MAAM,CAAC,GAA0B;AAAA,EACtF,IAAI,SAAS,MAAc;AACzB,WAAO,iBAAiB,EAAE,IAAiC;AAAA,EAC7D;AACF,CAAC;",
4
+ "sourcesContent": ["/**\n * Consolidated configuration\n * All environment variables, constants, and LLM config in one place\n */\n\nimport { Logger } from 'mcp-use';\n\nimport { VERSION, PACKAGE_NAME, PACKAGE_DESCRIPTION } from '../version.js';\n\n// ============================================================================\n// Safe Integer Parsing Helper\n// ============================================================================\n\n/**\n * Safely parse an integer from environment variable with bounds checking\n */\nfunction safeParseInt(\n value: string | undefined,\n defaultVal: number,\n min: number,\n max: number\n): number {\n const logger = Logger.get('config');\n\n if (!value) {\n return defaultVal;\n }\n\n const parsed = parseInt(value, 10);\n\n if (isNaN(parsed)) {\n logger.warn(`Invalid number \"${value}\", using default ${defaultVal}`);\n return defaultVal;\n }\n\n if (parsed < min) {\n logger.warn(`Value ${parsed} below minimum ${min}, clamping to ${min}`);\n return min;\n }\n\n if (parsed > max) {\n logger.warn(`Value ${parsed} above maximum ${max}, clamping to ${max}`);\n return max;\n }\n\n return parsed;\n}\n\n\n// ============================================================================\n// Environment Parsing\n// ============================================================================\n\ninterface EnvConfig {\n SCRAPER_API_KEY: string;\n SEARCH_API_KEY: string | undefined;\n REDDIT_CLIENT_ID: string | undefined;\n REDDIT_CLIENT_SECRET: string | undefined;\n JINA_API_KEY: string | undefined;\n}\n\nlet cachedEnv: EnvConfig | null = null;\n\nexport function parseEnv(): EnvConfig {\n if (cachedEnv) return cachedEnv;\n cachedEnv = {\n SCRAPER_API_KEY: process.env.SCRAPEDO_API_KEY || '',\n SEARCH_API_KEY: process.env.SERPER_API_KEY || undefined,\n REDDIT_CLIENT_ID: process.env.REDDIT_CLIENT_ID || undefined,\n REDDIT_CLIENT_SECRET: process.env.REDDIT_CLIENT_SECRET || undefined,\n JINA_API_KEY: process.env.JINA_API_KEY || undefined,\n };\n return cachedEnv;\n}\n\n// ============================================================================\n// MCP Server Configuration\n// ============================================================================\n\nexport const SERVER = {\n NAME: PACKAGE_NAME,\n VERSION: VERSION,\n DESCRIPTION: PACKAGE_DESCRIPTION,\n} as const;\n\n// ============================================================================\n// Capability Detection (which features are available based on ENV)\n// ============================================================================\n\nexport interface Capabilities {\n reddit: boolean; // REDDIT_CLIENT_ID + REDDIT_CLIENT_SECRET\n search: boolean; // SERPER_API_KEY\n scraping: boolean; // SCRAPEDO_API_KEY\n llmExtraction: boolean; // LLM_API_KEY + LLM_BASE_URL + LLM_MODEL\n}\n\nexport function getCapabilities(): Capabilities {\n const env = parseEnv();\n return {\n reddit: !!(env.REDDIT_CLIENT_ID && env.REDDIT_CLIENT_SECRET),\n search: !!env.SEARCH_API_KEY,\n scraping: !!env.SCRAPER_API_KEY,\n llmExtraction: getLLMConfigStatus().configured,\n };\n}\n\nexport function getMissingEnvMessage(capability: keyof Capabilities): string {\n const messages: Record<keyof Capabilities, string> = {\n reddit: '\u274C **Reddit tools unavailable.** Set `REDDIT_CLIENT_ID` and `REDDIT_CLIENT_SECRET` to enable `get-reddit-post`.\\n\\n\uD83D\uDC49 Create a Reddit app at: https://www.reddit.com/prefs/apps (select \"script\" type)',\n search: '\u274C **Search unavailable.** Set `SERPER_API_KEY` to enable `web-search` (including `scope: \"reddit\"`).\\n\\n\uD83D\uDC49 Get your free API key at: https://serper.dev (2,500 free queries)',\n scraping: '\u274C **Web scraping unavailable.** Set `SCRAPEDO_API_KEY` to enable `scrape-links`.\\n\\n\uD83D\uDC49 Sign up at: https://scrape.do (1,000 free credits)',\n llmExtraction: '\u26A0\uFE0F **AI extraction disabled.** Set `LLM_API_KEY`, `LLM_BASE_URL`, and `LLM_MODEL` to enable AI-powered content extraction and search classification.\\n\\nScraping will work but without intelligent content filtering.',\n };\n return messages[capability];\n}\n\n// ============================================================================\n// Concurrency Limits\n// ============================================================================\n\nexport const CONCURRENCY = {\n SEARCH: safeParseInt(process.env.CONCURRENCY_SEARCH, 50, 1, 200),\n SCRAPER: safeParseInt(process.env.CONCURRENCY_SCRAPER, 50, 1, 200),\n REDDIT: safeParseInt(process.env.CONCURRENCY_REDDIT, 50, 1, 200),\n LLM_EXTRACTION: safeParseInt(process.env.LLM_CONCURRENCY, 50, 1, 200),\n} as const;\n\nexport const SCRAPER = {\n BATCH_SIZE: 30,\n EXTRACTION_PREFIX: 'Extract from document only \u2014 never hallucinate or add external knowledge.',\n EXTRACTION_SUFFIX: 'First line = content, not preamble. No confirmation messages.',\n} as const;\n\n// ============================================================================\n// Reddit Configuration\n// ============================================================================\n\nexport const REDDIT = {\n BATCH_SIZE: 10,\n MAX_WORDS_PER_POST: 50_000,\n MAX_WORDS_TOTAL: 500_000,\n MIN_POSTS: 1,\n MAX_POSTS: 50,\n RETRY_COUNT: 5,\n RETRY_DELAYS: [2000, 4000, 8000, 16000, 32000] as const,\n} as const;\n\n// ============================================================================\n// CTR Weights for URL Ranking (inspired from CTR research)\n// ============================================================================\n\nexport const CTR_WEIGHTS: Record<number, number> = {\n 1: 100.00,\n 2: 60.00,\n 3: 48.89,\n 4: 33.33,\n 5: 28.89,\n 6: 26.44,\n 7: 24.44,\n 8: 17.78,\n 9: 13.33,\n 10: 12.56,\n} as const;\n\n// ============================================================================\n// LLM Configuration\n//\n// Required vars (all must be set together when LLM is enabled):\n// LLM_API_KEY \u2014 API key for the OpenAI-compatible endpoint\n// LLM_BASE_URL \u2014 endpoint base URL (e.g. https://server.up.railway.app/v1)\n// LLM_MODEL \u2014 primary model (e.g. gpt-5.4-mini)\n//\n// Optional:\n// LLM_FALLBACK_MODEL \u2014 model to use after primary exhausts all retries (e.g. gpt-5.4)\n// LLM_CONCURRENCY \u2014 parallel LLM calls (default: 50)\n//\n// Reasoning effort is always 'low' \u2014 not configurable.\n// ============================================================================\n\ninterface LlmExtractionConfig {\n readonly MODEL: string;\n readonly FALLBACK_MODEL: string;\n readonly BASE_URL: string;\n readonly API_KEY: string;\n}\n\nexport type LLMRequiredEnvVar = 'LLM_API_KEY' | 'LLM_BASE_URL' | 'LLM_MODEL';\n\nexport interface LLMConfigStatus {\n readonly configured: boolean;\n readonly apiKeyPresent: boolean;\n readonly baseUrlPresent: boolean;\n readonly modelPresent: boolean;\n readonly missingVars: readonly LLMRequiredEnvVar[];\n readonly error: string | null;\n}\n\nexport function getLLMConfigStatus(): LLMConfigStatus {\n const apiKeyPresent = !!process.env.LLM_API_KEY?.trim();\n const baseUrlPresent = !!process.env.LLM_BASE_URL?.trim();\n const modelPresent = !!process.env.LLM_MODEL?.trim();\n const missingVars: LLMRequiredEnvVar[] = [];\n\n if (!apiKeyPresent) missingVars.push('LLM_API_KEY');\n if (!baseUrlPresent) missingVars.push('LLM_BASE_URL');\n if (!modelPresent) missingVars.push('LLM_MODEL');\n\n const configured = missingVars.length === 0;\n return {\n configured,\n apiKeyPresent,\n baseUrlPresent,\n modelPresent,\n missingVars,\n error: configured\n ? null\n : `LLM disabled: missing ${missingVars.join(', ')}`,\n };\n}\n\nlet cachedLlmExtraction: LlmExtractionConfig | null = null;\n\nfunction getLlmExtraction(): LlmExtractionConfig {\n if (cachedLlmExtraction) return cachedLlmExtraction;\n\n const apiKey = process.env.LLM_API_KEY?.trim() || '';\n const baseUrl = process.env.LLM_BASE_URL?.trim();\n const model = process.env.LLM_MODEL?.trim();\n const fallbackModel = process.env.LLM_FALLBACK_MODEL?.trim() || '';\n\n if (apiKey && !baseUrl) {\n throw new Error(\n 'LLM_BASE_URL is required when LLM_API_KEY is set. ' +\n 'Set LLM_BASE_URL to your OpenAI-compatible endpoint.',\n );\n }\n if (apiKey && !model) {\n throw new Error(\n 'LLM_MODEL is required when LLM_API_KEY is set.',\n );\n }\n\n cachedLlmExtraction = {\n API_KEY: apiKey,\n BASE_URL: baseUrl || '',\n MODEL: model || '',\n FALLBACK_MODEL: fallbackModel,\n };\n return cachedLlmExtraction;\n}\n\nexport const LLM_EXTRACTION: LlmExtractionConfig = new Proxy({} as LlmExtractionConfig, {\n get(_target, prop: string) {\n return getLlmExtraction()[prop as keyof LlmExtractionConfig];\n },\n});\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -57,7 +57,14 @@ const webSearchOutputSchema = z.object({
57
57
  retried_with: z.string().describe("The relaxed form retried after the empty initial response."),
58
58
  rules: z.array(z.string()).describe("Rule ids applied (B1=strip all quotes, B2=drop site: filter)."),
59
59
  recovered_results: z.number().int().nonnegative().describe("How many hits the retry produced; 0 means the retry also failed.")
60
- })).optional().describe("On-empty retries \u2014 Phase B relaxations applied after the initial Serper batch returned 0 results for a query.")
60
+ })).optional().describe("On-empty retries \u2014 Phase B relaxations applied after the initial Serper batch returned 0 results for a query."),
61
+ retry_error: z.object({
62
+ phase: z.literal("relax-retry").describe("Retry phase that failed after the initial batch succeeded."),
63
+ code: z.string().describe("Structured error code from the retry batch."),
64
+ message: z.string().describe("Provider error message from the retry batch."),
65
+ retryable: z.boolean().describe("Whether the retry-batch provider failure is retryable."),
66
+ statusCode: z.number().int().optional().describe("Provider status code when available.")
67
+ }).optional().describe("Non-fatal failure from the relaxed retry batch; initial search results were preserved.")
61
68
  }).strict()
62
69
  }).strict();
63
70
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/schemas/web-search.ts"],
4
- "sourcesContent": ["import { z } from 'zod';\n\nexport const webSearchParamsSchema = z.object({\n queries: z\n .array(\n z.string()\n .min(1, { message: 'web-search: Query cannot be empty' })\n .describe('A single Google search query. Each query runs as a separate parallel search. Use operators (site:, quotes, verbatim version numbers) to sharpen retrieval.'),\n )\n .min(1, { message: 'web-search: At least 1 query required' })\n .describe(\n 'Search queries to run in parallel via Google. Think of these as **concept groups** \u2014 clusters of semantically distinct facets of your research goal, each probing a DIFFERENT angle (official spec, implementation, failures, comparison, sentiment, changelog, CVE, pricing). Fire all groups in ONE call as a flat array. Overlapping queries waste budget; orthogonal facets multiply coverage. A narrow bug needs 10\u201320 queries across 2\u20133 facets; a comparison needs 25\u201335 across 4\u20136 facets; open-ended synthesis needs 40\u201380 across 8+ facets.',\n ),\n extract: z\n .string()\n .min(1, { message: 'web-search: extract cannot be empty' })\n .describe(\n 'Semantic instruction for the relevance classifier \u2014 what \"relevant\" means for THIS goal. Drives tiering (HIGHLY_RELEVANT / MAYBE_RELEVANT / OTHER), synthesis, gap analysis, and refine-query suggestions. Be specific: \"OAuth 2.1 support in TypeScript MCP frameworks \u2014 runnable code, not marketing\", not \"MCP OAuth\". The classifier uses this to choose a source-of-truth rubric (vendor_doc for spec, github for bugs, reddit/blog for migration/sentiment, cve_databases for security).',\n ),\n raw: z\n .boolean()\n .default(false)\n .describe('Skip LLM classification and return the raw ranked URL list. Use when you need unprocessed results.'),\n scope: z\n .enum(['web', 'reddit', 'both'])\n .default('web')\n .describe(\n 'Search scope. \"web\" (default) = open web, no augmentation. \"reddit\" = server appends `site:reddit.com` to every query and filters results to post permalinks (`/r/.+/comments/[a-z0-9]+/`); subreddit homepages are dropped. \"both\" = runs every query twice (open web + reddit-scoped), merges the result set, and tags each row with its source. Use \"reddit\" for sentiment/migration/lived-experience research; use \"both\" when you want one call to cover both branches.',\n ),\n verbose: z\n .boolean()\n .default(false)\n .describe(\n 'Include the per-row scoring/coverage metadata, the trailing Signals block, and the CONSENSUS labels even when they carry little signal (single-query hits, threshold of 1). Default false \u2014 most agents do not need this and it costs ~1.5KB per call on a typical 3-query fan-out.',\n ),\n}).strict();\n\nexport type WebSearchParams = z.infer<typeof webSearchParamsSchema>;\n\nexport const webSearchOutputSchema = z.object({\n // `content` deliberately NOT duplicated here \u2014 the primary markdown lives in\n // the MCP tool result's `content[0].text`. Previously this schema echoed the\n // whole markdown under `structuredContent.content`, doubling token cost for\n // clients that forward both fields to an LLM.\n results: z\n .array(z.object({\n rank: z.number().int().positive().describe('1-based rank in the merged ranking.'),\n url: z.string().describe('Result URL.'),\n title: z.string().describe('Page title from the result.'),\n snippet: z.string().describe('Search snippet from the result.'),\n source_type: z\n .enum(['reddit', 'github', 'docs', 'blog', 'paper', 'qa', 'cve', 'news', 'video', 'web'])\n .describe(\n 'Heuristic source kind from the URL. When the LLM classifier is online its tag overrides this.',\n ),\n score: z.number().describe('Composite CTR-weighted score, normalized to 100.'),\n seen_in: z.number().int().nonnegative().describe('Number of input queries this URL appeared in.'),\n best_position: z.number().int().nonnegative().describe('Best (lowest) SERP position observed.'),\n }))\n .optional()\n .describe('Per-result structured payload \u2014 same data the markdown table renders, machine-readable.'),\n metadata: z.object({\n total_items: z.number().int().nonnegative().describe('Number of queries executed.'),\n successful: z.number().int().nonnegative().describe('Queries that returned results.'),\n failed: z.number().int().nonnegative().describe('Queries that failed.'),\n execution_time_ms: z.number().int().nonnegative().describe('Wall clock time in milliseconds.'),\n llm_classified: z.boolean().describe('Whether LLM classification was applied.'),\n llm_error: z.string().optional().describe('LLM error if classification failed and fell back to raw.'),\n scope: z.enum(['web', 'reddit', 'both']).optional().describe('Search scope used.'),\n coverage_summary: z\n .array(z.object({\n query: z.string().describe('The search query.'),\n result_count: z.number().int().nonnegative().describe('Results returned for this query.'),\n top_url: z.string().optional().describe('Domain of the top result.'),\n }))\n .optional()\n .describe('Per-query result counts and top URLs.'),\n low_yield_queries: z\n .array(z.string())\n .optional()\n .describe('Queries that produced 0-1 results.'),\n query_rewrites: z\n .array(z.object({\n original: z.string().describe('The query as the agent submitted it.'),\n rewritten: z.string().describe('The query as dispatched to Google after Phase A normalization.'),\n rules: z.array(z.string()).describe('Rule ids applied (A1=operator-char de-quote, A2=path/URL de-quote, A3=phrase-AND collapse).'),\n }))\n .optional()\n .describe('Pre-dispatch query rewrites \u2014 Phase A normalizations (operator-char and path/URL de-quote, phrase-AND \u2192 anchor + OR collapse).'),\n retried_queries: z\n .array(z.object({\n original: z.string().describe('The query as dispatched (post-Phase-A) that returned 0 results.'),\n retried_with: z.string().describe('The relaxed form retried after the empty initial response.'),\n rules: z.array(z.string()).describe('Rule ids applied (B1=strip all quotes, B2=drop site: filter).'),\n recovered_results: z.number().int().nonnegative().describe('How many hits the retry produced; 0 means the retry also failed.'),\n }))\n .optional()\n .describe('On-empty retries \u2014 Phase B relaxations applied after the initial Serper batch returned 0 results for a query.'),\n }).strict(),\n}).strict();\n\nexport type WebSearchOutput = z.infer<typeof webSearchOutputSchema>;\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { z } from 'zod';\n\nexport const webSearchParamsSchema = z.object({\n queries: z\n .array(\n z.string()\n .min(1, { message: 'web-search: Query cannot be empty' })\n .describe('A single Google search query. Each query runs as a separate parallel search. Use operators (site:, quotes, verbatim version numbers) to sharpen retrieval.'),\n )\n .min(1, { message: 'web-search: At least 1 query required' })\n .describe(\n 'Search queries to run in parallel via Google. Think of these as **concept groups** \u2014 clusters of semantically distinct facets of your research goal, each probing a DIFFERENT angle (official spec, implementation, failures, comparison, sentiment, changelog, CVE, pricing). Fire all groups in ONE call as a flat array. Overlapping queries waste budget; orthogonal facets multiply coverage. A narrow bug needs 10\u201320 queries across 2\u20133 facets; a comparison needs 25\u201335 across 4\u20136 facets; open-ended synthesis needs 40\u201380 across 8+ facets.',\n ),\n extract: z\n .string()\n .min(1, { message: 'web-search: extract cannot be empty' })\n .describe(\n 'Semantic instruction for the relevance classifier \u2014 what \"relevant\" means for THIS goal. Drives tiering (HIGHLY_RELEVANT / MAYBE_RELEVANT / OTHER), synthesis, gap analysis, and refine-query suggestions. Be specific: \"OAuth 2.1 support in TypeScript MCP frameworks \u2014 runnable code, not marketing\", not \"MCP OAuth\". The classifier uses this to choose a source-of-truth rubric (vendor_doc for spec, github for bugs, reddit/blog for migration/sentiment, cve_databases for security).',\n ),\n raw: z\n .boolean()\n .default(false)\n .describe('Skip LLM classification and return the raw ranked URL list. Use when you need unprocessed results.'),\n scope: z\n .enum(['web', 'reddit', 'both'])\n .default('web')\n .describe(\n 'Search scope. \"web\" (default) = open web, no augmentation. \"reddit\" = server appends `site:reddit.com` to every query and filters results to post permalinks (`/r/.+/comments/[a-z0-9]+/`); subreddit homepages are dropped. \"both\" = runs every query twice (open web + reddit-scoped), merges the result set, and tags each row with its source. Use \"reddit\" for sentiment/migration/lived-experience research; use \"both\" when you want one call to cover both branches.',\n ),\n verbose: z\n .boolean()\n .default(false)\n .describe(\n 'Include the per-row scoring/coverage metadata, the trailing Signals block, and the CONSENSUS labels even when they carry little signal (single-query hits, threshold of 1). Default false \u2014 most agents do not need this and it costs ~1.5KB per call on a typical 3-query fan-out.',\n ),\n}).strict();\n\nexport type WebSearchParams = z.infer<typeof webSearchParamsSchema>;\n\nexport const webSearchOutputSchema = z.object({\n // `content` deliberately NOT duplicated here \u2014 the primary markdown lives in\n // the MCP tool result's `content[0].text`. Previously this schema echoed the\n // whole markdown under `structuredContent.content`, doubling token cost for\n // clients that forward both fields to an LLM.\n results: z\n .array(z.object({\n rank: z.number().int().positive().describe('1-based rank in the merged ranking.'),\n url: z.string().describe('Result URL.'),\n title: z.string().describe('Page title from the result.'),\n snippet: z.string().describe('Search snippet from the result.'),\n source_type: z\n .enum(['reddit', 'github', 'docs', 'blog', 'paper', 'qa', 'cve', 'news', 'video', 'web'])\n .describe(\n 'Heuristic source kind from the URL. When the LLM classifier is online its tag overrides this.',\n ),\n score: z.number().describe('Composite CTR-weighted score, normalized to 100.'),\n seen_in: z.number().int().nonnegative().describe('Number of input queries this URL appeared in.'),\n best_position: z.number().int().nonnegative().describe('Best (lowest) SERP position observed.'),\n }))\n .optional()\n .describe('Per-result structured payload \u2014 same data the markdown table renders, machine-readable.'),\n metadata: z.object({\n total_items: z.number().int().nonnegative().describe('Number of queries executed.'),\n successful: z.number().int().nonnegative().describe('Queries that returned results.'),\n failed: z.number().int().nonnegative().describe('Queries that failed.'),\n execution_time_ms: z.number().int().nonnegative().describe('Wall clock time in milliseconds.'),\n llm_classified: z.boolean().describe('Whether LLM classification was applied.'),\n llm_error: z.string().optional().describe('LLM error if classification failed and fell back to raw.'),\n scope: z.enum(['web', 'reddit', 'both']).optional().describe('Search scope used.'),\n coverage_summary: z\n .array(z.object({\n query: z.string().describe('The search query.'),\n result_count: z.number().int().nonnegative().describe('Results returned for this query.'),\n top_url: z.string().optional().describe('Domain of the top result.'),\n }))\n .optional()\n .describe('Per-query result counts and top URLs.'),\n low_yield_queries: z\n .array(z.string())\n .optional()\n .describe('Queries that produced 0-1 results.'),\n query_rewrites: z\n .array(z.object({\n original: z.string().describe('The query as the agent submitted it.'),\n rewritten: z.string().describe('The query as dispatched to Google after Phase A normalization.'),\n rules: z.array(z.string()).describe('Rule ids applied (A1=operator-char de-quote, A2=path/URL de-quote, A3=phrase-AND collapse).'),\n }))\n .optional()\n .describe('Pre-dispatch query rewrites \u2014 Phase A normalizations (operator-char and path/URL de-quote, phrase-AND \u2192 anchor + OR collapse).'),\n retried_queries: z\n .array(z.object({\n original: z.string().describe('The query as dispatched (post-Phase-A) that returned 0 results.'),\n retried_with: z.string().describe('The relaxed form retried after the empty initial response.'),\n rules: z.array(z.string()).describe('Rule ids applied (B1=strip all quotes, B2=drop site: filter).'),\n recovered_results: z.number().int().nonnegative().describe('How many hits the retry produced; 0 means the retry also failed.'),\n }))\n .optional()\n .describe('On-empty retries \u2014 Phase B relaxations applied after the initial Serper batch returned 0 results for a query.'),\n retry_error: z\n .object({\n phase: z.literal('relax-retry').describe('Retry phase that failed after the initial batch succeeded.'),\n code: z.string().describe('Structured error code from the retry batch.'),\n message: z.string().describe('Provider error message from the retry batch.'),\n retryable: z.boolean().describe('Whether the retry-batch provider failure is retryable.'),\n statusCode: z.number().int().optional().describe('Provider status code when available.'),\n })\n .optional()\n .describe('Non-fatal failure from the relaxed retry batch; initial search results were preserved.'),\n }).strict(),\n}).strict();\n\nexport type WebSearchOutput = z.infer<typeof webSearchOutputSchema>;\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -116,6 +116,26 @@ function buildChatRequestBody(model, prompt) {
116
116
  reasoning_effort: "low"
117
117
  };
118
118
  }
119
+ function normalizeProviderError(err, message) {
120
+ if (typeof err === "object" && err !== null) return err;
121
+ return new Error(message);
122
+ }
123
+ function getProviderFailure(response) {
124
+ if (response.content !== null || response.failureKind !== "provider") return null;
125
+ return response.errorCause;
126
+ }
127
+ function emptyLLMExtractionResult(content) {
128
+ return {
129
+ content,
130
+ processed: false,
131
+ error: "LLM returned empty response",
132
+ errorDetails: {
133
+ code: ErrorCode.INTERNAL_ERROR,
134
+ message: "LLM returned empty response",
135
+ retryable: false
136
+ }
137
+ };
138
+ }
119
139
  async function requestText(processor, prompt, operationLabel, signal, modelOverride) {
120
140
  const model = modelOverride || LLM_EXTRACTION.MODEL;
121
141
  try {
@@ -137,20 +157,26 @@ async function requestText(processor, prompt, operationLabel, signal, modelOverr
137
157
  }
138
158
  const err = `Empty response from model ${model}`;
139
159
  mcpLog("warning", `${operationLabel} returned empty content for model ${model}`, "llm");
140
- return { content: null, model, error: err };
160
+ return { content: null, model, error: err, failureKind: "empty" };
141
161
  } catch (err) {
142
162
  const message = err instanceof Error ? err.message : String(err);
143
163
  mcpLog("warning", `${operationLabel} failed for model ${model}: ${message}`, "llm");
144
- return { content: null, model, error: message };
164
+ return {
165
+ content: null,
166
+ model,
167
+ error: message,
168
+ failureKind: "provider",
169
+ errorCause: normalizeProviderError(err, message)
170
+ };
145
171
  }
146
172
  }
147
173
  async function requestTextWithFallback(processor, prompt, operationLabel, signal) {
148
174
  const primary = await requestText(processor, prompt, operationLabel, signal);
149
- if (primary.content) return primary;
175
+ if (primary.content !== null) return primary;
150
176
  const fallbackModel = LLM_EXTRACTION.FALLBACK_MODEL;
151
177
  if (!fallbackModel) return primary;
152
178
  mcpLog("warning", `Primary model failed, switching to fallback ${fallbackModel}`, "llm");
153
- let lastError = primary.error;
179
+ let lastFailure = primary;
154
180
  for (let attempt = 0; attempt < FALLBACK_RETRY_COUNT; attempt++) {
155
181
  if (attempt > 0) {
156
182
  const delayMs = calculateLLMBackoff(attempt - 1);
@@ -162,10 +188,10 @@ async function requestTextWithFallback(processor, prompt, operationLabel, signal
162
188
  }
163
189
  }
164
190
  const result = await requestText(processor, prompt, `${operationLabel} [fallback]`, signal, fallbackModel);
165
- if (result.content) return result;
166
- lastError = result.error;
191
+ if (result.content !== null) return result;
192
+ lastFailure = result;
167
193
  }
168
- return { content: null, model: fallbackModel, error: lastError };
194
+ return lastFailure;
169
195
  }
170
196
  function isRetryableLLMError(error) {
171
197
  if (!error || typeof error !== "object") return false;
@@ -316,23 +342,18 @@ ${truncatedContent}`;
316
342
  mcpLog("warning", `Retry attempt ${attempt}/${LLM_RETRY_CONFIG.maxRetries}`, "llm");
317
343
  }
318
344
  const response = await requestText(processor, prompt, "LLM extraction", signal);
319
- if (response.content) {
345
+ if (response.content !== null) {
320
346
  mcpLog("info", `Successfully extracted ${response.content.length} characters`, "llm");
321
347
  markLLMSuccess("extractor");
322
348
  return { content: response.content, processed: true };
323
349
  }
350
+ const providerFailure = getProviderFailure(response);
351
+ if (providerFailure) {
352
+ throw providerFailure;
353
+ }
324
354
  mcpLog("warning", "Received empty response from LLM", "llm");
325
355
  markLLMFailure("extractor", "LLM returned empty response");
326
- return {
327
- content,
328
- processed: false,
329
- error: "LLM returned empty response",
330
- errorDetails: {
331
- code: ErrorCode.INTERNAL_ERROR,
332
- message: "LLM returned empty response",
333
- retryable: false
334
- }
335
- };
356
+ return emptyLLMExtractionResult(content);
336
357
  } catch (err) {
337
358
  lastError = classifyError(err);
338
359
  const status = hasStatus(err) ? err.status : void 0;
@@ -372,13 +393,18 @@ ${truncatedContent}`;
372
393
  }
373
394
  try {
374
395
  const response = await requestText(processor, prompt, "LLM extraction [fallback]", signal, fallbackModel);
375
- if (response.content) {
396
+ if (response.content !== null) {
376
397
  mcpLog("info", `Fallback extracted ${response.content.length} characters`, "llm");
377
398
  markLLMSuccess("extractor");
378
399
  return { content: response.content, processed: true };
379
400
  }
401
+ const providerFailure = getProviderFailure(response);
402
+ if (providerFailure) {
403
+ throw providerFailure;
404
+ }
380
405
  mcpLog("warning", "Fallback returned empty response", "llm");
381
- break;
406
+ markLLMFailure("extractor", "LLM returned empty response");
407
+ return emptyLLMExtractionResult(content);
382
408
  } catch (err) {
383
409
  lastError = classifyError(err);
384
410
  mcpLog("error", `Fallback error (attempt ${attempt + 1}): ${lastError.message}`, "llm");
@@ -489,7 +515,7 @@ ${lines.join("\n")}`;
489
515
  prompt,
490
516
  "Search classification"
491
517
  );
492
- if (!response.content) {
518
+ if (response.content === null) {
493
519
  const errMsg = response.error ?? "LLM returned empty classification response";
494
520
  markLLMFailure("planner", errMsg);
495
521
  return { result: null, error: errMsg };
@@ -551,7 +577,7 @@ RULES:
551
577
  prompt,
552
578
  "Raw-mode refine query generation"
553
579
  );
554
- if (!response.content) {
580
+ if (response.content === null) {
555
581
  const errMsg = response.error ?? "LLM returned empty raw-mode refine query response";
556
582
  markLLMFailure("planner", errMsg);
557
583
  return { result: [], error: errMsg };
@@ -679,7 +705,7 @@ freshness_window:
679
705
  "Research brief generation",
680
706
  signal
681
707
  );
682
- if (!response.content) {
708
+ if (response.content === null) {
683
709
  mcpLog("warning", `Research brief generation returned no content: ${response.error ?? "unknown"}`, "llm");
684
710
  markLLMFailure("planner", response.error ?? "empty response");
685
711
  return null;