toolcraft 0.0.309 → 0.0.311

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.
Files changed (26) hide show
  1. package/composition.json +3 -3
  2. package/dist/composition.json +3 -3
  3. package/node_modules/auth-store/README.md +16 -0
  4. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +3 -0
  5. package/node_modules/auth-store/dist/encrypted-file-store.js +7 -0
  6. package/node_modules/auth-store/dist/index.d.ts +1 -0
  7. package/node_modules/auth-store/dist/keychain-store.d.ts +8 -0
  8. package/node_modules/auth-store/dist/keychain-store.js +13 -0
  9. package/node_modules/auth-store/dist/transaction-lock.d.ts +24 -0
  10. package/node_modules/auth-store/dist/transaction-lock.js +152 -0
  11. package/node_modules/auth-store/dist/types.d.ts +2 -0
  12. package/node_modules/tiny-http-mcp-server/dist/composition.json +1 -1
  13. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/README.md +33 -0
  14. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +8 -0
  15. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +202 -185
  16. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/session-transaction.d.ts +6 -0
  17. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/session-transaction.js +42 -0
  18. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/token-endpoint.d.ts +3 -1
  19. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/token-endpoint.js +7 -3
  20. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/types.d.ts +14 -1
  21. package/node_modules/tiny-http-mcp-server/package.json +1 -1
  22. package/node_modules/tiny-mcp-client/dist/index.d.ts +39 -1
  23. package/node_modules/tiny-mcp-client/dist/index.js +503 -254
  24. package/node_modules/tiny-stdio-mcp-server/dist/composition.json +1 -1
  25. package/node_modules/toolcraft-schema/package.json +1 -1
  26. package/package.json +2 -2
@@ -7,6 +7,7 @@ import { createAuthorizationState } from "./authorization-state.js";
7
7
  import { generateCodeChallenge, generateCodeVerifier } from "./pkce.js";
8
8
  import { exchangeAuthorizationCode, OAuthError, refreshAccessToken, isRetryableOAuthError, readOAuthJsonObjectResponse } from "./token-endpoint.js";
9
9
  import { canonicalizeResourceIndicator } from "../resource-indicator.js";
10
+ import { withOAuthSessionTransaction } from "./session-transaction.js";
10
11
  const MAX_JS_DATE_MS = 8_640_000_000_000_000;
