toolcraft 0.0.310 → 0.0.312

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 +20 -0
  4. package/node_modules/auth-store/dist/encrypted-file-store.d.ts +6 -0
  5. package/node_modules/auth-store/dist/encrypted-file-store.js +15 -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 +30 -0
  14. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +23 -2
  15. package/node_modules/tiny-http-mcp-server/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +176 -182
  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 +9 -0
  21. package/node_modules/tiny-http-mcp-server/package.json +1 -1
  22. package/node_modules/tiny-mcp-client/dist/index.d.ts +35 -0
  23. package/node_modules/tiny-mcp-client/dist/index.js +486 -249
  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 {
@@ -115,205 +114,196 @@ export function createDefaultOAuthClientProvider(options) {
115
114
  async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal, rejectedTokens) {
116
115
  signal?.throwIfAborted();
117
116
  const canonicalResource = canonicalizeResourceIndicator(resource);
118
- let session = await loadSession(canonicalResource);
119
- if (session !== null && initialGrant?.resource === canonicalResource)
120
- initialGrantConsumed = true;
121
- signal?.throwIfAborted();
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;
117
+ return withOAuthSessionTransaction(sessionStore, canonicalResource, async () => {
118
+ let session = await loadSession(canonicalResource);
119
+ if (session !== null && initialGrant?.resource === canonicalResource)
120
+ initialGrantConsumed = true;
138
121
  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?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
144
- return session;
145
- }
146
- if (session?.tokens?.refreshToken !== undefined &&
147
- sessionDiscovery !== undefined &&
148
- (forceRefresh || isExpired(session.tokens, now))) {
149
- session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
150
- 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)) {
151
149
  return session;
152
150
  }
153
- }
154
- if (forceRefresh && session?.tokens !== undefined) {
155
- session = clearSessionTokens(session);
156
- await saveSession(canonicalResource, session);
157
- }
158
- if (!allowInteractive || sessionDiscovery === undefined) {
159
- return session;
160
- }
161
- if (options.allowInteractive === false)
162
- throw new Error("OAuth interactive authorization is disabled");
163
- 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 });
164
170
  }
165
171
  async function refreshSession(resource, session, discovery, fetch, signal) {
166
172
  signal?.throwIfAborted();
167
173
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
168
- const inFlight = refreshPromises.get(resource);
169
- if (inFlight !== undefined) {
170
- return inFlight;
174
+ if (session.tokens?.refreshToken === undefined) {
175
+ return session;
171
176
  }
172
- 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) {
173
183
  try {
174
- if (session.tokens?.refreshToken === undefined) {
175
- 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;
176
205
  }
177
- let refreshAttempted = false;
178
- let refreshedTokens;
179
- while (true) {
180
- try {
181
- refreshedTokens = await refreshAccessToken({
182
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
183
- clientId: session.client.clientId,
184
- clientSecret: session.client.clientSecret,
185
- refreshToken: session.tokens.refreshToken,
186
- resource,
187
- fetch, signal,
188
- now
189
- });
190
- break;
191
- }
192
- catch (error) {
193
- signal?.throwIfAborted();
194
- if (error instanceof OAuthError && error.error === "invalid_grant") {
195
- const clearedSession = clearSessionTokens(session);
196
- await saveSession(resource, clearedSession);
197
- return clearedSession;
198
- }
199
- if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
200
- await clearRegisteredClient(discovery.authorizationServer);
201
- await clearSession(resource);
202
- return null;
203
- }
204
- if (!refreshAttempted && isRetryableOAuthError(error)) {
205
- refreshAttempted = true;
206
- continue;
207
- }
208
- throw error;
209
- }
206
+ if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
207
+ await clearRegisteredClient(discovery.authorizationServer);
208
+ await clearSession(resource);
209
+ return null;
210
210
  }
211
- const updatedSession = {
212
- ...session,
213
- tokens: {
214
- ...refreshedTokens,
215
- refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
216
- },
217
- discovery: toStoredDiscovery(discovery)
218
- };
219
- await saveSession(resource, updatedSession);
220
- return updatedSession;
221
- }
222
- finally {
223
- refreshPromises.delete(resource);
211
+ if (!refreshAttempted && isRetryableOAuthError(error)) {
212
+ refreshAttempted = true;
213
+ continue;
214
+ }
215
+ await saveSession(resource, session);
216
+ throw error;
224
217
  }
225
- })();
226
- refreshPromises.set(resource, promise);
227
- 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;
228
229
  }
229
230
  async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
