dsh-mcp 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/oauth.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * OAuth 2.0 (authorization-code + PKCE) client provider for MCP servers,
3
+ * implemented for the dsh-mcp host half.
4
+ *
5
+ * The MCP SDK drives the flow (metadata discovery, dynamic client registration,
6
+ * PKCE, token refresh) and only asks this provider to: persist tokens, and
7
+ * redirect the user agent to the authorization URL. This provider opens the
8
+ * system browser, hosts a loopback callback server, exchanges the returned
9
+ * code for tokens via the SDK, and stores them in the credentials document
10
+ * (per server id), so reconnects reuse the tokens and the SDK refreshes them
11
+ * transparently.
12
+ */
13
+
14
+ import { createServer } from "node:http";
15
+ import { spawn } from "node:child_process";
16
+ import {
17
+ discoverAuthorizationServerMetadata,
18
+ exchangeAuthorization,
19
+ } from "@modelcontextprotocol/sdk/client/auth.js";
20
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
21
+
22
+ /** Credential reference namespace prefix for per-server OAuth tokens. */
23
+ const OAUTH_REF_PREFIX = "DSH_MCP_OAUTH_";
24
+
25
+ /** Short stable hash so sanitized server ids cannot collide. */
26
+ function shortHash(value) {
27
+ let hash = 0;
28
+ for (let i = 0; i < value.length; i += 1) {
29
+ hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
30
+ }
31
+ return hash.toString(36);
32
+ }
33
+
34
+ /**
35
+ * One server's OAuth token credential reference.
36
+ *
37
+ * Credential ref names must match /^[A-Za-z_][A-Za-z0-9_]*$/, but managed
38
+ * server ids may contain hyphens, slashes, spaces, etc.; sanitize the id and
39
+ * append a short stable hash of the original so distinct ids cannot collide
40
+ * (e.g. "a-b" and "a_b").
41
+ */
42
+ function oauthTokenRef(serverId) {
43
+ const safe = serverId.replace(/[^A-Za-z0-9_]/g, "_");
44
+ return credentialRef(`${OAUTH_REF_PREFIX}${safe}_${shortHash(serverId)}`);
45
+ }
46
+
47
+ /** Open the system browser to one URL, best effort. */
48
+ function openBrowser(url) {
49
+ const command =
50
+ process.platform === "darwin" ? "open"
51
+ : process.platform === "win32" ? "start"
52
+ : "xdg-open";
53
+ const child = spawn(command, [url], { detached: true, stdio: "ignore" });
54
+ child.on("error", () => {});
55
+ child.unref();
56
+ }
57
+
58
+ /**
59
+ * Build one OAuthClientProvider for a managed server.
60
+ * @param serverId - Managed server id; keys the stored token.
61
+ * @param ctx - Host context carrying the credentials service.
62
+ * @returns an MCP SDK `OAuthClientProvider` implementation.
63
+ */
64
+ export function createOAuthProvider(serverId, ctx) {
65
+ let codeVerifierValue = null;
66
+ let savedClientInfo = null;
67
+ const port = 3100 + Math.floor(Math.random() * 20000);
68
+ const redirectUrl = `http://127.0.0.1:${port}/callback`;
69
+ const log = ctx?.logger ?? console;
70
+ const tag = `mcp-oauth(${serverId})`;
71
+
72
+ return {
73
+ get redirectUrl() {
74
+ return redirectUrl;
75
+ },
76
+ clientMetadata: {
77
+ client_name: "dsh-mcp",
78
+ redirect_uris: [redirectUrl],
79
+ },
80
+ async clientInformation() {
81
+ return savedClientInfo;
82
+ },
83
+ async saveClientInformation(info) {
84
+ savedClientInfo = info;
85
+ },
86
+ async tokens() {
87
+ const hit = await ctx.credentials.resolve(oauthTokenRef(serverId));
88
+ if (hit === undefined) return undefined;
89
+ try {
90
+ return JSON.parse(hit.value);
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ },
95
+ async saveTokens(tokens) {
96
+ await ctx.credentials.set(oauthTokenRef(serverId), JSON.stringify(tokens));
97
+ },
98
+ async clearTokens() {
99
+ await ctx.credentials.unset(oauthTokenRef(serverId));
100
+ },
101
+ async saveCodeVerifier(codeVerifier) {
102
+ codeVerifierValue = codeVerifier;
103
+ },
104
+ async codeVerifier() {
105
+ return codeVerifierValue;
106
+ },
107
+ async redirectToAuthorization(authorizationUrl) {
108
+ let resolveResult;
109
+ const resultPromise = new Promise((resolve) => {
110
+ resolveResult = resolve;
111
+ });
112
+ const server = createServer((req, res) => {
113
+ const url = new URL(req.url, redirectUrl);
114
+ const code = url.searchParams.get("code");
115
+ const error = url.searchParams.get("error");
116
+ res.setHeader("content-type", "text/html; charset=utf-8");
117
+ if (error) {
118
+ res.end(`授权失败:${error},可关闭此页面并返回 DSH。`);
119
+ log.error(`${tag}: callback rejected: ${error}`);
120
+ resolveResult({ error });
121
+ return;
122
+ }
123
+ res.end("授权成功,可关闭此页面并返回 DSH。");
124
+ log.info(`${tag}: authorization callback received (code)`);
125
+ resolveResult({ code });
126
+ });
127
+ await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
128
+ log.info(`${tag}: opening browser for authorization: ${authorizationUrl}`);
129
+ openBrowser(authorizationUrl.toString());
130
+ try {
131
+ const result = await resultPromise;
132
+ if (result.error) throw new Error(`OAuth 授权失败:${result.error}`);
133
+ const authServerUrl = new URL(authorizationUrl.origin);
134
+ log.info(`${tag}: discovering authorization server metadata at ${authServerUrl}`);
135
+ const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
136
+ log.info(`${tag}: exchanging authorization code for tokens`);
137
+ const tokens = await exchangeAuthorization(authServerUrl, {
138
+ metadata,
139
+ clientInformation: savedClientInfo,
140
+ authorizationCode: result.code,
141
+ codeVerifier: codeVerifierValue,
142
+ redirectUri: redirectUrl,
143
+ });
144
+ await this.saveTokens(tokens);
145
+ log.info(`${tag}: tokens saved`);
146
+ } catch (error) {
147
+ log.error(`${tag}: OAuth exchange failed: ${error instanceof Error ? error.message : String(error)}`);
148
+ throw error;
149
+ } finally {
150
+ server.close();
151
+ log.info(`${tag}: callback server closed`);
152
+ }
153
+ },
154
+ };
155
+ }
package/lib/probe.js CHANGED
@@ -29,16 +29,33 @@ async function probeConnection(config, options = {}) {
29
29
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
30
30
  throw new Error(`probeConnection: timeoutMs must be a positive finite number, received ${String(timeoutMs)}`);
31
31
  }
32
+ // OAuth authorization is interactive (browser login), so a probe with an
33
+ // auth provider gets a generous budget; plain servers connect or fail
34
+ // immediately and never wait out the budget. 5 minutes covers slow SSO
35
+ // login + approval screens; an exhausted budget reports a failure view and
36
+ // the loopback callback keeps running, so a late authorization still stores
37
+ // the tokens and the next probe succeeds.
38
+ const OAUTH_PROBE_BUDGET_MS = 5 * 60e3;
39
+ const budgetMs = options.authProvider === void 0 ? timeoutMs : Math.max(timeoutMs, OAUTH_PROBE_BUDGET_MS);
32
40
  const client = new Client(
33
41
  { name: "dsh-mcp-client", version: "0.0.1" },
34
- { capabilities: {} }
42
+ {
43
+ capabilities: {},
44
+ ...(options.authProvider === void 0 ? {} : { authProvider: options.authProvider })
45
+ }
35
46
  );
36
47
  const timeout = new Promise((_, reject) => {
37
- const timer = setTimeout(() => reject(new Error(`connection probe timed out after ${timeoutMs}ms`)), timeoutMs);
48
+ const timer = setTimeout(() => reject(new Error(`connection probe timed out after ${budgetMs}ms`)), budgetMs);
38
49
  timer.unref();
39
50
  });
40
51
  try {
41
- return await Promise.race([runProbe(client, config), timeout]);
52
+ // The SDK performs OAuth in the transport layer, so the authProvider must
53
+ // reach the transport config (not only the Client options).
54
+ const transportConfig = {
55
+ ...config,
56
+ ...(options.authProvider === void 0 ? {} : { authProvider: options.authProvider })
57
+ };
58
+ return await Promise.race([runProbe(client, transportConfig), timeout]);
42
59
  } catch (error) {
43
60
  return { ok: false, message: probeErrorMessage(error) };
44
61
  } finally {
package/lib/transport.js CHANGED
@@ -16,7 +16,10 @@ function createTransport(config) {
16
16
  case "streamable-http":
17
17
  return new StreamableHTTPClientTransport(
18
18
  new URL(config.url),
19
- { requestInit: { headers: config.headers } }
19
+ {
20
+ requestInit: { headers: config.headers },
21
+ ...(config.authProvider === void 0 ? {} : { authProvider: config.authProvider })
22
+ }
20
23
  );
21
24
  }
22
25
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mcp",
3
- "version": "1.0.0",
4
- "description": "MCP server 管理插件(独立版):托管 MCP 服务器注册表(持久化定义、运行时挂载、环境变量注入、连接探测)+ Web 设置管理页。由 deepseek-harness 仓库内的 mcp-manager / ui-settings-mcp / web-mcp 迁移合并而来。MCP server registry with WebUI management for DeepSeek Harness.",
3
+ "version": "1.2.0",
4
+ "description": "MCP 管理界面 + tool search:可视化配置/启停/刷新 MCP 服务器与工具勾选,按需检索(tool search)热注入;工具列表稳定命中缓存、不撑爆上下文。DeepSeek Harness plugin: MCP management UI + tool search — stable tool list, cache-friendly, no context bloat.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -49,7 +49,7 @@ button:hover {
49
49
  }
50
50
 
51
51
  button:focus-visible {
52
- outline: 2px solid var(--dsw-alias-state-business-primary);
52
+ outline: 2px solid var(--dsw-alias-brand-primary);
53
53
  outline-offset: 1px;
54
54
  }
55
55
 
@@ -59,13 +59,13 @@ button:disabled {
59
59
  }
60
60
 
61
61
  .primary {
62
- border-color: var(--dsw-alias-state-business-primary);
63
- background: var(--dsw-alias-state-business-primary);
64
- color: var(--dsw-alias-label-on-accent);
62
+ border-color: var(--dsw-alias-button-primary-fill);
63
+ background: var(--dsw-alias-button-primary-fill);
64
+ color: var(--dsw-alias-label-primary-foreground);
65
65
  }
66
66
 
67
67
  .primary:hover {
68
- background: var(--dsw-alias-state-business-primary-hover);
68
+ background: var(--dsw-alias-button-primary-hover);
69
69
  }
70
70
 
71
71
  .list {
@@ -123,8 +123,8 @@ button:disabled {
123
123
  }
124
124
 
125
125
  .badge.mounting {
126
- background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
127
- color: var(--dsw-alias-state-business-primary);
126
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);
127
+ color: var(--dsw-alias-brand-primary);
128
128
  }
129
129
 
130
130
  .badge.live {
@@ -248,6 +248,14 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
248
248
  t={t}
249
249
  actions={actions}
250
250
  injected={{ save, remove, test, list }}
251
+ onSaved={() => {
252
+ // Mounting is asynchronous: refresh again after the connection
253
+ // settles so the badge and tool count reflect the live state without
254
+ // a manual refresh (mirrors the enable-toggle path).
255
+ const serverName = state.draft.serverName
256
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 2000))
257
+ timersRef.current.push(window.setTimeout(() => refreshServer(serverName), 6000))
258
+ }}
251
259
  />
