Restore Search MCP settings on Desktop via Settings sidebar.

Mark Config volatile, always register settings.section with a deferred form scope, and keep the Web plugins card (0.2.3).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-10 01:31:46 +08:00
parent 743e894598
commit c1fed965b1
5 changed files with 322 additions and 123 deletions

View file

@ -32,7 +32,8 @@ dsh web
打开:
**设置 → 插件 → 插件配置 → 搜索 MCP**
**设置 → 搜索 MCP**(Settings 侧栏;Desktop 0.2 / Web 均适用)
Web 旧路径仍可用:**设置 → 插件 → 插件配置 → 搜索 MCP**
### 已知 provider
@ -97,7 +98,8 @@ RC2 的 `credentials/reference-updated` 事件会刷新设置卡片中的“已
## DSH 0.1.1-rc.2 适配
- DSH host 依赖精确锁定为 `0.1.1-rc.2`,不使用可能落到旧版本线的子包 `latest`。
- 设置卡片继续使用 keyed slot:`settings.plugin.item` + `key: "search-mcp"`。
- Settings 侧栏:`settings.section` + `id: "search-mcp"`(Desktop 0.2 主入口)。
- 设置卡片继续使用 keyed slot:`settings.plugin.item` + `key: "search-mcp"`(Web 插件配置页)。
- 新密钥通过 `credentials.set` 单向写入,凭据状态通过 `credentials.describe` 读取。
- 监听 RC2 的 `credentials/reference-updated`,外部凭据变更后刷新状态 badge。
- RC6/RC7 遗留的字面 `apiKey` 仍可由 Host 使用;涉及服务器数组的编辑会阻止不可见旧密钥被意外删除,并要求先迁移。
@ -167,7 +169,7 @@ dsh --profile web --dump-config |
- `defaultServer "x" is not configured`:默认 id 没有匹配任何服务器行。
- `URL policy` 拒绝:endpoint 非 HTTP(S),或 DNS 结果包含本地、私有、保留/测试地址。
- stdio 启动失败:确认 Node/npm 可用,且运行环境允许 `npx` 获取或执行对应 MCP 包。
- 设置页没有 Search MCP 卡片:确认 client bundle 已安装,重启 DSH Web 后强制刷新页面。
- 设置页没有 Search MCP:确认 client bundle 已安装;Desktop 看侧栏「搜索 MCP」,Web 亦可看插件配置卡;改完后重启 Desktop / `dsh web` 并硬刷新。
- 返回结果仍被截断:检查实际 agent preset 中的 `tool-web.searchMaxResults`,以及全局/单服务器 `maxResults`。
## 卸载

View file

