tiny-http-mcp-server 0.1.25 → 0.1.27

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.
@@ -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,183 @@ 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?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
151
144
  return session;
152
145
  }
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);
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)) {
151
+ return session;
152
+ }
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);
164
+ }, { signal, timeoutMs: options.sessionLockTimeoutMs });
164
165
  }
165
166
  async function refreshSession(resource, session, discovery, fetch, signal) {
166
167
  signal?.throwIfAborted();
167
168
  assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
168
- const inFlight = refreshPromises.get(resource);
169
- if (inFlight !== undefined) {
170
- return inFlight;
169
+ if (session.tokens?.refreshToken === undefined) {
170
+ return session;
171
171
  }
172
- const promise = (async () => {
172
+ let refreshAttempted = false;
173
+ let refreshedTokens;
174
+ while (true) {
173
175
  try {
174
- if (session.tokens?.refreshToken === undefined) {
175
- return session;
176
+ refreshedTokens = await refreshAccessToken({
177
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
178
+ clientId: session.client.clientId,
179
+ clientSecret: session.client.clientSecret,
180
+ refreshToken: session.tokens.refreshToken,
181
+ resource,
182
+ fetch, signal,
183
+ now
184
+ });
185
+ break;
186
+ }
187
+ catch (error) {
188
+ signal?.throwIfAborted();
189
+ if (error instanceof OAuthError && error.error === "invalid_grant") {
190
+ const clearedSession = clearSessionTokens(session);
191
+ await saveSession(resource, clearedSession);
192
+ return clearedSession;
176
193
  }
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
- }
194
+ if (shouldReRegisterStoredDynamicClient(error, await loadRegisteredClient(discovery.authorizationServer), false)) {
195
+ await clearRegisteredClient(discovery.authorizationServer);
196
+ await clearSession(resource);
197
+ return null;
210
198
  }
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);
199
+ if (!refreshAttempted && isRetryableOAuthError(error)) {
200
+ refreshAttempted = true;
201
+ continue;
202
+ }
203
+ throw error;
224
204
  }
225
- })();
226
- refreshPromises.set(resource, promise);
227
- return promise;
205
+ }
206
+ const updatedSession = {
207
+ ...session,
208
+ tokens: {
209
+ ...refreshedTokens,
210
+ refreshToken: refreshedTokens.refreshToken ?? session.tokens.refreshToken
211
+ },
212
+ discovery: toStoredDiscovery(discovery)
213
+ };
214
+ await saveSession(resource, updatedSession);
215
+ return updatedSession;
228
216
  }
229
217
  async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
230
218
  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
219
+ assertS256PkceSupport(discovery.authorizationServerMetadata);
220
+ assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
221
+ let currentSession = existingSession;
222
+ let transientRetryAttempted = false;
223
+ let reRegistrationAttempted = false;
224
+ while (true) {
225
+ const loopback = await createLoopbackAuthorizationSession({
226
+ openBrowser: options.browser.openBrowser,
227
+ readLine: options.browser.readLine,
228
+ createServer: options.browser.createServer,
229
+ landingPage: options.browser.landingPage,
230
+ redirectUri: options.browser.redirectUri,
231
+ signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
232
+ timeoutMs: options.browser.timeoutMs
233
+ });
234
+ let resolvedClient = null;
235
+ try {
236
+ resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
237
+ const sessionWithoutTokens = {
238
+ resource,
239
+ authorizationServer: discovery.authorizationServer,
240
+ client: resolvedClient.client,
241
+ discovery: toStoredDiscovery(discovery)
242
+ };
243
+ await saveSession(resource, sessionWithoutTokens);
244
+ const verifier = generateCodeVerifier();
245
+ const challenge = generateCodeChallenge(verifier);
246
+ const authorizationUrl = buildAuthorizationUrl({
247
+ metadata: discovery.authorizationServerMetadata,
248
+ resource,
249
+ clientId: resolvedClient.client.clientId,
250
+ redirectUri: loopback.redirectUri,
251
+ codeChallenge: challenge,
252
+ clientMetadata: getClientMetadata(options.client)
250
253
  });
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;
254
+ const code = await loopback.waitForCode(authorizationUrl);
255
+ const tokens = await exchangeAuthorizationCode({
256
+ tokenEndpoint: requireOwnString(discovery.authorizationServerMetadata, "token_endpoint", "Authorization server metadata"),
257
+ clientId: resolvedClient.client.clientId,
258
+ clientSecret: resolvedClient.client.clientSecret,
259
+ code,
260
+ codeVerifier: verifier,
261
+ redirectUri: loopback.redirectUri,
262
+ resource,
263
+ fetch, signal,
264
+ now
265
+ });
266
+ const session = {
267
+ ...sessionWithoutTokens,
268
+ tokens
269
+ };
270
+ await saveSession(resource, session);
271
+ return session;
272
+ }
273
+ catch (error) {
274
+ signal?.throwIfAborted();
275
+ if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
276
+ reRegistrationAttempted = true;
277
+ await clearRegisteredClient(discovery.authorizationServer);
278
+ await clearSession(resource);
279
+ currentSession = null;
280
+ continue;
306
281
  }
307
- finally {
308
- loopback.close();
282
+ if (!transientRetryAttempted && isRetryableOAuthError(error)) {
283
+ transientRetryAttempted = true;
284
+ await clearSession(resource);
285
+ currentSession = null;
286
+ continue;
309
287
  }
288
+ throw error;
310
289
  }
311
- })();
312
- const finalPromise = promise.finally(() => {
313
- authorizationPromises.delete(resource);
314
- });
315
- authorizationPromises.set(resource, finalPromise);
316
- return finalPromise;
290
+ finally {
291
+ loopback.close();
292
+ }
293
+ }
317
294
  }
