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/CHANGELOG.en.md +24 -0
- package/CHANGELOG.md +24 -0
- package/README.en.md +80 -7
- package/README.md +72 -6
- package/lib/client.js +32 -7
- package/lib/index.js +19 -19
- package/lib/mcp-client.js +733 -0
- package/lib/oauth.js +155 -0
- package/lib/probe.js +20 -3
- package/lib/transport.js +4 -1
- package/package.json +2 -2
- package/src/client/McpSettingsSection.module.css +7 -7
- package/src/client/McpSettingsSection.tsx +9 -1
- package/src/client/ServerForm.module.css +7 -7
- package/src/client/ServerForm.tsx +10 -3
- package/src/client/locales.ts +2 -0
- package/src/client/mcp-store.ts +3 -1
- package/static/snapshot.en.webp +0 -0
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
|
-
{
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
{
|
|
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.
|
|
4
|
-
"description": "MCP
|
|
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-
|
|
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-
|
|
63
|
-
background: var(--dsw-alias-
|
|
64
|
-
color: var(--dsw-alias-label-
|
|
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-
|
|
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-
|
|
127
|
-
color: var(--dsw-alias-
|
|
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-
|
|
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-
|
|
57
|
-
background: var(--dsw-alias-
|
|
58
|
-
color: var(--dsw-alias-label-
|
|
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-
|
|
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-
|
|
110
|
-
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-
|
|
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.
|
|
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')}
|
package/src/client/locales.ts
CHANGED
|
@@ -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',
|
package/src/client/mcp-store.ts
CHANGED
|
@@ -102,7 +102,9 @@ export function emptyDraft(): McpDraft {
|
|
|
102
102
|
headersText: '',
|
|
103
103
|
env: [],
|
|
104
104
|
toolCallTimeoutMs: String(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
|
105
|
-
|
|
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
|