dsh-mcp 1.3.0 → 1.5.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 CHANGED
@@ -22,6 +22,9 @@ import { credentialRef } from "@deepseek-ai/dsh-credentials";
22
22
  /** Credential reference namespace prefix for per-server OAuth tokens. */
23
23
  const OAUTH_REF_PREFIX = "DSH_MCP_OAUTH_";
24
24
 
25
+ /** Credential reference namespace prefix for per-server OAuth client info. */
26
+ const OAUTH_CLIENT_REF_PREFIX = "DSH_MCP_OAUTH_CLIENT_";
27
+
25
28
  /** Short stable hash so sanitized server ids cannot collide. */
26
29
  function shortHash(value) {
27
30
  let hash = 0;
@@ -32,16 +35,24 @@ function shortHash(value) {
32
35
  }
33
36
 
34
37
  /**
35
- * One server's OAuth token credential reference.
36
- *
37
38
  * Credential ref names must match /^[A-Za-z_][A-Za-z0-9_]*$/, but managed
38
39
  * server ids may contain hyphens, slashes, spaces, etc.; sanitize the id and
39
40
  * append a short stable hash of the original so distinct ids cannot collide
40
41
  * (e.g. "a-b" and "a_b").
41
42
  */
42
- function oauthTokenRef(serverId) {
43
+ function oauthRef(prefix, serverId) {
43
44
  const safe = serverId.replace(/[^A-Za-z0-9_]/g, "_");
44
- return credentialRef(`${OAUTH_REF_PREFIX}${safe}_${shortHash(serverId)}`);
45
+ return credentialRef(`${prefix}${safe}_${shortHash(serverId)}`);
46
+ }
47
+
48
+ /** One server's OAuth token credential reference. */
49
+ function oauthTokenRef(serverId) {
50
+ return oauthRef(OAUTH_REF_PREFIX, serverId);
51
+ }
52
+
53
+ /** One server's registered OAuth client credential reference. */
54
+ function oauthClientRef(serverId) {
55
+ return oauthRef(OAUTH_CLIENT_REF_PREFIX, serverId);
45
56
  }
46
57
 
47
58
  /** Open the system browser to one URL, best effort. */
@@ -55,6 +66,54 @@ function openBrowser(url) {
55
66
  child.unref();
56
67
  }
57
68
 
69
+ /**
70
+ * Extract the `exp` claim from a JWT access token, when it is a JWT.
71
+ * @param accessToken - The access token string.
72
+ * @returns the expiry epoch seconds, or undefined when not a JWT / no exp.
73
+ */
74
+ function jwtExp(accessToken) {
75
+ try {
76
+ const payload = JSON.parse(
77
+ Buffer.from(String(accessToken).split(".")[1] ?? "", "base64url").toString("utf8")
78
+ );
79
+ return typeof payload.exp === "number" ? payload.exp : undefined;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Derive a stable loopback port from the server id. The redirect URI must
87
+ * stay identical across processes because the registered OAuth client's
88
+ * redirect_uris are fixed at registration time; a random port per process
89
+ * would make the authorization server reject the redirect with
90
+ * `redirect_uri_mismatch`.
91
+ * @param serverId - Managed server id.
92
+ * @returns a stable port in [3100, 23099].
93
+ */
94
+ function stablePort(serverId) {
95
+ let hash = 0;
96
+ for (let i = 0; i < serverId.length; i += 1) {
97
+ hash = (hash * 31 + serverId.charCodeAt(i)) >>> 0;
98
+ }
99
+ return 3100 + (hash % 20000);
100
+ }
101
+
102
+ /**
103
+ * Serialize authorization flows per server: the loopback port is stable per
104
+ * server, so two concurrent flows (e.g. mount and a test connection) would
105
+ * both try to listen on the same port and one fails with EADDRINUSE. Queue
106
+ * them so only one browser authorization runs at a time per server.
107
+ */
108
+ const authLocks = /* @__PURE__ */ new Map();
109
+
110
+ function withAuthLock(serverId, fn) {
111
+ const previous = authLocks.get(serverId) ?? Promise.resolve();
112
+ const run = previous.then(fn, fn);
113
+ authLocks.set(serverId, run.then(() => {}, () => {}));
114
+ return run;
115
+ }
116
+
58
117
  /**
59
118
  * Build one OAuthClientProvider for a managed server.
60
119
  * @param serverId - Managed server id; keys the stored token.
@@ -64,7 +123,7 @@ function openBrowser(url) {
64
123
  export function createOAuthProvider(serverId, ctx) {
65
124
  let codeVerifierValue = null;
66
125
  let savedClientInfo = null;
67
- const port = 3100 + Math.floor(Math.random() * 20000);
126
+ const port = stablePort(serverId);
68
127
  const redirectUrl = `http://127.0.0.1:${port}/callback`;
69
128
  const log = ctx?.logger ?? console;
70
129
  const tag = `mcp-oauth(${serverId})`;
@@ -78,19 +137,70 @@ export function createOAuthProvider(serverId, ctx) {
78
137
  redirect_uris: [redirectUrl],
79
138
  },
80
139
  async clientInformation() {
81
- return savedClientInfo;
140
+ if (savedClientInfo !== null) return savedClientInfo;
141
+ const hit = await ctx.credentials.resolve(oauthClientRef(serverId));
142
+ if (hit === undefined) return undefined;
143
+ try {
144
+ const info = JSON.parse(hit.value);
145
+ // The persisted client's registered redirect_uris must cover this
146
+ // loopback URI; otherwise the authorization server rejects the
147
+ // redirect with `redirect_uri_mismatch`. Drop it so the SDK
148
+ // re-registers a client for the current (stable) redirect URI.
149
+ if (!Array.isArray(info.redirect_uris) || !info.redirect_uris.includes(redirectUrl)) {
150
+ log.info(`${tag}: persisted client redirect_uris mismatch, re-registering`);
151
+ return undefined;
152
+ }
153
+ return info;
154
+ } catch {
155
+ return undefined;
156
+ }
82
157
  },
83
158
  async saveClientInformation(info) {
84
159
  savedClientInfo = info;
160
+ // Persist the registered client (client_id) with the tokens so a later
161
+ // token refresh uses the same client. Without this, every process
162
+ // re-registers a fresh client and the server rejects refresh with
163
+ // "client_id mismatch".
164
+ if (info === undefined || info === null) {
165
+ await ctx.credentials.unset(oauthClientRef(serverId));
166
+ } else {
167
+ await ctx.credentials.set(oauthClientRef(serverId), JSON.stringify(info));
168
+ }
169
+ },
170
+ async invalidateCredentials(credentialType) {
171
+ // The SDK calls this when a refresh/registration fails with an
172
+ // unrecoverable error (InvalidClient / UnauthorizedClient / InvalidGrant)
173
+ // so the next attempt starts a fresh authorization flow. Clearing the
174
+ // stored tokens is what lets that flow actually open the browser again.
175
+ if (credentialType === undefined || credentialType === "all" || credentialType === "tokens") {
176
+ await ctx.credentials.unset(oauthTokenRef(serverId));
177
+ }
178
+ if (credentialType === undefined || credentialType === "all") {
179
+ savedClientInfo = null;
180
+ await ctx.credentials.unset(oauthClientRef(serverId));
181
+ }
182
+ log.info(`${tag}: credentials invalidated (${String(credentialType)})`);
85
183
  },
86
184
  async tokens() {
87
185
  const hit = await ctx.credentials.resolve(oauthTokenRef(serverId));
88
186
  if (hit === undefined) return undefined;
187
+ let tokens;
89
188
  try {
90
- return JSON.parse(hit.value);
189
+ tokens = JSON.parse(hit.value);
91
190
  } catch {
92
191
  return undefined;
93
192
  }
193
+ // An expired access token makes the SDK try refresh; if the refresh
194
+ // token is also dead (short-lived servers), the SDK throws
195
+ // InvalidTokenError and never re-authorizes. Pre-expire it here so the
196
+ // SDK falls through to a fresh browser authorization flow instead.
197
+ const exp = jwtExp(tokens.access_token);
198
+ if (exp !== undefined && Date.now() / 1000 >= exp) {
199
+ log.info(`${tag}: stored access token expired, clearing for re-authorization`);
200
+ await ctx.credentials.unset(oauthTokenRef(serverId));
201
+ return undefined;
202
+ }
203
+ return tokens;
94
204
  },
95
205
  async saveTokens(tokens) {
96
206
  await ctx.credentials.set(oauthTokenRef(serverId), JSON.stringify(tokens));
@@ -105,51 +215,61 @@ export function createOAuthProvider(serverId, ctx) {
105
215
  return codeVerifierValue;
106
216
  },
107
217
  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;
218
+ // Serialize per server: the loopback port is stable, so concurrent
219
+ // flows (mount + test) would collide on the same port. Queue them.
220
+ return withAuthLock(serverId, async () => {
221
+ let resolveResult;
222
+ const resultPromise = new Promise((resolve) => {
223
+ resolveResult = resolve;
224
+ });
225
+ const server = createServer((req, res) => {
226
+ const url = new URL(req.url, redirectUrl);
227
+ const code = url.searchParams.get("code");
228
+ const error = url.searchParams.get("error");
229
+ res.setHeader("content-type", "text/html; charset=utf-8");
230
+ if (error) {
231
+ res.end(`授权失败:${error},可关闭此页面并返回 DSH。`);
232
+ log.error(`${tag}: callback rejected: ${error}`);
233
+ resolveResult({ error });
234
+ return;
235
+ }
236
+ res.end("授权成功,可关闭此页面并返回 DSH。");
237
+ log.info(`${tag}: authorization callback received (code)`);
238
+ resolveResult({ code });
239
+ });
240
+ await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));
241
+ log.info(`${tag}: opening browser for authorization: ${authorizationUrl}`);
242
+ openBrowser(authorizationUrl.toString());
243
+ try {
244
+ const result = await resultPromise;
245
+ if (result.error) throw new Error(`OAuth 授权失败:${result.error}`);
246
+ const authServerUrl = new URL(authorizationUrl.origin);
247
+ log.info(`${tag}: discovering authorization server metadata at ${authServerUrl}`);
248
+ const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
249
+ log.info(`${tag}: exchanging authorization code for tokens`);
250
+ // Use the provider accessors, not the closure fields: when the
251
+ // client was loaded from persistence the SDK never calls
252
+ // saveClientInformation, so the closure copy is null and the token
253
+ // request would lack client_id (CAS: "... are required").
254
+ const clientInformation = await this.clientInformation();
255
+ const codeVerifier = await this.codeVerifier();
256
+ const tokens = await exchangeAuthorization(authServerUrl, {
257
+ metadata,
258
+ clientInformation,
259
+ authorizationCode: result.code,
260
+ codeVerifier,
261
+ redirectUri: redirectUrl,
262
+ });
263
+ await this.saveTokens(tokens);
264
+ log.info(`${tag}: tokens saved`);
265
+ } catch (error) {
266
+ log.error(`${tag}: OAuth exchange failed: ${error instanceof Error ? error.message : String(error)}`);
267
+ throw error;
268
+ } finally {
269
+ server.close();
270
+ log.info(`${tag}: callback server closed`);
122
271
  }
123
- res.end("授权成功,可关闭此页面并返回 DSH。");
124
- log.info(`${tag}: authorization callback received (code)`);
125
- resolveResult({ code });
126
272
  });
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
273
  },
154
274
  };
155
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
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",
@@ -0,0 +1,204 @@
1
+ .panel {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 8px;
5
+ border: 1px solid var(--dsw-alias-border-l2);
6
+ border-radius: 10px;
7
+ padding: 10px 14px;
8
+ background: var(--dsw-alias-bg-layer-3);
9
+ }
10
+
11
+ .hint,
12
+ .muted {
13
+ margin: 0;
14
+ font-size: 12px;
15
+ line-height: 18px;
16
+ color: var(--dsw-alias-label-tertiary);
17
+ }
18
+
19
+ .row {
20
+ display: grid;
21
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.8fr) auto auto;
22
+ align-items: center;
23
+ gap: 8px;
24
+ }
25
+
26
+ .row input {
27
+ box-sizing: border-box;
28
+ border: 1px solid var(--dsw-alias-border-l2);
29
+ border-radius: 6px;
30
+ padding: 5px 8px;
31
+ outline: none;
32
+ background: var(--dsw-alias-bg-layer-1);
33
+ color: var(--dsw-alias-label-primary);
34
+ font: inherit;
35
+ font-size: 13px;
36
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
37
+ }
38
+
39
+ .row input:focus-visible {
40
+ border-color: var(--dsw-alias-brand-primary);
41
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
42
+ }
43
+
44
+ .name {
45
+ width: 100%;
46
+ }
47
+
48
+ .value {
49
+ width: 100%;
50
+ }
51
+
52
+ .secretToggle {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 4px;
56
+ color: var(--dsw-alias-label-secondary);
57
+ font-size: 12px;
58
+ line-height: 18px;
59
+ white-space: nowrap;
60
+ cursor: pointer;
61
+ user-select: none;
62
+ }
63
+
64
+ .secretToggle input {
65
+ cursor: pointer;
66
+ }
67
+
68
+ .secretToggle.secretOn {
69
+ color: var(--dsw-alias-brand-primary);
70
+ font-weight: 600;
71
+ }
72
+
73
+ .danger {
74
+ border: 1px solid transparent !important;
75
+ background: transparent !important;
76
+ color: var(--dsw-alias-state-error-primary) !important;
77
+ cursor: pointer;
78
+ }
79
+
80
+ .overlay {
81
+ position: fixed;
82
+ inset: 0;
83
+ z-index: 1000;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ background: rgba(0, 0, 0, 0.4);
88
+ }
89
+
90
+ .dialog {
91
+ display: flex;
92
+ flex-direction: column;
93
+ gap: 8px;
94
+ width: min(520px, calc(100vw - 48px));
95
+ max-height: 80vh;
96
+ border: 1px solid var(--dsw-alias-border-l2);
97
+ border-radius: 12px;
98
+ padding: 16px;
99
+ background: var(--dsw-alias-bg-overlay);
100
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
101
+ }
102
+
103
+ .dialogTitle {
104
+ margin: 0;
105
+ font-size: 15px;
106
+ line-height: 22px;
107
+ font-weight: 600;
108
+ color: var(--dsw-alias-label-primary);
109
+ }
110
+
111
+ .bulkText {
112
+ width: 100%;
113
+ box-sizing: border-box;
114
+ border: 1px solid var(--dsw-alias-border-l2);
115
+ border-radius: 8px;
116
+ padding: 8px 10px;
117
+ outline: none;
118
+ background: var(--dsw-alias-bg-layer-1);
119
+ color: var(--dsw-alias-label-primary);
120
+ font: inherit;
121
+ font-size: 12px;
122
+ line-height: 18px;
123
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
124
+ resize: vertical;
125
+ }
126
+
127
+ .bulkText:focus-visible {
128
+ border-color: var(--dsw-alias-brand-primary);
129
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);
130
+ }
131
+
132
+ .retryRow {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 10px;
136
+ }
137
+
138
+ .retryRow button {
139
+ border: 1px solid var(--dsw-alias-border-l2);
140
+ border-radius: 6px;
141
+ padding: 3px 10px;
142
+ background: transparent;
143
+ color: var(--dsw-alias-label-primary);
144
+ font: inherit;
145
+ font-size: 12px;
146
+ line-height: 18px;
147
+ cursor: pointer;
148
+ }
149
+
150
+ .retryRow button:hover {
151
+ background: var(--dsw-alias-interactive-bg-hover);
152
+ }
153
+
154
+ .error {
155
+ margin: 0;
156
+ color: var(--dsw-alias-state-error-primary);
157
+ font-size: 12px;
158
+ line-height: 18px;
159
+ word-break: break-all;
160
+ }
161
+
162
+ .notice {
163
+ margin: 0;
164
+ color: var(--dsw-alias-state-success-primary);
165
+ font-size: 12px;
166
+ line-height: 18px;
167
+ }
168
+
169
+ .actions {
170
+ display: flex;
171
+ justify-content: flex-end;
172
+ gap: 8px;
173
+ }
174
+
175
+ .actions button {
176
+ border: 1px solid var(--dsw-alias-border-l2);
177
+ border-radius: 6px;
178
+ padding: 5px 12px;
179
+ background: transparent;
180
+ color: var(--dsw-alias-label-primary);
181
+ font: inherit;
182
+ font-size: 13px;
183
+ line-height: 20px;
184
+ cursor: pointer;
185
+ }
186
+
187
+ .actions button:hover {
188
+ background: var(--dsw-alias-interactive-bg-hover);
189
+ }
190
+
191
+ .actions button:disabled {
192
+ cursor: not-allowed;
193
+ opacity: 0.55;
194
+ }
195
+
196
+ .actions .primary {
197
+ border-color: var(--dsw-alias-button-primary-fill);
198
+ background: var(--dsw-alias-button-primary-fill);
199
+ color: var(--dsw-alias-label-primary-foreground);
200
+ }
201
+
202
+ .actions .primary:hover {
203
+ background: var(--dsw-alias-button-primary-hover);
204
+ }