11
12
  export function createOAuthClientProvider(options) {
12
13
  if (isProviderOptions(options)) {
@@ -20,8 +21,6 @@ export function createDefaultOAuthClientProvider(options) {
20
21
  const clientStore = options.authStore === undefined ? null : createAuthStoreClientStore(options.authStore);
21
22
  const now = options.now ?? Date.now;
22
23
  const registeredClients = new Map();
23
- const refreshPromises = new Map();
24
- const authorizationPromises = new Map();
25
24
  if (options.initialGrant !== undefined) {
26
25
  let resource;
27
26
  try {
@@ -58,7 +57,7 @@ export function createDefaultOAuthClientProvider(options) {
58
57
  if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
59
58
  initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
60
59
  input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
61
- return;
60
+ return { ...initialGrant.tokens };
62
61
  }
63
62
  if (session === null ||
64
63
  accessToken === undefined ||
@@ -68,6 +67,7 @@ export function createDefaultOAuthClientProvider(options) {
68
67
  }
69
68
  assertRequestMatchesResource(requestUrl, session.resource);
70
69
  input.headers.set("Authorization", `Bearer ${accessToken}`);
70
+ return { ...session.tokens };
71
71
  },
72
72
  async handleUnauthorized(input) {
73
73
  try {
@@ -75,12 +75,28 @@ export function createDefaultOAuthClientProvider(options) {
75
75
  const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
76
76
  const resource = canonicalizeResourceIndicator(input.discovery.resource);
77
77
  assertRequestMatchesResource(requestUrl, resource);
78
- const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
79
- input.challenge?.params.error === "invalid_token";
78
+ const cached = await loadSession(resource);
79
+ const currentTokens = cached?.tokens ?? (!initialGrantConsumed && initialGrant?.resource === resource ? initialGrant.tokens : undefined);
80
+ let rejectedCurrentGrant = hasCachedAccessToken(cached) || (!initialGrantConsumed && initialGrant?.resource === resource);
81
+ let presentedTokens = input.presentedTokens;
82
+ if (input.presentedTokens !== undefined) {
83
+ rejectedCurrentGrant = false;
84
+ if (input.presentedTokens !== null) {
85
+ const presented = normalizeStoredTokens(input.presentedTokens);
86
+ const header = input.requestHeaders?.get("Authorization") ?? "";
87
+ const separator = header.indexOf(" ");
88
+ if (presented === undefined || header.slice(0, separator).toLowerCase() !== "bearer" || header.slice(separator + 1).trim() !== presented.accessToken)
89
+ throw new Error("OAuth rejected-request provenance does not match its authorization header");
90
+ presentedTokens = presented;
91
+ rejectedCurrentGrant = currentTokens !== undefined && sameTokenGrant(currentTokens, presented);
92
+ }
93
+ }
94
+ const challengeError = input.challenge?.params.error;
95
+ const forceRefresh = rejectedCurrentGrant && (challengeError === "invalid_token" || (input.presentedTokens !== undefined && challengeError === undefined));
80
96
  const session = await ensureAuthorizedSession(resource, {
81
97
  ...input.discovery,
82
98
  resource
83
- }, input.fetch, true, forceRefresh, input.signal);
99
+ }, input.fetch, true, forceRefresh, input.signal, presentedTokens);
84
100
  if (session?.tokens?.accessToken === undefined) {
85
101
  return { action: "fail" };
86
102
  }
@@ -95,206 +111,199 @@ export function createDefaultOAuthClientProvider(options) {
95
111
  }
96
112
  }
97
113
  };
98
- async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
114
+ async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
99
115
  signal?.throwIfAborted();
100
116
  const canonicalResource = canonicalizeResourceIndicator(resource);
101
- let session = await loadSession(canonicalResource);
102
- if (session !== null && initialGrant?.resource === canonicalResource)
103
- initialGrantConsumed = true;
104
- signal?.throwIfAborted();
105
- if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
106
- throw new Error("OAuth discovery authorization-server issuer mismatch");
107
- }
108
- if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource
109
- || getOwnString(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer
110
- || (discovery !== undefined && discovery.authorizationServer !== session.authorizationServer))) {
111
- await clearSession(canonicalResource);
112
- session = null;
113
- }
114
- if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
115
- initialGrant.tokens !== undefined && initialGrant.client !== null) {
116
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
117
- session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
118
- client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
119
- await saveSession(canonicalResource, session);
120
- initialGrantConsumed = true;
117
+ return withOAuthSessionTransaction(sessionStore, canonicalResource, async () => {
118
+ let session = await loadSession(canonicalResource);
119
+ if (session !== null && initialGrant?.resource === canonicalResource)
120
+ initialGrantConsumed = true;
121
121
  signal?.throwIfAborted();
122
- }
123
- const sessionDiscovery = resolveDiscovery(discovery, session);
124
- if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
125
- return session;
126
- }
127
- if (session?.tokens?.refreshToken !== undefined &&
128
- sessionDiscovery !== undefined &&
129
- (forceRefresh || isExpired(session.tokens, now))) {
130
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
131
- if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
122
+ if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
123
+ throw new Error("OAuth discovery authorization-server issuer mismatch");
124
+ }
125
+ if (session !== null && (canonicalizeResourceIndicator(session.resource) !== canonicalResource
126
+ || getOwnString(session.discovery.authorizationServerMetadata, "issuer") !== session.authorizationServer
127
+ || (discovery !== undefined && discovery.authorizationServer !== session.authorizationServer))) {
128
+ await clearSession(canonicalResource);
129
+ session = null;
130
+ }
131
+ if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
132
+ initialGrant.tokens !== undefined && initialGrant.client !== null) {
133
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
134
+ session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
135
+ client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
136
+ await saveSession(canonicalResource, session);
137
+ initialGrantConsumed = true;
138
+ signal?.throwIfAborted();
139
+ }
140
+ if (forceRefresh && rejectedTokens !== undefined && (rejectedTokens === null || session?.tokens === undefined || !sameTokenGrant(session.tokens, rejectedTokens)))
141
+ forceRefresh = false;
142
+ const sessionDiscovery = resolveDiscovery(discovery, session);
143
+ if (session?.refreshState === "pending") {
144
+ if (!allowInteractive || options.allowInteractive === false || sessionDiscovery === undefined)
145
+ throw new Error("OAuth refresh outcome is unknown; authorize again before using this resource");
146
+ return authorizeSession(canonicalResource, clearSessionTokens(session), sessionDiscovery, fetch, signal);
147
+ }
148
+ if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
132
149
  return session;
133
150
  }
