mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-17 10:53:26 +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
|
### 已知 provider
|
||||||
|
|
||||||
|
|
@ -97,7 +98,8 @@ RC2 的 `credentials/reference-updated` 事件会刷新设置卡片中的“已
|
||||||
## DSH 0.1.1-rc.2 适配
|
## DSH 0.1.1-rc.2 适配
|
||||||
|
|
||||||
- DSH host 依赖精确锁定为 `0.1.1-rc.2`,不使用可能落到旧版本线的子包 `latest`。
|
- 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` 读取。
|
- 新密钥通过 `credentials.set` 单向写入,凭据状态通过 `credentials.describe` 读取。
|
||||||
- 监听 RC2 的 `credentials/reference-updated`,外部凭据变更后刷新状态 badge。
|
- 监听 RC2 的 `credentials/reference-updated`,外部凭据变更后刷新状态 badge。
|
||||||
- RC6/RC7 遗留的字面 `apiKey` 仍可由 Host 使用;涉及服务器数组的编辑会阻止不可见旧密钥被意外删除,并要求先迁移。
|
- RC6/RC7 遗留的字面 `apiKey` 仍可由 Host 使用;涉及服务器数组的编辑会阻止不可见旧密钥被意外删除,并要求先迁移。
|
||||||
|
|
@ -167,7 +169,7 @@ dsh --profile web --dump-config |
|
||||||
- `defaultServer "x" is not configured`:默认 id 没有匹配任何服务器行。
|
- `defaultServer "x" is not configured`:默认 id 没有匹配任何服务器行。
|
||||||
- `URL policy` 拒绝:endpoint 非 HTTP(S),或 DNS 结果包含本地、私有、保留/测试地址。
|
- `URL policy` 拒绝:endpoint 非 HTTP(S),或 DNS 结果包含本地、私有、保留/测试地址。
|
||||||
- stdio 启动失败:确认 Node/npm 可用,且运行环境允许 `npx` 获取或执行对应 MCP 包。
|
- 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`。
|
- 返回结果仍被截断:检查实际 agent preset 中的 `tool-web.searchMaxResults`,以及全局/单服务器 `maxResults`。
|
||||||
|
|
||||||
## 卸载
|
## 卸载
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
/*
|
/*
|
||||||
* dsh-search-mcp — browser half.
|
* dsh-search-mcp — browser half.
|
||||||
*
|
*
|
||||||
* Registers one card into the Settings → Plugins → 插件配置 (configurable) tab,
|
* Surfaces (dual-stack), matching netxops / Desktop 0.2:
|
||||||
* bound to the `search-mcp` settings namespace registered by the host half.
|
* - 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
|
* Form: structured (no raw JSON) — default server, max results, timeout,
|
||||||
* the neighbor cards:
|
* quick-add presets, collapsible server rows.
|
||||||
* - 默认服务器: 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.
|
|
||||||
*
|
*
|
||||||
* Loaded through window.__ModuleLoader__ like every shipped client bundle.
|
* Loaded through window.__ModuleLoader__ like every shipped client bundle.
|
||||||
*/
|
*/
|
||||||
|
|
@ -101,6 +99,9 @@ window.__ModuleLoader__.load({
|
||||||
|
|
||||||
//#region styles
|
//#region styles
|
||||||
const css = [
|
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{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_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)}",
|
".smcp_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
|
||||||
|
|
@ -815,9 +816,15 @@ window.__ModuleLoader__.load({
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region components
|
//#region components
|
||||||
|
function SearchMcpSection(props) {
|
||||||
|
return SearchMcpView(props, true);
|
||||||
|
}
|
||||||
function SearchMcpCard(props) {
|
function SearchMcpCard(props) {
|
||||||
|
return SearchMcpView(props, false);
|
||||||
|
}
|
||||||
|
function SearchMcpView(props, asSection) {
|
||||||
const { t } = props;
|
const { t } = props;
|
||||||
const [open, setOpen] = (0, react.useState)(false);
|
const [open, setOpen] = (0, react.useState)(!!asSection);
|
||||||
const state = props.useSearchMcpCard((snapshot) => snapshot);
|
const state = props.useSearchMcpCard((snapshot) => snapshot);
|
||||||
if (!state.available) return null;
|
if (!state.available) return null;
|
||||||
const disabled = !state.writable;
|
const disabled = !state.writable;
|
||||||
|
|
@ -829,35 +836,10 @@ window.__ModuleLoader__.load({
|
||||||
];
|
];
|
||||||
const rowIds = value.servers.map((row) => row.id.trim()).filter(Boolean);
|
const rowIds = value.servers.map((row) => row.id.trim()).filter(Boolean);
|
||||||
const dupIds = new Set(rowIds.filter((id, index) => rowIds.indexOf(id) !== index));
|
const dupIds = new Set(rowIds.filter((id, index) => rowIds.indexOf(id) !== index));
|
||||||
return (0, react_jsx_runtime.jsxs)("li", {
|
const showBody = asSection || open;
|
||||||
className: "smcp_card" + (open ? " smcp_cardOpen" : ""),
|
const formBody = showBody ? (0, react_jsx_runtime.jsxs)("div", {
|
||||||
|
className: asSection ? "smcp_body smcp_bodyFlat" : "smcp_body",
|
||||||
children: [
|
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,
|
!state.writable ? (0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: t("readOnly") }) : null,
|
||||||
(0, react_jsx_runtime.jsx)(SelectField, {
|
(0, react_jsx_runtime.jsx)(SelectField, {
|
||||||
id: "smcp-default-server",
|
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 MEMORY_DEFAULTS = {
|
||||||
const remote = resolveRemote(ctx);
|
defaultServer: "",
|
||||||
const controller = new SearchMcpCardController(scope, remote, describeFace || noopDescribeFace());
|
maxResults: 8,
|
||||||
try {
|
searchTimeoutMs: 30000,
|
||||||
ctx.effect(() => {
|
servers: [],
|
||||||
if (typeof remote.$on !== "function") return () => {};
|
};
|
||||||
return remote.$on("credentials/reference-updated", (ref) => {
|
|
||||||
const used = controller.baseDraft().servers.some((row) => row.apiKeyEnv.trim() === ref);
|
/** In-memory form so Settings nav can mount before configForms attaches. */
|
||||||
if (used) controller.readSecretState();
|
function createMemoryScope(initial) {
|
||||||
});
|
let value = { ...initial, servers: Array.isArray(initial.servers) ? [...initial.servers] : [] };
|
||||||
}, "dsh-search-mcp: credential status invalidation");
|
let revision = 0;
|
||||||
} catch { /* optional */ }
|
const listeners = new Set();
|
||||||
try {
|
const notify = () => { for (const listener of listeners) listener(); };
|
||||||
ctx.slots.inject("settings.plugin.item", function* () {
|
return {
|
||||||
yield ctx.slots.register({
|
getSnapshot: () => ({ status: "ready", value, writable: true, revision }),
|
||||||
name: "settings.plugin.item",
|
subscribe: (listener) => {
|
||||||
key: NS,
|
listeners.add(listener);
|
||||||
order: 30,
|
return () => { listeners.delete(listener); };
|
||||||
locale: NS,
|
},
|
||||||
inject: () => controller.inject(),
|
set: async (field, next) => {
|
||||||
}, SearchMcpCard);
|
value = { ...value, [field]: next };
|
||||||
});
|
revision += 1;
|
||||||
} catch (error) {
|
notify();
|
||||||
ctx.logger?.warn?.("dsh-search-mcp: settings.plugin.item unavailable: %s", error);
|
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) {
|
function apply(ctx) {
|
||||||
|
|
@ -1174,52 +1250,134 @@ window.__ModuleLoader__.load({
|
||||||
}
|
}
|
||||||
}, "dsh-search-mcp: section dictionaries");
|
}, "dsh-search-mcp: section dictionaries");
|
||||||
|
|
||||||
let mounted = false;
|
const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS));
|
||||||
const tryMount = (scope, describeFace, source) => {
|
const remote = resolveRemote(ctx);
|
||||||
if (mounted || !scope || typeof scope.getSnapshot !== "function") return;
|
const controller = new SearchMcpCardController(deferred, remote, noopDescribeFace());
|
||||||
mounted = true;
|
let pluginsItemId;
|
||||||
ctx.logger?.info?.("dsh-search-mcp: settings card via %s", source);
|
let pluginCardMounted = false;
|
||||||
mountSearchCard(ctx, scope, describeFace);
|
|
||||||
|
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) => {
|
ctx.inject(["configForms"], (formsCtx) => {
|
||||||
const forms = formsCtx.configForms || formsCtx.get?.("configForms");
|
const forms = formsCtx.configForms || formsCtx.get?.("configForms");
|
||||||
if (!forms || typeof forms.get !== "function") {
|
if (!forms) {
|
||||||
formsCtx.logger?.warn?.("dsh-search-mcp: configForms present but .get missing");
|
formsCtx.logger?.warn?.("dsh-search-mcp: configForms inject fired but service missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const candidates = [NS, "dsh-search-mcp", "search-mcp"];
|
const candidates = namespaceCandidates(formsCtx);
|
||||||
const hit = candidates.map((id) => {
|
formsCtx.logger?.info?.("dsh-search-mcp: configForms candidates=%s", candidates.join(","));
|
||||||
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}`);
|
|
||||||
if (typeof forms.whileServed === "function") {
|
if (typeof forms.whileServed === "function") {
|
||||||
formsCtx.effect(() => forms.whileServed(candidates, (served) => {
|
formsCtx.effect(() => forms.whileServed(candidates, (served) => {
|
||||||
const id = candidates.find((c) => served.has(c));
|
const ns = candidates.find((id) => served.has(id));
|
||||||
if (!id) return () => {};
|
if (!ns) return () => {};
|
||||||
try {
|
const hit = tryGetForm(forms, [ns, ...candidates]);
|
||||||
const scope = forms.get(id);
|
if (!hit) return () => {};
|
||||||
tryMount(scope, noopDescribeFace(), `whileServed:${id}`);
|
return attachForm(hit, "whileServed");
|
||||||
} catch { /* ignore */ }
|
|
||||||
return () => {};
|
|
||||||
}), "dsh-search-mcp: configForms 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.
|
// DSH ≤0.1.5 — settingsScope binder.
|
||||||
ctx.inject(["settingsScope"], (scopeCtx) => {
|
ctx.inject(["settingsScope"], (scopeCtx) => {
|
||||||
if (mounted) return;
|
|
||||||
const binder = scopeCtx.settingsScope || scopeCtx.get?.("settingsScope");
|
const binder = scopeCtx.settingsScope || scopeCtx.get?.("settingsScope");
|
||||||
if (!binder || typeof binder.bind !== "function") {
|
if (!binder || typeof binder.bind !== "function") {
|
||||||
scopeCtx.logger?.warn?.("dsh-search-mcp: settingsScope present but .bind missing");
|
scopeCtx.logger?.warn?.("dsh-search-mcp: settingsScope present but .bind missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const describeFace = typeof binder.describe === "function" ? binder.describe() : noopDescribeFace();
|
scopeCtx.logger?.info?.("dsh-search-mcp: attach form via settingsScope");
|
||||||
tryMount(binder.bind({ namespace: NS }), describeFace, "settingsScope");
|
deferred.attach(binder.bind({ namespace: NS }));
|
||||||
});
|
});
|
||||||
|
|
||||||
void t;
|
|
||||||
}
|
}
|
||||||
//#endregion
|
//#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. */
|
/** The web seam this provider registers into. */
|
||||||
export const inject = ['web'];
|
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({
|
const serverSchema = z.object({
|
||||||
id: z.string(),
|
id: vol(z.string()),
|
||||||
kind: z.string().default('custom'),
|
kind: vol(z.string().default('custom')),
|
||||||
transport: z.string().default('http'),
|
transport: vol(z.string().default('http')),
|
||||||
url: z.string().default(''),
|
url: vol(z.string().default('')),
|
||||||
command: z.string().default(''),
|
command: vol(z.string().default('')),
|
||||||
args: z.array(z.string()).default([]),
|
args: vol(z.array(z.string()).default([])),
|
||||||
apiKey: z.string().role('secret'),
|
apiKey: vol(z.string().role('secret')),
|
||||||
apiKeyEnv: z.string().role('credential-ref').default(''),
|
apiKeyEnv: vol(z.string().role('credential-ref').default('')),
|
||||||
authStyle: z.string().default(''),
|
authStyle: vol(z.string().default('')),
|
||||||
authParam: z.string().default(''),
|
authParam: vol(z.string().default('')),
|
||||||
authPrefix: z.string().default(''),
|
authPrefix: vol(z.string().default('')),
|
||||||
toolName: z.string().default(''),
|
toolName: vol(z.string().default('')),
|
||||||
// Note: this schemastery fork has no `.optional()`; object fields are
|
// Note: this schemastery fork has no `.optional()`; object fields are
|
||||||
// optional unless `.required()` is applied, so absence is already allowed.
|
// 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({
|
export const Config = z.object({
|
||||||
defaultServer: z.string().default(''),
|
defaultServer: vol(z.string().default('')),
|
||||||
maxResults: z.number().step(1).min(1).max(50).default(8),
|
maxResults: vol(z.number().step(1).min(1).max(50).default(8)),
|
||||||
searchTimeoutMs: z.number().step(1).min(1000).default(30000),
|
searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)),
|
||||||
servers: z.array(serverSchema).default([]),
|
servers: vol(z.array(serverSchema).default([])),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Settings namespace owning this plugin's section (Settings → Plugins card). */
|
/** 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);
|
settings.installSection(ctx, SEARCH_MCP_SETTINGS_NAMESPACE, Config, config, hooks);
|
||||||
return;
|
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).
|
// DSH ≥0.1.7 / 0.2.0 — Config projection via describe() (no installSection).
|
||||||
const readLive = () => {
|
const readLive = () => {
|
||||||
const row = describeRows(settings?.describe).find((item) => item.ns === SEARCH_MCP_SETTINGS_NAMESPACE);
|
const row = describeRows(settings?.describe).find((item) => item.ns === SEARCH_MCP_SETTINGS_NAMESPACE);
|
||||||
|
|
@ -106,7 +132,7 @@ export function apply(ctx, config) {
|
||||||
}, 2_000);
|
}, 2_000);
|
||||||
settingsCtx.effect(() => () => clearInterval(timer), 'dsh-search-mcp: settings describe poll');
|
settingsCtx.effect(() => () => clearInterval(timer), 'dsh-search-mcp: settings describe poll');
|
||||||
settingsCtx.logger?.info?.(
|
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).
|
// `registerSearchProvider` owns its cleanup via ctx.effect (HMR/dispose safe).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dsh-search-mcp",
|
"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.",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ 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 RC2 dependencies stay pinned', 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.3');
|
||||||
|
|
||||||
for (const name of [
|
for (const name of [
|
||||||
'@deepseek-ai/dsh-api-remotes',
|
'@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-launch-environment',
|
||||||
'@deepseek-ai/dsh-settings',
|
'@deepseek-ai/dsh-settings',
|
||||||
'@deepseek-ai/dsh-web',
|
'@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.undici, '6.28.0');
|
||||||
assert.equal(pkg.dependencies['ipaddr.js'], '2.5.0');
|
assert.equal(pkg.dependencies['ipaddr.js'], '2.5.0');
|
||||||
assert.equal(pkg.dependencies['@modelcontextprotocol/sdk'], '1.30.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');
|
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.match(client, /name: "settings\.plugin\.item",\s+key: NS,/);
|
||||||
assert.doesNotMatch(client, /name: "settings\.plugin\.item",\s+id:/);
|
assert.doesNotMatch(client, /name: "settings\.plugin\.item",\s+id:/);
|
||||||
assert.match(client, /api\.settings\.describe\(\{\}\)/);
|
assert.match(client, /name: "plugins\.item"/);
|
||||||
assert.match(client, /api\.credentials\.describe\(\{ refs: refs\.slice\(index, index \+ CREDENTIAL_DESCRIBE_BATCH_SIZE\) \}\)/);
|
assert.match(client, /configForms/);
|
||||||
assert.match(client, /api\.credentials\.set\(\{ ref, value \}\)/);
|
assert.match(client, /whileServed/);
|
||||||
assert.match(client, /credentials\/reference-updated/);
|
assert.match(client, /credentials\/reference-updated/);
|
||||||
assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/);
|
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, /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 () => {
|
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.match(transport, /await client\.close\(\)[\s\S]*await runtime\?\.close\(\)/);
|
||||||
assert.doesNotMatch(transport, /new URL\(server\.url\)[\s\S]*new StreamableHTTPClientTransport\(url, \{\s*requestInit:/);
|
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 replaces built-in search and leaves default row endpoint-free', async () => {
|
||||||
const patch = await read('cordis.patch.yml');
|
const patch = await read('cordis.patch.yml');
|
||||||
assert.match(patch, /searchProvider: search-mcp/);
|
assert.match(patch, /searchProvider: search-mcp/);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue