11import type { SimulatorBackend } from '@linkcode/engine' ;
2- import { SimulatorService } from '@linkcode/engine' ;
2+ import { SimulatorConsentService , SimulatorService } from '@linkcode/engine' ;
33import type { McpServer , SessionId } from '@linkcode/schema' ;
44// eslint-disable-next-line import-x/no-unresolved -- the SDK's exports-map subpaths defeat the resolver; tsc resolves them fine
55import { Client } from '@modelcontextprotocol/sdk/client/index.js' ;
@@ -53,6 +53,13 @@ function fakeBackend(): SimulatorBackend {
5353 } ;
5454}
5555
56+ /** Consent pre-granted for the fixture device, so a test exercises the tools and not the gate. */
57+ async function granted ( udid = 'U-1' ) : Promise < SimulatorConsentService > {
58+ const consent = new SimulatorConsentService ( ) ;
59+ await consent . decide ( udid , 'granted' ) ;
60+ return consent ;
61+ }
62+
5663function urlOf ( entry : McpServer | undefined ) : string {
5764 if ( entry ?. type !== 'http' ) throw new Error ( 'expected an http MCP endpoint' ) ;
5865 return entry . url ;
@@ -74,11 +81,15 @@ describe('SimulatorMcpEndpoint', () => {
7481
7582 it ( 'serves session-scoped tools over MCP streamable http' , async ( ) => {
7683 const activity : string [ ] = [ ] ;
77- endpoint = await SimulatorMcpEndpoint . create ( new SimulatorService ( fakeBackend ( ) ) , {
78- activity ( a ) {
79- activity . push ( `${ a . tool } :${ a . phase } :${ a . sessionId } ` ) ;
84+ endpoint = await SimulatorMcpEndpoint . create (
85+ new SimulatorService ( fakeBackend ( ) ) ,
86+ await granted ( ) ,
87+ {
88+ activity ( a ) {
89+ activity . push ( `${ a . tool } :${ a . phase } :${ a . sessionId } ` ) ;
90+ } ,
8091 } ,
81- } ) ;
92+ ) ;
8293 const entry = endpoint . endpointFor ( S1 ) ;
8394 expect ( entry ) . toMatchObject ( { type : 'http' , name : 'linkcode-sim' } ) ;
8495
@@ -117,7 +128,7 @@ describe('SimulatorMcpEndpoint', () => {
117128
118129 it ( 'enforces cross-session ownership through the shared service' , async ( ) => {
119130 const service = new SimulatorService ( fakeBackend ( ) ) ;
120- endpoint = await SimulatorMcpEndpoint . create ( service ) ;
131+ endpoint = await SimulatorMcpEndpoint . create ( service , await granted ( ) ) ;
121132 const first = await connect ( urlOf ( endpoint . endpointFor ( S1 ) ) ) ;
122133 const second = await connect ( urlOf ( endpoint . endpointFor ( S2 ) ) ) ;
123134
@@ -137,8 +148,80 @@ describe('SimulatorMcpEndpoint', () => {
137148 await second . close ( ) ;
138149 } ) ;
139150
151+ it ( 'suspends an agent tool on an unknown device until the user answers' , async ( ) => {
152+ const consent = new SimulatorConsentService ( ) ;
153+ const asked : string [ ] = [ ] ;
154+ consent . setHooks ( {
155+ ask ( _sessionId , udid , tool ) {
156+ asked . push ( `${ tool } :${ udid } ` ) ;
157+ return true ;
158+ } ,
159+ publish : noop ,
160+ } ) ;
161+ endpoint = await SimulatorMcpEndpoint . create ( new SimulatorService ( fakeBackend ( ) ) , consent ) ;
162+ const client = await connect ( urlOf ( endpoint . endpointFor ( S1 ) ) ) ;
163+
164+ const call = client . callTool ( { name : 'sim_boot' , arguments : { udid : 'U-1' } } ) ;
165+ // The tool must still be in flight: it is waiting on the prompt, not failing fast.
166+ await vi . waitFor ( ( ) => expect ( asked ) . toEqual ( [ 'sim_boot:U-1' ] ) ) ;
167+ await consent . decide ( 'U-1' , 'granted' ) ;
168+ expect ( ( await call ) . isError ) . toBeFalsy ( ) ;
169+
170+ // The decision is remembered, so the next call goes straight through without asking again.
171+ const second = await client . callTool ( { name : 'sim_boot' , arguments : { udid : 'U-1' } } ) ;
172+ expect ( second . isError ) . toBeFalsy ( ) ;
173+ expect ( asked ) . toEqual ( [ 'sim_boot:U-1' ] ) ;
174+ await client . close ( ) ;
175+ } ) ;
176+
177+ it ( 'refuses a denied device and tells the agent not to retry' , async ( ) => {
178+ const consent = new SimulatorConsentService ( ) ;
179+ await consent . decide ( 'U-1' , 'denied' ) ;
180+ endpoint = await SimulatorMcpEndpoint . create ( new SimulatorService ( fakeBackend ( ) ) , consent ) ;
181+ const client = await connect ( urlOf ( endpoint . endpointFor ( S1 ) ) ) ;
182+
183+ const refused = await client . callTool ( { name : 'sim_boot' , arguments : { udid : 'U-1' } } ) ;
184+ expect ( refused . isError ) . toBe ( true ) ;
185+ expect ( JSON . stringify ( refused . content ) ) . toContain ( 'do not retry' ) ;
186+ await client . close ( ) ;
187+ } ) ;
188+
189+ it ( 'refuses everything while the global kill switch is off, including device-less tools' , async ( ) => {
190+ const consent = await granted ( ) ;
191+ await consent . setAgentToolsEnabled ( false ) ;
192+ endpoint = await SimulatorMcpEndpoint . create ( new SimulatorService ( fakeBackend ( ) ) , consent ) ;
193+ const client = await connect ( urlOf ( endpoint . endpointFor ( S1 ) ) ) ;
194+
195+ const listed = await client . callTool ( { name : 'sim_list_devices' , arguments : { } } ) ;
196+ expect ( listed . isError ) . toBe ( true ) ;
197+ expect ( JSON . stringify ( listed . content ) ) . toContain ( 'disabled for agents' ) ;
198+
199+ // And it lifts again without a restart.
200+ await consent . setAgentToolsEnabled ( true ) ;
201+ expect (
202+ ( await client . callTool ( { name : 'sim_list_devices' , arguments : { } } ) ) . isError ,
203+ ) . toBeFalsy ( ) ;
204+ await client . close ( ) ;
205+ } ) ;
206+
207+ it ( 'refuses an unknown device outright when no client is attached to ask' , async ( ) => {
208+ // `ask` reporting false is the daemon's "nobody is listening" signal; blocking for the full
209+ // timeout there would look like a hang to the agent.
210+ const consent = new SimulatorConsentService ( ) ;
211+ consent . setHooks ( { ask : ( ) => false , publish : noop } ) ;
212+ endpoint = await SimulatorMcpEndpoint . create ( new SimulatorService ( fakeBackend ( ) ) , consent ) ;
213+ const client = await connect ( urlOf ( endpoint . endpointFor ( S1 ) ) ) ;
214+
215+ const refused = await client . callTool ( { name : 'sim_boot' , arguments : { udid : 'U-1' } } ) ;
216+ expect ( refused . isError ) . toBe ( true ) ;
217+ await client . close ( ) ;
218+ } ) ;
219+
140220 it ( 'rejects unknown tokens and released sessions' , async ( ) => {
141- endpoint = await SimulatorMcpEndpoint . create ( new SimulatorService ( fakeBackend ( ) ) ) ;
221+ endpoint = await SimulatorMcpEndpoint . create (
222+ new SimulatorService ( fakeBackend ( ) ) ,
223+ await granted ( ) ,
224+ ) ;
142225 const url = urlOf ( endpoint . endpointFor ( S1 ) ) ;
143226 endpoint . release ( S1 ) ;
144227 await expect ( connect ( url ) ) . rejects . toThrow ( ) ;
0 commit comments