134
- }
135
- if (forceRefresh && session?.tokens !== undefined) {
136
- session = clearSessionTokens(session);
137
- await saveSession(canonicalResource, session);
138
- }
139
- if (!allowInteractive || sessionDiscovery === undefined) {
140
- return session;
141
- }
142
- if (options.allowInteractive === false)
143
- throw new Error("OAuth interactive authorization is disabled");
144
- return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
151
+ if (session?.tokens?.refreshToken !== undefined &&
152
+ sessionDiscovery !== undefined &&
153
+ (forceRefresh || isExpired(session.tokens, now))) {
154
+ session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
155
+ if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
156
+ return session;
157
+ }
158
+ }
159
+ if (forceRefresh && session?.tokens !== undefined) {
160
+ session = clearSessionTokens(session);
161
+ await saveSession(canonicalResource, session);
162
+ }
163
+ if (!allowInteractive || sessionDiscovery === undefined) {
164
+ return session;
165
+ }
166
+ if (options.allowInteractive === false)
167
+ throw new Error("OAuth interactive authorization is disabled");
168
+ return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
169
+ }, { signal, timeoutMs: options.sessionLockTimeoutMs });
145
170
  }
146
171
  async function refreshSession(resource, session, discovery, fetch, signal) {
147
172
  signal?.throwIfAborted();
148
173
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
149
- const inFlight = refreshPromises.get(resource);
150
- if (inFlight !== undefined) {
151
- return inFlight;
174
+ if (session.tokens?.refreshToken === undefined) {
175
+ return session;
152
176
  }
153
- const promise = (async () => {
177
+ const pendingSession = { ...clearSessionTokens(session), refreshState: "pending" };
178
+ await saveSession(resource, pendingSession);
179
+ signal?.throwIfAborted();
180
+ let refreshAttempted = false;
181
+ let refreshedTokens;
182
+ while (true) {
154
183
  try {
155
- if (session.tokens?.refreshToken === undefined) {
156
- return session;
184
+ refreshedTokens = await refreshAccessToken({
185
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
186
+ clientId: session.client.clientId,
187
+ clientSecret: session.client.clientSecret,
188
+ refreshToken: session.tokens.refreshToken,
189
+ resource,
190
+ fetch, signal,
191
+ now
192
+ });
193
+ break;
194
+ }
195
+ catch (error) {
196
+ signal?.throwIfAborted();
197
+ // Network errors, lost/malformed bodies and gateway failures cannot
198
+ // establish whether a rotating refresh token was already consumed.
199
+ if (!(error instanceof OAuthError) || !error.outcomeKnown)
200
+ throw error;
201
+ if (error.error === "invalid_grant") {
202
+ const clearedSession = clearSessionTokens(session);
203
+ await saveSession(resource, clearedSession);
204
+ return clearedSession;
157
205
  }
158
- let refreshAttempted = false;
159
- let refreshedTokens;
160
- while (true) {
161
- try {
162
- refreshedTokens = await refreshAccessToken({
163
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
164
- clientId: session.client.clientId,
165
- clientSecret: session.client.clientSecret,
166
- refreshToken: session.tokens.refreshToken,
167
- resource,
168
- fetch, signal,
169
- now
170
- });
171
- break;
172
- }
173
- catch (error) {
174
- signal?.throwIfAborted();
175
- if (error instanceof OAuthError && error.error === "invalid_grant") {
176
- const clearedSession = clearSessionTokens(session);
177
- await saveSession(resource, clearedSession);
178
- return clearedSession;
179
- }
180
- if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
181
- await clearRegisteredClient(discovery.authorizationServer);
182
- await clearSession(resource);
183
- return null;
184
- }
185
- if (!refreshAttempted && isRetryableOAuthError(error)) {
186
- refreshAttempted = true;
187
- continue;
188
- }
189
- throw error;
190
- }
206
+ if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
207
+ await clearRegisteredClient(discovery.authorizationServer);
208
+ await clearSession(resource);
209
+ return null;
191
210
  }
192
- const updatedSession = {
193
- ...session,
194
- tokens: {
195
- ...refreshedTokens,
196
- refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
197
- },
198
- discovery: toStoredDiscovery(discovery)
199
- };
200
- await saveSession(resource, updatedSession);
201
- return updatedSession;
202
- }
203
- finally {
204
- refreshPromises.delete(resource);
211
+ if (!refreshAttempted && isRetryableOAuthError(error)) {
212
+ refreshAttempted = true;
213
+ continue;
214
+ }
215
+ await saveSession(resource, session);
216
+ throw error;
205
217
  }
206
- })();
207
- refreshPromises.set(resource, promise);
208
- return promise;
218
+ }
219
+ const updatedSession = {
220
+ ...session,
221
+ tokens: {
222
+ ...refreshedTokens,
223
+ refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
224
+ },
225
+ discovery: toStoredDiscovery(discovery)
226
+ };
227
+ await saveSession(resource, updatedSession);
228
+ return updatedSession;
209
229
  }
210
230
  async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
211
231
  signal?.throwIfAborted();
212
- const inFlight = authorizationPromises.get(resource);
213
- if (inFlight !== undefined) {
214
- return inFlight;
215
- }
216
- const promise = (async () => {
217
- assertS256PkceSupport(discovery.authorizationServerMetadata);
218
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
219
- let currentSession = existingSession;
220
- let transientRetryAttempted = false;
221
- let reRegistrationAttempted = false;
222
- while (true) {
223
- const loopback = await createLoopbackAuthorizationSession({
224
- openBrowser: options.browser.openBrowser,
225
- readLine: options.browser.readLine,
226
- createServer: options.browser.createServer,
227
- landingPage: options.browser.landingPage,
228
- redirectUri: options.browser.redirectUri,
229
- signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
230
- timeoutMs: options.browser.timeoutMs
232
+ assertS256PkceSupport(discovery.authorizationServerMetadata);
233
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
234
+ let currentSession = existingSession;
235
+ let transientRetryAttempted = false;
236
+ let reRegistrationAttempted = false;
237
+ while (true) {
238
+ const loopback = await createLoopbackAuthorizationSession({
239
+ openBrowser: options.browser.openBrowser,
240
+ readLine: options.browser.readLine,
241
+ createServer: options.browser.createServer,
242
+ landingPage: options.browser.landingPage,
243
+ redirectUri: options.browser.redirectUri,
244
+ signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
245
+ timeoutMs: options.browser.timeoutMs
246
+ });
247
+ let resolvedClient = null;
248
+ try {
249
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
250
+ const sessionWithoutTokens = {
251
+ resource,
252
+ authorizationServer: discovery.authorizationServer,
253
+ client: resolvedClient.client,
254
+ discovery: toStoredDiscovery(discovery)
255
+ };
256
+ await saveSession(resource, sessionWithoutTokens);
257
+ const verifier = generateCodeVerifier();
258
+ const challenge = generateCodeChallenge(verifier);
259
+ const authorizationUrl = buildAuthorizationUrl({
260
+ metadata: discovery.authorizationServerMetadata,
261
+ resource,
262
+ clientId: resolvedClient.client.clientId,
263
+ redirectUri: loopback.redirectUri,
264
+ codeChallenge: challenge,
265
+ clientMetadata: getClientMetadata(options.client)
231
266
  });
232
- let resolvedClient = null;
233
- try {
234
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
235
- const sessionWithoutTokens = {
236
- resource,
237
- authorizationServer: discovery.authorizationServer,
238
- client: resolvedClient.client,
239
- discovery: toStoredDiscovery(discovery)
240
- };
241
- await saveSession(resource, sessionWithoutTokens);
242
- const verifier = generateCodeVerifier();
243
- const challenge = generateCodeChallenge(verifier);
244
- const authorizationUrl = buildAuthorizationUrl({
245
- metadata: discovery.authorizationServerMetadata,
246
- resource,
247
- clientId: resolvedClient.client.clientId,
248
- redirectUri: loopback.redirectUri,
249
- codeChallenge: challenge,
250
- clientMetadata: getClientMetadata(options.client)
251
- });
252
- const code = await loopback.waitForCode(authorizationUrl);
253
- const tokens = await exchangeAuthorizationCode({
254
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
255
- clientId: resolvedClient.client.clientId,
256
- clientSecret: resolvedClient.client.clientSecret,
257
- code,
258
- codeVerifier: verifier,
259
- redirectUri: loopback.redirectUri,
260
- resource,
261
- fetch, signal,
262
- now
263
- });
264
- const session = {
265
- ...sessionWithoutTokens,
266
- tokens
267
- };
268
- await saveSession(resource, session);
269
- return session;
270
- }
271
- catch (error) {
272
- signal?.throwIfAborted();
273
- if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
274
- reRegistrationAttempted = true;
275
- await clearRegisteredClient(discovery.authorizationServer);
276
- await clearSession(resource);
277
- currentSession = null;
278
- continue;
279
- }
280
- if (!transientRetryAttempted && isRetryableOAuthError(error)) {
281
- transientRetryAttempted = true;
282
- await clearSession(resource);
283
- currentSession = null;
284
- continue;
285
- }
286
- throw error;
267
+ const code = await loopback.waitForCode(authorizationUrl);
268
+ const tokens = await exchangeAuthorizationCode({
269
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
270
+ clientId: resolvedClient.client.clientId,
271
+ clientSecret: resolvedClient.client.clientSecret,
272
+ code,
273
+ codeVerifier: verifier,
274
+ redirectUri: loopback.redirectUri,
275
+ resource,
276
+ fetch, signal,
277
+ now
278
+ });
279
+ const session = {
280
+ ...sessionWithoutTokens,
281
+ tokens
282
+ };
283
+ await saveSession(resource, session);
284
+ return session;
285
+ }
286
+ catch (error) {
287
+ signal?.throwIfAborted();
288
+ if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
289
+ reRegistrationAttempted = true;
290
+ await clearRegisteredClient(discovery.authorizationServer);
291
+ await clearSession(resource);
292
+ currentSession = null;
293
+ continue;
287
294
  }
288
- finally {
289
- loopback.close();
295
+ if (!transientRetryAttempted && isRetryableOAuthError(error)) {
296
+ transientRetryAttempted = true;
297
+ await clearSession(resource);
298
+ currentSession = null;
299
+ continue;
290
300
  }
301
+ throw error;
291
302
  }
292
- })();
293
- const finalPromise = promise.finally(() => {
294
- authorizationPromises.delete(resource);
295
- });
296
- authorizationPromises.set(resource, finalPromise);
297
- return finalPromise;
303
+ finally {
304
+ loopback.close();
305
+ }
306
+ }
298
307
  }
299
308
  async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
300
309
  parentSignal?.throwIfAborted();
@@ -454,9 +463,14 @@ function resolveDiscovery(discovery, session) {
454
463
  authorizationServerMetadata: metadata
455
464
  };
456
465
  }
466
+ function sameTokenGrant(left, right) {
467
+ return left.accessToken === right.accessToken && left.refreshToken === right.refreshToken &&
468
+ left.tokenType === right.tokenType && left.expiresAt === right.expiresAt && left.scope === right.scope;
469
+ }
457
470
  function clearSessionTokens(session) {
458
471
  const nextSession = { ...session };
459
472
  delete nextSession.tokens;
473
+ delete nextSession.refreshState;
460
474
  return nextSession;
461
475
  }
462
476
  function hasCachedAccessToken(session) {
@@ -466,6 +480,9 @@ function normalizeLoadedSession(session) {
466
480
  if (session === null) {
467
481
  return null;
468
482
  }
483
+ const refreshState = getOwnEntry(session, "refreshState");
484
+ if (refreshState !== undefined && (refreshState !== "pending" || getOwnEntry(session, "tokens") !== undefined))
485
+ throw new Error("Stored OAuth refresh state is invalid");
469
486
  const client = normalizeStoredClient(getOwnEntry(session, "client"));
470
487
  if (client === null) {
471
488
  return { ...session, client: { clientId: "" }, tokens: undefined };
@@ -0,0 +1,6 @@
1
+ import type { OAuthSessionStore } from "./types.js";
2
+ /** Serialize the complete read/redeem/write operation, with independent cancellation for waiters. */
3
+ export declare function withOAuthSessionTransaction<T>(store: OAuthSessionStore, resource: string, operation: () => Promise<T>, options?: {
4
+ signal?: AbortSignal;
5
+ timeoutMs?: number;
6
+ }): Promise<T>;
@@ -0,0 +1,42 @@
1
+ const queues = new WeakMap();
2
+ /** Serialize the complete read/redeem/write operation, with independent cancellation for waiters. */
3
+ export async function withOAuthSessionTransaction(store, resource, operation, options = {}) {
4
+ const timeoutMs = options.timeoutMs ?? 30_000;
5
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
6
+ throw new Error("sessionLockTimeoutMs must be an integer from 1 to 2147483647 milliseconds");
7
+ options.signal?.throwIfAborted();
8
+ const started = performance.now();
9
+ const pending = queues.get(store) ?? new Map();
10
+ queues.set(store, pending);
11
+ const previous = pending.get(resource) ?? Promise.resolve();
12
+ let release;
13
+ const current = new Promise(resolve => { release = resolve; });
14
+ const tail = previous.then(() => current);
15
+ pending.set(resource, tail);
16
+ try {
17
+ let timer;
18
+ let rejectWait;
19
+ const waiting = new Promise((resolve, reject) => { rejectWait = reject; previous.then(resolve, reject); });
20
+ const abort = () => rejectWait(options.signal?.reason);
21
+ try {
22
+ timer = setTimeout(() => rejectWait(new Error("Timed out waiting for OAuth session transaction lock")), timeoutMs);
23
+ options.signal?.addEventListener("abort", abort, { once: true });
24
+ if (options.signal?.aborted)
25
+ abort();
26
+ await waiting;
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ options.signal?.removeEventListener("abort", abort);
31
+ }
32
+ options.signal?.throwIfAborted();
33
+ return store.withLock === undefined ? await operation() : await store.withLock(resource, operation, {
34
+ signal: options.signal, timeoutMs: Math.max(0, timeoutMs - (performance.now() - started))
35
+ });
36
+ }
37
+ finally {
38
+ release();
39
+ void tail.then(() => { if (pending.get(resource) === tail)
40
+ pending.delete(resource); });
41
+ }
42
+ }
@@ -13,7 +13,9 @@ export declare class OAuthError extends Error {
13
13
  readonly status: number;
14
14
  readonly retryable: boolean;
15
15
  readonly terminal: boolean;
16
- constructor(shape: OAuthErrorShape, status: number);
16
+ /** True only when a complete OAuth error response establishes rejection. */
17
+ readonly outcomeKnown: boolean;
18
+ constructor(shape: OAuthErrorShape, status: number, outcomeKnown?: boolean);
17
19
  }
18
20
  export declare function isRetryableOAuthError(error: unknown): error is OAuthError;
19
21
  export declare function exchangeAuthorizationCode(input: {
@@ -11,7 +11,9 @@ export class OAuthError extends Error {
11
11
  status;
12
12
  retryable;
13
13
  terminal;
14
- constructor(shape, status) {
14
+ /** True only when a complete OAuth error response establishes rejection. */
15
+ outcomeKnown;
16
+ constructor(shape, status, outcomeKnown = true) {
15
17
  super(shape.error_description ?? shape.error);
16
18
  this.name = "OAuthError";
17
19
  this.error = shape.error;
@@ -22,6 +24,7 @@ export class OAuthError extends Error {
22
24
  this.status = status;
23
25
  this.retryable = isRetryableOAuthError(this);
24
26
  this.terminal = !this.retryable;
27
+ this.outcomeKnown = outcomeKnown;
25
28
  }
26
29
  }
27
30
  export function isRetryableOAuthError(error) {
@@ -147,7 +150,8 @@ export async function readOAuthJsonObjectResponse(response, signal) {
147
150
  }
148
151
  const record = payload;
149
152
  if (!response.ok) {
150
- throw new OAuthError(readOAuthError(record, fallbackError.error), response.status);
153
+ const error = getOwnEntry(record, "error");
154
+ throw new OAuthError(readOAuthError(record, fallbackError.error), response.status, typeof error === "string" && error.trim().length > 0);
151
155
  }
152
156
  return record;
153
157
  }
@@ -166,7 +170,7 @@ function getOwnEntry(record, key) {
166
170
  }
167
171
  function createFallbackOAuthError(status) {
168
172
  const error = status === 503 ? "temporarily_unavailable" : "server_error";
169
- return new OAuthError({ error }, status);
173
+ return new OAuthError({ error }, status, false);
170
174
  }
171
175
  function normalizeBearerTokenType(value) {
172
176
  if (typeof value !== "string") {
@@ -33,7 +33,7 @@ export interface OAuthClientProvider {
33
33
  headers: Headers;
34
34
  fetch: OAuthMetadataFetch;
35
35
  signal?: AbortSignal;
36
- }): Promise<void> | void;
36
+ }): Promise<StoredOAuthTokens | void> | StoredOAuthTokens | void;
37
37
  handleUnauthorized(input: {
38
38
  requestUrl: URL;
39
39
  response: Response;
@@ -41,6 +41,10 @@ export interface OAuthClientProvider {
41
41
  discovery: OAuthDiscoveryResult;
42
42
  fetch: OAuthMetadataFetch;
43
43
  signal?: AbortSignal;
44
+ /** Headers actually attached to this rejected request. */
45
+ requestHeaders?: Headers;
46
+ /** Owned snapshot returned when this request was authorized, or null if absent. */
47
+ presentedTokens?: StoredOAuthTokens | null;
44
48
  }): Promise<{
45
49
  action: "retry";
46
50
  } | {
@@ -74,6 +78,8 @@ export interface StoredOAuthSession {
74
78
  clientSecret?: string;
75
79
  };
76
80
  tokens?: StoredOAuthTokens;
81
+ /** A refresh was begun; its winning response may not have been persisted. */
82
+ refreshState?: "pending";
77
83
  discovery: {
78
84
  resourceMetadataUrl: string;
79
85
  resourceMetadata: Record<string, unknown>;
@@ -84,6 +90,11 @@ export interface OAuthSessionStore {
84
90
  load(resource: string): Promise<StoredOAuthSession | null>;
85
91
  save(resource: string, session: StoredOAuthSession): Promise<void>;
86
92
  clear(resource: string): Promise<void>;
93
+ /** Backend-wide lock covering a complete read/redeem/write transaction. */
94
+ withLock?<T>(resource: string, operation: () => Promise<T>, options: {
95
+ signal?: AbortSignal;
96
+ timeoutMs: number;
97
+ }): Promise<T>;
87
98
  }
88
99
  export interface DefaultOAuthClientProviderOptions {
89
100
  client: {
@@ -99,6 +110,8 @@ export interface DefaultOAuthClientProviderOptions {
99
110
  };
100
111
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
101
112
  allowInteractive?: boolean;
113
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
114
+ sessionLockTimeoutMs?: number;
102
115
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
103
116
  initialGrant?: {
104
117
  resource: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.24",
3
+ "version": "0.1.28",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",