@@ -12,13 +12,13 @@ function nullHost(): DevframeHost {
1212 }
1313}
1414
15- async function bootPair ( ) {
15+ async function bootPair ( exposeSharedState : boolean | ( ( key : string ) => boolean ) = true ) {
1616 const ctx = await createHostContext ( { cwd : process . cwd ( ) , mode : 'dev' , host : nullHost ( ) } )
1717
1818 const server = buildMcpServerFromContext ( ctx , {
1919 serverName : 'test' ,
2020 serverVersion : '0.0.0-test' ,
21- exposeSharedState : true ,
21+ exposeSharedState,
2222 } )
2323
2424 const [ clientTransport , serverTransport ] = InMemoryTransport . createLinkedPair ( )
@@ -312,49 +312,98 @@ describe('mcp adapter (in-memory)', () => {
312312 } )
313313
314314 it ( 'hides devframe:state:read when shared-state exposure is disabled' , async ( ) => {
315- const ctx = await createHostContext ( { cwd : process . cwd ( ) , mode : 'dev' , host : nullHost ( ) } )
316- const server = buildMcpServerFromContext ( ctx , {
317- serverName : 'test' ,
318- serverVersion : '0.0.0-test' ,
319- exposeSharedState : false ,
320- } )
321- const [ clientTransport , serverTransport ] = InMemoryTransport . createLinkedPair ( )
322- await server . connect ( serverTransport )
323- const client = new Client ( { name : 'test-client' , version : '0.0.0' } )
324- await client . connect ( clientTransport )
315+ const { client, cleanup } = await bootPair ( false )
325316 try {
326317 const listed = await client . listTools ( )
327318 expect ( listed . tools . map ( t => t . name ) ) . not . toContain ( 'devframe_state_read' )
328319 }
329320 finally {
330- await client . close ( )
331- await server . close ( )
321+ await cleanup ( )
332322 }
333323 } )
334324
335325 it ( 'respects the shared-state filter in devframe:state:read' , async ( ) => {
336- const ctx = await createHostContext ( { cwd : process . cwd ( ) , mode : 'dev' , host : nullHost ( ) } )
337- await ctx . rpc . sharedState . get ( 'visible:key' , { initialValue : { n : 1 } } )
338- await ctx . rpc . sharedState . get ( 'hidden:key' , { initialValue : { n : 2 } } )
339- const server = buildMcpServerFromContext ( ctx , {
340- serverName : 'test' ,
341- serverVersion : '0.0.0-test' ,
342- exposeSharedState : key => key . startsWith ( 'visible:' ) ,
343- } )
344- const [ clientTransport , serverTransport ] = InMemoryTransport . createLinkedPair ( )
345- await server . connect ( serverTransport )
346- const client = new Client ( { name : 'test-client' , version : '0.0.0' } )
347- await client . connect ( clientTransport )
326+ const { ctx, client, cleanup } = await bootPair ( key => key . startsWith ( 'visible:' ) )
348327 try {
328+ await ctx . rpc . sharedState . get ( 'visible:key' , { initialValue : { n : 1 } } )
329+ await ctx . rpc . sharedState . get ( 'hidden:key' , { initialValue : { n : 2 } } )
330+
349331 const keys = await client . callTool ( { name : 'devframe_state_read' , arguments : { } } )
350332 expect ( keys . structuredContent ) . toEqual ( { keys : [ 'visible:key' ] } )
351333
352334 const hidden = await client . callTool ( { name : 'devframe_state_read' , arguments : { key : 'hidden:key' } } )
353335 expect ( hidden . isError ) . toBe ( true )
354336 }
355337 finally {
356- await client . close ( )
357- await server . close ( )
338+ await cleanup ( )
339+ }
340+ } )
341+
342+ it ( 'omits state resources and rejects a direct URI read when exposure is disabled' , async ( ) => {
343+ const { ctx, client, cleanup } = await bootPair ( false )
344+ try {
345+ await ctx . rpc . sharedState . get ( 'alpha:key' , { initialValue : { n : 1 } } )
346+
347+ const listed = await client . listResources ( )
348+ expect ( listed . resources . some ( r => r . uri . startsWith ( 'devframe://state/' ) ) ) . toBe ( false )
349+
350+ // A caller that knows the key must not bypass the policy by reading the
351+ // URI directly.
352+ await expect (
353+ client . readResource ( { uri : `devframe://state/${ encodeURIComponent ( 'alpha:key' ) } ` } ) ,
354+ ) . rejects . toThrow ( )
355+ }
356+ finally {
357+ await cleanup ( )
358+ }
359+ } )
360+
361+ it ( 'applies the filter to direct URI reads, not only discovery' , async ( ) => {
362+ const { ctx, client, cleanup } = await bootPair ( key => key . startsWith ( 'visible:' ) )
363+ try {
364+ await ctx . rpc . sharedState . get ( 'visible:key' , { initialValue : { n : 1 } } )
365+ await ctx . rpc . sharedState . get ( 'hidden:key' , { initialValue : { n : 2 } } )
366+
367+ const listed = await client . listResources ( )
368+ const stateUris = listed . resources . filter ( r => r . uri . startsWith ( 'devframe://state/' ) ) . map ( r => r . uri )
369+ expect ( stateUris ) . toEqual ( [ `devframe://state/${ encodeURIComponent ( 'visible:key' ) } ` ] )
370+
371+ const allowed = await client . readResource ( { uri : `devframe://state/${ encodeURIComponent ( 'visible:key' ) } ` } )
372+ const c = allowed . contents [ 0 ] as { text : string }
373+ expect ( JSON . parse ( c . text ) ) . toEqual ( { n : 1 } )
374+
375+ // Known key, denied by predicate — rejected before storage access.
376+ await expect (
377+ client . readResource ( { uri : `devframe://state/${ encodeURIComponent ( 'hidden:key' ) } ` } ) ,
378+ ) . rejects . toThrow ( )
379+ }
380+ finally {
381+ await cleanup ( )
382+ }
383+ } )
384+
385+ it ( 'agrees between the state-read tool and the resource path for the same policy' , async ( ) => {
386+ const { ctx, client, cleanup } = await bootPair ( key => key . startsWith ( 'visible:' ) )
387+ try {
388+ await ctx . rpc . sharedState . get ( 'visible:key' , { initialValue : { n : 1 } } )
389+ await ctx . rpc . sharedState . get ( 'hidden:key' , { initialValue : { n : 2 } } )
390+
391+ // Allowed key: both paths return the same value.
392+ const toolValue = await client . callTool ( { name : 'devframe_state_read' , arguments : { key : 'visible:key' } } )
393+ expect ( toolValue . structuredContent ) . toEqual ( { key : 'visible:key' , value : { n : 1 } } )
394+ const resourceValue = await client . readResource ( { uri : `devframe://state/${ encodeURIComponent ( 'visible:key' ) } ` } )
395+ const c = resourceValue . contents [ 0 ] as { text : string }
396+ expect ( JSON . parse ( c . text ) ) . toEqual ( { n : 1 } )
397+
398+ // Denied key: both paths reject.
399+ const toolHidden = await client . callTool ( { name : 'devframe_state_read' , arguments : { key : 'hidden:key' } } )
400+ expect ( toolHidden . isError ) . toBe ( true )
401+ await expect (
402+ client . readResource ( { uri : `devframe://state/${ encodeURIComponent ( 'hidden:key' ) } ` } ) ,
403+ ) . rejects . toThrow ( )
404+ }
405+ finally {
406+ await cleanup ( )
358407 }
359408 } )
360409} )
0 commit comments