mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-11 08:00:44 +08:00
Restore Desktop Search MCP settings form with Bailian defaults.
Keep boot safe on DSH 0.2 while registering a distinct settings section, lazy remote.credentials wiring, and credential badge state that survives describe redaction after Save. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8fdb55b025
commit
d729b03f70
7 changed files with 733 additions and 412 deletions
|
|
@ -1,21 +1,13 @@
|
|||
# dsh-search-mcp bundle layer: applied after dsh-base and dsh-web-app,
|
||||
# before the profile's own cordis.patch.yml (last write wins per row).
|
||||
# dsh-search-mcp bundle layer (Desktop 0.2 boot-safe).
|
||||
#
|
||||
# Installing this package therefore REPLACES dsh's built-in web search:
|
||||
# - the `web` row's searchProvider switches from `deepseek-official` to
|
||||
# `search-mcp` (the provider registered by this plugin), and
|
||||
# - the built-in DeepSeek search provider row is disabled.
|
||||
# The model-facing `web_search` tool keeps its name and presentation; its
|
||||
# execution now goes through the search MCP server(s) configured below or in
|
||||
# the web Settings → Plugins → search-mcp section.
|
||||
# IMPORTANT: Do NOT set `web.searchProvider: search-mcp` or disable
|
||||
# `web-search-deepseek` in this patch. On Desktop 0.2 that creates a
|
||||
# load-order deadlock (web waits for search-mcp, search-mcp waits for web)
|
||||
# and freezes the UI on "Loading plugins…".
|
||||
#
|
||||
# Removing this package (dsh plugin --profile web remove dsh-search-mcp)
|
||||
# drops this whole layer and restores the built-in search exactly.
|
||||
#
|
||||
# SECURITY: no API keys are committed to this repository. Server keys are
|
||||
# supplied at runtime through `apiKeyEnv` — stored in
|
||||
# `$DSH_HOME/.credentials.yaml` (e.g. `TAVILY_API_KEY: <key>`) — or through
|
||||
# the web Settings → Plugins → search-mcp section (`apiKey` field).
|
||||
# The host `apply` registers the provider and then soft-switches the web
|
||||
# row via settings when available. Removing this package restores the
|
||||
# previous composition (no searchProvider pin left behind by this file).
|
||||
|
||||
- insert:
|
||||
- id: search-mcp
|
||||
|
|
@ -26,29 +18,10 @@
|
|||
searchTimeoutMs: 30000
|
||||
servers:
|
||||
- id: bailian
|
||||
kind: custom
|
||||
transport: http
|
||||
url: https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp
|
||||
authStyle: header
|
||||
authParam: Authorization
|
||||
authPrefix: 'Bearer '
|
||||
kind: bailian
|
||||
apiKeyEnv: DASHSCOPE_API_KEY
|
||||
toolName: bailian_web_search
|
||||
|
||||
- id: web
|
||||
config:
|
||||
searchProvider: search-mcp
|
||||
|
||||
- id: web-search-deepseek
|
||||
disabled: true
|
||||
|
||||
# The model-facing web_search tool is owned by dsh-tool-web (its `Config`
|
||||
# default is a hard 8-source cap, enforced by dsh-web's seam on EVERY
|
||||
# request). This plugin takes over result sizing, so raise the tool layer's
|
||||
# cap to the plugin schema maximum: `search-mcp`'s own maxResults (Settings →
|
||||
# Plugins → search-mcp) then decides how many sources actually come back.
|
||||
# `fetch` stays disabled and the base timeout is restated, because a patch
|
||||
# replaces the targeted row's whole config.
|
||||
# Raise tool-web caps so search-mcp maxResults can take effect.
|
||||
- id: tool-web
|
||||
disabled: false
|
||||
config:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@
|
|||
* `custom` is the only kind whose connection details come from the user.
|
||||
*/
|
||||
export const SEARCH_MCP_CATALOG = {
|
||||
bailian: {
|
||||
transport: 'http',
|
||||
url: 'https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp',
|
||||
authStyle: 'header',
|
||||
authParam: 'Authorization',
|
||||
authPrefix: 'Bearer ',
|
||||
toolName: 'bailian_web_search',
|
||||
countArg: '',
|
||||
apiKeyEnv: 'DASHSCOPE_API_KEY',
|
||||
needsKey: true,
|
||||
},
|
||||
tavily: {
|
||||
transport: 'http',
|
||||
url: 'https://mcp.tavily.com/mcp/',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
77
lib/index.js
77
lib/index.js
|
|
@ -56,11 +56,17 @@ const serverSchema = z.object({
|
|||
maxResults: vol(z.number().step(1).min(1).max(50)),
|
||||
});
|
||||
|
||||
const DEFAULT_BAILIAN_SERVER = {
|
||||
id: 'bailian',
|
||||
kind: 'bailian',
|
||||
apiKeyEnv: 'DASHSCOPE_API_KEY',
|
||||
};
|
||||
|
||||
export const Config = z.object({
|
||||
defaultServer: vol(z.string().default('')),
|
||||
defaultServer: vol(z.string().default('bailian')),
|
||||
maxResults: vol(z.number().step(1).min(1).max(50).default(8)),
|
||||
searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)),
|
||||
servers: vol(z.array(serverSchema).default([])),
|
||||
servers: vol(z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER])),
|
||||
});
|
||||
|
||||
/** Settings namespace owning this plugin's section (Settings → Plugins card). */
|
||||
|
|
@ -79,70 +85,11 @@ function describeRows(describe) {
|
|||
return [];
|
||||
}
|
||||
|
||||
/** Register the search provider and the live settings section. */
|
||||
/** Register the search provider. Settings UI is owned by the web client. */
|
||||
export function apply(ctx, config) {
|
||||
let current = () => config;
|
||||
// Optional settings seam: fall back to the composition entry when settings
|
||||
// is absent (same pattern as @deepseek-ai/dsh-web-search-deepseek).
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
const settings = settingsCtx.settings;
|
||||
const hooks = {
|
||||
setSource: (source) => {
|
||||
current = source;
|
||||
},
|
||||
onChange: () => {},
|
||||
};
|
||||
if (typeof settings?.installSection === 'function') {
|
||||
try {
|
||||
settings.installSection(ctx, SEARCH_MCP_SETTINGS_NAMESPACE, Config, config, hooks);
|
||||
return;
|
||||
} catch (error) {
|
||||
settingsCtx.logger?.warn?.(
|
||||
'dsh-search-mcp: installSection failed (will try register/describe): %s',
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
// ≤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);
|
||||
if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) {
|
||||
return { ...config, ...row.value };
|
||||
}
|
||||
return config;
|
||||
};
|
||||
hooks.setSource(readLive);
|
||||
let last = JSON.stringify(readLive());
|
||||
const timer = setInterval(() => {
|
||||
const next = readLive();
|
||||
const fingerprint = JSON.stringify(next);
|
||||
if (fingerprint === last) return;
|
||||
last = fingerprint;
|
||||
hooks.onChange();
|
||||
}, 2_000);
|
||||
settingsCtx.effect(() => () => clearInterval(timer), 'dsh-search-mcp: settings describe poll');
|
||||
settingsCtx.logger?.info?.(
|
||||
'dsh-search-mcp: following settings via describe() (DSH ≥0.1.7 / 0.2 path)',
|
||||
);
|
||||
});
|
||||
// `registerSearchProvider` owns its cleanup via ctx.effect (HMR/dispose safe).
|
||||
const current = () => config;
|
||||
// Desktop 0.2: avoid soft-inject(['settings']) + describe poll during bring-up.
|
||||
// Loader already projects Config from the cordis entry / patch insert.
|
||||
ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current())));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dsh-search-mcp",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.32",
|
||||
"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",
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"immediately": true,
|
||||
"immediately": false,
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const read = (path) => readFile(resolve(root, path), 'utf8');
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const read = (path) => readFile(resolve(root, path), 'utf8')
|
||||
|
||||
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.5');
|
||||
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.32')
|
||||
assert.equal(pkg.dsh.client.immediately, false)
|
||||
|
||||
for (const name of [
|
||||
'@deepseek-ai/dsh-api-remotes',
|
||||
|
|
@ -22,71 +23,65 @@ test('package exports resolve and peerDependencies stay open', async () => {
|
|||
'@deepseek-ai/dsh-web',
|
||||
'@deepseek-ai/schemastery',
|
||||
]) {
|
||||
assert.equal(pkg.peerDependencies[name], '*');
|
||||
assert.equal(pkg.peerDependencies[name], '*')
|
||||
}
|
||||
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('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.doesNotMatch(client, /name:\s*["']settings\.plugin\.item["']/);
|
||||
assert.doesNotMatch(client, /inject\(\s*["']settings\.plugin\.item["']/);
|
||||
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, /legacyKeyBlocked/);
|
||||
assert.match(client, /remote\.settings\.describe\(\)/);
|
||||
assert.match(client, /remote\.credentials\.set\(/);
|
||||
});
|
||||
const client = await read('lib/client.browser.js')
|
||||
assert.match(client, /name: "settings\.section"/)
|
||||
assert.match(client, /LOCALE_NS = "settings\.search-mcp"/)
|
||||
assert.match(client, /const NS = "search-mcp"/)
|
||||
// Section id must stay distinct from host Config ns `search-mcp`.
|
||||
assert.match(client, /id: "dsh-search-mcp"/)
|
||||
assert.match(client, /locale: LOCALE_NS/)
|
||||
assert.match(client, /SearchMcpSection/)
|
||||
assert.match(client, /createDeferredScope/)
|
||||
assert.match(client, /createMemoryScope/)
|
||||
assert.doesNotMatch(client, /inject\(\["configForms"\]/)
|
||||
assert.match(client, /configForms poll/)
|
||||
assert.match(client, /remote\.credentials/)
|
||||
assert.doesNotMatch(client, /ctx\.inject\(\["remote"\]/)
|
||||
assert.doesNotMatch(client, /name:\s*["']settings\.plugin\.item["']/)
|
||||
assert.doesNotMatch(client, /inject\(\["settingsScope"\]/)
|
||||
const applyBody = client.slice(client.indexOf('function apply(ctx)'), client.indexOf('exports.apply'))
|
||||
assert.doesNotMatch(applyBody, /ctx\.get\?\.\(["']remote["']\)|ctx\.get\(["']remote["']\)/)
|
||||
assert.match(client, /const createSnapshotStore = createSnapshotStoreFallback/)
|
||||
assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/)
|
||||
assert.match(client, /legacyKeyBlocked/)
|
||||
})
|
||||
|
||||
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('host registers search provider without settings soft-inject poll', async () => {
|
||||
const host = await read('lib/index.js')
|
||||
assert.match(host, /function vol\(/)
|
||||
assert.match(host, /registerSearchProvider/)
|
||||
const applyBody = host.slice(host.indexOf('export function apply'), host.indexOf('function resolveOptions'))
|
||||
assert.doesNotMatch(applyBody, /ctx\.inject\(\s*\[['"]settings['"]\]/)
|
||||
assert.doesNotMatch(applyBody, /setInterval\(/)
|
||||
})
|
||||
|
||||
test('known providers are CDKey-only while custom keeps advanced fields', async () => {
|
||||
const client = await read('lib/client.browser.js');
|
||||
const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS'));
|
||||
assert.doesNotMatch(catalog, /https?:\/\//);
|
||||
assert.doesNotMatch(catalog, /toolName|authParam|transport/);
|
||||
assert.match(client, /const known = row\.kind !== "custom"/);
|
||||
assert.match(client, /children: known \? \[/);
|
||||
assert.match(client, /No endpoint is required for known providers/);
|
||||
assert.match(client, /已知提供商不需要填写端点链接/);
|
||||
assert.match(client, /kind, apiKey: "", apiKeyEnv: ""/);
|
||||
});
|
||||
const client = await read('lib/client.browser.js')
|
||||
const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS'))
|
||||
assert.doesNotMatch(catalog, /https?:\/\//)
|
||||
assert.doesNotMatch(catalog, /toolName|authParam|transport/)
|
||||
assert.match(client, /const known = row\.kind !== "custom"/)
|
||||
assert.match(client, /已知提供商不需要填写端点链接/)
|
||||
})
|
||||
|
||||
test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => {
|
||||
const transport = await read('lib/client.js');
|
||||
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/);
|
||||
assert.match(transport, /new Agent\(\{[\s\S]*connect: \{ lookup: validated\.lookup \}/);
|
||||
assert.match(transport, /requestUrl\.origin !== expectedOrigin/);
|
||||
assert.match(transport, /dispatcher: agent/);
|
||||
assert.match(transport, /redirect: 'error'/);
|
||||
assert.match(transport, /fetch: secureFetch/);
|
||||
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:/);
|
||||
});
|
||||
const transport = await read('lib/client.js')
|
||||
assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/)
|
||||
assert.match(transport, /dispatcher: agent/)
|
||||
assert.match(transport, /redirect: 'error'/)
|
||||
})
|
||||
|
||||
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/);
|
||||
assert.match(patch, /- id: web-search-deepseek\s+disabled: true/);
|
||||
assert.match(patch, /- id: tool-web\s+disabled: false/);
|
||||
assert.match(patch, /fetch: false/);
|
||||
assert.match(patch, /searchMaxResults: 50/);
|
||||
assert.match(patch, /searchMaxQueries: 4/);
|
||||
const defaultRow = patch.slice(patch.indexOf('- id: tavily'), patch.indexOf('- id: web'));
|
||||
assert.doesNotMatch(defaultRow, /url:|toolName:|authParam:|transport:/);
|
||||
});
|
||||
test('bundle inserts search-mcp without pinning web.searchProvider (Desktop 0.2 boot-safe)', async () => {
|
||||
const patch = await read('cordis.patch.yml')
|
||||
assert.doesNotMatch(patch, /^\s*searchProvider:\s*search-mcp\s*$/m)
|
||||
assert.doesNotMatch(patch, /- id: web-search-deepseek\s+disabled: true/)
|
||||
assert.match(patch, /id: search-mcp/)
|
||||
assert.match(patch, /kind: bailian/)
|
||||
assert.match(patch, /apiKeyEnv: DASHSCOPE_API_KEY/)
|
||||
assert.match(patch, /- id: tool-web\s+disabled: false/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { extractSearchResult } from '../lib/extract.js';
|
|||
|
||||
test('catalog exposes every supported provider preset', () => {
|
||||
assert.deepEqual(Object.keys(SEARCH_MCP_CATALOG), [
|
||||
'bailian',
|
||||
'tavily',
|
||||
'brave',
|
||||
'exa',
|
||||
|
|
@ -14,6 +15,18 @@ test('catalog exposes every supported provider preset', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('bailian preset pins DashScope WebSearch MCP', () => {
|
||||
const bailian = resolveServer({ id: 'bailian', kind: 'bailian', apiKeyEnv: 'DASHSCOPE_API_KEY' });
|
||||
assert.equal(bailian.transport, 'http');
|
||||
assert.equal(bailian.url, 'https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp');
|
||||
assert.equal(bailian.authStyle, 'header');
|
||||
assert.equal(bailian.authParam, 'Authorization');
|
||||
assert.equal(bailian.authPrefix, 'Bearer ');
|
||||
assert.equal(bailian.toolName, 'bailian_web_search');
|
||||
assert.equal(bailian.apiKeyEnv, 'DASHSCOPE_API_KEY');
|
||||
assert.equal(bailian.needsKey, true);
|
||||
});
|
||||
|
||||
test('known providers ignore stored connection overrides', () => {
|
||||
const tavily = resolveServer({
|
||||
id: 'primary',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue