deepseek-web-retrieval 0.2.0 → 0.3.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +187 -1
  2. package/README.md +13 -9
  3. package/dist/cli.js +4228 -178
  4. package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
  5. package/docs/agents/codex.md +193 -0
  6. package/docs/agents/domain.md +51 -0
  7. package/docs/agents/issue-tracker.md +45 -0
  8. package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
  9. package/docs/{pi.md → agents/pi.md} +35 -7
  10. package/docs/agents/triage-labels.md +15 -0
  11. package/docs/design/installer-design.md +184 -0
  12. package/docs/hosts.jsonc +59 -33
  13. package/docs/integration.md +45 -52
  14. package/docs/research/control-matrix.md +257 -0
  15. package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
  16. package/package.json +5 -3
  17. package/src/core/auth.ts +1 -1
  18. package/src/core/client.ts +42 -249
  19. package/src/core/constants.ts +5 -0
  20. package/src/core/descriptions.ts +19 -0
  21. package/src/core/format.ts +31 -1
  22. package/src/core/jsonc.ts +16 -0
  23. package/src/core/options.ts +2 -3
  24. package/src/core/search.ts +5 -7
  25. package/src/core/transport.ts +253 -0
  26. package/src/core/types.ts +13 -11
  27. package/src/installer/apply.ts +176 -0
  28. package/src/installer/cli.ts +215 -0
  29. package/src/installer/detect.ts +85 -0
  30. package/src/installer/formats/index.ts +58 -0
  31. package/src/installer/formats/json.ts +151 -0
  32. package/src/installer/formats/rules.ts +62 -0
  33. package/src/installer/formats/toml.ts +163 -0
  34. package/src/installer/formats/value.ts +57 -0
  35. package/src/installer/hosts/codex.ts +128 -0
  36. package/src/installer/hosts/index.ts +134 -0
  37. package/src/installer/hosts/opencode.ts +118 -0
  38. package/src/installer/hosts/pi.ts +115 -0
  39. package/src/installer/hosts/shared.ts +31 -0
  40. package/src/installer/paths.ts +18 -0
  41. package/src/installer/render.ts +62 -0
  42. package/src/installer/revert.ts +61 -0
  43. package/src/installer/rulesContent.ts +32 -0
  44. package/src/installer/state.ts +63 -0
  45. package/src/installer/types.ts +126 -0
  46. package/src/installer/uninstall.ts +33 -0
  47. package/src/mcp/auth.ts +2 -1
  48. package/src/mcp/cli.ts +21 -5
  49. package/src/mcp/config.ts +70 -27
  50. package/src/mcp/server.ts +81 -65
  51. package/src/opencode/auth.ts +3 -2
  52. package/src/opencode/plugin.ts +93 -56
  53. package/src/mcp/format.ts +0 -31