230
231
  signal?.throwIfAborted();
231
- const inFlight = authorizationPromises.get(resource);
232
- if (inFlight !== undefined) {
233
- return inFlight;
234
- }
235
- const promise = (async () => {
236
- assertS256PkceSupport(discovery.authorizationServerMetadata);
237
- assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
238
- let currentSession = existingSession;
239
- let transientRetryAttempted = false;
240
- let reRegistrationAttempted = false;
241
- while (true) {
242
- const loopback = await createLoopbackAuthorizationSession({
243
- openBrowser: options.browser.openBrowser,
244
- readLine: options.browser.readLine,
245
- createServer: options.browser.createServer,
246
- landingPage: options.browser.landingPage,
247
- redirectUri: options.browser.redirectUri,
248
- signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
249
- 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)
250
266
  });
251
- let resolvedClient = null;
252
- try {
253
- resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
254
- const sessionWithoutTokens = {
255
- resource,
256
- authorizationServer: discovery.authorizationServer,
257
- client: resolvedClient.client,
258
- discovery: toStoredDiscovery(discovery)
259
- };
260
- await saveSession(resource, sessionWithoutTokens);
261
- const verifier = generateCodeVerifier();
262
- const challenge = generateCodeChallenge(verifier);
263
- const authorizationUrl = buildAuthorizationUrl({
264
- metadata: discovery.authorizationServerMetadata,
265
- resource,
266
- clientId: resolvedClient.client.clientId,
267
- redirectUri: loopback.redirectUri,
268
- codeChallenge: challenge,
269
- clientMetadata: getClientMetadata(options.client)
270
- });
271
- const code = await loopback.waitForCode(authorizationUrl);
272
- const tokens = await exchangeAuthorizationCode({
273
- tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
274
- clientId: resolvedClient.client.clientId,
275
- clientSecret: resolvedClient.client.clientSecret,
276
- code,
277
- codeVerifier: verifier,
278
- redirectUri: loopback.redirectUri,
279
- resource,
280
- fetch, signal,
281
- now
282
- });
283
- const session = {
284
- ...sessionWithoutTokens,
285
- tokens
286
- };
287
- await saveSession(resource, session);
288
- return session;
289
- }
290
- catch (error) {
291
- signal?.throwIfAborted();
292
- if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
293
- reRegistrationAttempted = true;
294
- await clearRegisteredClient(discovery.authorizationServer);
295
- await clearSession(resource);
296
- currentSession = null;
297
- continue;
298
- }
299
- if (!transientRetryAttempted && isRetryableOAuthError(error)) {
300
- transientRetryAttempted = true;
301
- await clearSession(resource);
302
- currentSession = null;
303
- continue;
304
- }
305
- 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;
306
294
  }
307
- finally {
308
- loopback.close();
295
+ if (!transientRetryAttempted && isRetryableOAuthError(error)) {
296
+ transientRetryAttempted = true;
297
+ await clearSession(resource);
298
+ currentSession = null;
299
+ continue;
309
300
  }
301
+ throw error;
310
302
  }
311
- })();
312
- const finalPromise = promise.finally(() => {
313
- authorizationPromises.delete(resource);
314
- });
315
- authorizationPromises.set(resource, finalPromise);
316
- return finalPromise;
303
+ finally {
304
+ loopback.close();
305
+ }
306
+ }
317
307
  }
318
308
  async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
319
309
  parentSignal?.throwIfAborted();
