Restore Desktop Search MCP settings form with Bailian defaults.

Keep boot safe on DSH 0.2 while registering a distinct settings section,
lazy remote.credentials wiring, and credential badge state that survives
describe redaction after Save.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-10 04:24:13 +08:00
parent 8fdb55b025
commit d729b03f70
7 changed files with 733 additions and 412 deletions

View file

@ -1,21 +1,13 @@
# dsh-search-mcp bundle layer: applied after dsh-base and dsh-web-app, # dsh-search-mcp bundle layer (Desktop 0.2 boot-safe).
# before the profile's own cordis.patch.yml (last write wins per row).
# #
# Installing this package therefore REPLACES dsh's built-in web search: # IMPORTANT: Do NOT set `web.searchProvider: search-mcp` or disable
# - the `web` row's searchProvider switches from `deepseek-official` to # `web-search-deepseek` in this patch. On Desktop 0.2 that creates a
# `search-mcp` (the provider registered by this plugin), and # load-order deadlock (web waits for search-mcp, search-mcp waits for web)
# - the built-in DeepSeek search provider row is disabled. # and freezes the UI on "Loading plugins…".
# The model-facing `web_search` tool keeps its name and presentation; its
# execution now goes through the search MCP server(s) configured below or in
# the web Settings → Plugins → search-mcp section.
# #
# Removing this package (dsh plugin --profile web remove dsh-search-mcp) # The host `apply` registers the provider and then soft-switches the web
# drops this whole layer and restores the built-in search exactly. # row via settings when available. Removing this package restores the
# # previous composition (no searchProvider pin left behind by this file).
# SECURITY: no API keys are committed to this repository. Server keys are
# supplied at runtime through `apiKeyEnv` — stored in
# `$DSH_HOME/.credentials.yaml` (e.g. `TAVILY_API_KEY: <key>`) — or through
# the web Settings → Plugins → search-mcp section (`apiKey` field).
- insert: - insert:
- id: search-mcp - id: search-mcp
@ -26,29 +18,10 @@
searchTimeoutMs: 30000 searchTimeoutMs: 30000
servers: servers:
- id: bailian - id: bailian
kind: custom kind: bailian
transport: http
url: https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp
authStyle: header
authParam: Authorization
authPrefix: 'Bearer '
apiKeyEnv: DASHSCOPE_API_KEY apiKeyEnv: DASHSCOPE_API_KEY
toolName: bailian_web_search
- id: web # Raise tool-web caps so search-mcp maxResults can take effect.
config:
searchProvider: search-mcp
- id: web-search-deepseek
disabled: true
# The model-facing web_search tool is owned by dsh-tool-web (its `Config`
# default is a hard 8-source cap, enforced by dsh-web's seam on EVERY
# request). This plugin takes over result sizing, so raise the tool layer's
# cap to the plugin schema maximum: `search-mcp`'s own maxResults (Settings →
# Plugins → search-mcp) then decides how many sources actually come back.
# `fetch` stays disabled and the base timeout is restated, because a patch
# replaces the targeted row's whole config.
- id: tool-web - id: tool-web
disabled: false disabled: false
config: config:

View file

