tiny-http-mcp-server 0.1.20 → 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.20",
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?: {
@@ -36,6 +36,8 @@ Legacy SSE mode opens a GET stream and posts messages to the endpoint announced
36
36
  by the server. Announced endpoints must remain on the original origin without
37
37
  embedded credentials or fragments; endpoint changes close the connection.
38
38
  Both transports accept the same headers, OAuth provider and response limits.
39
+ HTTP failures expose `HttpTransportError.status` and `.method`, so callers can
40
+ make transport decisions without parsing error messages.
39
41
 
40
42
  ## OAuth HTTP support
41
43
 
@@ -64,7 +66,9 @@ const transport = new HttpTransport({
64
66
  });
65
67
  ```
66
68
 
67
- 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.
68
72
 
69
73
  ## Testing helpers
70
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
 
@@ -583,6 +592,11 @@ interface StdioTransportOptions {
583
592
  spawn?: StdioSpawn;
584
593
  }
585
594
  type HttpTransportFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
595
+ declare class HttpTransportError extends Error {
596
+ readonly status: number;
597
+ readonly method: "GET" | "POST" | "DELETE";
598
+ constructor(message: string, status: number, method: "GET" | "POST" | "DELETE");
599
+ }
586
600
  interface HttpTransportOptions {
587
601
  url: string;
588
602
  /** Legacy SSE uses a GET stream that announces the RPC POST endpoint. */
@@ -629,6 +643,7 @@ declare class HttpTransport implements McpTransport {
629
643
  private readonly oauthProvider;
630
644
  private readonly oauthMetadataDiscovery;
631
645
  private readonly inFlightFetchAbortControllers;
646
+ private readonly inFlightOAuthAbortControllers;
632
647
  private readonly openResponseReaders;
633
648
  private readonly modernRequests;
634
649
  private modernMode;
@@ -743,5 +758,5 @@ declare class JsonRpcMessageLayer {
743
758
  private handleCancellationNotification;
744
759
  }
745
760
 
746
- export { ERROR_INTERNAL, ERROR_INVALID_PARAMS, ERROR_INVALID_REQUEST, ERROR_METHOD_NOT_FOUND, ERROR_PARSE, HttpTransport, JsonRpcMessageLayer, McpClient, McpError, OAuthMetadataDiscovery, StdioTransport, createInMemoryTransportPair, createSdkTestPair, createTestPair, discoverOAuthMetadata, fetchMcpResponse };
761
+ export { ERROR_INTERNAL, ERROR_INVALID_PARAMS, ERROR_INVALID_REQUEST, ERROR_METHOD_NOT_FOUND, ERROR_PARSE, HttpTransport, HttpTransportError, JsonRpcMessageLayer, McpClient, McpError, OAuthMetadataDiscovery, StdioTransport, createInMemoryTransportPair, createSdkTestPair, createTestPair, discoverOAuthMetadata, fetchMcpResponse };
747
762
  export type { AudioContent, BlobResourceContents, CacheableResultMetadata, CallToolOptions, CallToolParams, CallToolResult, ClientCapabilities, CompleteArgument, CompleteParams, CompleteResult, Completion, ConnectResult, ContentItem, CreateMessageParams, CreateMessageResult, ElicitationParams, ElicitationResult, EmbeddedResource, GetPromptParams, GetPromptResult, HttpTransportFetch, HttpTransportOptions, ImageContent, InMemoryTransportPair, IncludeContext, InitializeParams, InitializeResult, JsonRpcErrorObject, JsonRpcErrorResponse, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcRequestOptions, JsonRpcResponse, JsonRpcSuccessResponse, LogLevel, LogMessage, McpClientConnection, McpClientOptions, McpRequestContext, McpSubscription, McpTransport, McpTransportClosedEvent, ModelHint, ModelPreferences, NotificationFilter, OAuthAuthorizationServerMetadata, OAuthClientProvider, OAuthClientProviderOptions, OAuthDiscoveryCache, OAuthDiscoveryResult, OAuthMetadataFetch, OAuthProtectedResourceMetadata, OAuthSessionStore, OAuthUnauthorizedChallenge, PaginatedParams, PaginatedResult, ProgressParams, ProgressToken, PromptMessage, PromptReference, ReadResourceParams, RequestId, ResourceContents, ResourceReference, ResultMetadata, Root, SamplingContent, SamplingMessage, SdkTestPair, ServerCapabilities, StdioSpawn, StdioTransportOptions, StoredOAuthSession, SubscriptionOptions, TextContent, TextResourceContents, Tool, ToolResultContent, ToolUseContent };