tiny-http-mcp-server 0.1.21 → 0.1.22

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.
@@ -18,7 +18,7 @@
18
18
  },
19
19
  {
20
20
  "name": "tiny-http-mcp-server",
21
- "version": "0.1.21",
21
+ "version": "0.1.22",
22
22
  "license": "MIT"
23
23
  },
24
24
  {
@@ -40,10 +40,14 @@ const verifier = createJwksTokenVerifier({
40
40
  - `client`
41
41
  - `mode: "dynamic"` with optional `metadata`
42
42
  - `mode: "static"` with `clientId`, optional `clientSecret`, optional `metadata`
43
- - `browser.openBrowser(url)`
43
+ - `allowInteractive: false` prevents interactive login while retaining cached tokens and silent refresh
44
+ - `browser.openBrowser(url)` optional
44
45
  - `browser.readLine()` optional
45
46
  - `browser.createServer()` optional
46
47
  - `browser.landingPage` optional
48
+ - `browser.redirectUri` optional exact registered HTTP loopback callback with a fixed port
49
+ - `browser.signal` optional cancellation signal
50
+ - `browser.timeoutMs` optional authorization deadline (default 120,000 ms)
47
51
  - `sessionStore` optional
48
52
  - `authStore` optional `auth-store` backend config for the default session store
49
53
  - `now()` optional clock override
@@ -62,6 +66,23 @@ const verifier = createJwksTokenVerifier({
62
66
  | `requireAccessTokenType` | `boolean` | `false` | Require the JWT `typ` protected header to be `at+jwt`. |
63
67
  | `fetch` | `typeof fetch` | global `fetch` | Custom fetch implementation. |
64
68
 
69
+ Fixed redirects support `localhost`, `127.0.0.1`, and `::1` over HTTP. Their
70
+ exact spelling, port, path and query are preserved through registration,
71
+ authorization and code exchange. Credentials, fragments, port zero and reserved
72
+ OAuth callback query parameters are rejected before binding a listener. Omit
73
+ `redirectUri` to allocate a random loopback port. Standalone callback sessions
74
+ accept the same `redirectUri`, `signal` and `timeoutMs` options. Cancellation,
75
+ timeout and explicit close settle pending code waits and release listeners.
76
+ Always close a successful standalone session in `finally`.
77
+
78
+ Provider request inputs accept an optional `signal`. It reaches callback waits,
79
+ registration, token requests and bounded token-body reads. Cancellation retains
80
+ its original reason and does not retry authorization. Custom providers should
81
+ observe the supplied signal and pass it to any work they start.
82
+
83
+ Configure `client.metadata.scope` to request a precise scope set; broader
84
+ discovery metadata does not override it.
85
+
65
86
  `createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
66
87
 
67
88
  ## Environment Variables
@@ -25,7 +25,7 @@ export function createDefaultOAuthClientProvider(options) {
25
25
  async authorizeRequest(input) {
26
26
  assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
27
27
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
28
- const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false);
28
+ const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false, false, input.signal);
29
29
  const accessToken = session?.tokens?.accessToken;
30
30
  if (session === null ||
31
31
  accessToken === undefined ||
@@ -47,13 +47,14 @@ export function createDefaultOAuthClientProvider(options) {
47
47
  const session = await ensureAuthorizedSession(resource, {
48
48
  ...input.discovery,
49
49
  resource
50
- }, input.fetch, true, forceRefresh);
50
+ }, input.fetch, true, forceRefresh, input.signal);
51
51
  if (session?.tokens?.accessToken === undefined) {
52
52
  return { action: "fail" };
53
53
  }
54
54
  return { action: "retry" };
55
55
  }
56
56
  catch (error) {
57
+ input.signal?.throwIfAborted();
57
58
  return {
58
59
  action: "fail",
59
60
  error: error instanceof Error ? error : new Error(String(error))
@@ -61,9 +62,11 @@ export function createDefaultOAuthClientProvider(options) {
61
62
  }
62
63
  }
63
64
  };
64
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false) {
65
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
66
+ signal?.throwIfAborted();
65
67
  const canonicalResource = canonicalizeResourceIndicator(resource);
66
68
  let session = await loadSession(canonicalResource);
69
+ signal?.throwIfAborted();
67
70
  if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
68
71
  throw new Error("OAuth discovery authorization-server issuer mismatch");
69
72
  }
@@ -80,7 +83,7 @@ export function createDefaultOAuthClientProvider(options) {
80
83
  if (session?.tokens?.refreshToken !== undefined &&
81
84
  sessionDiscovery !== undefined &&
82
85
  (forceRefresh || isExpired(session.tokens, now))) {
83
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch);
86
+ session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
84
87
  if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
85
88
  return session;
86
89
  }
@@ -92,9 +95,12 @@ export function createDefaultOAuthClientProvider(options) {
92
95
  if (!allowInteractive || sessionDiscovery === undefined) {
93
96
  return session;
94
97
  }
95
- return authorizeSession(canonicalResource, session, sessionDiscovery, fetch);
98
+ if (options.allowInteractive === false)
99
+ throw new Error("OAuth interactive authorization is disabled");
100
+ return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
96
101
  }
97
- async function refreshSession(resource, session, discovery, fetch) {
102
+ async function refreshSession(resource, session, discovery, fetch, signal) {
103
+ signal?.throwIfAborted();
98
104
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
99
105
  const inFlight = refreshPromises.get(resource);
100
106
  if (inFlight !== undefined) {
@@ -115,12 +121,13 @@ export function createDefaultOAuthClientProvider(options) {
115
121
  clientSecret: session.client.clientSecret,
116
122
  refreshToken: session.tokens.refreshToken,
117
123
  resource,
118
- fetch,
124
+ fetch, signal,
119
125
  now
120
126
  });
121
127
  break;
122
128
  }
123
129
  catch (error) {
130
+ signal?.throwIfAborted();
124
131
  if (error instanceof OAuthError && error.error === "invalid_grant") {
125
132
  const clearedSession = clearSessionTokens(session);
126
133
  await saveSession(resource, clearedSession);
@@ -156,7 +163,8 @@ export function createDefaultOAuthClientProvider(options) {
156
163
  refreshPromises.set(resource, promise);
157
164
  return promise;
158
165
  }
159
- async function authorizeSession(resource, existingSession, discovery, fetch) {
166
+ async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
167
+ signal?.throwIfAborted();
160
168
  const inFlight = authorizationPromises.get(resource);
161
169
  if (inFlight !== undefined) {
162
170
  return inFlight;
@@ -172,11 +180,14 @@ export function createDefaultOAuthClientProvider(options) {
172
180
  openBrowser: options.browser.openBrowser,
173
181
  readLine: options.browser.readLine,
174
182
  createServer: options.browser.createServer,
175
- landingPage: options.browser.landingPage
183
+ landingPage: options.browser.landingPage,
184
+ redirectUri: options.browser.redirectUri,
185
+ signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
186
+ timeoutMs: options.browser.timeoutMs
176
187
  });
177
188
  let resolvedClient = null;
178
189
  try {
179
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch);
190
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
180
191
  const sessionWithoutTokens = {
181
192
  resource,
182
193
  authorizationServer: discovery.authorizationServer,
@@ -203,7 +214,7 @@ export function createDefaultOAuthClientProvider(options) {
203
214
  codeVerifier: verifier,
204
215
  redirectUri: loopback.redirectUri,
205
216
  resource,
206
- fetch,
217
+ fetch, signal,
207
218
  now
208
219
  });
209
220
  const session = {
@@ -214,6 +225,7 @@ export function createDefaultOAuthClientProvider(options) {
214
225
  return session;
215
226
  }
216
227
  catch (error) {
228
+ signal?.throwIfAborted();
217
229
  if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
218
230
  reRegistrationAttempted = true;
219
231
  await clearRegisteredClient(discovery.authorizationServer);
@@ -240,7 +252,8 @@ export function createDefaultOAuthClientProvider(options) {
240
252
  authorizationPromises.set(resource, finalPromise);
241
253
  return finalPromise;
242
254
  }
243
- async function resolveClient(existingSession, discovery, redirectUri, fetch) {
255
+ async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
256
+ parentSignal?.throwIfAborted();
244
257
  const configuredClient = normalizeConfiguredClient(options.client);
245
258
  if (options.client.mode === "static") {
246
259
  if (configuredClient === null) {
@@ -292,7 +305,8 @@ export function createDefaultOAuthClientProvider(options) {
292
305
  }
293
306
  }
294
307
  const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
295
- const signal = AbortSignal.timeout(30_000);
308
+ const deadline = AbortSignal.timeout(30_000);
309
+ const signal = parentSignal === undefined ? deadline : AbortSignal.any([parentSignal, deadline]);
296
310
  const response = await fetchMcpResponse(fetch, registrationEndpoint, {
297
311
  method: "POST",
298
312
  headers: {
@@ -9,6 +9,11 @@ export interface LoopbackAuthorizationOptions {
9
9
  createServer?: () => http.Server;
10
10
  landingPage?: OAuthLandingPage;
11
11
  callbackPath?: string;
12
+ /** Exact registered HTTP loopback redirect, including its fixed port and query. */
13
+ redirectUri?: string;
14
+ signal?: AbortSignal;
15
+ /** Bounds listener setup and authorization; defaults to two minutes. */
16
+ timeoutMs?: number;
12
17
  }
13
18
  export interface LoopbackAuthorizationSession {
14
19
  redirectUri: string;
@@ -1,77 +1,150 @@
1
1
  import http from "node:http";
2
2
  import { parseAuthorizationState } from "./authorization-state.js";
3
3
  export async function createLoopbackAuthorizationSession(options = {}) {
4
- const callbackPath = options.callbackPath ?? "/callback";
4
+ options.signal?.throwIfAborted();
5
+ const timeoutMs = options.timeoutMs ?? 120_000;
6
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
7
+ throw new Error("OAuth authorization timeoutMs must be a positive supported timer interval");
8
+ const target = loopbackTarget(options);
5
9
  const server = options.createServer ? options.createServer() : http.createServer();
6
- const port = await startServer(server);
7
- const redirectUri = `http://127.0.0.1:${port}${callbackPath}`;
10
+ const controller = new AbortController();
11
+ let closed = false;
12
+ let used = false;
13
+ const callerAbort = () => controller.abort(options.signal?.reason);
14
+ const teardown = () => {
15
+ if (closed)
16
+ return;
17
+ closed = true;
18
+ clearTimeout(timer);
19
+ options.signal?.removeEventListener("abort", callerAbort);
20
+ server.closeAllConnections?.();
21
+ server.close();
22
+ };
23
+ const timer = setTimeout(() => controller.abort(new Error("OAuth authorization timed out")), timeoutMs);
24
+ timer.unref?.();
25
+ controller.signal.addEventListener("abort", teardown, { once: true });
26
+ options.signal?.addEventListener("abort", callerAbort, { once: true });
27
+ if (options.signal?.aborted)
28
+ callerAbort();
29
+ let port;
30
+ try {
31
+ port = await startServer(server, target.port, target.host, controller.signal);
32
+ }
33
+ catch (error) {
34
+ controller.abort(error);
35
+ throw error;
36
+ }
37
+ const redirectUri = options.redirectUri ?? `http://127.0.0.1:${port}${target.callbackPath}`;
8
38
  return {
9
39
  redirectUri,
10
40
  async waitForCode(authorizationUrl) {
11
- return waitForAuthorizationCode(server, authorizationUrl, options, callbackPath);
41
+ controller.signal.throwIfAborted();
42
+ if (used)
43
+ throw new Error("OAuth authorization session has already been used");
44
+ used = true;
45
+ try {
46
+ return await waitForAuthorizationCode(server, authorizationUrl, options, target.callbackPath, controller.signal);
47
+ }
48
+ finally {
49
+ clearTimeout(timer);
50
+ }
12
51
  },
13
52
  close() {
14
- server.closeAllConnections?.();
15
- server.close();
53
+ controller.abort(new Error("OAuth authorization session closed"));
16
54
  }
17
55
  };
18
56
  }
19
- async function startServer(server) {
57
+ function loopbackTarget(options) {
58
+ if (options.redirectUri !== undefined) {
59
+ let url;
60
+ try {
61
+ url = new URL(options.redirectUri);
62
+ }
63
+ catch (cause) {
64
+ throw new Error("Invalid OAuth loopback redirect URI", { cause });
65
+ }
66
+ const forbiddenQuery = ["code", "state", "error", "error_description", "iss"];
67
+ if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
68
+ || url.username || url.password || url.hash || url.port === "0"
69
+ || forbiddenQuery.some(name => url.searchParams.has(name))
70
+ || [...options.redirectUri].some(char => char.codePointAt(0) <= 32)
71
+ || (options.callbackPath !== undefined && options.callbackPath !== url.pathname))
72
+ throw new Error("Invalid OAuth loopback redirect URI");
73
+ return { port: url.port ? Number(url.port) : 80, host: url.hostname === "[::1]" ? "::1" : url.hostname, callbackPath: url.pathname };
74
+ }
75
+ const callbackPath = options.callbackPath ?? "/callback";
76
+ const parsed = new URL(callbackPath, "http://127.0.0.1");
77
+ if (!callbackPath.startsWith("/") || parsed.origin !== "http://127.0.0.1" || parsed.pathname !== callbackPath || parsed.search || parsed.hash)
78
+ throw new Error("Invalid OAuth loopback callback path");
79
+ return { port: 0, host: "127.0.0.1", callbackPath };
80
+ }
81
+ async function startServer(server, port, host, signal) {
82
+ signal.throwIfAborted();
20
83
  return new Promise((resolve, reject) => {
21
- const handleError = (error) => {
22
- server.off("error", handleError);
23
- reject(error);
24
- };
84
+ const cleanup = () => { server.off("error", handleError); signal.removeEventListener("abort", aborted); };
85
+ const handleError = (error) => { cleanup(); reject(error); };
86
+ const aborted = () => { cleanup(); reject(signal.reason); };
25
87
  server.once("error", handleError);
26
- server.listen(0, "127.0.0.1", () => {
27
- server.off("error", handleError);
28
- const address = server.address();
29
- resolve(address.port);
30
- });
88
+ signal.addEventListener("abort", aborted, { once: true });
89
+ try {
90
+ server.listen(port, host, () => {
91
+ cleanup();
92
+ if (signal.aborted) {
93
+ server.close();
94
+ reject(signal.reason);
95
+ return;
96
+ }
97
+ const address = server.address();
98
+ if (address === null || typeof address === "string") {
99
+ reject(new Error("OAuth listener has no TCP address"));
100
+ return;
101
+ }
102
+ resolve(address.port);
103
+ });
104
+ }
105
+ catch (error) {
106
+ cleanup();
107
+ reject(error);
108
+ }
31
109
  });
32
110
  }
33
- function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath) {
111
+ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath, signal) {
112
+ signal.throwIfAborted();
34
113
  const expectedAuthorization = readExpectedAuthorizationCallback(authorizationUrl);
35
114
  return new Promise((resolve, reject) => {
36
115
  let settled = false;
37
116
  const settle = (fn) => {
38
- if (!settled) {
39
- settled = true;
40
- fn();
41
- }
117
+ if (settled)
118
+ return;
119
+ settled = true;
120
+ server.off("request", request);
121
+ signal.removeEventListener("abort", aborted);
122
+ fn();
42
123
  };
43
- server.on("request", (req, res) => {
44
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
124
+ const aborted = () => settle(() => reject(signal.reason));
125
+ const request = (req, res) => {
126
+ let url;
127
+ try {
128
+ url = new URL(req.url ?? "/", "http://127.0.0.1");
129
+ }
130
+ catch {
131
+ res.writeHead(400);
132
+ res.end("Invalid callback URL");
133
+ return;
134
+ }
45
135
  if (url.pathname !== callbackPath) {
46
136
  res.writeHead(404);
47
137
  res.end("Not found");
48
138
  return;
49
139
  }
50
140
  const callbackParameters = {
51
- code: url.searchParams.get("code"),
52
- error: url.searchParams.get("error"),
53
- errorDescription: url.searchParams.get("error_description"),
54
- state: url.searchParams.get("state"),
55
- iss: url.searchParams.get("iss")
141
+ code: url.searchParams.get("code"), error: url.searchParams.get("error"),
142
+ errorDescription: url.searchParams.get("error_description"), state: url.searchParams.get("state"), iss: url.searchParams.get("iss")
56
143
  };
57
144
  try {
58
145
  validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
59
- }
60
- catch (error) {
61
- res.writeHead(400);
62
- res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
63
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
64
- return;
65
- }
66
- const authorizationError = callbackParameters.error;
67
- if (authorizationError !== null) {
68
- const description = callbackParameters.errorDescription ?? authorizationError;
69
- res.writeHead(400);
70
- res.end(`Authorization failed: ${description}`);
71
- settle(() => reject(createAuthorizationError(authorizationError, description)));
72
- return;
73
- }
74
- try {
146
+ if (callbackParameters.error !== null)
147
+ throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
75
148
  const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
76
149
  res.writeHead(200, { "Content-Type": "text/html" });
77
150
  res.end(buildSuccessPage(options.landingPage));
@@ -80,39 +153,28 @@ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPat
80
153
  catch (error) {
81
154
  res.writeHead(400);
82
155
  res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
83
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
156
+ settle(() => reject(error));
84
157
  }
85
- });
158
+ };
159
+ server.on("request", request);
160
+ signal.addEventListener("abort", aborted, { once: true });
86
161
  if (options.readLine !== undefined) {
87
- options
88
- .readLine()
89
- .then((input) => {
90
- const callbackParameters = extractCallbackParametersFromInput(input);
91
- if (callbackParameters === null) {
92
- settle(() => reject(new Error("OAuth callback missing authorization code")));
162
+ void Promise.resolve().then(() => settled ? undefined : options.readLine()).then(input => {
163
+ if (settled)
93
164
  return;
94
- }
95
- try {
96
- validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
97
- if (callbackParameters.error !== null) {
98
- const description = callbackParameters.errorDescription ?? callbackParameters.error;
99
- throw createAuthorizationError(callbackParameters.error, description);
100
- }
101
- const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
102
- settle(() => resolve(code));
103
- }
104
- catch (error) {
105
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
106
- }
107
- })
108
- .catch((error) => {
109
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
110
- });
165
+ const callbackParameters = extractCallbackParametersFromInput(input);
166
+ if (callbackParameters === null)
167
+ throw new Error("OAuth callback missing authorization code");
168
+ validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
169
+ if (callbackParameters.error !== null)
170
+ throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
171
+ const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
172
+ settle(() => resolve(code));
173
+ }).catch(error => settle(() => reject(error)));
111
174
  }
112
175
  if (options.openBrowser !== undefined) {
113
- options.openBrowser(authorizationUrl).catch((error) => {
114
- settle(() => reject(error));
115
- });
176
+ void Promise.resolve().then(() => settled ? undefined : options.openBrowser(authorizationUrl))
177
+ .catch(error => settle(() => reject(error)));
116
178
  }
117
179
  });
118
180
  }
@@ -25,6 +25,7 @@ export declare function exchangeAuthorizationCode(input: {
25
25
  redirectUri: string;
26
26
  resource: string;
27
27
  fetch: OAuthMetadataFetch;
28
+ signal?: AbortSignal;
28
29
  now: () => number;
29
30
  }): Promise<StoredOAuthTokens>;
30
31
  export declare function refreshAccessToken(input: {
@@ -34,6 +35,7 @@ export declare function refreshAccessToken(input: {
34
35
  refreshToken: string;
35
36
  resource: string;
36
37
  fetch: OAuthMetadataFetch;
38
+ signal?: AbortSignal;
37
39
  now: () => number;
38
40
  }): Promise<StoredOAuthTokens>;
39
41
  export declare function readOAuthJsonObjectResponse(response: Response, signal?: AbortSignal): Promise<Record<string, unknown>>;
@@ -44,6 +44,7 @@ export async function exchangeAuthorizationCode(input) {
44
44
  resource
45
45
  },
46
46
  fetch: input.fetch,
47
+ signal: input.signal,
47
48
  now: input.now
48
49
  });
49
50
  }
@@ -59,6 +60,7 @@ export async function refreshAccessToken(input) {
59
60
  resource
60
61
  },
61
62
  fetch: input.fetch,
63
+ signal: input.signal,
62
64
  now: input.now
63
65
  });
64
66
  }