252
260
  )
253
261
  }
@@ -298,7 +306,7 @@ export function McpSettingsSection(props: McpSettingsSectionProps): ReactNode {
298
306
  <div className={css.rowTitle}>
299
307
  <span className={css.serverName}>{server.serverName}</span>
300
308
  <span className={`${css.badge} ${css[server.status.phase]}`}>{t(phaseKey(server.status.phase))}</span>
301
- {!server.enabled ? <span className={css.muted}>{t('statusStopped')}</span> : null}
309
+ {!server.enabled && server.status.phase !== 'stopped' ? <span className={css.muted}>{t('statusStopped')}</span> : null}
302
310
  </div>
303
311
  <div className={css.rowMeta}>
304
312
  <span>{server.transport}</span>
@@ -43,7 +43,7 @@ button:hover {
43
43
  }
44
44
 
45
45
  button:focus-visible {
46
- outline: 2px solid var(--dsw-alias-state-business-primary);
46
+ outline: 2px solid var(--dsw-alias-brand-primary);
47
47
  outline-offset: 1px;
48
48
  }
49
49
 
@@ -53,13 +53,13 @@ button:disabled {
53
53
  }
54
54
 
55
55
  .primary {
56
- border-color: var(--dsw-alias-state-business-primary);
57
- background: var(--dsw-alias-state-business-primary);
58
- color: var(--dsw-alias-label-on-accent);
56
+ border-color: var(--dsw-alias-button-primary-fill);
57
+ background: var(--dsw-alias-button-primary-fill);
58
+ color: var(--dsw-alias-label-primary-foreground);
59
59
  }
60
60
 
61
61
  .primary:hover {
62
- background: var(--dsw-alias-state-business-primary-hover);
62
+ background: var(--dsw-alias-button-primary-hover);
63
63
  }
64
64
 
65
65
  .danger {
@@ -106,8 +106,8 @@ button:disabled {
106
106
  .field input:focus-visible,
107
107
  .field select:focus-visible,
108
108
  .field textarea:focus-visible {
109
- border-color: var(--dsw-alias-state-business-primary);
110
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
109
+ border-color: var(--dsw-alias-brand-primary);
110
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
111
111
  }
112
112
 
113
113
  .envBlock {
@@ -3,7 +3,7 @@ import type { McpServerId, McpServerView } from './types.ts'
3
3
  import type {
4
4
  McpDraft, McpEnvRowDraft, McpTestOutcome,
5
5
  } from './mcp-store.ts'
6
- import { createEnvRowDraft } from './mcp-store.ts'
6
+ import { createEnvRowDraft, failureLocaleKey } from './mcp-store.ts'
7
7
  import type { McpSettingsLocaleKey } from './locales.ts'
8
8
  import css from './ServerForm.module.css'
9
9
 
@@ -34,11 +34,13 @@ export interface McpServerFormProps {
34
34
  t: (key: McpSettingsLocaleKey) => string
35
35
  actions: McpServerFormActions
36
36
  injected: McpServerFormRemote
37
+ /** Called after a successful save, before the editor closes. */
38
+ onSaved?: () => void
37
39
  }
38
40
 
39
41
  /** Render the editor for one server draft. */
40
42
  export function McpServerForm(props: McpServerFormProps): ReactNode {
41
- const { draft, busy, testRunning, test, t, actions, injected } = props
43
+ const { draft, busy, testRunning, test, t, actions, injected, onSaved } = props
42
44
  const [saveError, setSaveError] = useState<string | null>(null)
43
45
 
44
46
  const update = (patch: Partial<McpDraft>): void => {
@@ -76,11 +78,12 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
76
78
  try {
77
79
  const failure = await injected.save(draft)
78
80
  if (failure !== null) {
79
- setSaveError(failure.message)
81
+ setSaveError(t(failureLocaleKey(failure.code)))
80
82
  return
81
83
  }
82
84
  await refresh()
83
85
  actions.cancelEdit()
86
+ onSaved?.()
84
87
  } catch {
85
88
  setSaveError(t('failureTitle'))
86
89
  } finally {
@@ -287,6 +290,10 @@ export function McpServerForm(props: McpServerFormProps): ReactNode {
287
290
  </p>
288
291
  ) : null}
289
292
 
293
+ {testRunning && draft.transport === 'streamable-http' ? (
294
+ <p className={css.muted} role="status">{t('testBrowserHint')}</p>
295
+ ) : null}
296
+
290
297
  <div className={css.actions}>
291
298
  <button type="button" disabled={testRunning || busy !== null} onClick={() => void runTest()}>
292
299
  {testRunning ? t('testRunning') : t('test')}
@@ -43,6 +43,7 @@ export const zh = {
43
43
  edit: '编辑',
44
44
  test: '测试连接',
45
45
  testRunning: '正在测试…',
46
+ testBrowserHint: '正在测试…若弹出浏览器授权页,请完成授权后返回,结果会自动刷新。',
46
47
  testOk: '连接成功',
47
48
  testFail: '连接失败',
48
49
  testUnknown: '未知结果',
@@ -120,6 +121,7 @@ export const en = {
120
121
  edit: 'Edit',
121
122
  test: 'Test connection',
122
123
  testRunning: 'Testing…',
124
+ testBrowserHint: 'Testing… if a browser authorization page opens, complete it and return; the result refreshes automatically.',
123
125
  testOk: 'Connection succeeded',
124
126
  testFail: 'Connection failed',
125
127
  testUnknown: 'Unknown result',
@@ -102,7 +102,9 @@ export function emptyDraft(): McpDraft {
102
102
  headersText: '',
103
103
  env: [],
104
104
  toolCallTimeoutMs: String(DEFAULT_TOOL_CALL_TIMEOUT_MS),
105
- failOnStartupError: false,
105
+ // Reject the mount by default when the startup connection fails, so a
106
+ // broken server never registers stale tools.
107
+ failOnStartupError: true,
106
108
  }
107
109
  }
108
110
 
Binary file