dsh-oauth-mcp 0.0.1 → 0.0.2

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/index.js CHANGED
@@ -825,5 +825,12 @@ export async function apply(ctx, config) {
825
825
  } catch (error) {
826
826
  logger?.warn?.('dsh-oauth-mcp: the tool registry did not dispose cleanly', { error: String(error?.message ?? error) })
827
827
  }
828
+ try {
829
+ // The pending authorizations hold a ref'd timer; they must be given up
830
+ // when the plugin goes away, or the timer keeps the process alive.
831
+ service.dispose()
832
+ } catch (error) {
833
+ logger?.warn?.('dsh-oauth-mcp: the pending authorizations did not release cleanly', { error: String(error?.message ?? error) })
834
+ }
828
835
  }
829
836
  }
package/lib/build-info.js CHANGED
@@ -12,13 +12,13 @@ import { createTranslator } from './i18n/index.js'
12
12
  export const PLUGIN_ID = 'dsh-oauth-mcp'
13
13
 
14
14
  /** The version of the plugin. It is reported to every server it talks to. */
15
- export const PLUGIN_VERSION = '0.0.1'
15
+ export const PLUGIN_VERSION = '0.0.2'
16
16
 
17
17
  /**
18
18
  * Short marker of the current source revision. Bump it whenever the behaviour
19
19
  * of the user facing surfaces (commands, tools, panel, callback pages) changes.
20
20
  */
21
- export const BUILD_STAMP = '2026-11-08-first-npm-release'
21
+ export const BUILD_STAMP = '2026-11-08-release-timer-fix'
22
22
 