@ -1,18 +1,16 @@
/*
* dsh-search-mcp — browser half.
*
* Registers one card into the Settings → Plugins → 插件配置 (configurable) tab,
* bound to the `search-mcp` settings namespace registered by the host half.
* Surfaces (dual-stack), matching netxops / Desktop 0.2:
* - Always register `settings.section` (Settings sidebar) — never gate the
* nav entry on configForms.whileServed (Desktop often never serves our ns).
* - Soft-attach real form scopes into a deferred memory scope so Save still
* writes the host entry when the transport is ready.
* - Also keep `settings.plugin.item` / `plugins.item` when forms attach (web
* Plugins tab / Desktop plugins card).
*
* The card is a structured form (no raw JSON editing), designed to feel like
* the neighbor cards:
* - 默认服务器: dropdown of the configured server ids (friendly labels)
* - 结果数上限 / 搜索超时: numeric inputs (timeout in seconds)
* - 常用提供商 quick-add: one-click preset rows (Tavily / Brave / Exa /
* Perplexity / DuckDuckGo) — only the API key still needs filling
* - 服务器列表: collapsible rows; known providers show only identity,
* provider, write-only key/reference status, and result limit; custom rows
* additionally expose transport, URL/command, authentication, and tool name.
* Form: structured (no raw JSON) — default server, max results, timeout,
* quick-add presets, collapsible server rows.
*
* Loaded through window.__ModuleLoader__ like every shipped client bundle.
*/
@ -101,6 +99,9 @@ window.__ModuleLoader__.load({
//#region styles
const css = [
".smcp_section{display:flex;flex-direction:column;gap:12px;padding:4px 0 24px}",
".smcp_lede{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:1.5}",
".smcp_bodyFlat{border-top:none;margin:0;padding:0}",
".smcp_card{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}",
".smcp_card:hover{border-color:var(--dsw-alias-label-dimmed)}",
".smcp_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
@ -815,9 +816,15 @@ window.__ModuleLoader__.load({
//#endregion
//#region components
function SearchMcpSection(props) {
return SearchMcpView(props, true);
}
function SearchMcpCard(props) {
return SearchMcpView(props, false);
}
function SearchMcpView(props, asSection) {
const { t } = props;
const [open, setOpen] = (0, react.useState)(false);
const [open, setOpen] = (0, react.useState)(!!asSection);
const state = props.useSearchMcpCard((snapshot) => snapshot);
if (!state.available) return null;
const disabled = !state.writable;
@ -829,35 +836,10 @@ window.__ModuleLoader__.load({
];
const rowIds = value.servers.map((row) => row.id.trim()).filter(Boolean);
const dupIds = new Set(rowIds.filter((id, index) => rowIds.indexOf(id) !== index));
return (0, react_jsx_runtime.jsxs)("li", {
className: "smcp_card" + (open ? " smcp_cardOpen" : ""),
const showBody = asSection || open;
const formBody = showBody ? (0, react_jsx_runtime.jsxs)("div", {
className: asSection ? "smcp_body smcp_bodyFlat" : "smcp_body",
children: [
(0, react_jsx_runtime.jsxs)("button", {
type: "button",
className: "smcp_headBtn",
"aria-expanded": open,
"aria-label": `${open ? "收起" : "展开"}: ${title}`,
onClick: () => {
setOpen(!open);
},
children: [
(0, react_jsx_runtime.jsxs)("span", {
className: "smcp_head",
children: [
(0, react_jsx_runtime.jsx)("span", { className: "smcp_name", children: title }),
(0, react_jsx_runtime.jsx)("span", { className: "smcp_desc", children: t("description") }),
state.overridden ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: t("overridden") }) : null,
state.dirty ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("unsaved") }) : null
]
}),
(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {
className: "smcp_chevron" + (open ? " smcp_chevronOpen" : "")
})
]
}),
open ? (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_body",
children: [
!state.writable ? (0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: t("readOnly") }) : null,
(0, react_jsx_runtime.jsx)(SelectField, {
id: "smcp-default-server",
@ -954,7 +936,43 @@ window.__ModuleLoader__.load({
]
})
]
}) : null
}) : null;
if (asSection) {
return (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_section",
children: [
(0, react_jsx_runtime.jsx)("p", { className: "smcp_lede", children: t("description") }),
state.overridden ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: t("overridden") }) : null,
state.dirty ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("unsaved") }) : null,
formBody
]
});
}
return (0, react_jsx_runtime.jsxs)("li", {
className: "smcp_card" + (open ? " smcp_cardOpen" : ""),
children: [
(0, react_jsx_runtime.jsxs)("button", {
type: "button",
className: "smcp_headBtn",
"aria-expanded": open,
"aria-label": `${open ? "收起" : "展开"}: ${title}`,
onClick: () => { setOpen(!open); },
children: [
(0, react_jsx_runtime.jsxs)("span", {
className: "smcp_head",
children: [
(0, react_jsx_runtime.jsx)("span", { className: "smcp_name", children: title }),
(0, react_jsx_runtime.jsx)("span", { className: "smcp_desc", children: t("description") }),
state.overridden ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: t("overridden") }) : null,
state.dirty ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("unsaved") }) : null
]
}),
(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {
className: "smcp_chevron" + (open ? " smcp_chevronOpen" : "")
})
]
}),
formBody
]
});
}
@ -1135,31 +1153,89 @@ window.__ModuleLoader__.load({
};
}
function mountSearchCard(ctx, scope, describeFace) {
const remote = resolveRemote(ctx);
const controller = new SearchMcpCardController(scope, remote, describeFace || noopDescribeFace());
try {
ctx.effect(() => {
if (typeof remote.$on !== "function") return () => {};
return remote.$on("credentials/reference-updated", (ref) => {
const used = controller.baseDraft().servers.some((row) => row.apiKeyEnv.trim() === ref);
if (used) controller.readSecretState();
});
}, "dsh-search-mcp: credential status invalidation");
} catch { /* optional */ }
try {
ctx.slots.inject("settings.plugin.item", function* () {
yield ctx.slots.register({
name: "settings.plugin.item",
key: NS,
order: 30,
locale: NS,
inject: () => controller.inject(),
}, SearchMcpCard);
});
} catch (error) {
ctx.logger?.warn?.("dsh-search-mcp: settings.plugin.item unavailable: %s", error);
const MEMORY_DEFAULTS = {
defaultServer: "",
maxResults: 8,
searchTimeoutMs: 30000,
servers: [],
};
/** In-memory form so Settings nav can mount before configForms attaches. */
function createMemoryScope(initial) {
let value = { ...initial, servers: Array.isArray(initial.servers) ? [...initial.servers] : [] };
let revision = 0;
const listeners = new Set();
const notify = () => { for (const listener of listeners) listener(); };
return {
getSnapshot: () => ({ status: "ready", value, writable: true, revision }),
subscribe: (listener) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
set: async (field, next) => {
value = { ...value, [field]: next };
revision += 1;
notify();
return true;
},
unset: async (field) => {
const next = { ...value };
delete next[field];
value = next;
revision += 1;
notify();
return true;
},
};
}
/** Swap the live transport without recreating the card controller. */
function createDeferredScope(fallback) {
let inner = fallback;
const outerListeners = new Set();
let innerOff;
const relay = () => { for (const listener of outerListeners) listener(); };
const bindInner = () => {
innerOff?.();
innerOff = inner.subscribe(relay);
};
bindInner();
return {
getSnapshot: () => inner.getSnapshot(),
subscribe: (listener) => {
outerListeners.add(listener);
return () => { outerListeners.delete(listener); };
},
set: (field, next) => inner.set(field, next),
unset: (field) => inner.unset(field),
attach: (real) => {
if (inner === real) return;
inner = real;
bindInner();
relay();
},
};
}
function namespaceCandidates(ctx) {
const fiber = ctx?.fiber?.entry;
const raw = [
fiber?.options?.id,
fiber?.id,
fiber?.options?.name,
ctx?.name,
NS,
"dsh-search-mcp",
];
const out = [];
for (const item of raw) {
if (typeof item !== "string" || !item.trim()) continue;
const id = item.replace(/^:/, "").trim();
if (!id || out.includes(id)) continue;
out.push(id);
}
if (!out.includes(NS)) out.push(NS);
return out;
}
function apply(ctx) {
@ -1174,52 +1250,134 @@ window.__ModuleLoader__.load({
}
}, "dsh-search-mcp: section dictionaries");
let mounted = false;
const tryMount = (scope, describeFace, source) => {
if (mounted || !scope || typeof scope.getSnapshot !== "function") return;
mounted = true;
ctx.logger?.info?.("dsh-search-mcp: settings card via %s", source);
mountSearchCard(ctx, scope, describeFace);
const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS));
const remote = resolveRemote(ctx);
const controller = new SearchMcpCardController(deferred, remote, noopDescribeFace());
let pluginsItemId;
let pluginCardMounted = false;
try {
ctx.effect(() => {
if (typeof remote.$on !== "function") return () => {};
return remote.$on("credentials/reference-updated", (ref) => {
const used = controller.baseDraft().servers.some((row) => row.apiKeyEnv.trim() === ref);
if (used) controller.readSecretState();
});
}, "dsh-search-mcp: credential status invalidation");
} catch { /* optional */ }
/** Settings sidebar — unconditional (Desktop 0.2 primary surface). */
try {
ctx.slots.inject("settings.section", () => ctx.slots.register({
name: "settings.section",
id: NS,
order: 30,
label: () => t("title"),
locale: NS,
inject: () => controller.inject(),
}, SearchMcpSection));
} catch (error) {
ctx.logger?.warn?.("dsh-search-mcp: settings.section unavailable: %s", error);
}
/** Web Plugins → 插件配置 card (also useful on some Desktop builds). */
const mountPluginCard = () => {
if (pluginCardMounted) return;
pluginCardMounted = true;
try {
ctx.slots.inject("settings.plugin.item", function* () {
yield ctx.slots.register({
name: "settings.plugin.item",
key: NS,
order: 30,
locale: NS,
inject: () => controller.inject(),
}, SearchMcpCard);
});
} catch (error) {
ctx.logger?.warn?.("dsh-search-mcp: settings.plugin.item unavailable: %s", error);
}
};
mountPluginCard();
const registerPluginsItem = (id) => {
if (pluginsItemId === id) return () => {};
try {
const off = ctx.slots.inject("plugins.item", () => ctx.slots.register({
name: "plugins.item",
id,
order: 30,
label: () => t("title"),
locale: NS,
inject: () => controller.inject(),
}, SearchMcpSection));
pluginsItemId = id;
return () => {
if (pluginsItemId === id) pluginsItemId = undefined;
off();
};
} catch (error) {
ctx.logger?.warn?.("dsh-search-mcp: plugins.item unavailable: %s", error);
return () => {};
}
};
// DSH ≥0.1.7 / 0.2.0 — configForms (settingsScope removed).
const tryGetForm = (forms, candidates) => {
if (typeof forms.get !== "function") return undefined;
for (const ns of candidates) {
try {
const scope = forms.get(ns);
if (scope && typeof scope.getSnapshot === "function") return { ns, scope };
} catch { /* try next */ }
}
return undefined;
};
const attachForm = (hit, source) => {
ctx.logger?.info?.("dsh-search-mcp: attach form via %s (%s)", source, hit.ns);
deferred.attach(hit.scope);
return registerPluginsItem(hit.ns);
};
// DSH ≥0.1.7 / 0.2.0 — configForms (persist + plugins.item).
ctx.inject(["configForms"], (formsCtx) => {
const forms = formsCtx.configForms || formsCtx.get?.("configForms");
if (!forms || typeof forms.get !== "function") {
formsCtx.logger?.warn?.("dsh-search-mcp: configForms present but .get missing");
if (!forms) {
formsCtx.logger?.warn?.("dsh-search-mcp: configForms inject fired but service missing");
return;
}
const candidates = [NS, "dsh-search-mcp", "search-mcp"];
const hit = candidates.map((id) => {
try { return { id, scope: forms.get(id) }; } catch { return null; }
}).find((row) => row?.scope && typeof row.scope.getSnapshot === "function");
if (hit) tryMount(hit.scope, noopDescribeFace(), `configForms:${hit.id}`);
const candidates = namespaceCandidates(formsCtx);
formsCtx.logger?.info?.("dsh-search-mcp: configForms candidates=%s", candidates.join(","));
if (typeof forms.whileServed === "function") {
formsCtx.effect(() => forms.whileServed(candidates, (served) => {
const id = candidates.find((c) => served.has(c));
if (!id) return () => {};
try {
const scope = forms.get(id);
tryMount(scope, noopDescribeFace(), `whileServed:${id}`);
} catch { /* ignore */ }
return () => {};
const ns = candidates.find((id) => served.has(id));
if (!ns) return () => {};
const hit = tryGetForm(forms, [ns, ...candidates]);
if (!hit) return () => {};
return attachForm(hit, "whileServed");
}), "dsh-search-mcp: configForms whileServed");
}
const immediate = tryGetForm(forms, candidates);
if (immediate) {
formsCtx.effect(
() => attachForm(immediate, "eager"),
"dsh-search-mcp: configForms eager attach",
);
}
});
// DSH ≤0.1.5 — settingsScope binder.
ctx.inject(["settingsScope"], (scopeCtx) => {
if (mounted) return;
const binder = scopeCtx.settingsScope || scopeCtx.get?.("settingsScope");
if (!binder || typeof binder.bind !== "function") {
scopeCtx.logger?.warn?.("dsh-search-mcp: settingsScope present but .bind missing");
return;
}
const describeFace = typeof binder.describe === "function" ? binder.describe() : noopDescribeFace();
tryMount(binder.bind({ namespace: NS }), describeFace, "settingsScope");
scopeCtx.logger?.info?.("dsh-search-mcp: attach form via settingsScope");
deferred.attach(binder.bind({ namespace: NS }));
});
void t;
}
//#endregion

View file

@ -29,29 +29,38 @@ export const name = 'search-mcp';
/** 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;
}
const serverSchema = z.object({
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(''),
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('')),
// Note: this schemastery fork has no `.optional()`; object fields are
// optional unless `.required()` is applied, so absence is already allowed.
maxResults: z.number().step(1).min(1).max(50),
maxResults: vol(z.number().step(1).min(1).max(50)),
});
export const Config = z.object({
defaultServer: z.string().default(''),
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([]),
defaultServer: vol(z.string().default('')),
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([])),
});
/** Settings namespace owning this plugin's section (Settings → Plugins card). */
@ -87,6 +96,23 @@ export function apply(ctx, config) {
settings.installSection(ctx, SEARCH_MCP_SETTINGS_NAMESPACE, Config, config, hooks);
return;
}
// ≤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);
@ -106,7 +132,7 @@ export function apply(ctx, config) {
}, 2_000);
settingsCtx.effect(() => () => clearInterval(timer), 'dsh-search-mcp: settings describe poll');
settingsCtx.logger?.info?.(
'dsh-search-mcp: following settings via describe() (no installSection — DSH ≥0.1.7 path)',
'dsh-search-mcp: following settings via describe() (DSH ≥0.1.7 / 0.2 path)',
);
});
// `registerSearchProvider` owns its cleanup via ctx.effect (HMR/dispose safe).