@ -6,6 +6,17 @@
* `custom` is the only kind whose connection details come from the user. * `custom` is the only kind whose connection details come from the user.
*/ */
export const SEARCH_MCP_CATALOG = { export const SEARCH_MCP_CATALOG = {
bailian: {
transport: 'http',
url: 'https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp',
authStyle: 'header',
authParam: 'Authorization',
authPrefix: 'Bearer ',
toolName: 'bailian_web_search',
countArg: '',
apiKeyEnv: 'DASHSCOPE_API_KEY',
needsKey: true,
},
tavily: { tavily: {
transport: 'http', transport: 'http',
url: 'https://mcp.tavily.com/mcp/', url: 'https://mcp.tavily.com/mcp/',

File diff suppressed because it is too large Load diff

View file

@ -56,11 +56,17 @@ const serverSchema = z.object({
maxResults: vol(z.number().step(1).min(1).max(50)), maxResults: vol(z.number().step(1).min(1).max(50)),
}); });
const DEFAULT_BAILIAN_SERVER = {
id: 'bailian',
kind: 'bailian',
apiKeyEnv: 'DASHSCOPE_API_KEY',
};
export const Config = z.object({ export const Config = z.object({
defaultServer: vol(z.string().default('')), defaultServer: vol(z.string().default('bailian')),
maxResults: vol(z.number().step(1).min(1).max(50).default(8)), maxResults: vol(z.number().step(1).min(1).max(50).default(8)),
searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)), searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)),
servers: vol(z.array(serverSchema).default([])), servers: vol(z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER])),
}); });
/** Settings namespace owning this plugin's section (Settings → Plugins card). */ /** Settings namespace owning this plugin's section (Settings → Plugins card). */
@ -79,70 +85,11 @@ function describeRows(describe) {
return []; return [];
} }
/** Register the search provider and the live settings section. */ /** Register the search provider. Settings UI is owned by the web client. */
export function apply(ctx, config) { export function apply(ctx, config) {
let current = () => config; const current = () => config;
// Optional settings seam: fall back to the composition entry when settings // Desktop 0.2: avoid soft-inject(['settings']) + describe poll during bring-up.
// is absent (same pattern as @deepseek-ai/dsh-web-search-deepseek). // Loader already projects Config from the cordis entry / patch insert.
ctx.inject(['settings'], (settingsCtx) => {
const settings = settingsCtx.settings;
const hooks = {
setSource: (source) => {
current = source;
},
onChange: () => {},
};
if (typeof settings?.installSection === 'function') {
try {
settings.installSection(ctx, SEARCH_MCP_SETTINGS_NAMESPACE, Config, config, hooks);
return;
} catch (error) {
settingsCtx.logger?.warn?.(
'dsh-search-mcp: installSection failed (will try register/describe): %s',
error instanceof Error ? error.message : error,
);
}
}
// ≤0.1.5 / some Desktop builds: explicit namespace registration.
// ≥0.1.7: Config is projected from the Loader entry — register may be
// absent or refuse; follow describe().
if (typeof settings?.register === 'function') {
try {
settings.register(SEARCH_MCP_SETTINGS_NAMESPACE, Config, { base: config, applies: 'live' });
settingsCtx.logger?.info?.(
'dsh-search-mcp: settings namespace "%s" registered',
SEARCH_MCP_SETTINGS_NAMESPACE,
);
} catch (error) {
settingsCtx.logger?.warn?.(
'dsh-search-mcp: settings.register failed (will follow describe): %s',
error instanceof Error ? error.message : error,
);
}
}
// DSH ≥0.1.7 / 0.2.0 — Config projection via describe() (no installSection).
const readLive = () => {
const row = describeRows(settings?.describe).find((item) => item.ns === SEARCH_MCP_SETTINGS_NAMESPACE);
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
return { ...config, ...row.value };
}
return config;
};
hooks.setSource(readLive);
let last = JSON.stringify(readLive());
const timer = setInterval(() => {
const next = readLive();
const fingerprint = JSON.stringify(next);
if (fingerprint === last) return;
last = fingerprint;
hooks.onChange();
}, 2_000);
settingsCtx.effect(() => () => clearInterval(timer), 'dsh-search-mcp: settings describe poll');
settingsCtx.logger?.info?.(
'dsh-search-mcp: following settings via describe() (DSH ≥0.1.7 / 0.2 path)',
);
});
// `registerSearchProvider` owns its cleanup via ctx.effect (HMR/dispose safe).
ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current()))); ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current())));
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "dsh-search-mcp", "name": "dsh-search-mcp",
"version": "0.2.5", "version": "0.2.32",
"description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.", "description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.",
"type": "module", "type": "module",
"main": "lib/index.js", "main": "lib/index.js",
@ -40,7 +40,7 @@
}, },
"client": { "client": {
"platform": "web", "platform": "web",
"immediately": true, "immediately": false,
"inject": [ "inject": [
"@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-locale" "@deepseek-ai/dsh-client-locale"

View file

@ -1,18 +1,19 @@
import test from 'node:test'; import test from 'node:test'
import assert from 'node:assert/strict'; import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (path) => readFile(resolve(root, path), 'utf8'); const read = (path) => readFile(resolve(root, path), 'utf8')
test('package exports resolve and peerDependencies stay open', async () => { test('package exports resolve and peerDependencies stay open', async () => {
const pkg = JSON.parse(await read('package.json')); const pkg = JSON.parse(await read('package.json'))
assert.equal(pkg.exports['.'], './lib/index.js'); assert.equal(pkg.exports['.'], './lib/index.js')
assert.equal(pkg.exports['./client'], './lib/client.browser.js'); assert.equal(pkg.exports['./client'], './lib/client.browser.js')
assert.equal(pkg.engines.node, '>=20'); assert.equal(pkg.engines.node, '>=20')
assert.equal(pkg.version, '0.2.5'); assert.equal(pkg.version, '0.2.32')
assert.equal(pkg.dsh.client.immediately, false)
for (const name of [ for (const name of [
'@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remotes',
@ -22,71 +23,65 @@ test('package exports resolve and peerDependencies stay open', async () => {
'@deepseek-ai/dsh-web', '@deepseek-ai/dsh-web',
'@deepseek-ai/schemastery', '@deepseek-ai/schemastery',
]) { ]) {
assert.equal(pkg.peerDependencies[name], '*'); assert.equal(pkg.peerDependencies[name], '*')
} }
assert.equal(pkg.dependencies.undici, '6.28.0'); })
assert.equal(pkg.dependencies['ipaddr.js'], '2.5.0');
assert.equal(pkg.dependencies['@modelcontextprotocol/sdk'], '1.30.0');
});
test('browser half registers Settings sidebar + deferred form attach', async () => { test('browser half registers Settings sidebar + deferred form attach', async () => {
const client = await read('lib/client.browser.js'); const client = await read('lib/client.browser.js')
assert.match(client, /name: "settings\.section"/); assert.match(client, /name: "settings\.section"/)
assert.match(client, /SearchMcpSection/); assert.match(client, /LOCALE_NS = "settings\.search-mcp"/)
assert.match(client, /createDeferredScope/); assert.match(client, /const NS = "search-mcp"/)
assert.match(client, /createMemoryScope/); // Section id must stay distinct from host Config ns `search-mcp`.
assert.doesNotMatch(client, /name:\s*["']settings\.plugin\.item["']/); assert.match(client, /id: "dsh-search-mcp"/)
assert.doesNotMatch(client, /inject\(\s*["']settings\.plugin\.item["']/); assert.match(client, /locale: LOCALE_NS/)
assert.match(client, /name: "plugins\.item"/); assert.match(client, /SearchMcpSection/)
assert.match(client, /configForms/); assert.match(client, /createDeferredScope/)
assert.match(client, /whileServed/); assert.match(client, /createMemoryScope/)
assert.match(client, /credentials\/reference-updated/); assert.doesNotMatch(client, /inject\(\["configForms"\]/)
assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/); assert.match(client, /configForms poll/)
assert.match(client, /legacyKeyBlocked/); assert.match(client, /remote\.credentials/)
assert.match(client, /remote\.settings\.describe\(\)/); assert.doesNotMatch(client, /ctx\.inject\(\["remote"\]/)
assert.match(client, /remote\.credentials\.set\(/); assert.doesNotMatch(client, /name:\s*["']settings\.plugin\.item["']/)
}); assert.doesNotMatch(client, /inject\(\["settingsScope"\]/)
const applyBody = client.slice(client.indexOf('function apply(ctx)'), client.indexOf('exports.apply'))
assert.doesNotMatch(applyBody, /ctx\.get\?\.\(["']remote["']\)|ctx\.get\(["']remote["']\)/)
assert.match(client, /const createSnapshotStore = createSnapshotStoreFallback/)
assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/)
assert.match(client, /legacyKeyBlocked/)
})
test('host Config marks editable fields volatile for Desktop 0.2', async () => { test('host registers search provider without settings soft-inject poll', async () => {
const host = await read('lib/index.js'); const host = await read('lib/index.js')
assert.match(host, /function vol\(/); assert.match(host, /function vol\(/)
assert.match(host, /defaultServer: vol\(/); assert.match(host, /registerSearchProvider/)
assert.match(host, /servers: vol\(/); const applyBody = host.slice(host.indexOf('export function apply'), host.indexOf('function resolveOptions'))
assert.match(host, /settings\.register\(/); assert.doesNotMatch(applyBody, /ctx\.inject\(\s*\[['"]settings['"]\]/)
}); assert.doesNotMatch(applyBody, /setInterval\(/)
})
test('known providers are CDKey-only while custom keeps advanced fields', async () => { test('known providers are CDKey-only while custom keeps advanced fields', async () => {
const client = await read('lib/client.browser.js'); const client = await read('lib/client.browser.js')
const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS')); const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS'))
assert.doesNotMatch(catalog, /https?:\/\//); assert.doesNotMatch(catalog, /https?:\/\//)
assert.doesNotMatch(catalog, /toolName|authParam|transport/); assert.doesNotMatch(catalog, /toolName|authParam|transport/)
assert.match(client, /const known = row\.kind !== "custom"/); assert.match(client, /const known = row\.kind !== "custom"/)
assert.match(client, /children: known \? \[/); assert.match(client, /已知提供商不需要填写端点链接/)
assert.match(client, /No endpoint is required for known providers/); })
assert.match(client, /已知提供商不需要填写端点链接/);
assert.match(client, /kind, apiKey: "", apiKeyEnv: ""/);
});
test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => { test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => {
const transport = await read('lib/client.js'); const transport = await read('lib/client.js')
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/); assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/)
assert.match(transport, /new Agent\(\{[\s\S]*connect: \{ lookup: validated\.lookup \}/); assert.match(transport, /dispatcher: agent/)
assert.match(transport, /requestUrl\.origin !== expectedOrigin/); assert.match(transport, /redirect: 'error'/)
assert.match(transport, /dispatcher: agent/); })
assert.match(transport, /redirect: 'error'/);
assert.match(transport, /fetch: secureFetch/);
assert.match(transport, /await client\.close\(\)[\s\S]*await runtime\?\.close\(\)/);
assert.doesNotMatch(transport, /new URL\(server\.url\)[\s\S]*new StreamableHTTPClientTransport\(url, \{\s*requestInit:/);
});
test('bundle replaces built-in search and leaves default row endpoint-free', async () => { test('bundle inserts search-mcp without pinning web.searchProvider (Desktop 0.2 boot-safe)', async () => {
const patch = await read('cordis.patch.yml'); const patch = await read('cordis.patch.yml')
assert.match(patch, /searchProvider: search-mcp/); assert.doesNotMatch(patch, /^\s*searchProvider:\s*search-mcp\s*$/m)
assert.match(patch, /- id: web-search-deepseek\s+disabled: true/); assert.doesNotMatch(patch, /- id: web-search-deepseek\s+disabled: true/)
assert.match(patch, /- id: tool-web\s+disabled: false/); assert.match(patch, /id: search-mcp/)
assert.match(patch, /fetch: false/); assert.match(patch, /kind: bailian/)
assert.match(patch, /searchMaxResults: 50/); assert.match(patch, /apiKeyEnv: DASHSCOPE_API_KEY/)
assert.match(patch, /searchMaxQueries: 4/); assert.match(patch, /- id: tool-web\s+disabled: false/)
const defaultRow = patch.slice(patch.indexOf('- id: tavily'), patch.indexOf('- id: web')); })
assert.doesNotMatch(defaultRow, /url:|toolName:|authParam:|transport:/);
});

View file

@ -5,6 +5,7 @@ import { extractSearchResult } from '../lib/extract.js';
test('catalog exposes every supported provider preset', () => { test('catalog exposes every supported provider preset', () => {
assert.deepEqual(Object.keys(SEARCH_MCP_CATALOG), [ assert.deepEqual(Object.keys(SEARCH_MCP_CATALOG), [
'bailian',
'tavily', 'tavily',
'brave', 'brave',
'exa', 'exa',
@ -14,6 +15,18 @@ test('catalog exposes every supported provider preset', () => {
]); ]);
}); });
test('bailian preset pins DashScope WebSearch MCP', () => {
const bailian = resolveServer({ id: 'bailian', kind: 'bailian', apiKeyEnv: 'DASHSCOPE_API_KEY' });
assert.equal(bailian.transport, 'http');
assert.equal(bailian.url, 'https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp');
assert.equal(bailian.authStyle, 'header');
assert.equal(bailian.authParam, 'Authorization');
assert.equal(bailian.authPrefix, 'Bearer ');
assert.equal(bailian.toolName, 'bailian_web_search');
assert.equal(bailian.apiKeyEnv, 'DASHSCOPE_API_KEY');
assert.equal(bailian.needsKey, true);
});
test('known providers ignore stored connection overrides', () => { test('known providers ignore stored connection overrides', () => {
const tavily = resolveServer({ const tavily = resolveServer({
id: 'primary', id: 'primary',