diff --git a/README.md b/README.md index e0f5049..baca395 100644 --- a/README.md +++ b/README.md @@ -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`。 ## 卸载 diff --git a/lib/client.browser.js b/lib/client.browser.js index 0713ca2..75d7e37 100644 --- a/lib/client.browser.js +++ b/lib/client.browser.js @@ -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 diff --git a/lib/index.js b/lib/index.js index 58c3e91..713753b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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). diff --git a/package.json b/package.json index ef17343..050566d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/compatibility.test.js b/test/compatibility.test.js index ff4183b..29d0eef 100644 --- a/test/compatibility.test.js +++ b/test/compatibility.test.js @@ -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/);