deepseek-web-retrieval 0.9.0 → 0.10.0

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.
@@ -113,30 +113,31 @@ scopes; keys locked to a single scope are enumerated in the
113
113
  `web_search = "disabled"` in `~/.codex/config.toml`. Trap: the `[tools] web_search =
114
114
  <bool>` form parses but discards the value at 0.163.0 — a silent no-op
115
115
  ([config source at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/config/src/config_toml.rs)).
116
- - **MCP registration** — `~/.codex/config.toml` `[mcp_servers.<name>]`: stdio
117
- `command`/`args`/`env`/`env_vars`; `startup_timeout_sec` (30 s) and `tool_timeout_sec`
118
- (300 s) defaults — the `DEFAULT_STARTUP_TIMEOUT`/`DEFAULT_TOOL_TIMEOUT` constants
119
- ([rmcp_client.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/codex-mcp/src/rmcp_client.rs));
120
- `enabled_tools`/`disabled_tools` (verbatim raw tool-name match, filtered before
121
- registration so the model never sees a disabled tool; an empty `enabled_tools` allow-list
122
- exposes nothing), per-tool approval modes, and output limits: MCP tool output can be
123
- capped per tool via `output_token_limit` ("Token budget for one MCP tool's output,
124
- before the standard 20% serialization allowance"; the default cap is not published
125
- there)
126
- ([mcp_types.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/config/src/mcp_types.rs)).
127
- stdio children do not inherit the parent environment (only `HOME`, `PATH`, and explicit
128
- `env`), so `DEEPSEEK_API_KEY` must be passed explicitly. When a tool result has
129
- `structuredContent`, Codex replaces the content blocks with the serialized JSON. No SSE.
130
- Codex identifies itself in the initialize handshake as clientInfo name `codex-mcp-client`
131
- with title `Codex`
132
- ([rmcp_client.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/codex-mcp/src/rmcp_client.rs)).
133
- The official docs moved to learn.chatgpt.com
134
- ([MCP](https://learn.chatgpt.com/docs/extend/mcp),
135
- [config reference](https://learn.chatgpt.com/docs/config-file/config-reference); the old
136
- developers.openai.com URLs 308-redirect there) and now document every key above — but
137
- state 10 s/60 s timeout defaults, which describe an older release line; the 30 s/300 s
138
- constants above are the facts at the locked 0.163.0 (docs discrepancy re-verified
139
- 2026-10-21).
116
+ - **MCP registration** — `~/.codex/config.toml` `[mcp_servers.<name>]`:
117
+ - **Entry keys:** stdio `command`/`args`/`env`/`env_vars`; per-tool approval modes.
118
+ - **Timeouts:** `startup_timeout_sec` (30 s) and `tool_timeout_sec` (300 s) defaults —
119
+ the `DEFAULT_STARTUP_TIMEOUT`/`DEFAULT_TOOL_TIMEOUT` constants
120
+ ([rmcp_client.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/codex-mcp/src/rmcp_client.rs)).
121
+ - **Tool filtering:** `enabled_tools`/`disabled_tools` — verbatim raw tool-name match,
122
+ filtered before registration so the model never sees a disabled tool; an empty
123
+ `enabled_tools` allow-list exposes nothing.
124
+ - **Output limits:** MCP tool output can be capped per tool via `output_token_limit`
125
+ ("Token budget for one MCP tool's output, before the standard 20% serialization
126
+ allowance"; the default cap is not published there)
127
+ ([mcp_types.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/config/src/mcp_types.rs)).
128
+ - **Environment:** stdio children do not inherit the parent environment (only `HOME`,
129
+ `PATH`, and explicit `env`), so `DEEPSEEK_API_KEY` must be passed explicitly.
130
+ - **Result shape and identity:** when a tool result has `structuredContent`, Codex
131
+ replaces the content blocks with the serialized JSON. No SSE. Codex identifies itself
132
+ in the initialize handshake as clientInfo name `codex-mcp-client` with title `Codex`
133
+ ([rmcp_client.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/codex-mcp/src/rmcp_client.rs)).
134
+ - **Docs drift:** the official docs moved to learn.chatgpt.com
135
+ ([MCP](https://learn.chatgpt.com/docs/extend/mcp),
136
+ [config reference](https://learn.chatgpt.com/docs/config-file/config-reference); the
137
+ old developers.openai.com URLs 308-redirect there) and now document every key above —
138
+ but state 10 s/60 s timeout defaults, which describe an older release line; the
139
+ 30 s/300 s constants above are the facts at the locked 0.163.0 (docs discrepancy
140
+ re-verified 2026-10-21).
140
141
  - **Rules** — `~/.codex/AGENTS.md` (with `AGENTS.override.md` preferred) at user scope;
141
142
  every `AGENTS.md` from the project root down to the cwd, concatenated root→cwd.
142
143
  Extensible via `project_doc_fallback_filenames` and `developer_instructions`
@@ -184,8 +185,10 @@ scopes; keys locked to a single scope are enumerated in the
184
185
  channel: the runtime config surface is the `DEEPSEEK_*` environment (the same pipeline as
185
186
  the MCP server), the shared config file
186
187
  (`~/.config/deepseek-web-retrieval/config.json`, layered under the environment), plus a
187
- `deepseekWebRetrieval` settings namespace the installer writes for
188
- the off tier — Pi preserves unknown settings keys on read and on its own rewrites
188
+ `deepseekWebRetrieval` settings namespace the installer writes for the `off` tier (a
189
+ capability switched off — the [tier-to-artifact
190
+ mapping](../design/installer-design.md#tier-to-artifact-mapping) defines it) — Pi
191
+ preserves unknown settings keys on read and on its own rewrites
189
192
  (`pi install`), verified at 1.3.0. Action methods such as `getSettings()` cannot be
190
193
  called during extension loading, so the adapter reads the settings files itself.
191
194
  - **MCP registration** — `~/.pi/agent/mcp.json` or `.pi/mcp.json`: stdio
@@ -320,11 +323,13 @@ scopes; keys locked to a single scope are enumerated in the
320
323
  `toolPolicies`). Codex's docs location and Copilot's `GEMINI.md` dating were re-verified
321
324
  2026-10-21 and are settled in their sections above. Protocol-revision evidence for every
322
325
  host lives in [mcp-compatibility.md](mcp-compatibility.md).
323
- - **Claude Code forward-compat anchors (checked at the 2.1.303 lock):** the headless docs
324
- pre-announce `--bare` becoming the default for `-p`, and bare mode never reads
325
- `.mcp.json` — the billed e2e scenario and the project-scope install story both need
326
- re-checking when that lands. `${VAR}` expansion in the `env` block of a user-scope
327
- `~/.claude.json` entry is supported but only indirectly documented (the docs spell out
328
- `.mcp.json` and `command`/`args`; the e2e's `hasCompletedOnboarding` flag and the
329
- redundant `enableAllProjectMcpServers` belt-and-suspenders are undocumented), so a
330
- baseline bump should re-verify all three against the then-current docs.
326
+ - **Claude Code forward-compat anchors (checked at the 2.1.303 lock)** — three facts a
327
+ baseline bump should re-verify against the then-current docs:
328
+ 1. The headless docs pre-announce `--bare` becoming the default for `-p`, and bare mode
329
+ never reads `.mcp.json` — the billed `claude-code` e2e scenario in this repo's test
330
+ suite and the project-scope install story both need re-checking when that lands.
331
+ 2. `${VAR}` expansion in the `env` block of a user-scope `~/.claude.json` entry is
332
+ supported but only indirectly documented (the docs spell out `.mcp.json` and
333
+ `command`/`args`).
334
+ 3. The same e2e scenario's `hasCompletedOnboarding` flag and the redundant
335
+ `enableAllProjectMcpServers` belt-and-suspenders are undocumented.
@@ -127,7 +127,9 @@ differences:
127
127
  spec says clients SHOULD support it whenever possible. Legacy SSE is never required —
128
128
  Codex and Pi reject it anyway.
129
129
  3. **Build on the official SDK v2 dual-era serving.** One build then serves the full range:
130
- the `2024-11-05` floor set by Cline (v2 legacy support spans `2024-10-07`–`2025-11-25`)
130
+ the `2024-11-05` floor set by Cline (v2 legacy support spans `2024-10-07`–`2025-11-25`;
131
+ `2024-10-07` is the pre-1.0 draft version string the SDK accepts for legacy
132
+ compatibility — it predates the spec's initial `2024-11-05` revision)
131
133
  up to the `2026-07-28` negotiators (Claude Code 2.1.274+, Devin 3000.11.92, Copilot CLI
132
134
  1.0.81+). A v1.x-built server still works today — modern clients fall back to the legacy
133
135
  handshake — but it can never offer the stateless era; v2 serves both from one entry
@@ -54,7 +54,7 @@ MCP never carries conversation context to a server. The facts, per revision:
54
54
  human-reviewed
55
55
  ([sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling)). A server
56
56
  could only ask a model to *guess* intent — not obtain the user's question.
57
- - **Elicititation is interactive, not derivation.** `elicitation/create` asks the user a form/choice
57
+ - **Elicitation is interactive, not derivation.** `elicitation/create` asks the user a form/choice
58
58
  question and waits ([2025-06-18+](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation));
59
59
  it requires client capability (only Codex in this matrix has it, per
60
60
  [mcp-compatibility.md](mcp-compatibility.md)) and an explicit round trip.
@@ -184,21 +184,11 @@ the declared schema; raw JSON Schema forwarded verbatim) and B feasible
184
184
  (`ctx.session.context`). Websearch provider path (the search replacement this repo ships): neither
185
185
  — the input schema is host-fixed to `query`, and the provider context has no session handle.**
186
186
 
187
- ### Cline 0.0.13
188
-
189
- Closed source; the platform binary
190
- ([@cline/cli-linux-x64 0.0.13](https://registry.npmjs.org/@cline/cli-linux-x64/0.0.13), ~119 MB)
191
- yielded the protocol facts already recorded — custom client negotiating `2024-11-05`,
192
- text-content-only results ([mcp-compatibility.md](mcp-compatibility.md)). No primary source
193
- documents whether the client validates tool arguments against `inputSchema` or mangles schemas;
194
- strings cannot prove a runtime behavior either way, so this is **undeterminable at the lock**. The
195
- 2024-11-05 revision itself puts no validation obligation on clients and defines `arguments` as a
196
- free-form object, so there is no protocol-level obstacle to channel A — merely no evidence for or
197
- against host behavior. Channel B: none (MCP only, `paths` in hosts.jsonc).
198
-
199
187
  ## Proprietary hosts
200
188
 
201
- No proprietary host documents a context channel; the question is whether anything documents
189
+ Closed-source hosts, from binary analysis (Cline) and official docs/changelogs (the
190
+ rest). No proprietary host documents a context
191
+ channel; the question is whether anything documents
202
192
  input-schema handling that would break an optional parameter.
203
193
 
204
194
  - **Claude Code 2.1.303.** The [MCP docs](https://code.claude.com/docs/en/mcp) say nothing about
@@ -212,6 +202,16 @@ input-schema handling that would break an optional parameter.
212
202
  preventing calling MCP tools that have nested references in their input schemas". Together these
213
203
  show the server's `inputSchema` is what the model provider sees and that arguments follow it;
214
204
  no host-side validation is ever mentioned. **A feasible by this evidence; B none.**
205
+ - **Cline 0.0.13.** Closed source; the platform binary
206
+ ([@cline/cli-linux-x64 0.0.13](https://registry.npmjs.org/@cline/cli-linux-x64/0.0.13), ~119 MB)
207
+ yielded the protocol facts already recorded — custom client negotiating `2024-11-05`,
208
+ text-content-only results ([mcp-compatibility.md](mcp-compatibility.md)). No primary
209
+ source documents whether the client validates tool arguments against `inputSchema` or
210
+ mangles schemas; strings cannot prove a runtime behavior either way, so this is
211
+ **undeterminable at the lock**. The 2024-11-05 revision itself puts no validation
212
+ obligation on clients and defines `arguments` as a free-form object, so there is no
213
+ protocol-level obstacle to channel A — merely no evidence for or against host behavior.
214
+ Channel B: none (MCP only, `paths` in hosts.jsonc).
215
215
  - **Cursor 2026.10.08.** The [MCP docs](https://cursor.com/docs/mcp) describe tools only as
216
216
  "Functions for the AI model to execute" and say nothing about input schemas, validation, or
217
217
  context — and the protocol revision itself is unpinnable from primary sources
@@ -293,7 +293,7 @@ negotiated revision in the matrix.
293
293
  [2025-11-25 tasks (experimental)](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
294
294
  - [2026-07-28 overview (`_meta`, statelessness)](https://modelcontextprotocol.io/specification/2026-07-28/basic/index)
295
295
 
296
- ### Open-source hosts
296
+ ### Open-source host sources
297
297
 
298
298
  - Codex: [rust-v0.163.0 tag](https://github.com/openai/codex/tree/rust-v0.163.0) —
299
299
  [mcp_tool_call.rs (argument parsing, no validation)](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/core/src/mcp_tool_call.rs),
@@ -324,15 +324,15 @@ negotiated revision in the matrix.
324
324
  (Tool.Context, WebSearch.ProviderInput),
325
325
  [@opencode/client](https://registry.npmjs.org/@opencode/client/2.0.32)
326
326
  (session.context → SessionMessageInfo[], SessionMessageUser.text)
327
- - Cline: [@cline/cli-linux-x64 0.0.13](https://registry.npmjs.org/@cline/cli-linux-x64/0.0.13)
328
- (manifest only; behavior undeterminable) and prior binary analysis recorded in
329
- [mcp-compatibility.md](mcp-compatibility.md)
330
327
 
331
- ### Proprietary hosts
328
+ ### Proprietary host sources
332
329
 
333
330
  - Claude Code: [changelog](https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md)
334
331
  (entries 2.0.50, 2.1.30, 2.1.246, 2.1.300 — all ≤ the 2.1.303 lock),
335
332
  [MCP docs](https://code.claude.com/docs/en/mcp)
333
+ - Cline: [@cline/cli-linux-x64 0.0.13](https://registry.npmjs.org/@cline/cli-linux-x64/0.0.13)
334
+ (manifest only; behavior undeterminable) and prior binary analysis recorded in
335
+ [mcp-compatibility.md](mcp-compatibility.md)
336
336
  - Cursor: [MCP docs](https://cursor.com/docs/mcp) (silent on the question)
337
337
  - Devin: [MCP overview](https://docs.devin.ai/cli/extensibility/mcp/overview),
338
338
  [changelog](https://docs.devin.ai/cli/changelog) (silent on the question)
@@ -5,8 +5,8 @@ This note answers one question: through which channel can DeepSeek's per-result
5
5
  (string|null) — reach the **model** (not the human UI) on each host at its locked version? The
6
6
  repo filters on the first two in `src/core/search.ts` (thresholds default to off); at the
7
7
  research baseline it dropped all three before returning — the adapters now pass them through
8
- as a text marker plus a structured mirror (item 1 below is implemented as
9
- `formatQualityMarker`).
8
+ as a text marker plus a structured mirror (the text marker of *What this means for this
9
+ repo*, item 1, is implemented as `formatQualityMarker`).
10
10
  Related: [ori-question-sourcing.md](ori-question-sourcing.md) (input side),
11
11
  [search-inline-content.md](search-inline-content.md) (content volume).
12
12
 
@@ -47,7 +47,8 @@ The server-side validator (`@modelcontextprotocol/server` 2.3.1,
47
47
  `Output validation error: Tool <name> has an output schema but no structured content was
48
48
  provided` (JSON-RPC `InvalidParams`). Text-only results are not allowed for schema'd tools.
49
49
  - `structuredContent` is then validated against the schema; a mismatch is rejected the same way.
50
- - The v2 codec's SEP-2106 §4.3 auto-append (`appendTextFallbackForNonObject`) adds a
50
+ - The v2 codec's SEP-2106 §4.3 auto-append (so named in the installed SDK's own source;
51
+ implemented as `appendTextFallbackForNonObject`) adds a
51
52
  `JSON.stringify(structuredContent)` TextContent block **only when `structuredContent` is a
52
53
  non-object** (primitive/array/null) and no text block exists. Object-shaped
53
54
  `structuredContent` gets **no** automatic text mirror — the server must mirror it itself to
@@ -108,7 +109,8 @@ From the official repo at tag `v2.0.32` (github.com/anomalyco/opencode, where `@
108
109
  - Every tool result then passes through `ToolOutput.truncate`:
109
110
  **`MAX_LINES = 2000`, `MAX_BYTES = 50 KiB`** (full output retained to a `tool-output/`
110
111
  file for 7 days) — [`packages/core/src/tool-output.ts`](https://github.com/anomalyco/opencode/blob/v2.0.32/packages/core/src/tool-output.ts).
111
- These caps were not previously recorded in [control-matrix.md](control-matrix.md).
112
+ These caps are also recorded in [control-matrix.md](control-matrix.md#opencode)'s
113
+ OpenCode section.
112
114
 
113
115
  ## What this means for this repo
114
116
 
@@ -128,9 +130,9 @@ From the official repo at tag `v2.0.32` (github.com/anomalyco/opencode, where `@
128
130
  The Pi adapter's extension `outputSchema` is safe as-is — Pi re-declares but does not
129
131
  validate it, and its `structuredContent` is event data.
130
132
  4. **New cap facts for the design margin:** OpenCode plugin results truncate at 2000 lines /
131
- 50 KiB (new), Pi extension tools bypass the MCP 20 KB truncation (already known), Claude
132
- Code 25k tokens, Codex per-tool `output_token_limit` (default unpublished). None of these
133
- bind at search-result volumes; they matter if [inline content](search-inline-content.md) is
133
+ 50 KiB, Pi extension tools bypass the MCP 20 KB truncation, Claude Code caps output at
134
+ 25k tokens, and Codex applies its per-tool `output_token_limit` (default unpublished).
135
+ None of these bind at search-result volumes; they matter if [inline content](search-inline-content.md) is
134
136
  ever enabled.
135
137
 
136
138
  ## Sources
@@ -159,7 +159,8 @@ the current official docs** (checked 2026-10-21), nor in the November 2025 snaps
159
159
  page on the Internet Archive; the published truncation mechanism is the token-based
160
160
  `max_content_tokens` parameter above, with no published default.
161
161
 
162
- SDK layer (official repos, default branch):
162
+ SDK layer (official repos, default branch at the 2026-10-21 check — moving heads, unlike
163
+ the pinned firecrawl 4.50.0 above; re-pin to a tag before citing these constants later):
163
164
 
164
165
  | SDK | Default timeout | Default max retries | Source |
165
166
  | --- | --- | --- | --- |
@@ -180,7 +181,8 @@ From the official guide
180
181
 
181
182
  No relevance/authority score or filter is exposed on any web-search surface.
182
183
 
183
- SDK layer (official repos, default branch):
184
+ SDK layer (official repos, default branch at the 2026-10-21 check — same moving-head
185
+ caveat as the Anthropic SDK table above):
184
186
 
185
187
  | SDK | Default timeout | Default max retries | Source |
186
188
  | --- | --- | --- | --- |
@@ -17,6 +17,12 @@ source at the repo's locked tag. Secondhand blogs are excluded. Where no primary
17
17
  a fact — most importantly the size of `content` — that is stated as such: an undocumented
18
18
  size is itself a decision-relevant fact.
19
19
 
20
+ **Short answer:** DeepSeek documents **no size bound** for `need_content` content and no
21
+ per-result budget knob; of the eight architectures surveyed below, only Jina ships
22
+ content-bearing search as the default; and the binding host cap is Pi's 20 KB MCP-path
23
+ truncation — the volume math below puts a safe per-result budget in the ~1k–2k char
24
+ range.
25
+
20
26
  ## What `need_content=true` provably returns
21
27
 
22
28
  From the official Search & Fetch guide, the parameter and its response field, verbatim:
@@ -101,15 +107,36 @@ search response without setting any parameter.
101
107
 
102
108
  | Vendor | Content inline by default? | Opt-in mechanism | Per-result content budget knob | Separate fetch tool? |
103
109
  | --- | --- | --- | --- | --- |
104
- | **Tavily** | Partial — default response carries per-result `content` of **relevance chunks**: `chunks_per_source` **3** chunks of ~500 chars (~1,500 chars/result) | Full body via `include_raw_content` (default **false**; accepts `markdown`/`text`/`true`) | Chunk count only (`chunks_per_source` 1–3, sizes `content`, **not** `raw_content`); no knob limits `raw_content` size in `/search` | Yes — `/extract` (up to 20 URLs; `extract_depth` basic/advanced; no per-URL cap; ~800-char chunks only when `query` is given) |
110
+ | **Tavily** | Partial — per-result `content` of relevance chunks by default | Full body via `include_raw_content` (default **false**) | Chunk count only (`chunks_per_source` 1–3); no knob limits `raw_content` size in `/search` | Yes — `/extract` (up to 20 URLs) |
105
111
  | **Exa** | No — `contents.text` defaults **false** | `contents.text: true` in the **same** `POST /search` body | **`maxCharacters`** (1–1,000,000, no published default) plus `verbosity` `compact`/`standard`/`full` | Yes — `/contents` endpoint by result ID/URL |
106
112
  | **You.com** | No — “Omit `extraction` for a plain search that returns snippets only” | `extraction.full_page` (“crawls each result and returns the page content”) | **None for `full_page`** (only `crawl_timeout`); the `highlights` mode has a response-wide `total_max_tokens` budget of **8192** (1,024–8,192) | No dedicated fetch endpoint documented on `/v1/search` |
107
- | **Perplexity** | No — Search API results are `title`/`url`/`snippet`/`date` only | Token budgets: `max_tokens` ("Maximum total webpage content tokens to return across all search results") and **`max_tokens_per_page`** ("Maximum webpage content tokens to extract from each result page"), on both the Search API and the Agent API `web_search` tool; **no default published** for either ("the default budgets apply"); on the Search API the extracted text surfaces through `snippet` | **`max_tokens_per_page`** (min 1, max 1,000,000, no default) | Yes — Agent API "Fetch URL Content" tool ("Fetch full content from known URLs") |
108
- | **Anthropic** | Not caller-visible — `web_search` results carry `url`/`title`/`page_age` plus `encrypted_content` (“Encrypted content that you must pass back in multi-turn conversations”); the model reads content in its context, the caller cannot | `response_inclusion` (`"full"` default / `"excluded"`) only controls whether consumed search blocks are echoed in the response — it is not a content-size knob | **None** (dynamic filtering on `web_search_20260209+` has Claude write code to filter results before they reach context, but no caller-set budget) | Yes — `web_fetch` with token-based `max_content_tokens` (no published default) |
109
- | **Firecrawl** | No — search returns `title`/`description`/`url` (+ `highlights`, default true) | `scrapeOptions` (default `{}`): "Include `scrapeOptions` with `formats: [{"type": "markdown"}] to get complete markdown content for each search result" | **None** for content size (`onlyMainContent` default true trims boilerplate, not length) | Collapsed into search via `scrapeOptions`; standalone `/scrape` endpoint also exists |
110
- | **Jina** (`s.jina.ai`) | **Yes — the only default-content architecture surveyed**: "Reader searches the web, fetches the top 5 results, visits each URL, and applies `r.jina.ai` to it"; result count is fixed at 5, not a parameter | None needed (content is the product) | **`x-token-budget`** header, no default (parsed only when sent) — a global budget, not per-result | Same product (`r.jina.ai`) serves plain fetch |
113
+ | **Perplexity** | No — Search API results are `title`/`url`/`snippet`/`date` only | Token budgets on both the Search API and the Agent API `web_search` tool (detail below) | **`max_tokens_per_page`** (min 1, max 1,000,000, no default) | Yes — Agent API “Fetch URL Content” tool (“Fetch full content from known URLs”) |
114
+ | **Anthropic** | Not caller-visible — content is model-only (`encrypted_content`) | None — `response_inclusion` is not a content-size knob | **None** (no caller-set budget) | Yes — `web_fetch` with token-based `max_content_tokens` (no published default) |
115
+ | **Firecrawl** | No — search returns `title`/`description`/`url` (+ `highlights`, default true) | `scrapeOptions` (default `{}`): “Include `scrapeOptions` with `formats: [{“type”: “markdown”}]` to get complete markdown content for each search result” | **None** for content size (`onlyMainContent` default true trims boilerplate, not length) | Collapsed into search via `scrapeOptions`; standalone `/scrape` endpoint also exists |
116
+ | **Jina** (`s.jina.ai`) | **Yes — the only default-content architecture surveyed**; result count is fixed at 5, not a parameter | None needed (content is the product) | **`x-token-budget`** header, no default (parsed only when sent) — a global budget, not per-result | Same product (`r.jina.ai`) serves plain fetch |
111
117
  | **DeepSeek (subject of this note)** | No — `need_content` defaults **false** | `need_content: true` | **None documented** (see above) | Yes — Fetch API, Markdown, no documented size cap |
112
118
 
119
+ Detail for the trimmed rows above:
120
+
121
+ - **Tavily:** the default per-result `content` carries **relevance chunks** —
122
+ `chunks_per_source` **3** chunks of ~500 chars (~1,500 chars/result); `include_raw_content`
123
+ accepts `markdown`/`text`/`true`; the chunk sizes apply to `content`, **not**
124
+ `raw_content`; `/extract` takes up to 20 URLs with `extract_depth` basic/advanced, no
125
+ per-URL cap, ~800-char chunks only when `query` is given.
126
+ - **Perplexity:** `max_tokens` (“Maximum total webpage content tokens to return across
127
+ all search results”) and **`max_tokens_per_page`** (“Maximum webpage content tokens to
128
+ extract from each result page”), **no default published** for either (“the default
129
+ budgets apply”); on the Search API the extracted text surfaces through `snippet`.
130
+ - **Anthropic:** `web_search` results carry `url`/`title`/`page_age` plus
131
+ `encrypted_content` (“Encrypted content that you must pass back in multi-turn
132
+ conversations”) — the model reads content in its context, the caller cannot.
133
+ `response_inclusion` (`”full”` default / `”excluded”`) only controls whether consumed
134
+ search blocks are echoed in the response; dynamic filtering on `web_search_20260209+`
135
+ has Claude write code to filter results before they reach context, but no caller-set
136
+ budget exists.
137
+ - **Jina:** “Reader searches the web, fetches the top 5 results, visits each URL, and
138
+ applies `r.jina.ai` to it.”
139
+
113
140
  The split, stated plainly: of the eight architectures surveyed, exactly one (Jina
114
141
  `s.jina.ai`) ships content-bearing search as the default, and it pins the result count at 5.
115
142
  Every other vendor makes inline content opt-in (Tavily `raw_content`, Exa `contents.text`,
@@ -119,6 +146,17 @@ exist they are per-result character caps (Exa `maxCharacters`) or per-result tok
119
146
  (Perplexity `max_tokens_per_page`); Tavily and You.com ship full-content modes with **no**
120
147
  per-result size knob at all — the two closest analogues to DeepSeek's `need_content`.
121
148
 
149
+ One further architecture reading (added 2026-10-24, same sources): **no surveyed vendor
150
+ narrows another parameter's value range when inline content is requested.** Every budget
151
+ knob in the table is static-range, conditional-effect — Exa's `maxCharacters` and
152
+ Perplexity's `max_tokens_per_page` keep their 1–1,000,000 ranges whether or not
153
+ `contents.text`/extraction is engaged, Tavily's `chunks_per_source` and Anthropic's
154
+ `max_content_tokens` are likewise unconditional, and none shrinks `count`/`numResults`
155
+ or any other parameter's domain when content flows (Jina's fixed 5 is its architecture,
156
+ not a conditional rule). The only conditional behavior anywhere in the table is which
157
+ budget *applies*; a content flag that reshaped other parameters' allowed values appears
158
+ in none of the eight.
159
+
122
160
  Sources: [Tavily /search](https://docs.tavily.com/documentation/api-reference/endpoint/search) ·
123
161
  [Tavily /extract](https://docs.tavily.com/documentation/api-reference/endpoint/extract) ·
124
162
  [Exa /search](https://exa.ai/docs/reference/search) ·
@@ -173,8 +211,9 @@ With `count=10` (the repo's default, and DeepSeek's) and `need_content: true`:
173
211
  [mcp_types.rs at rust-v0.163.0](https://github.com/openai/codex/blob/rust-v0.163.0/codex-rs/config/src/mcp_types.rs)).
174
212
  [control-matrix.md](control-matrix.md) records Codex's 300 s MCP tool timeout — latency,
175
213
  not size, is that host's documented ceiling.
176
- - **The binding constraint is Pi's 20 KB**, roughly an order of magnitude tighter than
177
- Claude Code's 25k-token default: a per-result content budget in the **~1k–2k char**
214
+ - **The binding constraint is Pi's 20 KB**, roughly 5× tighter than Claude Code's
215
+ 25k-token default (≈100k English chars by the conversions above): a per-result
216
+ content budget in the **~1k–2k char**
178
217
  range keeps count=10 under every published cap; reusing the repo's fetch budget (16,000
179
218
  chars) *per search result* would reach 160k chars per response — 8× Pi's cap and ~40k
180
219
  tokens on Claude Code.
@@ -195,8 +234,9 @@ maintainer decides.
195
234
  - **Today's pipeline would silently drop the content.** `DeepSeekSearchResult`
196
235
  ([types.ts](../../src/core/types.ts)) has no `content` field, and `processResults`
197
236
  ([search.ts](../../src/core/search.ts)) maps `SearchResult.content` from
198
- `summary ?? snippet` only — a `need_content` response's page bodies would be received and
199
- discarded, unbilled-for-nothing but also unrendered.
237
+ `summary ?? snippet` only — a `need_content` response's page bodies would be received
238
+ and discarded: they cost nothing extra (billing is per request), but they are also
239
+ never rendered.
200
240
  - **The only per-result clamp in the search path is `maxSummaryChars` (1,200)** — a
201
241
  summary/snippet budget. `fetchMaxChars` (16,000) and `formatFetchedContent`'s
202
242
  truncation-marker logic apply exclusively to the fetch tool path. There is **no per-result
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "deepseek-web-retrieval",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "description": "Token-efficient DeepSeek Search and Fetch for coding agents, starting with OpenCode v2.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -92,6 +92,12 @@ export interface SearchRequest extends RequestOptions {
92
92
  * ori_question to sharpen intent; adapters fill it from tool input.
93
93
  */
94
94
  readonly oriQuestion?: string;
95
+ /**
96
+ * Per-call only. The tool input's need_content flag: true asks for each
97
+ * result's page body, sent as the API's need_content and timed against
98
+ * contentTimeoutMs instead of timeoutMs.
99
+ */
100
+ readonly needContent?: boolean;
95
101
  }
96
102
 
97
103
  /**
@@ -130,14 +136,15 @@ export async function searchWeb(query: string, options: SearchOptions, request:
130
136
  const composed = composeSearchQuery(trimmed, options);
131
137
  const builtQuery = composed.query;
132
138
 
133
- const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
139
+ const needContent = request.needContent === true;
140
+ const timeout = effectiveTimeout(needContent ? options.contentTimeoutMs : options.timeoutMs, request.timeoutMs);
134
141
  const oriQuestion = oriQuestionValue(request.oriQuestion);
135
142
  const payload = await postJson<SearchResponse>(
136
143
  apiUrl(request.baseUrl, SEARCH_ENDPOINT_PATH),
137
144
  {
138
145
  query: builtQuery,
139
146
  count: options.count,
140
- need_content: false,
147
+ need_content: needContent,
141
148
  ...(oriQuestion === undefined ? {} : { ori_question: oriQuestion }),
142
149
  ...(options.localization === undefined ? {} : { localization: options.localization }),
143
150
  },
@@ -19,6 +19,8 @@ const VOCABULARY = [
19
19
  { key: "language", read: (o: ResolvedOptions) => o.search.language },
20
20
  { key: "searchLocalization", read: (o: ResolvedOptions) => o.search.localization },
21
21
  { key: "maxSummaryChars", read: (o: ResolvedOptions) => o.search.maxSummaryChars },
22
+ { key: "searchContentMaxChars", read: (o: ResolvedOptions) => o.search.contentMaxChars },
23
+ { key: "searchContentTimeoutMs", read: (o: ResolvedOptions) => o.search.contentTimeoutMs },
22
24
  { key: "searchTimeoutMs", read: (o: ResolvedOptions) => o.search.timeoutMs },
23
25
  { key: "fetchMaxChars", read: (o: ResolvedOptions) => o.fetch.maxChars },
24
26
  { key: "fetchAllowedDomains", read: (o: ResolvedOptions) => o.fetch.allowedDomains },
@@ -27,6 +27,19 @@ export const DEFAULT_MAX_RESULTS = 10;
27
27
  export const DEFAULT_MIN_RERANK = 0;
28
28
  export const DEFAULT_MIN_AUTHORITY = 0;
29
29
  export const DEFAULT_MAX_SUMMARY_CHARS = 1200;
30
+ /**
31
+ * The per-result budget a need_content page body is clamped to. 1,500 keeps a
32
+ * default count=10 response (bodies plus metadata ≈ 16 KB) under the tightest
33
+ * host output cap, Pi's 20 KB MCP-path truncation — see
34
+ * docs/research/search-inline-content.md.
35
+ */
36
+ export const DEFAULT_SEARCH_CONTENT_MAX_CHARS = 1500;
37
+ /**
38
+ * The timeout need_content calls use in place of searchTimeoutMs: DeepSeek
39
+ * fetches every result page server-side before responding, which the 30 s
40
+ * search default does not budget for.
41
+ */
42
+ export const DEFAULT_SEARCH_CONTENT_TIMEOUT_MS = 60000;
30
43
 
31
44
  export const DEFAULT_FETCH_MAX_CHARS = 16000;
32
45
  /** The ceiling for a per-call fetch budget, matching the configured knob's range. */
@@ -30,7 +30,8 @@ export const SEARCH_TOOL_DESCRIPTION =
30
30
  `${SEARCH_RANKED_FILTERED} ` +
31
31
  `${SEARCH_EMPTY_IS_NORMAL} ` +
32
32
  `${SEARCH_QUERY_OPERATORS} ` +
33
- "To read a full page, pass a result URL to the deepseek_fetch tool.";
33
+ "To read one full page in depth, pass a result URL to the deepseek_fetch tool; to carry each result's " +
34
+ "page body in this response, set need_content to true.";
34
35
 
35
36
  export const FETCH_TOOL_DESCRIPTION =
36
37
  "Fetch one web page as clean Markdown through DeepSeek's Fetch API. " +
@@ -54,3 +55,8 @@ export const FETCH_MAX_CHARS_PARAMETER =
54
55
  export const SEARCH_COUNT_PARAMETER =
55
56
  "Optional number of results for this call, 1-50. Overrides the configured count and result cap for this call; " +
56
57
  "billing is per request, not per result.";
58
+
59
+ export const SEARCH_NEED_CONTENT_PARAMETER =
60
+ "Optional; set true to attach each result's page body as Markdown — an excerpt clamped to a configured per-result " +
61
+ "budget, not a full page — in place of its short summary. Slower per call (DeepSeek fetches the result pages) and " +
62
+ "priced the same per request; for a full page, call deepseek_fetch.";
package/src/core/env.ts CHANGED
@@ -14,6 +14,8 @@ const NUMBER_ENV = {
14
14
  minAuthority: "DEEPSEEK_SEARCH_MIN_AUTHORITY",
15
15
  maxSummaryChars: "DEEPSEEK_SEARCH_MAX_SUMMARY_CHARS",
16
16
  searchTimeoutMs: "DEEPSEEK_SEARCH_TIMEOUT_MS",
17
+ searchContentMaxChars: "DEEPSEEK_SEARCH_CONTENT_MAX_CHARS",
18
+ searchContentTimeoutMs: "DEEPSEEK_SEARCH_CONTENT_TIMEOUT_MS",
17
19
  fetchMaxChars: "DEEPSEEK_FETCH_MAX_CHARS",
18
20
  fetchTimeoutMs: "DEEPSEEK_FETCH_TIMEOUT_MS",
19
21
  maxRetries: "DEEPSEEK_MAX_RETRIES",
@@ -97,6 +99,8 @@ export function envOptionsReport(env: Env = process.env): EnvOptionsReport {
97
99
  putText(options, "searchLocalization", env);
98
100
  putNumber(options, "maxSummaryChars", env, warnings);
99
101
  putNumber(options, "searchTimeoutMs", env, warnings);
102
+ putNumber(options, "searchContentMaxChars", env, warnings);
103
+ putNumber(options, "searchContentTimeoutMs", env, warnings);
100
104
 
101
105
  putNumber(options, "fetchMaxChars", env, warnings);
102
106
  putDomains(options, "fetchAllowedDomains", env, warnings);
@@ -118,7 +118,16 @@ export function formatSearchResults(results: readonly SearchResult[], context?:
118
118
  const marker = formatQualityMarker(result);
119
119
  const lines = [`${index + 1}. [${escapeBrackets(title)}](${result.url})${marker ? ` ${marker}` : ""}`];
120
120
  if (result.content) {
121
- lines.push(` ${result.content.replaceAll("\n", " ")}`);
121
+ // Page bodies keep their Markdown line structure, indented into the
122
+ // list item; digests compact to one line, as summaries always did.
123
+ lines.push(
124
+ result.contentKind === "page"
125
+ ? result.content
126
+ .split("\n")
127
+ .map((line) => ` ${line}`)
128
+ .join("\n")
129
+ : ` ${result.content.replaceAll("\n", " ")}`,
130
+ );
122
131
  }
123
132
  if (result.published !== undefined) {
124
133
  lines.push(` Published: ${formatPublished(result.published)}`);
@@ -9,6 +9,8 @@ import {
9
9
  DEFAULT_MIN_AUTHORITY,
10
10
  DEFAULT_MIN_RERANK,
11
11
  DEFAULT_RETRY_BASE_DELAY_MS,
12
+ DEFAULT_SEARCH_CONTENT_MAX_CHARS,
13
+ DEFAULT_SEARCH_CONTENT_TIMEOUT_MS,
12
14
  DEFAULT_SEARCH_COUNT,
13
15
  DEFAULT_TIMEOUT_MS,
14
16
  MAX_RETRIES,
@@ -67,6 +69,13 @@ export function resolveSearchOptions(input?: PluginOptions): SearchOptions {
67
69
  language: optionalText(input?.language),
68
70
  localization: localizationValue(input?.searchLocalization),
69
71
  maxSummaryChars: clampInt(input?.maxSummaryChars, DEFAULT_MAX_SUMMARY_CHARS, 0, 1_000_000),
72
+ contentMaxChars: clampInt(input?.searchContentMaxChars, DEFAULT_SEARCH_CONTENT_MAX_CHARS, 0, 1_000_000),
73
+ contentTimeoutMs: clampInt(
74
+ input?.searchContentTimeoutMs,
75
+ DEFAULT_SEARCH_CONTENT_TIMEOUT_MS,
76
+ MIN_TIMEOUT_MS,
77
+ MAX_TIMEOUT_MS,
78
+ ),
70
79
  timeoutMs: clampInt(input?.searchTimeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
71
80
  enabled: input?.searchEnabled !== false,
72
81
  };
@@ -142,12 +142,20 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
142
142
  }
143
143
 
144
144
  seen.add(url);
145
- const rawContent = optionalText(item.summary) ?? optionalText(item.snippet) ?? "";
146
- const cutContent = truncate(rawContent, options.maxSummaryChars);
145
+ // A need_content page body outranks the digest — the summary is only ever
146
+ // a proxy for the body it compresses. A zero budget drops the body so the
147
+ // digest renders instead.
148
+ const body = optionalText(item.content);
149
+ const digest = optionalText(item.summary) ?? optionalText(item.snippet) ?? "";
150
+ const fromPage = body !== undefined && options.contentMaxChars > 0;
151
+ const rawContent: string = fromPage && body !== undefined ? body : digest;
152
+ const contentBudget = fromPage ? options.contentMaxChars : options.maxSummaryChars;
153
+ const cutContent = truncate(rawContent, contentBudget);
147
154
  // The ellipsis marks a summary the cap actually shortened; official
148
155
  // summaries are typically 600-800 characters, so the 1,200 default
149
156
  // rarely triggers it.
150
- const content = cutContent && charCount(rawContent) > options.maxSummaryChars ? `${cutContent}…` : cutContent;
157
+ const content = cutContent && charCount(rawContent) > contentBudget ? `${cutContent}…` : cutContent;
158
+ const contentKind = fromPage ? "page" : "digest";
151
159
  const published = parsePublished(item.date);
152
160
  const title = optionalText(item.title);
153
161
  const rerankScore = typeof rerank === "number" && Number.isFinite(rerank) ? rerank : undefined;
@@ -156,7 +164,7 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
156
164
  results.push({
157
165
  url,
158
166
  ...(title ? { title } : {}),
159
- ...(content ? { content } : {}),
167
+ ...(content ? { content, contentKind } : {}),
160
168
  ...(published !== undefined ? { published } : {}),
161
169
  ...(rerankScore !== undefined ? { rerankScore } : {}),
162
170
  ...(authorityScore !== undefined ? { authority: authorityScore } : {}),
package/src/core/types.ts CHANGED
@@ -20,6 +20,14 @@ export interface SearchOptions {
20
20
  */
21
21
  readonly localization?: string;
22
22
  readonly maxSummaryChars: number;
23
+ /**
24
+ * Deployment-level only — never a per-call tool parameter. The budget a
25
+ * need_content page body is clamped to per result; the per-call boolean is
26
+ * `SearchRequest.needContent`.
27
+ */
28
+ readonly contentMaxChars: number;
29
+ /** Deployment-level only; the timeout a need_content call uses instead of `timeoutMs`. */
30
+ readonly contentTimeoutMs: number;
23
31
  readonly timeoutMs: number;
24
32
  readonly enabled: boolean;
25
33
  }
@@ -63,6 +71,8 @@ export interface DeepSeekSearchResult {
63
71
  readonly url?: string | null;
64
72
  readonly snippet?: string | null;
65
73
  readonly summary?: string | null;
74
+ /** The need_content page body; an empty string when need_content is false or the content is unavailable. */
75
+ readonly content?: string | null;
66
76
  readonly date?: string | null;
67
77
  }
68
78
 
@@ -71,6 +81,8 @@ export interface SearchResult {
71
81
  readonly url: string;
72
82
  readonly title?: string;
73
83
  readonly content?: string;
84
+ /** Which wire field `content` came from: a need_content page body or the summary/snippet digest. */
85
+ readonly contentKind?: "page" | "digest";
74
86
  readonly published?: number;
75
87
  readonly rerankScore?: number;
76
88
  /** Faithful to the wire: -1 means unknown authority. */
package/src/mcp/server.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  FETCH_TOOL_DESCRIPTION,
12
12
  FETCH_URL_PARAMETER,
13
13
  SEARCH_COUNT_PARAMETER,
14
+ SEARCH_NEED_CONTENT_PARAMETER,
14
15
  SEARCH_ORI_QUESTION_PARAMETER,
15
16
  SEARCH_QUERY_PARAMETER,
16
17
  SEARCH_TOOL_DESCRIPTION,
@@ -27,6 +28,7 @@ const SEARCH_INPUT = z.object({
27
28
  query: z.string().min(1).describe(SEARCH_QUERY_PARAMETER),
28
29
  original_question: z.string().optional().describe(SEARCH_ORI_QUESTION_PARAMETER),
29
30
  count: z.number().int().min(1).max(MAX_SEARCH_COUNT).optional().describe(SEARCH_COUNT_PARAMETER),
31
+ need_content: z.boolean().optional().describe(SEARCH_NEED_CONTENT_PARAMETER),
30
32
  });
31
33
 
32
34
  const FETCH_INPUT = z.object({
@@ -54,12 +56,13 @@ export function createDeepSeekServer(
54
56
  inputSchema: SEARCH_INPUT,
55
57
  annotations: { readOnlyHint: true },
56
58
  },
57
- async ({ query, original_question, count }, ctx) => {
59
+ async ({ query, original_question, count, need_content }, ctx) => {
58
60
  try {
59
61
  const request = requestContext(config, requireApiKey(env), ctx.mcpReq.signal);
60
62
  const outcome = await client.searchWeb(query, withPerCallCount(config.search, count), {
61
63
  ...request,
62
64
  oriQuestion: original_question,
65
+ needContent: need_content === true,
63
66
  });
64
67
  return callResult(server, ctx, formatSearchResults(outcome.results, outcome), {
65
68
  results: outcome.results.map(toStructuredResult),