tiny-http-mcp-server 0.1.21 → 0.1.23

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.23",
22
22
  "license": "MIT"
23
23
  },
24
24
  {
@@ -40,10 +40,15 @@ 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
+ - `initialGrant: { resource, tokens }` optionally imports an existing Bearer grant for one HTTP resource; requires the original client ID
45
+ - `browser.openBrowser(url)` optional
44
46
  - `browser.readLine()` optional
45
47
  - `browser.createServer()` optional
46
48
  - `browser.landingPage` optional
49
+ - `browser.redirectUri` optional exact registered HTTP loopback callback with a fixed port
50
+ - `browser.signal` optional cancellation signal
51
+ - `browser.timeoutMs` optional authorization deadline (default 120,000 ms)
47
52
  - `sessionStore` optional
48
53
  - `authStore` optional `auth-store` backend config for the default session store
49
54
  - `now()` optional clock override
@@ -62,6 +67,31 @@ const verifier = createJwksTokenVerifier({
62
67
  | `requireAccessTokenType` | `boolean` | `false` | Require the JWT `typ` protected header to be `at+jwt`. |
63
68
  | `fetch` | `typeof fetch` | global `fetch` | Custom fetch implementation. |
64
69
 
70
+ Fixed redirects support `localhost`, `127.0.0.1`, and `::1` over HTTP. Their
71
+ exact spelling, port, path and query are preserved through registration,
72
+ authorization and code exchange. Credentials, fragments, port zero and reserved
73
+ OAuth callback query parameters are rejected before binding a listener. Omit
74
+ `redirectUri` to allocate a random loopback port. Standalone callback sessions
75
+ accept the same `redirectUri`, `signal` and `timeoutMs` options. Cancellation,
76
+ timeout and explicit close settle pending code waits and release listeners.
77
+ Always close a successful standalone session in `finally`.
78
+
79
+ Provider request inputs accept an optional `signal`. It reaches callback waits,
80
+ registration, token requests and bounded token-body reads. Cancellation retains
81
+ its original reason and does not retry authorization. Custom providers should
82
+ observe the supplied signal and pass it to any work they start.
83
+
84
+ Configure `client.metadata.scope` to request a precise scope set; broader
85
+ discovery metadata does not override it.
86
+
87
+ Imported `initialGrant.tokens` use `accessToken`, optional `refreshToken`,
88
+ `tokenType: "Bearer"`, `expiresAt` (Unix epoch milliseconds or `null` if unknown),
89
+ and optional `scope`. A fresh imported token is used only for its resource.
90
+ Discovery binds an expired or explicitly rejected grant before silent refresh,
91
+ using the original configured client. Persisted sessions take precedence,
92
+ including sessions whose tokens have been cleared; an import cannot revive them.
93
+ Input tokens are copied and invalid expiry values fail before authorization.
94
+
65
95
  `createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
66
96
 
67
97
  ## Environment Variables
@@ -21,12 +21,36 @@ export function createDefaultOAuthClientProvider(options) {
21
21
  const registeredClients = new Map();
22
22
  const refreshPromises = new Map();
23
23
  const authorizationPromises = new Map();
24
+ if (options.initialGrant !== undefined) {
25
+ let resource;
26
+ try {
27
+ resource = new URL(options.initialGrant.resource);
28
+ }
29
+ catch {
30
+ throw new Error("OAuth initial grant resource must be an absolute HTTP URL");
31
+ }
32
+ if ((resource.protocol !== "http:" && resource.protocol !== "https:") || resource.username || resource.password || resource.hash)
33
+ throw new Error("OAuth initial grant resource must be an HTTP URL without credentials or fragments");
34
+ }
35
+ const initialGrant = options.initialGrant === undefined ? undefined : {
36
+ resource: canonicalizeResourceIndicator(options.initialGrant.resource),
37
+ tokens: normalizeStoredTokens(options.initialGrant.tokens),
38
+ client: normalizeConfiguredClient(options.client)
39
+ };
40
+ if (initialGrant !== undefined && (initialGrant.tokens === undefined || initialGrant.client === null))
41
+ throw new Error("OAuth initial grant requires valid tokens and the original client ID");
42
+ let initialGrantConsumed = false;
24
43
  return {
25
44
  async authorizeRequest(input) {
26
45
  assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
27
46
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
28
- const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false);
47
+ const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false, false, input.signal);
29
48
  const accessToken = session?.tokens?.accessToken;
49
+ if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
50
+ initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
51
+ input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
52
+ return;
53
+ }
30
54
  if (session === null ||
31
55
  accessToken === undefined ||
32
56
  session.tokens === undefined ||
@@ -42,18 +66,19 @@ export function createDefaultOAuthClientProvider(options) {
42
66
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
43
67
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
44
68
  assertRequestMatchesResource(requestUrl, resource);
45
- const forceRefresh = hasCachedAccessToken(await loadSession(resource)) &&
69
+ const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
46
70
  input.challenge?.params.error === "invalid_token";
47
71
  const session = await ensureAuthorizedSession(resource, {
48
72
  ...input.discovery,
49
73
  resource
50
- }, input.fetch, true, forceRefresh);
74
+ }, input.fetch, true, forceRefresh, input.signal);
51
75
  if (session?.tokens?.accessToken === undefined) {
52
76
  return { action: "fail" };
53
77
  }
54
78
  return { action: "retry" };
55
79
  }
56
80
  catch (error) {
81
+ input.signal?.throwIfAborted();
57
82
  return {
58
83
  action: "fail",
59
84
  error: error instanceof Error ? error : new Error(String(error))
@@ -61,9 +86,13 @@ export function createDefaultOAuthClientProvider(options) {
61
86
  }
62
87
  }
63
88
  };
64
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false) {
89
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
90
+ signal?.throwIfAborted();
65
91
  const canonicalResource = canonicalizeResourceIndicator(resource);
66
92
  let session = await loadSession(canonicalResource);
93
+ if (session !== null && initialGrant?.resource === canonicalResource)
94
+ initialGrantConsumed = true;
95
+ signal?.throwIfAborted();
67
96
  if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
68
97
  throw new Error("OAuth discovery authorization-server issuer mismatch");
69
98
  }
@@ -73,6 +102,15 @@ export function createDefaultOAuthClientProvider(options) {
73
102
  await clearSession(canonicalResource);
74
103
  session = null;
75
104
  }
105
+ if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
106
+ initialGrant.tokens !== undefined && initialGrant.client !== null) {
107
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
108
+ session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
109
+ client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
110
+ await saveSession(canonicalResource, session);
111
+ initialGrantConsumed = true;
112
+ signal?.throwIfAborted();
113
+ }
76
114
  const sessionDiscovery = resolveDiscovery(discovery, session);
77
115
  if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
78
116
  return session;
@@ -80,7 +118,7 @@ export function createDefaultOAuthClientProvider(options) {
80
118
  if (session?.tokens?.refreshToken !== undefined &&
81
119
  sessionDiscovery !== undefined &&
82
120
  (forceRefresh || isExpired(session.tokens, now))) {
83
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch);
121
+ session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
84
122
  if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
85
123
  return session;
86
124
  }
@@ -92,9 +130,12 @@ export function createDefaultOAuthClientProvider(options) {
92
130
  if (!allowInteractive || sessionDiscovery === undefined) {
93
131
  return session;
94
132
  }
95
- return authorizeSession(canonicalResource, session, sessionDiscovery, fetch);
133
+ if (options.allowInteractive === false)
134
+ throw new Error("OAuth interactive authorization is disabled");
135
+ return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
96
136
  }
97
- async function refreshSession(resource, session, discovery, fetch) {
137
+ async function refreshSession(resource, session, discovery, fetch, signal) {
138
+ signal?.throwIfAborted();
98
139
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
99
140
  const inFlight = refreshPromises.get(resource);
100
141
  if (inFlight !== undefined) {
@@ -115,12 +156,13 @@ export function createDefaultOAuthClientProvider(options) {
115
156
  clientSecret: session.client.clientSecret,
116
157
  refreshToken: session.tokens.refreshToken,
117
158
  resource,
118
- fetch,
159
+ fetch, signal,
119
160
  now
120
161
  });
121
162
  break;
122
163
  }
123
164
  catch (error) {
165
+ signal?.throwIfAborted();
124
166
  if (error instanceof OAuthError && error.error === "invalid_grant") {
125
167
  const clearedSession = clearSessionTokens(session);
126
168
  await saveSession(resource, clearedSession);
@@ -156,7 +198,8 @@ export function createDefaultOAuthClientProvider(options) {
156
198
  refreshPromises.set(resource, promise);
157
199
  return promise;
158
200
  }
159
- async function authorizeSession(resource, existingSession, discovery, fetch) {
201
+ async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
202
+ signal?.throwIfAborted();
160
203
  const inFlight = authorizationPromises.get(resource);
161
204
  if (inFlight !== undefined) {
162
205
  return inFlight;
@@ -172,11 +215,14 @@ export function createDefaultOAuthClientProvider(options) {
172
215
  openBrowser: options.browser.openBrowser,
173
216
  readLine: options.browser.readLine,
174
217
  createServer: options.browser.createServer,
175
- landingPage: options.browser.landingPage
218
+ landingPage: options.browser.landingPage,
219
+ redirectUri: options.browser.redirectUri,
220
+ signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
221
+ timeoutMs: options.browser.timeoutMs
176
222
  });
177
223
  let resolvedClient = null;
178
224
  try {
179
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch);
225
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
180
226
  const sessionWithoutTokens = {
181
227
  resource,
182
228
  authorizationServer: discovery.authorizationServer,
@@ -203,7 +249,7 @@ export function createDefaultOAuthClientProvider(options) {
203
249
  codeVerifier: verifier,
204
250
  redirectUri: loopback.redirectUri,
205
251
  resource,
206
- fetch,
252
+ fetch, signal,
207
253
  now
208
254
  });
209
255
  const session = {
@@ -214,6 +260,7 @@ export function createDefaultOAuthClientProvider(options) {
214
260
  return session;
215
261
  }
216
262
  catch (error) {
263
+ signal?.throwIfAborted();
217
264
  if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
218
265
  reRegistrationAttempted = true;
219
266
  await clearRegisteredClient(discovery.authorizationServer);
@@ -240,7 +287,8 @@ export function createDefaultOAuthClientProvider(options) {
240
287
  authorizationPromises.set(resource, finalPromise);
241
288
  return finalPromise;
242
289
  }
243
- async function resolveClient(existingSession, discovery, redirectUri, fetch) {
290
+ async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
291
+ parentSignal?.throwIfAborted();
244
292
  const configuredClient = normalizeConfiguredClient(options.client);
245
293
  if (options.client.mode === "static") {
246
294
  if (configuredClient === null) {
@@ -292,7 +340,8 @@ export function createDefaultOAuthClientProvider(options) {
292
340
  }
293
341
  }
294
342
  const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
295
- const signal = AbortSignal.timeout(30_000);
343
+ const deadline = AbortSignal.timeout(30_000);
344
+ const signal = parentSignal === undefined ? deadline : AbortSignal.any([parentSignal, deadline]);
296
345
  const response = await fetchMcpResponse(fetch, registrationEndpoint, {
297
346
  method: "POST",
298
347
  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,19 @@ 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;
102
+ /** Import an existing grant for one resource. Persisted sessions take precedence. */
103
+ initialGrant?: {
104
+ resource: string;
105
+ tokens: StoredOAuthTokens;
106
+ };
98
107
  browser: {
99
- openBrowser(url: string): Promise<void>;
108
+ openBrowser?(url: string): Promise<void>;
109
+ /** Exact registered HTTP loopback redirect URI. */
110
+ redirectUri?: string;
111
+ signal?: AbortSignal;
112
+ timeoutMs?: number;
100
113
  readLine?: () => Promise<string>;
101
114
  createServer?: () => http.Server;
102
115
  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