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.
- package/CHANGELOG.md +187 -1
- package/README.md +13 -9
- package/dist/cli.js +4228 -178
- package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
- package/docs/agents/codex.md +193 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
- package/docs/{pi.md → agents/pi.md} +35 -7
- package/docs/agents/triage-labels.md +15 -0
- package/docs/design/installer-design.md +184 -0
- package/docs/hosts.jsonc +59 -33
- package/docs/integration.md +45 -52
- package/docs/research/control-matrix.md +257 -0
- package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
- package/package.json +5 -3
- package/src/core/auth.ts +1 -1
- package/src/core/client.ts +42 -249
- package/src/core/constants.ts +5 -0
- package/src/core/descriptions.ts +19 -0
- package/src/core/format.ts +31 -1
- package/src/core/jsonc.ts +16 -0
- package/src/core/options.ts +2 -3
- package/src/core/search.ts +5 -7
- package/src/core/transport.ts +253 -0
- package/src/core/types.ts +13 -11
- package/src/installer/apply.ts +176 -0
- package/src/installer/cli.ts +215 -0
- package/src/installer/detect.ts +85 -0
- package/src/installer/formats/index.ts +58 -0
- package/src/installer/formats/json.ts +151 -0
- package/src/installer/formats/rules.ts +62 -0
- package/src/installer/formats/toml.ts +163 -0
- package/src/installer/formats/value.ts +57 -0
- package/src/installer/hosts/codex.ts +128 -0
- package/src/installer/hosts/index.ts +134 -0
- package/src/installer/hosts/opencode.ts +118 -0
- package/src/installer/hosts/pi.ts +115 -0
- package/src/installer/hosts/shared.ts +31 -0
- package/src/installer/paths.ts +18 -0
- package/src/installer/render.ts +62 -0
- package/src/installer/revert.ts +61 -0
- package/src/installer/rulesContent.ts +32 -0
- package/src/installer/state.ts +63 -0
- package/src/installer/types.ts +126 -0
- package/src/installer/uninstall.ts +33 -0
- package/src/mcp/auth.ts +2 -1
- package/src/mcp/cli.ts +21 -5
- package/src/mcp/config.ts +70 -27
- package/src/mcp/server.ts +81 -65
- package/src/opencode/auth.ts +3 -2
- package/src/opencode/plugin.ts +93 -56
- 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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
21
|
-
putNumber(options, "maxResults", env
|
|
22
|
-
putNumber(options, "minRerank", env
|
|
23
|
-
putNumber(options, "minAuthority", env
|
|
24
|
-
putBoolean(options, "allowUnknownAuthority", env
|
|
25
|
-
putDomains(options, "blockedDomains", env
|
|
26
|
-
putDomains(options, "includeDomains", env
|
|
27
|
-
putText(options, "language", env
|
|
28
|
-
putNumber(options, "maxSummaryChars", env
|
|
29
|
-
putNumber(options, "searchTimeoutMs", env
|
|
30
|
-
|
|
31
|
-
putNumber(options, "fetchMaxChars", env
|
|
32
|
-
putDomains(options, "fetchAllowedDomains", env
|
|
33
|
-
putDomains(options, "fetchBlockedDomains", env
|
|
34
|
-
putNumber(options, "fetchTimeoutMs", env
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
putNumber(options, "
|
|
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:
|
|
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:
|
|
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:
|
|
61
|
-
if (isTrueValue(
|
|
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:
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
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).
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
},
|
|
87
|
-
)
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
}
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
}
|
|
115
|
-
|
|
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:
|
|
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.
|
|
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 }
|
package/src/opencode/auth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context } from "@opencode/plugin/promise/plugin"
|
|
2
|
-
import { API_KEY_ENV
|
|
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
|
|
65
|
+
`Set ${API_KEY_ENV} or run "opencode auth login".`,
|
|
65
66
|
{ cause: storedError },
|
|
66
67
|
)
|
|
67
68
|
}
|