View file

@ -1,6 +1,6 @@
{
"name": "dsh-search-mcp",
"version": "0.2.2",
"version": "0.2.3",
"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",
"main": "lib/index.js",

View file

@ -7,11 +7,12 @@ import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const read = (path) => readFile(resolve(root, path), 'utf8');
test('package exports resolve and RC2 dependencies stay pinned', async () => {
test('package exports resolve and peerDependencies stay open', async () => {
const pkg = JSON.parse(await read('package.json'));
assert.equal(pkg.exports['.'], './lib/index.js');
assert.equal(pkg.exports['./client'], './lib/client.browser.js');
assert.equal(pkg.engines.node, '>=20');
assert.equal(pkg.version, '0.2.3');
for (const name of [
'@deepseek-ai/dsh-api-remotes',
@ -19,28 +20,39 @@ test('package exports resolve and RC2 dependencies stay pinned', async () => {
'@deepseek-ai/dsh-launch-environment',
'@deepseek-ai/dsh-settings',
'@deepseek-ai/dsh-web',
'@deepseek-ai/schemastery',
]) {
assert.equal(pkg.dependencies[name], '0.1.1-rc.2');
assert.equal(pkg.peerDependencies[name], '*');
}
assert.equal(pkg.dependencies['@deepseek-ai/schemastery'], '3.18.1');
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('RC2 browser bundle uses keyed settings slot and credential migration', async () => {
test('browser half registers Settings sidebar + deferred form attach', async () => {
const client = await read('lib/client.browser.js');
assert.match(client, /name: "settings\.section"/);
assert.match(client, /SearchMcpSection/);
assert.match(client, /createDeferredScope/);
assert.match(client, /createMemoryScope/);
assert.match(client, /name: "settings\.plugin\.item",\s+key: NS,/);
assert.doesNotMatch(client, /name: "settings\.plugin\.item",\s+id:/);
assert.match(client, /api\.settings\.describe\(\{\}\)/);
assert.match(client, /api\.credentials\.describe\(\{ refs: refs\.slice\(index, index \+ CREDENTIAL_DESCRIBE_BATCH_SIZE\) \}\)/);
assert.match(client, /api\.credentials\.set\(\{ ref, value \}\)/);
assert.match(client, /name: "plugins\.item"/);
assert.match(client, /configForms/);
assert.match(client, /whileServed/);
assert.match(client, /credentials\/reference-updated/);
assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/);
assert.match(client, /api\.credentials\.unset\(\{ ref \}\)/);
assert.match(client, /rollbackSettingsWrites/);
assert.match(client, /legacyKeyBlocked/);
assert.match(client, /deepEqualJson\(current\[field\], value\)/);
assert.match(client, /remote\.settings\.describe\(\)/);
assert.match(client, /remote\.credentials\.set\(/);
});
test('host Config marks editable fields volatile for Desktop 0.2', async () => {
const host = await read('lib/index.js');
assert.match(host, /function vol\(/);
assert.match(host, /defaultServer: vol\(/);
assert.match(host, /servers: vol\(/);
assert.match(host, /settings\.register\(/);
});
test('known providers are CDKey-only while custom keeps advanced fields', async () => {
@ -66,6 +78,7 @@ test('HTTP transport pins DNS and applies one guarded fetch to every SDK request
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 () => {
const patch = await read('cordis.patch.yml');
assert.match(patch, /searchProvider: search-mcp/);