11import { beforeAll , describe , expect , it } from "@effect/vitest" ;
22import { Effect , Predicate } from "effect" ;
33import { HttpServerResponse } from "effect/unstable/http" ;
4+ // oxlint-disable-next-line executor/no-vitest-import -- boundary: fake-clock coverage for the active-work deadline
5+ import { afterEach , vi } from "vitest" ;
46
57import {
68 ProtocolError ,
79 SdkErrorCode ,
810 SdkHttpError ,
911 type OAuthClientProvider ,
12+ type ClientContext ,
1013} from "@modelcontextprotocol/client" ;
1114import { ElicitationResponse } from "@executor-js/sdk" ;
1215import { serveTestHttpApp } from "@executor-js/sdk/testing" ;
@@ -19,7 +22,7 @@ import { createMcpConnector, type McpConnection, type McpConnector } from "./con
1922// that precondition here — these tests construct SDK errors directly.
2023beforeAll ( ( ) => loadMcpClientSdk ( ) ) ;
2124import { McpInvocationError , McpOAuthReauthorizationRequired } from "./errors" ;
22- import { invokeMcpTool } from "./invoke" ;
25+ import { invokeMcpTool , makeActiveWorkDeadline , MCP_ACTIVE_WORK_TIMEOUT_MS } from "./invoke" ;
2326
2427const acceptAll = ( ) => Effect . succeed ( ElicitationResponse . make ( { action : "accept" } ) ) ;
2528
@@ -148,6 +151,145 @@ const invocationRejectionCases = [
148151] ;
149152
150153describe ( "invokeMcpTool" , ( ) => {
154+ afterEach ( ( ) => vi . useRealTimers ( ) ) ;
155+
156+ it ( "pauses the active-work deadline across overlapping elicitations" , ( ) => {
157+ vi . useFakeTimers ( { toFake : [ "Date" , "setTimeout" , "clearTimeout" ] } ) ;
158+ const deadline = makeActiveWorkDeadline ( 100 ) ;
159+
160+ vi . advanceTimersByTime ( 40 ) ;
161+ deadline . pause ( ) ;
162+ deadline . pause ( ) ;
163+ vi . advanceTimersByTime ( 1_000 ) ;
164+ expect ( deadline . signal . aborted ) . toBe ( false ) ;
165+
166+ deadline . resume ( ) ;
167+ vi . advanceTimersByTime ( 100 ) ;
168+ expect ( deadline . signal . aborted ) . toBe ( false ) ;
169+
170+ deadline . resume ( ) ;
171+ vi . advanceTimersByTime ( 59 ) ;
172+ expect ( deadline . signal . aborted ) . toBe ( false ) ;
173+ vi . advanceTimersByTime ( 1 ) ;
174+ expect ( deadline . signal . aborted ) . toBe ( true ) ;
175+ deadline . dispose ( ) ;
176+ } ) ;
177+
178+ it ( "uses the active signal for a tool call and excludes elicitation from its deadline" , async ( ) => {
179+ vi . useFakeTimers ( { toFake : [ "Date" , "setTimeout" , "clearTimeout" ] } ) ;
180+
181+ let requestHandler :
182+ | ( ( request : { params : unknown } , context : ClientContext ) => Promise < unknown > )
183+ | undefined ;
184+ let callOptions : { signal : AbortSignal ; timeout : number } | undefined ;
185+ let finishElicitation : ( ( ) => void ) | undefined ;
186+ let resolveElicitationStarted : ( ( ) => void ) | undefined ;
187+ const elicitationStarted = new Promise < void > ( ( resolve ) => {
188+ resolveElicitationStarted = resolve ;
189+ } ) ;
190+ const connectionAbort = new AbortController ( ) ;
191+
192+ const client = {
193+ setRequestHandler : ( _method : string , handler : unknown ) => {
194+ requestHandler = handler as typeof requestHandler ;
195+ } ,
196+ callTool : async ( _request : unknown , options : { signal : AbortSignal ; timeout : number } ) => {
197+ callOptions = options ;
198+ await requestHandler ! (
199+ {
200+ params : { mode : "form" , message : "Approve?" , requestedSchema : { } } ,
201+ } ,
202+ { mcpReq : { signal : connectionAbort . signal } } as ClientContext ,
203+ ) ;
204+ // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client models SDK abort rejection
205+ return await new Promise < never > ( ( _resolve , reject ) => {
206+ // oxlint-disable-next-line executor/no-promise-reject -- boundary: fake MCP client models SDK abort rejection
207+ options . signal . addEventListener ( "abort" , ( ) => reject ( options . signal . reason ) , {
208+ once : true ,
209+ } ) ;
210+ } ) ;
211+ } ,
212+ } ;
213+
214+ const invocation = Effect . runPromise (
215+ invokeMcpTool ( {
216+ toolId : "slow" ,
217+ toolName : "slow" ,
218+ args : { } ,
219+ transport : "streamable-http" ,
220+ connector : Effect . succeed ( {
221+ // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only invokeMcpTool's surface
222+ client : client as unknown as McpConnection [ "client" ] ,
223+ close : ( ) => Promise . resolve ( ) ,
224+ } ) ,
225+ elicit : ( ) =>
226+ Effect . callback ( ( resume ) => {
227+ resolveElicitationStarted ! ( ) ;
228+ finishElicitation = ( ) =>
229+ resume ( Effect . succeed ( ElicitationResponse . make ( { action : "accept" } ) ) ) ;
230+ } ) ,
231+ } ) ,
232+ ) . then (
233+ ( ) => "completed" as const ,
234+ ( ) => "failed" as const ,
235+ ) ;
236+
237+ await elicitationStarted ;
238+ expect ( callOptions ?. timeout ) . toBeGreaterThan ( MCP_ACTIVE_WORK_TIMEOUT_MS ) ;
239+ vi . advanceTimersByTime ( MCP_ACTIVE_WORK_TIMEOUT_MS ) ;
240+ expect ( callOptions ?. signal . aborted ) . toBe ( false ) ;
241+
242+ finishElicitation ! ( ) ;
243+ await Promise . resolve ( ) ;
244+ await Promise . resolve ( ) ;
245+ vi . advanceTimersByTime ( MCP_ACTIVE_WORK_TIMEOUT_MS ) ;
246+ expect ( callOptions ?. signal . aborted ) . toBe ( true ) ;
247+ expect ( await invocation ) . toBe ( "failed" ) ;
248+ } ) ;
249+
250+ it ( "interrupts an elicitation when the MCP connection closes" , async ( ) => {
251+ let requestHandler :
252+ | ( ( request : { params : unknown } , context : ClientContext ) => Promise < unknown > )
253+ | undefined ;
254+ const connectionAbort = new AbortController ( ) ;
255+ const client = {
256+ setRequestHandler : ( _method : string , handler : unknown ) => {
257+ requestHandler = handler as typeof requestHandler ;
258+ } ,
259+ callTool : async ( ) => {
260+ await requestHandler ! (
261+ {
262+ params : { mode : "form" , message : "Approve?" , requestedSchema : { } } ,
263+ } ,
264+ { mcpReq : { signal : connectionAbort . signal } } as ClientContext ,
265+ ) ;
266+ return { content : [ ] } ;
267+ } ,
268+ } ;
269+
270+ const invocation = Effect . runPromise (
271+ invokeMcpTool ( {
272+ toolId : "closed" ,
273+ toolName : "closed" ,
274+ args : { } ,
275+ transport : "streamable-http" ,
276+ connector : Effect . succeed ( {
277+ // oxlint-disable-next-line executor/no-double-cast -- boundary: minimal fake MCP client implements only invokeMcpTool's surface
278+ client : client as unknown as McpConnection [ "client" ] ,
279+ close : ( ) => Promise . resolve ( ) ,
280+ } ) ,
281+ elicit : ( ) => Effect . callback ( ( ) => undefined ) ,
282+ } ) ,
283+ ) . then (
284+ ( ) => "completed" as const ,
285+ ( ) => "failed" as const ,
286+ ) ;
287+
288+ await Promise . resolve ( ) ;
289+ connectionAbort . abort ( ) ;
290+ expect ( await invocation ) . toBe ( "failed" ) ;
291+ } ) ;
292+
151293 for ( const testCase of invocationRejectionCases ) {
152294 it . effect ( testCase . name , ( ) =>
153295 Effect . gen ( function * ( ) {
0 commit comments