@@ -0,0 +1,32 @@
1
+ import type { Tier } from "./types"
2
+
3
+ /** Full MCP tool ids, per the installer design's rules non-negotiables. */
4
+ export const MCP_TOOL_IDS = { search: "mcp__deepseek__deepseek_web_search", fetch: "mcp__deepseek__deepseek_fetch" } as const
5
+
6
+ /**
7
+ * Builds the rules block lines for the preferred tier: names the full tool ids,
8
+ * states trigger conditions rather than unconditional preference, and lists the
9
+ * private-network exclusion. Only mentions capabilities that are not "off".
10
+ * Hosts whose tools are named differently pass their own ids.
11
+ */
12
+ export function rulesLines(ids: { readonly search: string; readonly fetch: string }, search: Tier, fetch: Tier): string[] {
13
+ const lines: string[] = ["DeepSeek retrieval guidance:"]
14
+ if (search !== "off") {
15
+ lines.push(
16
+ `- Use ${ids.search} when the task needs fresh external information: current docs, version numbers,`,
17
+ " changelogs, or anything you cannot know from the repository alone. Do not search for facts already",
18
+ " present locally.",
19
+ )
20
+ }
21
+ if (fetch !== "off") {
22
+ lines.push(
23
+ `- Use ${ids.fetch} to read a specific public web page as Markdown when its content is needed.`,
24
+ )
25
+ }
26
+ lines.push(
27
+ "- Never send localhost or private-network URLs to the DeepSeek tools; use local tools for those (the",
28
+ " server blocks private hosts by default).",
29
+ "- DeepSeek requests are billed per successful call; retrieve only what the task needs.",
30
+ )
31
+ return lines
32
+ }
@@ -0,0 +1,63 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { stateDir } from "./paths"
4
+ import type { FsRoots, InstallerInput, InstallRecord } from "./types"
5
+
6
+ export function statePathFor(input: InstallerInput): string {
7
+ return join(stateDir(input, input.scope), `install.${input.host}.json`)
8
+ }
9
+
10
+ export function readRecord(input: InstallerInput): InstallRecord | undefined {
11
+ const file = statePathFor(input)
12
+ if (!existsSync(file)) return undefined
13
+ try {
14
+ return JSON.parse(readFileSync(file, "utf8")) as InstallRecord
15
+ } catch {
16
+ return undefined
17
+ }
18
+ }
19
+
20
+ /**
21
+ * The one place that knows a file must never be claimed by two scopes of the
22
+ * same host: returns the subset of `files` the record at the other scope claims.
23
+ */
24
+ export function claimedByOtherScope(input: InstallerInput, files: readonly string[]): string[] {
25
+ const other = input.scope === "global" ? "project" : "global"
26
+ const record = readRecord({ ...input, scope: other })
27
+ if (record === undefined) return []
28
+ return files.filter((file) => record.files.some((candidate) => candidate.file === file))
29
+ }
30
+
31
+ /**
32
+ * Install records carry snapshots of replaced values, so they are written with
33
+ * owner-only permissions regardless of umask.
34
+ */
35
+ export function writeRecord(input: InstallerInput, record: InstallRecord): void {
36
+ const file = statePathFor(input)
37
+ mkdirSync(join(file, ".."), { recursive: true })
38
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
39
+ chmodSync(file, 0o600)
40
+ }
41
+
42
+ export function deleteRecord(input: InstallerInput): void {
43
+ const file = statePathFor(input)
44
+ if (existsSync(file)) rmSync(file)
45
+ }
46
+
47
+ /** Lists install records found in both scope locations for the given roots. */
48
+ export function listRecords(roots: FsRoots): InstallRecord[] {
49
+ const records: InstallRecord[] = []
50
+ for (const scope of ["global", "project"] as const) {
51
+ const dir = stateDir(roots, scope)
52
+ if (!existsSync(dir)) continue
53
+ for (const name of readdirSync(dir)) {
54
+ if (!name.startsWith("install.") || !name.endsWith(".json")) continue
55
+ try {
56
+ records.push(JSON.parse(readFileSync(join(dir, name), "utf8")) as InstallRecord)
57
+ } catch {
58
+ // Unreadable records are skipped; uninstall reports them as absent.
59
+ }
60
+ }
61
+ }
62
+ return records
63
+ }
@@ -0,0 +1,126 @@
1
+ /** Hosts the installer supports. */
2
+ export type HostName = "opencode" | "pi" | "codex"
3
+
4
+ /** Install scope, a first-class user choice. */
5
+ export type Scope = "project" | "global"
6
+
7
+ /**
8
+ * The filesystem roots path resolution needs; InstallerInput satisfies this structurally.
9
+ * Each host module owns the mapping from an input to its config and rules files.
10
+ */
11
+ export interface FsRoots {
12
+ readonly home: string
13
+ readonly cwd: string
14
+ /** Explicit config home; the CLI edge resolves XDG once and passes it down. */
15
+ readonly configHome?: string
16
+ }
17
+
18
+ /** The files a host edit touches for a given scope. */
19
+ export interface HostPaths {
20
+ /** Main host config file the installer edits. */
21
+ readonly config: string
22
+ /** Rules file receiving the marked block for the preferred tier. */
23
+ readonly rules: string
24
+ }
25
+
26
+ /** Control tier per capability; "off" skips the tool entirely. */
27
+ export type Tier = "optional" | "preferred" | "forced" | "off"
28
+
29
+ /** Everything the installer needs to build a plan. */
30
+ export interface InstallerInput {
31
+ readonly host: HostName
32
+ readonly scope: Scope
33
+ readonly search: Tier
34
+ readonly fetch: Tier
35
+ /** npm package reference written into host config; defaults to the published name. */
36
+ readonly packageRef: string
37
+ readonly home: string
38
+ readonly cwd: string
39
+ readonly configHome?: string
40
+ }
41
+
42
+ /** A mutation on a parsed config object, addressed by a dotted path. */
43
+ export interface ConfigOp {
44
+ readonly op: "set" | "append-array"
45
+ readonly path: readonly string[]
46
+ readonly value?: unknown
47
+ readonly entries?: readonly unknown[]
48
+ /** Plan-preview metadata: the value the set replaces (undefined when absent). */
49
+ readonly previous?: unknown
50
+ /** Plan-preview metadata: entries already present at an append-array path. */
51
+ readonly existingCount?: number
52
+ }
53
+
54
+ /** A JSON file edit: comment-preserving ops on JSONC text. */
55
+ export interface JsonEdit {
56
+ readonly file: string
57
+ readonly format: "json"
58
+ readonly ops: readonly ConfigOp[]
59
+ }
60
+
61
+ /** A TOML file edit: ops on the parsed value, serialized on write. */
62
+ export interface TomlEdit {
63
+ readonly file: string
64
+ readonly format: "toml"
65
+ readonly ops: readonly ConfigOp[]
66
+ }
67
+
68
+ /** A rules file edit: upserts the host's marked block; it carries no ops. */
69
+ export interface RulesEdit {
70
+ readonly file: string
71
+ readonly format: "rules"
72
+ readonly block: string
73
+ }
74
+
75
+ /** One file the plan touches, discriminated by config format. */
76
+ export type FileEdit = JsonEdit | TomlEdit | RulesEdit
77
+
78
+ /** A plan that refuses to proceed (for example a structurally unavailable tier). */
79
+ export interface PlanError {
80
+ readonly message: string
81
+ }
82
+
83
+ export interface InstallPlan {
84
+ readonly host: HostName
85
+ readonly scope: Scope
86
+ readonly tiers: { readonly search: Tier; readonly fetch: Tier }
87
+ readonly edits: readonly FileEdit[]
88
+ readonly notes: readonly string[]
89
+ readonly errors: readonly PlanError[]
90
+ /**
91
+ * Artifacts from a previous init that this plan no longer wants (for example the
92
+ * web_search kill switch after a forced → optional tier change). They are shown in
93
+ * the plan, reverted during apply, and then gone from the record — so uninstall
94
+ * still restores exactly what this install applied.
95
+ */
96
+ readonly reverts?: readonly RecordedFile[]
97
+ }
98
+
99
+ /** An op as it was actually applied, with the previous value for surgical undo. */
100
+ export interface RecordedOp {
101
+ readonly op: "set" | "append-array"
102
+ readonly path: readonly string[]
103
+ readonly applied: unknown
104
+ readonly previous: unknown
105
+ }
106
+
107
+ /** A file as it was actually written, for surgical uninstall. */
108
+ export interface RecordedFile {
109
+ readonly file: string
110
+ readonly format: "json" | "toml" | "rules"
111
+ readonly created: boolean
112
+ readonly ops: readonly RecordedOp[]
113
+ readonly blockApplied?: string
114
+ /** Path prefixes the apply step had to create; uninstall removes them when left empty. */
115
+ readonly createdParents?: readonly (readonly string[])[]
116
+ }
117
+
118
+ /** Persisted install state; one record per host and scope. */
119
+ export interface InstallRecord {
120
+ readonly version: 1
121
+ readonly host: HostName
122
+ readonly scope: Scope
123
+ readonly tiers: { readonly search: Tier; readonly fetch: Tier }
124
+ readonly createdAt: string
125
+ readonly files: readonly RecordedFile[]
126
+ }
@@ -0,0 +1,33 @@
1
+ import { stateDir } from "./paths"
2
+ import { pruneEmptyDir, revertRecordedFile } from "./revert"
3
+ import { claimedByOtherScope, deleteRecord, readRecord } from "./state"
4
+ import type { InstallerInput } from "./types"
5
+
6
+ /**
7
+ * Reverses exactly what the plan applied: overwritten values restore their
8
+ * snapshots (only when the current value still matches what the installer
9
+ * wrote — user edits win), merged arrays lose only the installer's entries,
10
+ * rules blocks are removed, and files the installer created are deleted.
11
+ */
12
+ export function uninstall(input: InstallerInput): string[] {
13
+ const record = readRecord(input)
14
+ if (record === undefined) {
15
+ throw new Error(`No ${input.host} install is recorded for the ${input.scope} scope.`)
16
+ }
17
+ const other = input.scope === "global" ? "project" : "global"
18
+ const overlap = claimedByOtherScope(input, record.files.map((file) => file.file))
19
+ if (overlap.length > 0) {
20
+ throw new Error(
21
+ `A ${input.host} install is also recorded at the ${other} scope and claims the same file(s): ${overlap.join(", ")}. ` +
22
+ `Uninstall the ${other}-scope install first so this rollback cannot clobber its entries.`,
23
+ )
24
+ }
25
+ const lines: string[] = []
26
+ for (const file of [...record.files].reverse()) {
27
+ lines.push(...revertRecordedFile(file, record.host, { label: "", whole: true }))
28
+ }
29
+ deleteRecord(input)
30
+ pruneEmptyDir(stateDir(input, input.scope))
31
+ lines.push(`removed install record (${input.host}, ${input.scope})`)
32
+ return lines
33
+ }
package/src/mcp/auth.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { API_KEY_ENV } from "../core/constants"
1
2
  import { apiKeyFromEnv } from "../core/auth"