@@ -480,6 +470,7 @@ function sameTokenGrant(left, right) {
480
470
  function clearSessionTokens(session) {
481
471
  const nextSession = { ...session };
482
472
  delete nextSession.tokens;
473
+ delete nextSession.refreshState;
483
474
  return nextSession;
484
475
  }
485
476
  function hasCachedAccessToken(session) {
@@ -489,6 +480,9 @@ function normalizeLoadedSession(session) {
489
480
  if (session === null) {
490
481
  return null;
491
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");
492
486
  const client = normalizeStoredClient(getOwnEntry(session, "client"));
493
487
  if (client === null) {
494
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") {
@@ -78,6 +78,8 @@ export interface StoredOAuthSession {
78
78
  clientSecret?: string;
79
79
  };
80
80
  tokens?: StoredOAuthTokens;
81
+ /** A refresh was begun; its winning response may not have been persisted. */
82
+ refreshState?: "pending";
81
83
  discovery: {
82
84
  resourceMetadataUrl: string;
83
85
  resourceMetadata: Record<string, unknown>;
@@ -88,6 +90,11 @@ export interface OAuthSessionStore {
88
90
  load(resource: string): Promise<StoredOAuthSession | null>;
89
91
  save(resource: string, session: StoredOAuthSession): Promise<void>;
90
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>;
91
98
  }
92
99
  export interface DefaultOAuthClientProviderOptions {
93
100
  client: {
@@ -103,6 +110,8 @@ export interface DefaultOAuthClientProviderOptions {
103
110
  };
104
111
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
105
112
  allowInteractive?: boolean;
113
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
114
+ sessionLockTimeoutMs?: number;
106
115
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
107
116
  initialGrant?: {
108
117
  resource: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tiny-http-mcp-server",
3
- "version": "0.1.25",
3
+ "version": "0.1.29",
4
4
  "description": "Minimal MCP server over HTTP built on tiny-stdio-mcp-server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,11 +20,31 @@ interface McpSubscription {
20
20
  cancel(): void;
21
21
  }
22
22
 
23
+ interface SecretStoreLockFileSystem {
24
+ mkdir(path: string, options?: {
25
+ recursive?: boolean;
26
+ mode?: number;
27
+ }): Promise<unknown>;
28
+ readdir(path: string): Promise<string[]>;
29
+ readFile(path: string, encoding: BufferEncoding): Promise<string>;
30
+ writeFile(path: string, value: string, options: {
31
+ encoding: BufferEncoding;
32
+ flag: string;
33
+ mode: number;
34
+ }): Promise<void>;
35
+ rename(from: string, to: string): Promise<void>;
36
+ unlink(path: string): Promise<void>;
37
+ lstat(path: string): Promise<{
38
+ isSymbolicLink(): boolean;
39
+ }>;
40
+ }
41
+
23
42
  interface MachineIdentity {
24
43
  hostname: string;
25
44
  username: string;
26
45
  }
27
46
  interface EncryptedFileStoreFileSystem {
47
+ readdir?(path: string): Promise<string[]>;
28
48
  readFile(path: string, encoding: BufferEncoding): Promise<string>;
29
49
  writeFile(path: string, data: string | NodeJS.ArrayBufferView, options?: {
30
50
  encoding?: BufferEncoding;
@@ -50,6 +70,8 @@ interface EncryptedFileStoreInput {
50
70
  getMachineIdentity?: () => MachineIdentity | Promise<MachineIdentity>;
51
71
  getHomeDirectory?: () => string;
52
72
  getRandomBytes?: (size: number) => Buffer;
73
+ /** Fail closed instead of treating malformed or unauthenticated documents as absent. */
74
+ throwOnInvalidDocument?: boolean;
53
75
  }
54
76
 
55
77
  interface KeychainCommandResult {
@@ -65,6 +87,10 @@ interface KeychainStoreInput {
65
87
  runCommand?: KeychainCommandRunner;
66
88
  service: string;
67
89
  account: string;
90
+ lock?: {
91
+ fs?: SecretStoreLockFileSystem;
92
+ directory?: string;
93
+ };
68
94
  }
69
95
 
70
96
  type StoreBackend = "file" | "keychain";
@@ -155,6 +181,8 @@ interface StoredOAuthSession {
155
181
  clientSecret?: string;
156
182
  };
157
183
  tokens?: StoredOAuthTokens;
184
+ /** A refresh was begun; its winning response may not have been persisted. */
185
+ refreshState?: "pending";
158
186
  discovery: {
159
187
  resourceMetadataUrl: string;
160
188
  resourceMetadata: Record<string, unknown>;
@@ -165,6 +193,11 @@ interface OAuthSessionStore {
165
193
  load(resource: string): Promise<StoredOAuthSession | null>;
166
194
  save(resource: string, session: StoredOAuthSession): Promise<void>;
167
195
  clear(resource: string): Promise<void>;
196
+ /** Backend-wide lock covering a complete read/redeem/write transaction. */
197
+ withLock?<T>(resource: string, operation: () => Promise<T>, options: {
198
+ signal?: AbortSignal;
199
+ timeoutMs: number;
200
+ }): Promise<T>;
168
201
  }
169
202
  interface DefaultOAuthClientProviderOptions {
170
203
  client: {
@@ -180,6 +213,8 @@ interface DefaultOAuthClientProviderOptions {
180
213
  };
181
214
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
182
215
  allowInteractive?: boolean;
216
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
217
+ sessionLockTimeoutMs?: number;
183
218
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
184
219
  initialGrant?: {
185
220
  resource: string;