@@ -70,7 +72,9 @@ async function requestTokens(input) {
70
72
  if (input.clientSecret !== undefined) {
71
73
  body.set("client_secret", input.clientSecret);
72
74
  }
73
- const signal = AbortSignal.timeout(30_000);
75
+ input.signal?.throwIfAborted();
76
+ const deadline = AbortSignal.timeout(30_000);
77
+ const signal = input.signal === undefined ? deadline : AbortSignal.any([input.signal, deadline]);
74
78
  const response = await fetchMcpResponse(input.fetch, input.tokenEndpoint, {
75
79
  method: "POST",
76
80
  headers: {
@@ -32,6 +32,7 @@ export interface OAuthClientProvider {
32
32
  requestUrl: URL;
33
33
  headers: Headers;
34
34
  fetch: OAuthMetadataFetch;
35
+ signal?: AbortSignal;
35
36
  }): Promise<void> | void;
36
37
  handleUnauthorized(input: {
37
38
  requestUrl: URL;
@@ -39,6 +40,7 @@ export interface OAuthClientProvider {
39
40
  challenge: OAuthUnauthorizedChallenge | null;
40
41
  discovery: OAuthDiscoveryResult;
41
42
  fetch: OAuthMetadataFetch;
43
+ signal?: AbortSignal;
42
44
  }): Promise<{
43
45
  action: "retry";
44
46
  } | {
@@ -95,8 +97,14 @@ export interface DefaultOAuthClientProviderOptions {
95
97
  clientSecret?: string;
96
98
  metadata?: OAuthClientMetadata;
97
99
  };
100
+ /** Disable interactive authorization while allowing cached tokens and silent refresh. */
101
+ allowInteractive?: boolean;
98
102
  browser: {
99
- openBrowser(url: string): Promise<void>;
103
+ openBrowser?(url: string): Promise<void>;
104
+ /** Exact registered HTTP loopback redirect URI. */
105
+ redirectUri?: string;
106
+ signal?: AbortSignal;
107
+ timeoutMs?: number;
100
108
  readLine?: () => Promise<string>;
101
109
  createServer?: () => http.Server;
102
110
  landingPage?: {
@@ -66,7 +66,9 @@ const transport = new HttpTransport({
66
66
  });
67
67
  ```
68
68
 
69
- You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or instantiate `OAuthMetadataDiscovery` with a custom `fetch` implementation and shared cache.
69
+ You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or instantiate `OAuthMetadataDiscovery` with a custom `fetch` implementation and shared cache. Lookup options accept `signal`; cancellation stops metadata fetches and body reads without trying another discovery candidate.
70
+
71
+ OAuth provider inputs receive the originating request's `signal`, covering header authorization and unauthorized handling. Modern request cancellation stops its OAuth work while leaving other requests usable; transport disposal cancels all pending OAuth operations. The default provider propagates this signal to callback, registration and token work. Custom providers must observe it for their own operations.
70
72
 
71
73
  ## Testing helpers
72
74
 
@@ -109,6 +109,7 @@ interface OAuthClientProvider {
109
109
  requestUrl: URL;
110
110
  headers: Headers;
111
111
  fetch: OAuthMetadataFetch;
112
+ signal?: AbortSignal;
112
113
  }): Promise<void> | void;
113
114
  handleUnauthorized(input: {
114
115
  requestUrl: URL;
@@ -116,6 +117,7 @@ interface OAuthClientProvider {
116
117
  challenge: OAuthUnauthorizedChallenge | null;
117
118
  discovery: OAuthDiscoveryResult;
118
119
  fetch: OAuthMetadataFetch;
120
+ signal?: AbortSignal;
119
121
  }): Promise<{
120
122
  action: "retry";
121
123
  } | {
@@ -172,8 +174,14 @@ interface DefaultOAuthClientProviderOptions {
172
174
  clientSecret?: string;
173
175
  metadata?: OAuthClientMetadata;
174
176
  };
177
+ /** Disable interactive authorization while allowing cached tokens and silent refresh. */
178
+ allowInteractive?: boolean;
175
179
  browser: {
176
- openBrowser(url: string): Promise<void>;
180
+ openBrowser?(url: string): Promise<void>;
181
+ /** Exact registered HTTP loopback redirect URI. */
182
+ redirectUri?: string;
183
+ signal?: AbortSignal;
184
+ timeoutMs?: number;
177
185
  readLine?: () => Promise<string>;
178
186
  createServer?: () => http.Server;
179
187
  landingPage?: {
@@ -202,6 +210,7 @@ interface OAuthMetadataDiscoveryOptions {
202
210
  }
203
211
  interface OAuthMetadataLookupOptions {
204
212
  resourceMetadataUrl?: string | URL;
213
+ signal?: AbortSignal;
205
214
  }
206
215
  declare class OAuthMetadataDiscovery {
207
216
  private readonly fetchImpl;
@@ -209,7 +218,7 @@ declare class OAuthMetadataDiscovery {
209
218
  private readonly memoryCache;
210
219
  constructor({ fetch, cache }?: OAuthMetadataDiscoveryOptions);
211
220
  private discoverProtectedResource;
212
- discover(resourceUrl: string | URL, { resourceMetadataUrl }?: OAuthMetadataLookupOptions): Promise<OAuthDiscoveryResult>;
221
+ discover(resourceUrl: string | URL, { resourceMetadataUrl, signal }?: OAuthMetadataLookupOptions): Promise<OAuthDiscoveryResult>;
213
222
  }
214
223
  declare function discoverOAuthMetadata(resourceUrl: string | URL, options?: OAuthMetadataDiscoveryOptions & OAuthMetadataLookupOptions): Promise<OAuthDiscoveryResult>;
215
224
 
@@ -634,6 +643,7 @@ declare class HttpTransport implements McpTransport {
634
643
  private readonly oauthProvider;
635
644
  private readonly oauthMetadataDiscovery;
636
645
  private readonly inFlightFetchAbortControllers;
646
+ private readonly inFlightOAuthAbortControllers;
637
647
  private readonly openResponseReaders;
638
648
  private readonly modernRequests;
639
649
  private modernMode;
@@ -4102,47 +4102,137 @@ function getOwnEntry4(record2, key2) {
4102
4102
 
4103
4103
  // ../mcp-oauth/dist/client/loopback-authorization.js
4104
4104
  async function createLoopbackAuthorizationSession(options = {}) {
4105
- const callbackPath = options.callbackPath ?? "/callback";
4105
+ options.signal?.throwIfAborted();
4106
+ const timeoutMs = options.timeoutMs ?? 12e4;
4107
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)
4108
+ throw new Error("OAuth authorization timeoutMs must be a positive supported timer interval");
4109
+ const target = loopbackTarget(options);
4106
4110
  const server = options.createServer ? options.createServer() : http.createServer();
4107
- const port = await startServer(server);
4108
- const redirectUri = `http://127.0.0.1:${port}${callbackPath}`;
4111
+ const controller = new AbortController();
4112
+ let closed = false;
4113
+ let used = false;
4114
+ const callerAbort = () => controller.abort(options.signal?.reason);
4115
+ const teardown = () => {
4116
+ if (closed)
4117
+ return;
4118
+ closed = true;
4119
+ clearTimeout(timer);
4120
+ options.signal?.removeEventListener("abort", callerAbort);
4121
+ server.closeAllConnections?.();
4122
+ server.close();
4123
+ };
4124
+ const timer = setTimeout(() => controller.abort(new Error("OAuth authorization timed out")), timeoutMs);
4125
+ timer.unref?.();
4126
+ controller.signal.addEventListener("abort", teardown, { once: true });
4127
+ options.signal?.addEventListener("abort", callerAbort, { once: true });
4128
+ if (options.signal?.aborted)
4129
+ callerAbort();
4130
+ let port;
4131
+ try {
4132
+ port = await startServer(server, target.port, target.host, controller.signal);
4133
+ } catch (error) {
4134
+ controller.abort(error);
4135
+ throw error;
4136
+ }
4137
+ const redirectUri = options.redirectUri ?? `http://127.0.0.1:${port}${target.callbackPath}`;
4109
4138
  return {
4110
4139
  redirectUri,
4111
4140
  async waitForCode(authorizationUrl) {
4112
- return waitForAuthorizationCode(server, authorizationUrl, options, callbackPath);
4141
+ controller.signal.throwIfAborted();
4142
+ if (used)
4143
+ throw new Error("OAuth authorization session has already been used");
4144
+ used = true;
4145
+ try {
4146
+ return await waitForAuthorizationCode(server, authorizationUrl, options, target.callbackPath, controller.signal);
4147
+ } finally {
4148
+ clearTimeout(timer);
4149
+ }
4113
4150
  },
4114
4151
  close() {
4115
- server.closeAllConnections?.();
4116
- server.close();
4152
+ controller.abort(new Error("OAuth authorization session closed"));
4117
4153
  }
4118
4154
  };
4119
4155
  }
4120
- async function startServer(server) {
4156
+ function loopbackTarget(options) {
4157
+ if (options.redirectUri !== void 0) {
4158
+ let url;
4159
+ try {
4160
+ url = new URL(options.redirectUri);
4161
+ } catch (cause) {
4162
+ throw new Error("Invalid OAuth loopback redirect URI", { cause });
4163
+ }
4164
+ const forbiddenQuery = ["code", "state", "error", "error_description", "iss"];
4165
+ if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || url.username || url.password || url.hash || url.port === "0" || forbiddenQuery.some((name) => url.searchParams.has(name)) || [...options.redirectUri].some((char) => char.codePointAt(0) <= 32) || options.callbackPath !== void 0 && options.callbackPath !== url.pathname)
4166
+ throw new Error("Invalid OAuth loopback redirect URI");
4167
+ return { port: url.port ? Number(url.port) : 80, host: url.hostname === "[::1]" ? "::1" : url.hostname, callbackPath: url.pathname };
4168
+ }
4169
+ const callbackPath = options.callbackPath ?? "/callback";
4170
+ const parsed = new URL(callbackPath, "http://127.0.0.1");
4171
+ if (!callbackPath.startsWith("/") || parsed.origin !== "http://127.0.0.1" || parsed.pathname !== callbackPath || parsed.search || parsed.hash)
4172
+ throw new Error("Invalid OAuth loopback callback path");
4173
+ return { port: 0, host: "127.0.0.1", callbackPath };
4174
+ }
4175
+ async function startServer(server, port, host, signal) {
4176
+ signal.throwIfAborted();
4121
4177
  return new Promise((resolve, reject) => {
4122
- const handleError = (error) => {
4178
+ const cleanup = () => {
4123
4179
  server.off("error", handleError);
4180
+ signal.removeEventListener("abort", aborted);
4181
+ };
4182
+ const handleError = (error) => {
4183
+ cleanup();
4124
4184
  reject(error);
4125
4185
  };
4186
+ const aborted = () => {
4187
+ cleanup();
4188
+ reject(signal.reason);
4189
+ };
4126
4190
  server.once("error", handleError);
4127
- server.listen(0, "127.0.0.1", () => {
4128
- server.off("error", handleError);
4129
- const address = server.address();
4130
- resolve(address.port);
4131
- });
4191
+ signal.addEventListener("abort", aborted, { once: true });
4192
+ try {
4193
+ server.listen(port, host, () => {
4194
+ cleanup();
4195
+ if (signal.aborted) {
4196
+ server.close();
4197
+ reject(signal.reason);
4198
+ return;
4199
+ }
4200
+ const address = server.address();
4201
+ if (address === null || typeof address === "string") {
4202
+ reject(new Error("OAuth listener has no TCP address"));
4203
+ return;
4204
+ }
4205
+ resolve(address.port);
4206
+ });
4207
+ } catch (error) {
4208
+ cleanup();
4209
+ reject(error);
4210
+ }
4132
4211
  });
4133
4212
  }
4134
- function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath) {
4213
+ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath, signal) {
4214
+ signal.throwIfAborted();
4135
4215
  const expectedAuthorization = readExpectedAuthorizationCallback(authorizationUrl);
4136
4216
  return new Promise((resolve, reject) => {
4137
4217
  let settled = false;
4138
4218
  const settle = (fn) => {
4139
- if (!settled) {
4140
- settled = true;
4141
- fn();
4142
- }
4219
+ if (settled)
4220
+ return;
4221
+ settled = true;
4222
+ server.off("request", request);
4223
+ signal.removeEventListener("abort", aborted);
4224
+ fn();
4143
4225
  };
4144
- server.on("request", (req, res) => {
4145
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
4226
+ const aborted = () => settle(() => reject(signal.reason));
4227
+ const request = (req, res) => {
4228
+ let url;
4229
+ try {
4230
+ url = new URL(req.url ?? "/", "http://127.0.0.1");
4231
+ } catch {
4232
+ res.writeHead(400);
4233
+ res.end("Invalid callback URL");
4234
+ return;
4235
+ }
4146
4236
  if (url.pathname !== callbackPath) {
4147
4237
  res.writeHead(404);
4148
4238
  res.end("Not found");
@@ -4157,21 +4247,8 @@ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPat
4157
4247
  };
4158
4248
  try {
4159
4249
  validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
4160
- } catch (error) {
4161
- res.writeHead(400);
4162
- res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
4163
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
4164
- return;
4165
- }
4166
- const authorizationError = callbackParameters.error;
4167
- if (authorizationError !== null) {
4168
- const description = callbackParameters.errorDescription ?? authorizationError;
4169
- res.writeHead(400);
4170
- res.end(`Authorization failed: ${description}`);
4171
- settle(() => reject(createAuthorizationError(authorizationError, description)));
4172
- return;
4173
- }
4174
- try {
4250
+ if (callbackParameters.error !== null)
4251
+ throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
4175
4252
  const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
4176
4253
  res.writeHead(200, { "Content-Type": "text/html" });
4177
4254
  res.end(buildSuccessPage(options.landingPage));
@@ -4179,35 +4256,27 @@ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPat
4179
4256
  } catch (error) {
4180
4257
  res.writeHead(400);
4181
4258
  res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
4182
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
4259
+ settle(() => reject(error));
4183
4260
  }
4184
- });
4261
+ };
4262
+ server.on("request", request);
4263
+ signal.addEventListener("abort", aborted, { once: true });
4185
4264
  if (options.readLine !== void 0) {
4186
- options.readLine().then((input) => {
4187
- const callbackParameters = extractCallbackParametersFromInput(input);
4188
- if (callbackParameters === null) {
4189
- settle(() => reject(new Error("OAuth callback missing authorization code")));
4265
+ void Promise.resolve().then(() => settled ? void 0 : options.readLine()).then((input) => {
4266
+ if (settled)
4190
4267
  return;
4191
- }
4192
- try {
4193
- validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
4194
- if (callbackParameters.error !== null) {
4195
- const description = callbackParameters.errorDescription ?? callbackParameters.error;
4196
- throw createAuthorizationError(callbackParameters.error, description);
4197
- }
4198
- const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
4199
- settle(() => resolve(code));
4200
- } catch (error) {
4201
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
4202
- }
4203
- }).catch((error) => {
4204
- settle(() => reject(error instanceof Error ? error : new Error(String(error))));
4205
- });
4268
+ const callbackParameters = extractCallbackParametersFromInput(input);
4269
+ if (callbackParameters === null)
4270
+ throw new Error("OAuth callback missing authorization code");
4271
+ validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
4272
+ if (callbackParameters.error !== null)
4273
+ throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
4274
+ const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
4275
+ settle(() => resolve(code));
4276
+ }).catch((error) => settle(() => reject(error)));
4206
4277
  }
4207
4278
  if (options.openBrowser !== void 0) {
4208
- options.openBrowser(authorizationUrl).catch((error) => {
4209
- settle(() => reject(error));
4210
- });
4279
+ void Promise.resolve().then(() => settled ? void 0 : options.openBrowser(authorizationUrl)).catch((error) => settle(() => reject(error)));
4211
4280
  }
4212
4281
  });
4213
4282
  }
@@ -4387,6 +4456,7 @@ async function exchangeAuthorizationCode(input) {
4387
4456
  resource
4388
4457
  },
4389
4458
  fetch: input.fetch,
4459
+ signal: input.signal,
4390
4460
  now: input.now
4391
4461
  });
4392
4462
  }
@@ -4402,6 +4472,7 @@ async function refreshAccessToken(input) {
4402
4472
  resource
4403
4473
  },
4404
4474
  fetch: input.fetch,
4475
+ signal: input.signal,
4405
4476
  now: input.now
4406
4477
  });
4407
4478
  }
