🔌 fix: Resolve MCP OAuth flow state race condition (#11941)

* 🔌 fix: Resolve MCP OAuth flow state race condition

The OAuth callback arrives before the flow state is stored because
`createFlow()` returns a long-running Promise that only resolves on
flow COMPLETION, not when the initial PENDING state is persisted.
Calling it fire-and-forget with `.catch(() => {})` meant the redirect
happened before the state existed, causing "Flow state not found"
errors.

Changes:
- Add `initFlow()` to FlowStateManager that stores PENDING state and
  returns immediately, decoupling state persistence from monitoring
- Await `initFlow()` before emitting the OAuth redirect so the
  callback always finds existing state
- Keep `createFlow()` in the background for monitoring, but log
  warnings instead of silently swallowing errors
- Increase FLOWS cache TTL from 3 minutes to 10 minutes to give
  users more time to complete OAuth consent screens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🔌 refactor: Revert FLOWS cache TTL change

The race condition fix (initFlow) is sufficient on its own.
TTL configurability should be a separate enhancement via
librechat.yaml mcpSettings rather than a hardcoded increase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 🔌 fix: Address PR review — restore FLOWS TTL, fix blocking-path race, clean up dead args

- Restore FLOWS cache TTL to 10 minutes (was silently dropped back to 3)
- Add initFlow before oauthStart in blocking handleOAuthRequired path
  to guarantee state persistence before any redirect
- Pass {} to createFlow metadata arg (dead after initFlow writes state)
- Downgrade background monitor .catch from logger.warn to logger.debug
- Replace process.nextTick with Promise.resolve in test (correct semantics)
- Add initFlow TTL assertion test
- Add blocking-path ordering test (initFlow → oauthStart → createFlow)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jón Levy 2026-03-03 00:27:36 +00:00 committed by GitHub
parent 2a5123bfa1
commit f7ac449ca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 345 additions and 16 deletions

View file

@ -24,7 +24,6 @@ class MockKeyv<T = string> {
return this.store.get(key);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async set(key: string, value: FlowState<T>, _ttl?: number): Promise<true> {
this.store.set(key, value);
return true;
@ -160,6 +159,71 @@ describe('FlowStateManager', () => {
}, 15000);
});
describe('initFlow', () => {
const flowId = 'init-test-flow';
const type = 'test-type';
const flowKey = `${type}:${flowId}`;
it('stores a PENDING flow state in the cache', async () => {
await flowManager.initFlow(flowId, type, { serverName: 'test' });
const state = await store.get(flowKey);
expect(state).toBeDefined();
expect(state!.status).toBe('PENDING');
expect(state!.type).toBe(type);
expect(state!.metadata).toEqual({ serverName: 'test' });
expect(state!.createdAt).toBeGreaterThan(0);
});
it('overwrites an existing flow state', async () => {
await store.set(flowKey, {
type,
status: 'COMPLETED',
metadata: { old: true },
createdAt: Date.now() - 10000,
});
await flowManager.initFlow(flowId, type, { new: true });
const state = await store.get(flowKey);
expect(state!.status).toBe('PENDING');
expect(state!.metadata).toEqual({ new: true });
});
it('allows createFlow to find and monitor the pre-stored state', async () => {
// initFlow stores the PENDING state
await flowManager.initFlow(flowId, type, { preStored: true });
// createFlow should find the existing state and start monitoring
const flowPromise = flowManager.createFlow(flowId, type);
// Complete the flow so the monitor resolves
await new Promise((resolve) => setTimeout(resolve, 500));
await flowManager.completeFlow(flowId, type, 'success');
const result = await flowPromise;
expect(result).toBe('success');
}, 15000);
it('passes the configured TTL to keyv.set', async () => {
const setSpy = jest.spyOn(store, 'set');
await flowManager.initFlow(flowId, type, { serverName: 'test' });
expect(setSpy).toHaveBeenCalledWith(
flowKey,
expect.objectContaining({ status: 'PENDING' }),
30000,
);
});
it('propagates store write failures', async () => {
jest.spyOn(store, 'set').mockRejectedValueOnce(new Error('Store write failed'));
await expect(flowManager.initFlow(flowId, type)).rejects.toThrow('Store write failed');
});
});
describe('deleteFlow', () => {
const flowId = 'test-flow-123';
const type = 'test-type';