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 +7 -0
- package/lib/build-info.js +2 -2
- package/lib/http/client.js +62 -57
- package/lib/mcp/transport.js +66 -57
- package/lib/service.js +25 -0
- package/package.json +1 -1
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.
|
|
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-
|
|
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.
|
package/lib/http/client.js
CHANGED
|
@@ -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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
throw
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
197
|
+
text = await response.text()
|
|
223
198
|
} catch {
|
|
224
|
-
|
|
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
|
/**
|
package/lib/mcp/transport.js
CHANGED
|
@@ -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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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