@@ -4413,7 +4484,9 @@ async function requestTokens(input) {
4413
4484
  if (input.clientSecret !== void 0) {
4414
4485
  body.set("client_secret", input.clientSecret);
4415
4486
  }
4416
- const signal = AbortSignal.timeout(3e4);
4487
+ input.signal?.throwIfAborted();
4488
+ const deadline = AbortSignal.timeout(3e4);
4489
+ const signal = input.signal === void 0 ? deadline : AbortSignal.any([input.signal, deadline]);
4417
4490
  const response = await fetchMcpResponse(input.fetch, input.tokenEndpoint, {
4418
4491
  method: "POST",
4419
4492
  headers: {
@@ -4525,7 +4598,7 @@ function createDefaultOAuthClientProvider(options) {
4525
4598
  async authorizeRequest(input) {
4526
4599
  assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
4527
4600
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
4528
- const session = await ensureAuthorizedSession(requestUrl, void 0, input.fetch, false);
4601
+ const session = await ensureAuthorizedSession(requestUrl, void 0, input.fetch, false, false, input.signal);
4529
4602
  const accessToken = session?.tokens?.accessToken;
4530
4603
  if (session === null || accessToken === void 0 || session.tokens === void 0 || isExpired(session.tokens, now)) {
4531
4604
  return;
@@ -4543,12 +4616,13 @@ function createDefaultOAuthClientProvider(options) {
4543
4616
  const session = await ensureAuthorizedSession(resource, {
4544
4617
  ...input.discovery,
4545
4618
  resource
4546
- }, input.fetch, true, forceRefresh);
4619
+ }, input.fetch, true, forceRefresh, input.signal);
4547
4620
  if (session?.tokens?.accessToken === void 0) {
4548
4621
  return { action: "fail" };
4549
4622
  }
4550
4623
  return { action: "retry" };
4551
4624
  } catch (error) {
4625
+ input.signal?.throwIfAborted();
4552
4626
  return {
4553
4627
  action: "fail",
4554
4628
  error: error instanceof Error ? error : new Error(String(error))
@@ -4556,9 +4630,11 @@ function createDefaultOAuthClientProvider(options) {
4556
4630
  }
4557
4631
  }
4558
4632
  };
4559
- async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false) {
4633
+ async function ensureAuthorizedSession(resource, discovery, fetch2, allowInteractive, forceRefresh = false, signal) {
4634
+ signal?.throwIfAborted();
4560
4635
  const canonicalResource = canonicalizeResourceIndicator(resource);
4561
4636
  let session = await loadSession(canonicalResource);
4637
+ signal?.throwIfAborted();
4562
4638
  if (discovery !== void 0 && getOwnString2(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
4563
4639
  throw new Error("OAuth discovery authorization-server issuer mismatch");
4564
4640
  }
@@ -4571,7 +4647,7 @@ function createDefaultOAuthClientProvider(options) {
4571
4647
  return session;
4572
4648
  }
4573
4649
  if (session?.tokens?.refreshToken !== void 0 && sessionDiscovery !== void 0 && (forceRefresh || isExpired(session.tokens, now))) {
4574
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch2);
4650
+ session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch2, signal);
4575
4651
  if (session?.tokens !== void 0 && !isExpired(session.tokens, now)) {
4576
4652
  return session;
4577
4653
  }
@@ -4583,9 +4659,12 @@ function createDefaultOAuthClientProvider(options) {
4583
4659
  if (!allowInteractive || sessionDiscovery === void 0) {
4584
4660
  return session;
4585
4661
  }
4586
- return authorizeSession(canonicalResource, session, sessionDiscovery, fetch2);
4662
+ if (options.allowInteractive === false)
4663
+ throw new Error("OAuth interactive authorization is disabled");
4664
+ return authorizeSession(canonicalResource, session, sessionDiscovery, fetch2, signal);
4587
4665
  }
4588
- async function refreshSession(resource, session, discovery, fetch2) {
4666
+ async function refreshSession(resource, session, discovery, fetch2, signal) {
4667
+ signal?.throwIfAborted();
4589
4668
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
4590
4669
  const inFlight = refreshPromises.get(resource);
4591
4670
  if (inFlight !== void 0) {
@@ -4607,10 +4686,12 @@ function createDefaultOAuthClientProvider(options) {
4607
4686
  refreshToken: session.tokens.refreshToken,
4608
4687
  resource,
4609
4688
  fetch: fetch2,
4689
+ signal,
4610
4690
  now
4611
4691
  });
4612
4692
  break;
4613
4693
  } catch (error) {
4694
+ signal?.throwIfAborted();
4614
4695
  if (error instanceof OAuthError && error.error === "invalid_grant") {
4615
4696
  const clearedSession = clearSessionTokens(session);
4616
4697
  await saveSession(resource, clearedSession);
@@ -4645,7 +4726,8 @@ function createDefaultOAuthClientProvider(options) {
4645
4726
  refreshPromises.set(resource, promise);
4646
4727
  return promise;
4647
4728
  }
4648
- async function authorizeSession(resource, existingSession, discovery, fetch2) {
4729
+ async function authorizeSession(resource, existingSession, discovery, fetch2, signal) {
4730
+ signal?.throwIfAborted();
4649
4731
  const inFlight = authorizationPromises.get(resource);
4650
4732
  if (inFlight !== void 0) {
4651
4733
  return inFlight;
@@ -4661,11 +4743,14 @@ function createDefaultOAuthClientProvider(options) {
4661
4743
  openBrowser: options.browser.openBrowser,
4662
4744
  readLine: options.browser.readLine,
4663
4745
  createServer: options.browser.createServer,
4664
- landingPage: options.browser.landingPage
4746
+ landingPage: options.browser.landingPage,
4747
+ redirectUri: options.browser.redirectUri,
4748
+ signal: options.browser.signal === void 0 ? signal : signal === void 0 ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
4749
+ timeoutMs: options.browser.timeoutMs
4665
4750
  });
4666
4751
  let resolvedClient = null;
4667
4752
  try {
4668
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch2);
4753
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch2, signal);
4669
4754
  const sessionWithoutTokens = {
4670
4755
  resource,
4671
4756
  authorizationServer: discovery.authorizationServer,
@@ -4693,6 +4778,7 @@ function createDefaultOAuthClientProvider(options) {
4693
4778
  redirectUri: loopback.redirectUri,
4694
4779
  resource,
4695
4780
  fetch: fetch2,
4781
+ signal,
4696
4782
  now
4697
4783
  });
4698
4784
  const session = {
@@ -4702,6 +4788,7 @@ function createDefaultOAuthClientProvider(options) {
4702
4788
  await saveSession(resource, session);
4703
4789
  return session;
4704
4790
  } catch (error) {
4791
+ signal?.throwIfAborted();
4705
4792
  if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
4706
4793
  reRegistrationAttempted = true;
4707
4794
  await clearRegisteredClient(discovery.authorizationServer);
@@ -4727,7 +4814,8 @@ function createDefaultOAuthClientProvider(options) {
4727
4814
  authorizationPromises.set(resource, finalPromise);
4728
4815
  return finalPromise;
4729
4816
  }
4730
- async function resolveClient(existingSession, discovery, redirectUri, fetch2) {
4817
+ async function resolveClient(existingSession, discovery, redirectUri, fetch2, parentSignal) {
4818
+ parentSignal?.throwIfAborted();
4731
4819
  const configuredClient = normalizeConfiguredClient(options.client);
4732
4820
  if (options.client.mode === "static") {
4733
4821
  if (configuredClient === null) {
@@ -4777,7 +4865,8 @@ function createDefaultOAuthClientProvider(options) {
4777
4865
  }
4778
4866
  }
4779
4867
  const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
4780
- const signal = AbortSignal.timeout(3e4);
4868
+ const deadline = AbortSignal.timeout(3e4);
4869
+ const signal = parentSignal === void 0 ? deadline : AbortSignal.any([parentSignal, deadline]);
4781
4870
  const response = await fetchMcpResponse(fetch2, registrationEndpoint, {
4782
4871
  method: "POST",
4783
4872
  headers: {
@@ -5340,8 +5429,10 @@ async function readJsonResponse(response, label, signal) {
5340
5429
  throw new Error(`${label} response must be valid JSON`);
5341
5430
  }
5342
5431
  }
5343
- async function fetchMetadata(fetch2, location, label) {
5344
- const signal = AbortSignal.timeout(1e4);
5432
+ async function fetchMetadata(fetch2, location, label, parentSignal) {
5433
+ parentSignal?.throwIfAborted();
5434
+ const deadline = AbortSignal.timeout(1e4);
5435
+ const signal = parentSignal === void 0 ? deadline : AbortSignal.any([deadline, parentSignal]);
5345
5436
  const response = await fetchMcpResponse(fetch2, location, {
5346
5437
  method: "GET",
5347
5438
  headers: { Accept: "application/json" },
@@ -5443,7 +5534,7 @@ var OAuthMetadataDiscovery = class {
5443
5534
  this.fetchImpl = fetch2;
5444
5535
  this.cache = cache;
5445
5536
  }
5446
- async discoverProtectedResource(resource, resourceMetadataUrl) {
5537
+ async discoverProtectedResource(resource, resourceMetadataUrl, signal) {
5447
5538
  const locations = /* @__PURE__ */ new Set([resolveProtectedResourceMetadataUrl(resource, resourceMetadataUrl)]);
5448
5539
  if (resourceMetadataUrl === void 0) {
5449
5540
  locations.add(new URL("/.well-known/oauth-protected-resource", resource).toString());
@@ -5452,17 +5543,19 @@ var OAuthMetadataDiscovery = class {
5452
5543
  for (const location of locations) {
5453
5544
  try {
5454
5545
  const metadata = validateProtectedResourceMetadata(
5455
- await fetchMetadata(this.fetchImpl, location, "Protected resource metadata"),
5546
+ await fetchMetadata(this.fetchImpl, location, "Protected resource metadata", signal),
5456
5547
  resource
5457
5548
  );
5458
5549
  return { location, metadata };
5459
5550
  } catch (error) {
5551
+ signal?.throwIfAborted();
5460
5552
  lastError = error;
5461
5553
  }
5462
5554
  }
5463
5555
  throw lastError;
5464
5556
  }
5465
- async discover(resourceUrl, { resourceMetadataUrl } = {}) {
5557
+ async discover(resourceUrl, { resourceMetadataUrl, signal } = {}) {
5558
+ signal?.throwIfAborted();
5466
5559
  const cacheKey = canonicalizeResourceIndicator(resourceUrl);
5467
5560
  resolveProtectedResourceMetadataUrl(cacheKey, resourceMetadataUrl);
5468
5561
  const memoryCachedResult = this.memoryCache.get(cacheKey);
@@ -5470,6 +5563,7 @@ var OAuthMetadataDiscovery = class {
5470
5563
  return structuredClone(memoryCachedResult);
5471
5564
  }
5472
5565
  const sharedCachedResult = await this.cache?.get(cacheKey);
5566
+ signal?.throwIfAborted();
5473
5567
  if (sharedCachedResult !== null && sharedCachedResult !== void 0 && resourceMetadataUrl === void 0) {
5474
5568
  try {
5475
5569
  const result = validateCachedDiscovery(sharedCachedResult, cacheKey);
@@ -5479,7 +5573,7 @@ var OAuthMetadataDiscovery = class {
5479
5573
  await this.cache?.delete?.(cacheKey);
5480
5574
  }
5481
5575
  }
5482
- const { location: resourceMetadataLocation, metadata: resourceMetadata } = await this.discoverProtectedResource(cacheKey, resourceMetadataUrl);
5576
+ const { location: resourceMetadataLocation, metadata: resourceMetadata } = await this.discoverProtectedResource(cacheKey, resourceMetadataUrl, signal);
5483
5577
  const authorizationServerErrors = [];
5484
5578
  for (const authorizationServer of resourceMetadata.authorization_servers) {
5485
5579
  const normalizedAuthorizationServer = validateAuthorizationServerIssuer(authorizationServer);
@@ -5487,7 +5581,7 @@ var OAuthMetadataDiscovery = class {
5487
5581
  for (const authorizationServerMetadataUrl of metadataLocations) {
5488
5582
  try {
5489
5583
  const authorizationServerMetadata = validateAuthorizationServerMetadata(
5490
- await fetchMetadata(this.fetchImpl, authorizationServerMetadataUrl, "Authorization server metadata"),
5584
+ await fetchMetadata(this.fetchImpl, authorizationServerMetadataUrl, "Authorization server metadata", signal),
5491
5585
  normalizedAuthorizationServer
5492
5586
  );
5493
5587
  const result = {
@@ -5502,6 +5596,7 @@ var OAuthMetadataDiscovery = class {
5502
5596
  await this.cache?.set(cacheKey, structuredClone(result));
5503
5597
  return result;
5504
5598
  } catch (error) {
5599
+ signal?.throwIfAborted();
5505
5600
  authorizationServerErrors.push(
5506
5601
  `${authorizationServerMetadataUrl}: ${error instanceof Error ? error.message : String(error)}`
5507
5602
  );
@@ -6642,6 +6737,7 @@ var HttpTransport = class {
6642
6737
  oauthProvider;
6643
6738
  oauthMetadataDiscovery;
6644
6739
  inFlightFetchAbortControllers = /* @__PURE__ */ new Set();
6740
+ inFlightOAuthAbortControllers = /* @__PURE__ */ new Set();
6645
6741
  openResponseReaders = /* @__PURE__ */ new Set();
6646
6742
  modernRequests = /* @__PURE__ */ new Map();
6647
6743
  modernMode = false;
@@ -6711,7 +6807,7 @@ var HttpTransport = class {
6711
6807
  this.rejectLegacyEndpoint = void 0;
6712
6808
  this.resolveLegacyEndpoint = void 0;
6713
6809
  this.toolParameterHeaders.clear();
6714
- this.abortInFlightFetches();
6810
+ this.abortInFlightFetches(reason);
6715
6811
  this.cancelOpenResponseReaders();
6716
6812
  if (!this.writeStream.destroyed && !this.writeStream.writableEnded) {
6717
6813
  this.writeStream.end();
@@ -6747,12 +6843,14 @@ var HttpTransport = class {
6747
6843
  this.resolveClosed = void 0;
6748
6844
  resolveClosed?.({ reason: closeReason });
6749
6845
  }
6750
- abortInFlightFetches() {
6751
- for (const controller of this.modernRequests.values()) controller.abort();
6846
+ abortInFlightFetches(reason) {
6847
+ for (const controller of this.modernRequests.values()) controller.abort(reason);
6752
6848
  this.modernRequests.clear();
6753
6849
  for (const abortController of this.inFlightFetchAbortControllers) {
6754
- abortController.abort();
6850
+ abortController.abort(reason);
6755
6851
  }
6852
+ for (const controller of this.inFlightOAuthAbortControllers) controller.abort(reason);
6853
+ this.inFlightOAuthAbortControllers.clear();
6756
6854
  this.inFlightFetchAbortControllers.clear();
6757
6855
  }
6758
6856
  cancelOpenResponseReaders() {
@@ -6818,7 +6916,7 @@ var HttpTransport = class {
6818
6916
  const response = await this.fetchWithOAuthRetry({
6819
6917
  url: postUrl,
6820
6918
  method: "POST",
6821
- createHeaders: () => this.createPostHeaders(message, modern),
6919
+ createHeaders: (signal) => this.createPostHeaders(message, modern, signal),
6822
6920
  body: line,
6823
6921
  controller
6824
6922
  });
@@ -6858,7 +6956,7 @@ var HttpTransport = class {
6858
6956
  this.modernRequests.delete(id);
6859
6957
  }
6860
6958
  }
6861
- async createPostHeaders(message, modern = false) {
6959
+ async createPostHeaders(message, modern = false, signal) {
6862
6960
  const headers = new Headers(this.headers);
6863
6961
  headers.set("Accept", "application/json, text/event-stream");
6864
6962
  headers.set("Content-Type", "application/json");
@@ -6888,9 +6986,9 @@ var HttpTransport = class {
6888
6986
  headers.set("Mcp-Session-Id", this.sessionId);
6889
6987
  headers.set("MCP-Protocol-Version", MCP_PROTOCOL_VERSION);
6890
6988
  }
6891
- return this.authorizeRequestHeaders(headers);
6989
+ return this.authorizeRequestHeaders(headers, signal);
6892
6990
  }
6893
- async createGetHeaders() {
6991
+ async createGetHeaders(signal) {
6894
6992
  const headers = new Headers(this.headers);
6895
6993
  headers.set("Accept", "text/event-stream");
6896
6994
  if (this.sessionId !== void 0) {
@@ -6900,20 +6998,26 @@ var HttpTransport = class {
6900
6998
  if (this.lastEventId !== void 0) {
6901
6999
  headers.set("Last-Event-ID", this.lastEventId);
6902
7000
  }
6903
- return this.authorizeRequestHeaders(headers);
7001
+ return this.authorizeRequestHeaders(headers, signal);
6904
7002
  }
6905
- async createDeleteHeaders(sessionId) {
7003
+ async createDeleteHeaders(sessionId, signal) {
6906
7004
  const headers = new Headers(this.headers);
6907
7005
  headers.set("Mcp-Session-Id", sessionId);
6908
7006
  headers.set("MCP-Protocol-Version", MCP_PROTOCOL_VERSION);
6909
- return this.authorizeRequestHeaders(headers);
7007
+ return this.authorizeRequestHeaders(headers, signal);
6910
7008
  }
6911
- async authorizeRequestHeaders(headers) {
7009
+ async authorizeRequestHeaders(headers, signal) {
7010
+ signal?.throwIfAborted();
6912
7011
  await this.oauthProvider?.authorizeRequest?.({
6913
7012
  requestUrl: new URL(this.url),
6914
7013
  headers,
6915
- fetch: this.fetchImpl
7014
+ signal,
7015
+ fetch: (url, init) => fetchMcpResponse(this.fetchImpl, url, {
7016
+ ...init,
7017
+ signal: signal === void 0 ? init?.signal : init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
7018
+ })
6916
7019
  });
7020
+ signal?.throwIfAborted();
6917
7021
  return headers;
6918
7022
  }
6919
7023
  captureSessionId(response) {
@@ -6950,7 +7054,7 @@ var HttpTransport = class {
6950
7054
  return this.legacyEndpointReady;
6951
7055
  }
6952
7056
  async sendSessionTerminationRequest(sessionId, signal) {
6953
- const headers = await this.createDeleteHeaders(sessionId);
7057
+ const headers = await this.createDeleteHeaders(sessionId, signal);
6954
7058
  signal.throwIfAborted();
6955
7059
  const response = await fetchMcpResponse(this.fetchImpl, this.url, {
6956
7060
  method: "DELETE",
@@ -6973,7 +7077,7 @@ var HttpTransport = class {
6973
7077
  async consumeGetSseStream() {
6974
7078
  const response = await this.fetchWithOAuthRetry({
6975
7079
  method: "GET",
6976
- createHeaders: () => this.createGetHeaders()
7080
+ createHeaders: (signal) => this.createGetHeaders(signal)
6977
7081
  });
6978
7082
  if (this.disposed) {
6979
7083
  void response.body?.cancel().catch(() => void 0);
@@ -7056,7 +7160,7 @@ var HttpTransport = class {
7056
7160
  const message = responseBody.length === 0 ? `HTTP transport POST failed (${statusDescriptor})` : `HTTP transport POST failed (${statusDescriptor}): ${responseBody}`;
7057
7161
  throw new HttpTransportError(message, response.status, "POST");
7058
7162
  }
7059
- async maybeHandleUnauthorizedResponse(response) {
7163
+ async maybeHandleUnauthorizedResponse(response, signal) {
7060
7164
  if (response.status !== 401 || this.oauthProvider === void 0) {
7061
7165
  return false;
7062
7166
  }
@@ -7067,7 +7171,7 @@ var HttpTransport = class {
7067
7171
  const challenge = parseBearerWwwAuthenticateHeader(response.headers.get("WWW-Authenticate"));
7068
7172
  const resourceMetadataUrl = challenge?.params.resource_metadata;
7069
7173
  try {
7070
- const discovery = await discoveryClient.discover(this.url, { resourceMetadataUrl });
7174
+ const discovery = await discoveryClient.discover(this.url, { resourceMetadataUrl, signal });
7071
7175
  const providerResponse = response.clone();
7072
7176
  let result;
7073
7177
  try {
@@ -7076,8 +7180,13 @@ var HttpTransport = class {
7076
7180
  response: providerResponse,
7077
7181
  challenge,
7078
7182
  discovery,
7079
- fetch: this.fetchImpl
7183
+ signal,
7184
+ fetch: (url, init) => fetchMcpResponse(this.fetchImpl, url, {
7185
+ ...init,
7186
+ signal: init?.signal == null ? signal : AbortSignal.any([signal, init.signal])
7187
+ })
7080
7188
  });
7189
+ signal.throwIfAborted();
7081
7190
  } finally {
7082
7191
  void providerResponse.body?.cancel().catch(() => void 0);
7083
7192
  }
@@ -7207,25 +7316,30 @@ var HttpTransport = class {
7207
7316
  `);
7208
7317
  }
7209
7318
  async fetchWithOAuthRetry(input) {
7210
- const request = async () => this.fetchWithAbort(
7211
- input.url ?? this.url,
7212
- {
7319
+ const controller = input.controller ?? new AbortController();
7320
+ this.inFlightOAuthAbortControllers.add(controller);
7321
+ const request = async () => {
7322
+ controller.signal.throwIfAborted();
7323
+ const headers = await input.createHeaders(controller.signal);
7324
+ controller.signal.throwIfAborted();
7325
+ return this.fetchWithAbort(input.url ?? this.url, {
7213
7326
  method: input.method,
7214
- headers: await input.createHeaders(),
7327
+ headers,
7215
7328
  body: input.body
7216
- },
7217
- input.controller
7218
- );
7219
- let response = await request();
7220
- if (await this.maybeHandleUnauthorizedResponse(response)) {
7221
- response = await request();
7222
- }
7223
- const oauthError = this.oauthProvider === void 0 ? null : this.readOAuthChallengeError(response);
7224
- if (oauthError !== null) {
7225
- void response.body?.cancel().catch(() => void 0);
7226
- throw oauthError;
7329
+ }, controller);
7330
+ };
7331
+ try {
7332
+ let response = await request();
7333
+ if (await this.maybeHandleUnauthorizedResponse(response, controller.signal)) response = await request();
7334
+ const oauthError = this.oauthProvider === void 0 ? null : this.readOAuthChallengeError(response);
7335
+ if (oauthError !== null) {
7336
+ void response.body?.cancel().catch(() => void 0);
7337
+ throw oauthError;
7338
+ }
7339
+ return response;
7340
+ } finally {
7341
+ this.inFlightOAuthAbortControllers.delete(controller);
7227
7342
  }
7228
- return response;
7229
7343
  }
7230
7344
  readOAuthChallengeError(response) {
7231
7345
  if (response.status !== 401 && response.status !== 403) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",