Skip to content

Commit 4e2294f

Browse files
committed
fix(agent-core-v2): expire MCP auth flows by host clock
1 parent 8f8ec57 commit 4e2294f

2 files changed

Lines changed: 34 additions & 22 deletions

File tree

packages/agent-core-v2/src/app/mcpManagement/mcpManagementService.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '#/mcpCore/oauth/service';
2020
import { canonicalMcpOAuthResource } from '#/mcpCore/oauth/store';
2121
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
22+
import { IHostClock } from '#/os/interface/hostClock';
2223
import { IHostProcessService } from '#/os/interface/hostProcess';
2324
import { LocalRuntime } from '#/runtime/localRuntime';
2425
import { RuntimeRegistry } from '#/runtime/runtimeRegistry';
@@ -62,7 +63,7 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
6263

6364
private readonly authFlows = new Map<
6465
string,
65-
{ flow: BeginAuthorizationResult; idleTimer: NodeJS.Timeout }
66+
{ flow: BeginAuthorizationResult; idleTimer: NodeJS.Timeout; expiresAt: number }
6667
>();
6768

6869
constructor(
@@ -74,6 +75,7 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
7475
@IRuntimeResolver private readonly runtimeResolver: IRuntimeResolver,
7576
@IWorkspaceInstanceManager private readonly workspaceInstances: IWorkspaceInstanceManager,
7677
@IHostEnvironment private readonly hostEnvironment: IHostEnvironment,
78+
@IHostClock private readonly clock: IHostClock,
7779
@IHostProcessService private readonly hostProcess: IHostProcessService,
7880
@ILogService private readonly log: ILogService,
7981
) {
@@ -291,7 +293,11 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
291293
void expired?.flow.cancel();
292294
}, AUTH_FLOW_IDLE_TIMEOUT_MS);
293295
idleTimer.unref();
294-
this.authFlows.set(flowId, { flow, idleTimer });
296+
this.authFlows.set(flowId, {
297+
flow,
298+
idleTimer,
299+
expiresAt: this.clock.now().getTime() + AUTH_FLOW_IDLE_TIMEOUT_MS,
300+
});
295301
return {
296302
status: 'authorization-required',
297303
flowId,
@@ -320,7 +326,11 @@ export class McpManagementService extends Disposable implements IMcpManagementSe
320326
`MCP OAuth timeoutMs must be an integer between 1 and ${MAX_AUTH_TIMEOUT_MS}`,
321327
);
322328
}
323-
const active = this.authFlows.get(handle.flowId);
329+
let active = this.authFlows.get(handle.flowId);
330+
if (active !== undefined && active.expiresAt <= this.clock.now().getTime()) {
331+
await this.cancelServerAuth(handle);
332+
active = undefined;
333+
}
324334
if (active === undefined) {
325335
throw new Error2(ErrorCodes.REQUEST_INVALID, `Unknown MCP OAuth flow: ${handle.flowId}`);
326336
}

packages/agent-core-v2/test/app/mcpManagement/mcpManagement.test.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { McpOAuthService, type McpOAuthEvent } from '#/mcpCore/oauth/service';
3434
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
3535
import { HostProcessService } from '#/os/backends/node-local/hostProcessService';
3636
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
37+
import { IHostClock } from '#/os/interface/hostClock';
3738
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
3839
import { IHostProcessService } from '#/os/interface/hostProcess';
3940
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
@@ -80,6 +81,7 @@ describe('McpManagementService', () => {
8081
let getOrCreate: Mock<IWorkspaceInstanceManager['getOrCreate']>;
8182
let findContaining: Mock<IWorkspaceInstanceManager['findContaining']>;
8283
let management: IMcpManagementService;
84+
let clockNowMs: number;
8385

8486
beforeEach(() => {
8587
home = mkdtempSync(join(tmpdir(), 'pythinker-mcp-management-home-'));
@@ -95,6 +97,7 @@ describe('McpManagementService', () => {
9597
identitySnapshot = stubAgentIdentity({ slug: 'test-agent' }).current();
9698
identityReady = Promise.resolve(identitySnapshot);
9799
trusted = true;
100+
clockNowMs = Date.UTC(2026, 0, 1);
98101
getOrCreate = vi.fn<IWorkspaceInstanceManager['getOrCreate']>(async () =>
99102
({ id: 'test-workspace' }) as unknown as WorkspaceInstance,
100103
);
@@ -130,6 +133,11 @@ describe('McpManagementService', () => {
130133
homeDir: home,
131134
ready: Promise.resolve(),
132135
});
136+
reg.defineInstance(IHostClock, {
137+
_serviceBrand: undefined,
138+
now: () => new Date(clockNowMs),
139+
timeZone: () => 'UTC',
140+
});
133141
reg.defineInstance(IHostProcessService, hostProcess);
134142
reg.definePartialInstance(IAtomicDocumentStore, {
135143
get: async <T>() => (trusted ? ({} as T) : undefined),
@@ -1571,31 +1579,25 @@ describe('McpManagementService', () => {
15711579
auth: 'oauth',
15721580
});
15731581
const cancel = vi.fn(async () => undefined);
1574-
const beginSpy = vi.spyOn(oauth, 'beginAuthorization').mockResolvedValue({
1582+
vi.spyOn(oauth, 'beginAuthorization').mockResolvedValue({
15751583
authorizationUrl: new URL('https://oauthable.example.test/authorize'),
15761584
complete: vi.fn(async () => undefined),
15771585
cancel,
15781586
});
1579-
vi.useFakeTimers();
1580-
try {
1581-
const begun = await management.beginServerAuth({ source: 'global', name: 'oauthable' });
1582-
if (begun.status !== 'authorization-required') {
1583-
throw new Error(`expected authorization-required, got ${begun.status}`);
1584-
}
1587+
const begun = await management.beginServerAuth({ source: 'global', name: 'oauthable' });
1588+
if (begun.status !== 'authorization-required') {
1589+
throw new Error(`expected authorization-required, got ${begun.status}`);
1590+
}
15851591

1586-
await vi.advanceTimersByTimeAsync(15 * 60_000);
1592+
clockNowMs += 15 * 60_000;
15871593

1588-
expect(cancel).toHaveBeenCalledTimes(1);
1589-
await expect(
1590-
management.completeServerAuth({ flowId: begun.flowId, timeoutMs: 1000 }),
1591-
).rejects.toMatchObject({
1592-
code: ErrorCodes.REQUEST_INVALID,
1593-
message: `Unknown MCP OAuth flow: ${begun.flowId}`,
1594-
});
1595-
} finally {
1596-
vi.useRealTimers();
1597-
beginSpy.mockRestore();
1598-
}
1594+
await expect(
1595+
management.completeServerAuth({ flowId: begun.flowId, timeoutMs: 1000 }),
1596+
).rejects.toMatchObject({
1597+
code: ErrorCodes.REQUEST_INVALID,
1598+
message: `Unknown MCP OAuth flow: ${begun.flowId}`,
1599+
});
1600+
expect(cancel).toHaveBeenCalledTimes(1);
15991601
});
16001602

16011603
it('complete rejects on timeout when the browser callback never arrives', async () => {

0 commit comments

Comments
 (0)