2
3
  import type { Env } from "./config"
3
4
 
4
5
  export const MISSING_API_KEY_MESSAGE =
5
- "DeepSeek API key not found. Set DEEPSEEK_API_KEY in the MCP server's env configuration."
6
+ `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`
6
7
 
7
8
  /** Resolves the API key from the environment or fails with shared guidance. */
8
9
  export function requireApiKey(env: Env): string {
package/src/mcp/cli.ts CHANGED
@@ -3,7 +3,15 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio"
3
3
  import { apiKeyFromEnv } from "../core/auth"
4
4
  import { MISSING_API_KEY_MESSAGE } from "./auth"
5
5
  import { resolveEnvOptions } from "./config"
6
- import { createDeepSeekServer } from "./server"
6
+ import { createDeepSeekServer, servePrecheck } from "./server"
7
+
8
+ // Installer subcommands run before the server path; MCP clients spawn this bin
9
+ // with no arguments, so the default stdio behavior is unchanged.
10
+ const subcommand = process.argv[2]
11
+ if (subcommand === "init" || subcommand === "uninstall" || subcommand === "list") {
12
+ const { runInstallerCli } = await import("../installer/cli")
13
+ process.exit(await runInstallerCli(process.argv.slice(2)))
14
+ }
7
15
 
8
16
  // stdout is the JSON-RPC channel; all diagnostics must go to stderr.
9
17
  const env = process.env
@@ -13,11 +21,19 @@ if (!apiKeyFromEnv(env)) {
13
21
  }
14
22
 
15
23
  const config = resolveEnvOptions(env)
24
+ const precheck = servePrecheck(config)
25
+ if (precheck) {
26
+ console.error(precheck)
27
+ process.exit(1)
28
+ }
16
29
  const handle = serveStdio(() => createDeepSeekServer(config, env))
17
- console.error(
18
- `deepseek-web-retrieval MCP server on stdio ` +
19
- `(search: max ${config.search.maxResults} results, fetch: max ${config.fetch.maxChars} chars)`,
20
- )
30
+ const tools = [
31
+ config.search.enabled ? `search (max ${config.search.maxResults} results)` : null,
32
+ config.fetch.enabled ? `fetch (max ${config.fetch.maxChars} chars)` : null,
33
+ ]
34
+ .filter(Boolean)
35
+ .join(", ")
36
+ console.error(`deepseek-web-retrieval MCP server on stdio (${tools})`)
21
37
 
22
38
  process.on("SIGINT", () => void handle.close())
23
39
  process.on("SIGTERM", () => void handle.close())
package/src/mcp/config.ts CHANGED
@@ -1,9 +1,46 @@
1
+ import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../core/constants"
1
2
  import { resolvePluginOptions } from "../core/options"
2
3
  import type { PluginOptions, ResolvedOptions } from "../core/types"
3
4
 
4
5
  /** The environment an MCP server reads its key and options from. */
5
6
  export type Env = Readonly<Record<string, string | undefined>>
6
7
 
8
+ /** Each supported option mapped onto its DEEPSEEK_* variable, grouped by value type. */
9
+ const NUMBER_ENV = {
10
+ count: "DEEPSEEK_SEARCH_COUNT",
11
+ maxResults: "DEEPSEEK_SEARCH_MAX_RESULTS",
12
+ minRerank: "DEEPSEEK_SEARCH_MIN_RERANK",
13
+ minAuthority: "DEEPSEEK_SEARCH_MIN_AUTHORITY",
14
+ maxSummaryChars: "DEEPSEEK_SEARCH_MAX_SUMMARY_CHARS",
15
+ searchTimeoutMs: "DEEPSEEK_SEARCH_TIMEOUT_MS",
16
+ fetchMaxChars: "DEEPSEEK_FETCH_MAX_CHARS",
17
+ fetchTimeoutMs: "DEEPSEEK_FETCH_TIMEOUT_MS",
18
+ maxRetries: "DEEPSEEK_MAX_RETRIES",
19
+ retryBaseDelayMs: "DEEPSEEK_RETRY_BASE_DELAY_MS",
20
+ } as const
21
+
22
+ const TEXT_ENV = {
23
+ language: "DEEPSEEK_SEARCH_LANGUAGE",
24
+ } as const
25
+
26
+ const DOMAINS_ENV = {
27
+ blockedDomains: "DEEPSEEK_SEARCH_BLOCKED_DOMAINS",
28
+ includeDomains: "DEEPSEEK_SEARCH_INCLUDE_DOMAINS",
29
+ fetchAllowedDomains: "DEEPSEEK_FETCH_ALLOWED_DOMAINS",
30
+ fetchBlockedDomains: "DEEPSEEK_FETCH_BLOCKED_DOMAINS",
31
+ } as const
32
+
33
+ const FLAG_ENV = {
34
+ allowUnknownAuthority: "DEEPSEEK_SEARCH_ALLOW_UNKNOWN_AUTHORITY",
35
+ } as const
36
+
37
+ /** Shared defaults are true; only an explicit false value disables them. */
38
+ const FALSE_ONLY_ENV = {
39
+ fetchBlockPrivateHosts: "DEEPSEEK_FETCH_BLOCK_PRIVATE_HOSTS",
40
+ searchEnabled: ENABLE_SEARCH_ENV,
41
+ fetchEnabled: ENABLE_FETCH_ENV,
42
+ } as const
43
+
7
44
  /**
8
45
  * Maps `DEEPSEEK_*` environment variables onto the shared plugin option keys and
9
46
  * resolves them through the same pipeline as the OpenCode adapter, so defaults
@@ -17,33 +54,33 @@ export function resolveEnvOptions(env: Env = process.env): ResolvedOptions {
17
54
  export function pluginOptionsFromEnv(env: Env): PluginOptions {
18
55
  const options: Record<string, unknown> = {}
19
56
 
20
- putNumber(options, "count", env.DEEPSEEK_SEARCH_COUNT)
21
- putNumber(options, "maxResults", env.DEEPSEEK_SEARCH_MAX_RESULTS)
22
- putNumber(options, "minRerank", env.DEEPSEEK_SEARCH_MIN_RERANK)
23
- putNumber(options, "minAuthority", env.DEEPSEEK_SEARCH_MIN_AUTHORITY)
24
- putBoolean(options, "allowUnknownAuthority", env.DEEPSEEK_SEARCH_ALLOW_UNKNOWN_AUTHORITY)
25
- putDomains(options, "blockedDomains", env.DEEPSEEK_SEARCH_BLOCKED_DOMAINS)
26
- putDomains(options, "includeDomains", env.DEEPSEEK_SEARCH_INCLUDE_DOMAINS)
27
- putText(options, "language", env.DEEPSEEK_SEARCH_LANGUAGE)
28
- putNumber(options, "maxSummaryChars", env.DEEPSEEK_SEARCH_MAX_SUMMARY_CHARS)
29
- putNumber(options, "searchTimeoutMs", env.DEEPSEEK_SEARCH_TIMEOUT_MS)
30
-
31
- putNumber(options, "fetchMaxChars", env.DEEPSEEK_FETCH_MAX_CHARS)
32
- putDomains(options, "fetchAllowedDomains", env.DEEPSEEK_FETCH_ALLOWED_DOMAINS)
33
- putDomains(options, "fetchBlockedDomains", env.DEEPSEEK_FETCH_BLOCKED_DOMAINS)
34
- putNumber(options, "fetchTimeoutMs", env.DEEPSEEK_FETCH_TIMEOUT_MS)
35
- // Shared default is true; only an explicit false value disables it.
36
- if (isFalseValue(env.DEEPSEEK_FETCH_BLOCK_PRIVATE_HOSTS)) {
37
- options.fetchBlockPrivateHosts = false
38
- }
39
-
40
- putNumber(options, "maxRetries", env.DEEPSEEK_MAX_RETRIES)
41
- putNumber(options, "retryBaseDelayMs", env.DEEPSEEK_RETRY_BASE_DELAY_MS)
57
+ putNumber(options, "count", env)
58
+ putNumber(options, "maxResults", env)
59
+ putNumber(options, "minRerank", env)
60
+ putNumber(options, "minAuthority", env)
61
+ putBoolean(options, "allowUnknownAuthority", env)
62
+ putDomains(options, "blockedDomains", env)
63
+ putDomains(options, "includeDomains", env)
64
+ putText(options, "language", env)
65
+ putNumber(options, "maxSummaryChars", env)
66
+ putNumber(options, "searchTimeoutMs", env)
67
+
68
+ putNumber(options, "fetchMaxChars", env)
69
+ putDomains(options, "fetchAllowedDomains", env)
70
+ putDomains(options, "fetchBlockedDomains", env)
71
+ putNumber(options, "fetchTimeoutMs", env)
72
+ putFalseOnly(options, "fetchBlockPrivateHosts", env)
73
+ putFalseOnly(options, "searchEnabled", env)
74
+ putFalseOnly(options, "fetchEnabled", env)
75
+
76
+ putNumber(options, "maxRetries", env)
77
+ putNumber(options, "retryBaseDelayMs", env)
42
78
 
43
79
  return options
44
80
  }
45
81
 
46
- function putNumber(target: Record<string, unknown>, key: string, value: string | undefined): void {
82
+ function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env): void {
83
+ const value = env[NUMBER_ENV[key]]
47
84
  if (value === undefined) return
48
85
  const trimmed = value.trim()
49
86
  if (!trimmed) return
@@ -51,17 +88,23 @@ function putNumber(target: Record<string, unknown>, key: string, value: string |
51
88
  if (Number.isFinite(parsed)) target[key] = parsed
52
89
  }
53
90
 
54
- function putText(target: Record<string, unknown>, key: string, value: string | undefined): void {
91
+ function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, env: Env): void {
92
+ const value = env[TEXT_ENV[key]]
55
93
  if (value === undefined) return
56
94
  const trimmed = value.trim()
57
95
  if (trimmed) target[key] = trimmed
58
96
  }
59
97
 
60
- function putBoolean(target: Record<string, unknown>, key: string, value: string | undefined): void {
61
- if (isTrueValue(value)) target[key] = true
98
+ function putBoolean(target: Record<string, unknown>, key: keyof typeof FLAG_ENV, env: Env): void {
99
+ if (isTrueValue(env[FLAG_ENV[key]])) target[key] = true
100
+ }
101
+
102
+ function putFalseOnly(target: Record<string, unknown>, key: keyof typeof FALSE_ONLY_ENV, env: Env): void {
103
+ if (isFalseValue(env[FALSE_ONLY_ENV[key]])) target[key] = false
62
104
  }
63
105
 
64
- function putDomains(target: Record<string, unknown>, key: string, value: string | undefined): void {
106
+ function putDomains(target: Record<string, unknown>, key: keyof typeof DOMAINS_ENV, env: Env): void {
107
+ const value = env[DOMAINS_ENV[key]]
65
108
  if (value === undefined || !value.trim()) return
66
109
  const domains = value
67
110
  .split(",")
package/src/mcp/server.ts CHANGED
@@ -1,15 +1,20 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server"
2
2
  import type { CallToolResult } from "@modelcontextprotocol/server"
3
3
  import * as z from "zod/v4"
4
- import { fetchPage, searchWeb } from "../core/client"
5
- import { assertFetchHostPolicy } from "../core/domains"
6
- import { formatFetchedContent } from "../core/format"
7
- import type { ResolvedOptions, WebSearchResult } from "../core/types"
8
- import { parseFetchUrl } from "../core/url"
4
+ import { fetchDocument, searchWeb } from "../core/client"
5
+ import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../core/constants"
6
+ import {
7
+ FETCH_OUTPUT_TRUNCATED,
8
+ FETCH_SERVICE_LIMITS,
9
+ SEARCH_EMPTY_IS_NORMAL,
10
+ SEARCH_QUERY_OPERATORS,
11
+ SEARCH_RANKED_FILTERED,
12
+ } from "../core/descriptions"
13
+ import { formatSearchResults } from "../core/format"
14
+ import type { ResolvedOptions, SearchResult } from "../core/types"
9
15
  import pkg from "../../package.json"
10
16
  import { requireApiKey } from "./auth"
11
17
  import type { Env } from "./config"
12
- import { formatSearchResults } from "./format"
13
18
 
14
19
  export const MCP_SERVER_NAME = "deepseek-web-retrieval"
15
20
 
@@ -46,15 +51,16 @@ const FETCH_OUTPUT = z.object({
46
51
 
47
52
  const SEARCH_DESCRIPTION =
48
53
  "Search the web with DeepSeek's in-house search engine and return a short, filtered result " +
49
- "list (title, URL, summary). Results are ranked by relevance and source authority, deduplicated " +
50
- "by URL, and capped at the configured maximum. An empty result list is normal, not an error. " +
51
- "Use site:/-site: operators in the query to include or exclude domains; the full query must stay " +
52
- "within 128 characters. To read a full page, pass a result URL to the deepseek_fetch tool."
54
+ "list (title, URL, summary). " +
55
+ `${SEARCH_RANKED_FILTERED} ` +
56
+ `${SEARCH_EMPTY_IS_NORMAL} ` +
57
+ `${SEARCH_QUERY_OPERATORS} ` +
58
+ "To read a full page, pass a result URL to the deepseek_fetch tool."
53
59
 
54
60
  const FETCH_DESCRIPTION =
55
- "Fetch one web page as clean Markdown through DeepSeek's Fetch API. DeepSeek does not render " +
56
- "JavaScript and cannot reach private networks; PDFs, images, and other binary files are " +
57
- "unsupported. Output is truncated to the configured size."
61
+ "Fetch one web page as clean Markdown through DeepSeek's Fetch API. " +
62
+ `${FETCH_SERVICE_LIMITS} ` +
63
+ `${FETCH_OUTPUT_TRUNCATED}`
58
64
 
59
65
  /**
60
66
  * Builds the host-agnostic MCP server exposing the two DeepSeek retrieval tools.
@@ -63,59 +69,60 @@ const FETCH_DESCRIPTION =
63
69
  export function createDeepSeekServer(config: ResolvedOptions, env: Env = process.env): McpServer {
64
70
  const server = new McpServer({ name: MCP_SERVER_NAME, version: pkg.version })
65
71
 
66
- server.registerTool(
67
- "deepseek_web_search",
68
- {
69
- title: "DeepSeek Web Search",
70
- description: SEARCH_DESCRIPTION,
71
- inputSchema: SEARCH_INPUT,
72
- outputSchema: SEARCH_OUTPUT,
73
- annotations: { readOnlyHint: true },
74
- },
75
- async ({ query }, ctx) => {
76
- try {
77
- const request = requestContext(config, env, ctx.mcpReq.signal)
78
- const results = await searchWeb(query, config.search, request)
79
- return {
80
- content: [{ type: "text", text: formatSearchResults(results) }],
81
- structuredContent: { results: results.map(toStructuredResult) },
72
+ if (config.search.enabled) {
73
+ server.registerTool(
74
+ "deepseek_web_search",
75
+ {
76
+ title: "DeepSeek Web Search",
77
+ description: SEARCH_DESCRIPTION,
78
+ inputSchema: SEARCH_INPUT,
79
+ outputSchema: SEARCH_OUTPUT,
80
+ annotations: { readOnlyHint: true },
81
+ },
82
+ async ({ query }, ctx) => {
83
+ try {
84
+ const request = requestContext(config, env, ctx.mcpReq.signal)
85
+ const results = await searchWeb(query, config.search, request)
86
+ return {
87
+ content: [{ type: "text", text: formatSearchResults(results) }],
88
+ structuredContent: { results: results.map(toStructuredResult) },
89
+ }
90
+ } catch (error) {
91
+ return toErrorResult(error)
82
92
  }
83
- } catch (error) {
84
- return toErrorResult(error)
85
- }
86
- },
87
- )
93
+ },
94
+ )
95
+ }
88
96
 
89
- server.registerTool(
90
- "deepseek_fetch",
91
- {
92
- title: "DeepSeek Fetch",
93
- description: FETCH_DESCRIPTION,
94
- inputSchema: FETCH_INPUT,
95
- outputSchema: FETCH_OUTPUT,
96
- annotations: { readOnlyHint: true },
97
- },
98
- async ({ url }, ctx) => {
99
- try {
100
- const request = requestContext(config, env, ctx.mcpReq.signal)
101
- const target = parseFetchUrl(url)
102
- assertFetchHostPolicy(target, config.fetch)
103
- const page = await fetchPage(target, config.fetch, request)
104
- const formatted = formatFetchedContent(page.title, page.content, config.fetch.maxChars)
105
- return {
106
- content: [{ type: "text", text: formatted.content }],
107
- structuredContent: {
108
- url: page.url,
109
- title: page.title,
110
- content: formatted.content,
111
- truncated: formatted.truncated,
112
- },
97
+ if (config.fetch.enabled) {
98
+ server.registerTool(
99
+ "deepseek_fetch",
100
+ {
101
+ title: "DeepSeek Fetch",
102
+ description: FETCH_DESCRIPTION,
103
+ inputSchema: FETCH_INPUT,
104
+ outputSchema: FETCH_OUTPUT,
105
+ annotations: { readOnlyHint: true },
106
+ },
107
+ async ({ url }, ctx) => {
108
+ try {
109
+ const request = requestContext(config, env, ctx.mcpReq.signal)
110
+ const page = await fetchDocument(url, config.fetch, request)
111
+ return {
112
+ content: [{ type: "text", text: page.content }],
113
+ structuredContent: {
114
+ url: page.url,
115
+ title: page.title,
116
+ content: page.content,
117
+ truncated: page.truncated,
118
+ },
119
+ }
120
+ } catch (error) {
121
+ return toErrorResult(error)
113
122
  }
114
- } catch (error) {
115
- return toErrorResult(error)
116
- }
117
- },
118
- )
123
+ },
124
+ )
125
+ }
119
126
 
120
127
  return server
121
128
  }
@@ -124,15 +131,24 @@ function requestContext(config: ResolvedOptions, env: Env, signal: AbortSignal)
124
131
  return { signal, auth: { apiKey: requireApiKey(env) }, retry: config.retry }
125
132
  }
126
133
 
127
- function toStructuredResult(result: WebSearchResult) {
134
+ function toStructuredResult(result: SearchResult) {
128
135
  return {
129
136
  url: result.url,
130
137
  ...(result.title ? { title: result.title } : {}),
131
138
  ...(result.content ? { content: result.content } : {}),
132
- ...(result.time.published !== undefined ? { published: result.time.published } : {}),
139
+ ...(result.published !== undefined ? { published: result.published } : {}),
133
140
  }
134
141
  }
135
142
 
143
+ /** Returns the both-disabled message, or undefined when a tool stays enabled. */
144
+ export function servePrecheck(config: ResolvedOptions): string | undefined {
145
+ if (config.search.enabled || config.fetch.enabled) return undefined
146
+ return (
147
+ `Both tools are disabled (${ENABLE_SEARCH_ENV} and ${ENABLE_FETCH_ENV} are false); ` +
148
+ "at least one must stay enabled."
149
+ )
150
+ }
151
+
136
152
  function toErrorResult(error: unknown): CallToolResult {
137
153
  const message = error instanceof Error ? error.message : String(error)
138
154
  return { content: [{ type: "text", text: message }], isError: true }
@@ -1,5 +1,6 @@
1
1
  import type { Context } from "@opencode/plugin/promise/plugin"
2
- import { API_KEY_ENV, apiKeyFromEnv } from "../core/auth"
2
+ import { API_KEY_ENV } from "../core/constants"
3
+ import { apiKeyFromEnv } from "../core/auth"
3
4
  import type { ApiKeySource } from "../core/types"
4
5
 
5
6
  const DEEPSEEK_INTEGRATION_ID = "deepseek"
@@ -61,7 +62,7 @@ export async function resolveApiKey(ctx: Context, source: ApiKeySource): Promise
61
62
  if (storedError) {
62
63
  throw new Error(
63
64
  `DeepSeek API key is not configured, and the OpenCode credential could not be read. ` +
64
- `Set DEEPSEEK_API_KEY or run "opencode auth login".`,
65
+ `Set ${API_KEY_ENV} or run "opencode auth login".`,
65
66
  { cause: storedError },
66
67
  )
67
68
  }