Fix Desktop search-mcp takeover so web_search uses Bailian.

Drop nested Config.volatile that left the fiber inactive, unwrap the cordis
web proxy to pin/wrap search at runtime, and restore deepseek on disable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-10 09:05:00 +08:00
parent d729b03f70
commit 23685cd545
8 changed files with 343 additions and 72 deletions

View file

@ -1,59 +1,75 @@
/**
* dsh-search-mcp — replace dsh's built-in web search with search MCP servers.
*
* Adapted for DeepSeek Harness 0.1.2+: settings use
* `ctx.settings.installSection` (the old free-function
* `installSettingsSection` from 0.1.1-rc.2 no longer exists).
* Registers a `ctx.web` search provider under id `search-mcp`, then **live-pins**
* the underlying WebRuntime's `searchProviderId` so `web_search` stops using
* `deepseek-official`.
*
* A Cordis plugin that
* - registers a `ctx.web` search provider under the id `search-mcp`, and
* - installs a Settings section (`search-mcp`) where the user manages the
* search MCP server list (kind, endpoint/command, API key or key env
* reference, tool name) plus `defaultServer` / `maxResults` /
* `searchTimeoutMs` from the web Settings → Plugins page.
* Why not cordis.patch.yml?
* Pinning `web.searchProvider: search-mcp` (or disabling deepseek) in the
* bundle patch deadlocks Desktop 0.2 boot: `web` waits for `search-mcp` while
* this plugin `inject: ['web']`. Live-pin after `registerSearchProvider` avoids
* that cycle. Do not persist the pin into settings.yaml either — a restart
* would reintroduce the same deadlock.
*
* The package's `cordis.patch.yml` (bundle layer) switches
* `web.searchProvider` to `search-mcp` and disables the built-in
* `web-search-deepseek` provider, so while this plugin is enabled the
* built-in search is unavailable and every `web_search` call runs through
* the configured MCP server(s). Removing the package restores the built-in.
* Why unwrap cordis.original?
* `ctx.web` is a traceable Proxy. Assigning `ctx.web.searchProviderId = …`
* does not update the field `search()` reads; pin the unwrapped instance.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import z from '@deepseek-ai/schemastery';
import { credentialRef } from '@deepseek-ai/dsh-credentials';
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
import { SearchMCPProvider } from './provider.js';
import { SEARCH_MCP_PROVIDER_ID, SearchMCPProvider } from './provider.js';
/** Cordis plugin name used by loader diagnostics. */
export const name = 'search-mcp';
/** Module-load breadcrumb — proves Desktop resolved this build (even if apply never runs). */
try {
const pkgVersion = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
).version;
writeFileSync(
join(process.env.USERPROFILE || process.env.HOME || '', '.dsh', 'search-mcp-loaded.json'),
`${JSON.stringify({ t: new Date().toISOString(), version: pkgVersion, name }, null, 2)}\n`,
);
} catch {
/* ignore */
}
/** The web seam this provider registers into. */
export const inject = ['web'];
/**
* DSH ≥0.1.7 / 0.2.0 only projects Config fields marked `.volatile()` into the
* settings UI. Zero volatile fields → the whole entry is silently filtered.
* Older schemastery without `volatile` keeps the bare schema (≤0.1.5 path).
*/
function vol(schema) {
return typeof schema?.volatile === 'function' ? schema.volatile() : schema;
}
/** Unwrap cordis service proxy → concrete Service instance. */
const CORDIS_ORIGINAL = Symbol.for('cordis.original');
/**
* Config schema — no `.volatile()` anywhere.
*
* Desktop 0.2 cordis rejects nested volatiles (`servers` + `servers.*.id`) with
* ValidationError and leaves the fiber inactive. The settings UI is a custom
* `settings.section` (client.browser.js), so Config does not need volatile
* projection for the form to appear.
*/
const serverSchema = z.object({
id: vol(z.string()),
kind: vol(z.string().default('custom')),
transport: vol(z.string().default('http')),
url: vol(z.string().default('')),
command: vol(z.string().default('')),
args: vol(z.array(z.string()).default([])),
apiKey: vol(z.string().role('secret')),
apiKeyEnv: vol(z.string().role('credential-ref').default('')),
authStyle: vol(z.string().default('')),
authParam: vol(z.string().default('')),
authPrefix: vol(z.string().default('')),
toolName: vol(z.string().default('')),
id: z.string(),
kind: z.string().default('custom'),
transport: z.string().default('http'),
url: z.string().default(''),
command: z.string().default(''),
args: z.array(z.string()).default([]),
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().role('credential-ref').default(''),
authStyle: z.string().default(''),
authParam: z.string().default(''),
authPrefix: z.string().default(''),
toolName: z.string().default(''),
// Note: this schemastery fork has no `.optional()`; object fields are
// optional unless `.required()` is applied, so absence is already allowed.
maxResults: vol(z.number().step(1).min(1).max(50)),
maxResults: z.number().step(1).min(1).max(50),
});
const DEFAULT_BAILIAN_SERVER = {
@ -63,34 +79,128 @@ const DEFAULT_BAILIAN_SERVER = {
};
export const Config = z.object({
defaultServer: vol(z.string().default('bailian')),
maxResults: vol(z.number().step(1).min(1).max(50).default(8)),
searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)),
servers: vol(z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER])),
defaultServer: z.string().default('bailian'),
maxResults: z.number().step(1).min(1).max(50).default(8),
searchTimeoutMs: z.number().step(1).min(1000).default(30000),
servers: z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER]),
});
/** Settings namespace owning this plugin's section (Settings → Plugins card). */
export const SEARCH_MCP_SETTINGS_NAMESPACE = 'search-mcp';
/** Normalize settings.describe() across DSH generations. */
function describeRows(describe) {
if (typeof describe !== 'function') return [];
try {
const raw = describe();
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === 'object' && Array.isArray(raw.namespaces)) return raw.namespaces;
} catch {
// describe can throw while the provider is settling
}
return [];
/** Concrete WebRuntime behind `ctx.web` (cordis traceable proxy). */
function unwrapWeb(web) {
if (!web || typeof web !== 'object') return null;
const raw = web[CORDIS_ORIGINAL];
return raw && typeof raw === 'object' ? raw : web;
}
/** Register the search provider. Settings UI is owned by the web client. */
/** Match WebRuntime.capSources so direct dispatch keeps the same contract. */
function capSources(result, maxResults) {
if (maxResults === undefined || !Array.isArray(result?.sources) || result.sources.length <= maxResults) {
return result;
}
return { ...result, sources: result.sources.slice(0, maxResults), truncated: true };
}
/**
* Hot-takeover for the life of this fiber:
* - pin + wrap on apply (enable / boot)
* - restore previous provider + unwrap on dispose (disable)
*
* No Desktop restart needed for enable/disable — only for loading a new
* package build into an already-running process.
*/
function takeOverWebSearch(ctx, provider) {
const web = unwrapWeb(ctx.web);
if (!web || typeof web.search !== 'function' || !provider) return false;
const previousId = web.searchProviderId;
// Leftover pin from a crashed unload → fall back to Desktop default.
const restoreId =
previousId === SEARCH_MCP_PROVIDER_ID || previousId === undefined
? 'deepseek-official'
: previousId;
const previousSearch = web.search.__searchMcpWrapped ? web.search.__searchMcpOriginal : web.search;
try {
web.searchProviderId = SEARCH_MCP_PROVIDER_ID;
} catch (error) {
ctx.logger?.warn?.('search-mcp: failed to assign web.searchProviderId: %s', error);
return false;
}
async function searchMcpPinned(request, signal) {
web.searchProviderId = SEARCH_MCP_PROVIDER_ID;
if (typeof provider.available === 'function' && provider.available()) {
const result = await provider.search(request, signal);
return capSources(result, request?.maxResults);
}
return previousSearch.call(web, request, signal);
}
searchMcpPinned.__searchMcpWrapped = true;
searchMcpPinned.__searchMcpOriginal = previousSearch;
web.search = searchMcpPinned;
// Fiber unload (plugin disable) → hand search back to the built-in path.
ctx.effect(() => () => {
if (web.search === searchMcpPinned) {
web.search = previousSearch;
}
if (web.searchProviderId === SEARCH_MCP_PROVIDER_ID) {
web.searchProviderId = restoreId;
}
ctx.logger?.info?.(
'search-mcp: released web search (restored searchProviderId → %s)',
restoreId === undefined ? '(unset)' : restoreId,
);
});
ctx.logger?.info?.(
'search-mcp: took over web.search (%s → %s); disable plugin to release',
previousId === undefined ? '(unset)' : previousId,
SEARCH_MCP_PROVIDER_ID,
);
return true;
}
/** Best-effort runtime breadcrumb for Desktop diagnosis (~/.dsh/search-mcp-runtime.json). */
async function writeRuntimeBreadcrumb(payload) {
try {
const { writeFileSync } = await import('node:fs');
const { join } = await import('node:path');
const home = process.env.USERPROFILE || process.env.HOME || '';
if (!home) return;
writeFileSync(
join(home, '.dsh', 'search-mcp-runtime.json'),
`${JSON.stringify({ t: new Date().toISOString(), ...payload }, null, 2)}\n`,
);
} catch {
/* ignore */
}
}
/** Register the search provider and take over web_search selection. */
export function apply(ctx, config) {
const current = () => config;
// Desktop 0.2: avoid soft-inject(['settings']) + describe poll during bring-up.
// Desktop 0.2: avoid soft-inject(['settings']) during bring-up.
// Loader already projects Config from the cordis entry / patch insert.
ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current())));
const provider = new SearchMCPProvider(() => resolveOptions(ctx, current()));
ctx.web.registerSearchProvider(provider);
// Hot path: enable = take over now; disable = effect disposer restores.
const tookOver = takeOverWebSearch(ctx, provider);
if (!tookOver) {
ctx.logger?.warn?.(
'search-mcp: could not take over search provider; web_search may still use deepseek-official',
);
}
void writeRuntimeBreadcrumb({
event: 'apply',
tookOver,
searchProviderId: unwrapWeb(ctx.web)?.searchProviderId,
servers: (config.servers ?? []).map((s) => ({ id: s.id, kind: s.kind })),
});
}
/**