318
295
  async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
319
296
  parentSignal?.throwIfAborted();
@@ -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
+ }
@@ -88,6 +88,11 @@ export interface OAuthSessionStore {
88
88
  load(resource: string): Promise<StoredOAuthSession | null>;
89
89
  save(resource: string, session: StoredOAuthSession): Promise<void>;
90
90
  clear(resource: string): Promise<void>;
91
+ /** Backend-wide lock covering a complete read/redeem/write transaction. */
92
+ withLock?<T>(resource: string, operation: () => Promise<T>, options: {
93
+ signal?: AbortSignal;
94
+ timeoutMs: number;
95
+ }): Promise<T>;
91
96
  }
92
97
  export interface DefaultOAuthClientProviderOptions {
93
98
  client: {
@@ -103,6 +108,8 @@ export interface DefaultOAuthClientProviderOptions {
103
108
  };
104
109
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
105
110
  allowInteractive?: boolean;
111
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
112
+ sessionLockTimeoutMs?: number;
106
113
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
107
114
  initialGrant?: {
108
115
  resource: string;
@@ -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;
@@ -65,6 +85,10 @@ interface KeychainStoreInput {
65
85
  runCommand?: KeychainCommandRunner;
66
86
  service: string;
67
87
  account: string;
88
+ lock?: {
89
+ fs?: SecretStoreLockFileSystem;
90
+ directory?: string;
91
+ };
68
92
  }
69
93
 
70
94
  type StoreBackend = "file" | "keychain";
@@ -165,6 +189,11 @@ interface OAuthSessionStore {
165
189
  load(resource: string): Promise<StoredOAuthSession | null>;
166
190
  save(resource: string, session: StoredOAuthSession): Promise<void>;
167
191
  clear(resource: string): Promise<void>;
192
+ /** Backend-wide lock covering a complete read/redeem/write transaction. */
193
+ withLock?<T>(resource: string, operation: () => Promise<T>, options: {
194
+ signal?: AbortSignal;
195
+ timeoutMs: number;
196
+ }): Promise<T>;
168
197
  }
169
198
  interface DefaultOAuthClientProviderOptions {
170
199
  client: {
@@ -180,6 +209,8 @@ interface DefaultOAuthClientProviderOptions {
180
209
  };
181
210
  /** Disable interactive authorization while allowing cached tokens and silent refresh. */
182
211
  allowInteractive?: boolean;
212
+ /** Maximum wait to acquire a session transaction lock (default 30,000 ms). */
213
+ sessionLockTimeoutMs?: number;
183
214
  /** Import an existing grant for one resource. Persisted sessions take precedence. */
184
215
  initialGrant?: {
185
216
  resource: string;