23
23
  /**
24
24
  * One line that names the version and the build of the running code.
@@ -160,71 +160,76 @@ export function createHttpClient({ timeoutMs = 15_000, fetchImpl = globalThis.fe
160
160
  const startedAt = now()
161
161
  let response
162
162
  try {
163
- response = await fetchImpl(target, {
164
- method,
165
- headers: requestHeaders,
166
- body,
167
- redirect: 'manual',
168
- signal: combined.signal,
169
- })
170
- } catch (error) {
171
- combined.dispose()
172
- timeout?.dispose()
173
- const durationMs = now() - startedAt
174
- if (timeout?.signal.aborted || error?.name === 'TimeoutError') {
175
- const timeoutError = new HttpError(`Request timed out after ${timeoutMs}ms`, {
163
+ try {
164
+ response = await fetchImpl(target, {
165
+ method,
166
+ headers: requestHeaders,
167
+ body,
168
+ redirect: 'manual',
169
+ signal: combined.signal,
170
+ })
171
+ } catch (error) {
172
+ const durationMs = now() - startedAt
173
+ if (timeout?.signal.aborted || error?.name === 'TimeoutError') {
174
+ const timeoutError = new HttpError(`Request timed out after ${timeoutMs}ms`, {
175
+ url: safeUrl,
176
+ cause: error,
177
+ })
178
+ timeoutError.durationMs = durationMs
179
+ throw timeoutError
180
+ }
181
+ if (signal?.aborted) {
182
+ const abortError = new HttpError('Request was aborted.', { url: safeUrl, cause: error })
183
+ abortError.durationMs = durationMs
184
+ throw abortError
185
+ }
186
+ const networkError = new HttpError(`Request to ${safeUrl} failed.`, {
176
187
  url: safeUrl,
188
+ detail: truncateDetail(redactText(error?.message ?? '')),
177
189
  cause: error,
178
190
  })
179
- timeoutError.durationMs = durationMs
180
- throw timeoutError
181
- }
182
- if (signal?.aborted) {
183
- const abortError = new HttpError('Request was aborted.', { url: safeUrl, cause: error })
184
- abortError.durationMs = durationMs
185
- throw abortError
191
+ networkError.durationMs = durationMs
192
+ throw networkError
186
193
  }
187
- const networkError = new HttpError(`Request to ${safeUrl} failed.`, {
188
- url: safeUrl,
189
- detail: truncateDetail(redactText(error?.message ?? '')),
190
- cause: error,
191
- })
192
- networkError.durationMs = durationMs
193
- throw networkError
194
- }
195
- combined.dispose()
196
- timeout?.dispose()
197
- let text = ''
198
- try {
199
- text = await response.text()
200
- } catch {
201
- text = ''
202
- }
203
- const status = response.status
204
- if (status >= 300 && status < 400) {
205
- throw new HttpError(`Cross-origin redirect is not allowed: ${safeUrl}`, {
206
- status,
207
- url: safeUrl,
208
- detail: safeDetail(text),
209
- })
210
- }
211
- if (status < 200 || status >= 300) {
212
- throw new HttpError(`Request to ${safeUrl} failed with status ${status}.`, {
213
- status,
214
- url: safeUrl,
215
- detail: safeDetail(text),
216
- headers: response.headers,
217
- })
218
- }
219
- let parsed
220
- if (text.length > 0) {
194
+
195
+ let text = ''
221
196
  try {
222
- parsed = JSON.parse(text)
197
+ text = await response.text()
223
198
  } catch {
224
- parsed = undefined
199
+ text = ''
200
+ }
201
+ const status = response.status
202
+ if (status >= 300 && status < 400) {
203
+ throw new HttpError(`Cross-origin redirect is not allowed: ${safeUrl}`, {
204
+ status,
205
+ url: safeUrl,
206
+ detail: safeDetail(text),
207
+ })
225
208
  }
209
+ if (status < 200 || status >= 300) {
210
+ throw new HttpError(`Request to ${safeUrl} failed with status ${status}.`, {
211
+ status,
212
+ url: safeUrl,
213
+ detail: safeDetail(text),
214
+ headers: response.headers,
215
+ })
216
+ }
217
+ let parsed
218
+ if (text.length > 0) {
219
+ try {
220
+ parsed = JSON.parse(text)
221
+ } catch {
222
+ parsed = undefined
223
+ }
224
+ }
225
+ return { status, headers: response.headers, body: parsed, text }
226
+ } finally {
227
+ // The timeout and the combined signal are released on every path,
228
+ // including the failing ones: a timer that outlives the request keeps
229
+ // the event loop alive.
230
+ combined.dispose()
231
+ timeout?.dispose()
226
232
  }
227
- return { status, headers: response.headers, body: parsed, text }
228
233
  }
229
234
 
230
235
  /**
@@ -374,65 +374,69 @@ export function createMcpTransport(options = {}) {
374
374
  const signal = linkSignals([sendOptions.signal, timeout.signal])
375
375
  let response
376
376
  try {
377
- response = await fetchImpl(url, {
378
- method: 'POST',
379
- headers: mergeHeaders(baseHeaders, extraHeaders),
380
- body: JSON.stringify(message),
381
- signal,
382
- redirect: 'manual',
383
- })
384
- } catch (cause) {
385
- timeout.dispose()
386
- if (timeout.signal.aborted) {
387
- throw new McpError(`Request timed out after ${timeoutMs}ms`, {
388
- code: ERROR_CODES.mcp_transport_error,
389
- cause,
377
+ try {
378
+ response = await fetchImpl(url, {
379
+ method: 'POST',
380
+ headers: mergeHeaders(baseHeaders, extraHeaders),
381
+ body: JSON.stringify(message),
382
+ signal,
383
+ redirect: 'manual',
390
384
  })
391
- }
392
- if (sendOptions.signal?.aborted) {
393
- throw new McpError('The MCP request was aborted.', {
385
+ } catch (cause) {
386
+ if (timeout.signal.aborted) {
387
+ throw new McpError(`Request timed out after ${timeoutMs}ms`, {
388
+ code: ERROR_CODES.mcp_transport_error,
389
+ cause,
390
+ })
391
+ }
392
+ if (sendOptions.signal?.aborted) {
393
+ throw new McpError('The MCP request was aborted.', {
394
+ code: ERROR_CODES.mcp_transport_error,
395
+ cause,
396
+ })
397
+ }
398
+ throw new McpError(`The MCP server at ${safeUrl} could not be reached.`, {
394
399
  code: ERROR_CODES.mcp_transport_error,
400
+ detail: redactText(cause?.message ?? String(cause)),
395
401
  cause,
396
402
  })
397
403
  }
398
- throw new McpError(`The MCP server at ${safeUrl} could not be reached.`, {
399
- code: ERROR_CODES.mcp_transport_error,
400
- detail: redactText(cause?.message ?? String(cause)),
401
- cause,
402
- })
403
- }
404
404
 
405
- const responseHeaders = response.headers
406
- timeout.dispose()
407
- const assignedSessionId = readHeader(responseHeaders, 'mcp-session-id')
408
- if (typeof assignedSessionId === 'string' && assignedSessionId.length > 0) {
409
- currentSessionId = assignedSessionId
410
- }
405
+ const responseHeaders = response.headers
406
+ const assignedSessionId = readHeader(responseHeaders, 'mcp-session-id')
407
+ if (typeof assignedSessionId === 'string' && assignedSessionId.length > 0) {
408
+ currentSessionId = assignedSessionId
409
+ }
411
410
 
412
- const status = typeof response.status === 'number' ? response.status : 0
413
- if (status === 202) {
414
- // The server accepted a notification; there is no body to read.
415
- await response.text().catch(() => '')
416
- return { status, headers: collectHeaders(responseHeaders), messages: [], response: undefined }
417
- }
418
- if (status < 200 || status >= 300) {
419
- const text = await response.text().catch(() => '')
420
- throw httpError(status, text, responseHeaders)
421
- }
411
+ const status = typeof response.status === 'number' ? response.status : 0
412
+ if (status === 202) {
413
+ // The server accepted a notification; there is no body to read.
414
+ await response.text().catch(() => '')
415
+ return { status, headers: collectHeaders(responseHeaders), messages: [], response: undefined }
416
+ }
417
+ if (status < 200 || status >= 300) {
418
+ const text = await response.text().catch(() => '')
419
+ throw httpError(status, text, responseHeaders)
420
+ }
422
421
 
423
- const contentType = readHeader(responseHeaders, 'content-type') ?? ''
424
- const messages = contentType.toLowerCase().includes(SSE_CONTENT_TYPE)
425
- ? await readEventStream(response, { matchId: requestId })
426
- : parseJsonBody(await response.text())
422
+ const contentType = readHeader(responseHeaders, 'content-type') ?? ''
423
+ const messages = contentType.toLowerCase().includes(SSE_CONTENT_TYPE)
424
+ ? await readEventStream(response, { matchId: requestId })
425
+ : parseJsonBody(await response.text())
427
426
 
428
- const matched = messages.find((message) => sameId(message?.id, requestId))
429
- if (requestId !== undefined && matched === undefined) {
430
- throw new McpError('The MCP server did not answer the request.', {
431
- code: ERROR_CODES.mcp_protocol_error,
432
- status,
433
- })
427
+ const matched = messages.find((message) => sameId(message?.id, requestId))
428
+ if (requestId !== undefined && matched === undefined) {
429
+ throw new McpError('The MCP server did not answer the request.', {
430
+ code: ERROR_CODES.mcp_protocol_error,
431
+ status,
432
+ })
433
+ }
434
+ return { status, headers: collectHeaders(responseHeaders), messages, response: matched }
435
+ } finally {
436
+ // The timer is released on every path, including the failing ones: a
437
+ // timer that outlives the request keeps the event loop alive.
438
+ timeout.dispose()
434
439
  }
435
- return { status, headers: collectHeaders(responseHeaders), messages, response: matched }
436
440
  }
437
441
 
438
442
  /**
@@ -450,14 +454,19 @@ export function createMcpTransport(options = {}) {
450
454
  if (currentProtocolVersion) extraHeaders['MCP-Protocol-Version'] = currentProtocolVersion
451
455
  try {
452
456
  const timeout = createTimeoutSignal(DEFAULT_REQUEST_TIMEOUT_MS)
453
- const response = await fetchImpl(url, {
454
- method: 'DELETE',
455
- headers: mergeHeaders(baseHeaders, { ...extraHeaders, 'Mcp-Session-Id': currentSessionId }),
456
- signal: timeout.signal,
457
- redirect: 'manual',
458
- })
459
- timeout.dispose()
460
- await response.text().catch(() => '')
457
+ try {
458
+ const response = await fetchImpl(url, {
459
+ method: 'DELETE',
460
+ headers: mergeHeaders(baseHeaders, { ...extraHeaders, 'Mcp-Session-Id': currentSessionId }),
461
+ signal: timeout.signal,
462
+ redirect: 'manual',
463
+ })
464
+ await response.text().catch(() => '')
465
+ } finally {
466
+ // The timer must be released on every path, including the failing one:
467
+ // a timer that outlives the request keeps the event loop alive.
468
+ timeout.dispose()
469
+ }
461
470
  } catch {
462
471
  // Terminating the session is best effort: the server may not support DELETE.
463
472
  }
package/lib/service.js CHANGED
@@ -626,6 +626,30 @@ export function createOauthMcpService(options) {
626
626
  return refreshed
627
627
  }
628
628
 
629
+ /**
630
+ * Release every pending authorization.
631
+ *
632
+ * The timer of a pending authorization keeps the event loop alive: it is
633
+ * ref'd on purpose, so that a request that waits for the user still times
634
+ * out on Node 22 (see `lib/http/timeout.js`). The host must therefore be
635
+ * able to give the pending authorizations up when the plugin is unloaded,
636
+ * and a test must be able to do the same after it is done.
637
+ *
638
+ * @returns {void}
639
+ */
640
+ function dispose() {
641
+ for (const serverId of [...pending.keys()]) {
642
+ rejectPending(
643
+ serverId,
644
+ new OAuthError(
645
+ ERROR_CODES.authorization_cancelled,
646
+ language().t('server.error.no_pending_authorization', { serverId }),
647
+ ),
648
+ )
649
+ }
650
+ stateIndex.clear()
651
+ }
652
+
629
653
  return {
630
654
  listServers,
631
655
  describeServer,
@@ -645,6 +669,7 @@ export function createOauthMcpService(options) {
645
669
  updateServer,
646
670
  isAuthorized,
647
671
  refreshToken,
672
+ dispose,
648
673
  /** @internal Exposed for the host callback receiver and the host command. */
649
674
  ctx,
650
675
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-oauth-mcp",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "OAuth 2.1 client and MCP bridge for DSH: discover and authorize remote MCP servers, keep tokens fresh, and expose their tools",
5
5
  "keywords": [
6
6
  "dsh",