mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-11 04:30:45 +08:00
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:
parent
743e894598
commit
c1fed965b1
5 changed files with 322 additions and 123 deletions
|
|
@ -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`。
|
||||
|
||||
## 卸载
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
62
lib/index.js
62
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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue