@@ -5,6 +5,7 @@ type MockGatewayTool = {
55 name : string ;
66 description : string ;
77 parameters : Record < string , unknown > ;
8+ ownerOnly ?: boolean ;
89 execute : ( ...args : unknown [ ] ) => Promise < { content : Array < { type : string ; text : string } > } > ;
910} ;
1011
@@ -13,6 +14,19 @@ type MockGatewayScopedTools = {
1314 tools : MockGatewayTool [ ] ;
1415} ;
1516
17+ type MockBeforeToolCallHookResult =
18+ | { blocked : true ; reason : string }
19+ | { blocked : false ; params : unknown } ;
20+
21+ const runBeforeToolCallHookMock = vi . hoisted ( ( ) =>
22+ vi . fn (
23+ async ( args : { params : unknown } ) : Promise < MockBeforeToolCallHookResult > => ( {
24+ blocked : false ,
25+ params : args . params ,
26+ } ) ,
27+ ) ,
28+ ) ;
29+
1630const resolveGatewayScopedToolsMock = vi . hoisted ( ( ) =>
1731 vi . fn < ( ...args : unknown [ ] ) => MockGatewayScopedTools > ( ( ) => ( {
1832 agentId : "main" ,
@@ -37,6 +51,11 @@ vi.mock("../config/sessions.js", () => ({
3751 resolveMainSessionKey : ( ) => "agent:main:main" ,
3852} ) ) ;
3953
54+ vi . mock ( "../agents/pi-tools.before-tool-call.js" , ( ) => ( {
55+ runBeforeToolCallHook : ( ...args : Parameters < typeof runBeforeToolCallHookMock > ) =>
56+ runBeforeToolCallHookMock ( ...args ) ,
57+ } ) ) ;
58+
4059vi . mock ( "./tool-resolution.js" , ( ) => ( {
4160 resolveGatewayScopedTools : ( ...args : Parameters < typeof resolveGatewayScopedToolsMock > ) =>
4261 resolveGatewayScopedToolsMock ( ...args ) ,
@@ -71,6 +90,13 @@ async function sendRaw(params: {
7190
7291beforeEach ( ( ) => {
7392 resolveGatewayScopedToolsMock . mockClear ( ) ;
93+ runBeforeToolCallHookMock . mockClear ( ) ;
94+ runBeforeToolCallHookMock . mockImplementation (
95+ async ( args : { params : unknown } ) : Promise < MockBeforeToolCallHookResult > => ( {
96+ blocked : false ,
97+ params : args . params ,
98+ } ) ,
99+ ) ;
74100 resolveGatewayScopedToolsMock . mockReturnValue ( {
75101 agentId : "main" ,
76102 tools : [
@@ -231,6 +257,256 @@ describe("mcp loopback server", () => {
231257 ) ;
232258 } ) ;
233259
260+ it ( "filters owner-only tools from non-owner tool lists" , async ( ) => {
261+ resolveGatewayScopedToolsMock . mockReturnValue ( {
262+ agentId : "main" ,
263+ tools : [
264+ {
265+ name : "message" ,
266+ description : "send a message" ,
267+ parameters : { type : "object" , properties : { } } ,
268+ execute : async ( ) => ( {
269+ content : [ { type : "text" , text : "ok" } ] ,
270+ } ) ,
271+ } ,
272+ {
273+ name : "cron" ,
274+ description : "manage schedules" ,
275+ parameters : { type : "object" , properties : { } } ,
276+ execute : async ( ) => ( {
277+ content : [ { type : "text" , text : "cron" } ] ,
278+ } ) ,
279+ } ,
280+ {
281+ name : "owner_probe" ,
282+ description : "owner-only by flag" ,
283+ parameters : { type : "object" , properties : { } } ,
284+ ownerOnly : true ,
285+ execute : async ( ) => ( {
286+ content : [ { type : "text" , text : "owner" } ] ,
287+ } ) ,
288+ } ,
289+ ] ,
290+ } ) ;
291+ server = await startMcpLoopbackServer ( 0 ) ;
292+ const runtime = getActiveMcpLoopbackRuntime ( ) ;
293+
294+ const response = await sendRaw ( {
295+ port : server . port ,
296+ token : runtime ? resolveMcpLoopbackBearerToken ( runtime , false ) : undefined ,
297+ headers : {
298+ "content-type" : "application/json" ,
299+ "x-session-key" : "agent:main:main" ,
300+ } ,
301+ body : JSON . stringify ( { jsonrpc : "2.0" , id : 1 , method : "tools/list" } ) ,
302+ } ) ;
303+ const payload = ( await response . json ( ) ) as {
304+ result ?: { tools ?: Array < { name : string } > } ;
305+ } ;
306+ const names = ( payload . result ?. tools ?? [ ] ) . map ( ( tool ) => tool . name ) ;
307+
308+ expect ( response . status ) . toBe ( 200 ) ;
309+ expect ( names ) . toContain ( "message" ) ;
310+ expect ( names ) . not . toContain ( "cron" ) ;
311+ expect ( names ) . not . toContain ( "owner_probe" ) ;
312+ } ) ;
313+
314+ it ( "keeps owner-only tools available to owner loopback callers" , async ( ) => {
315+ resolveGatewayScopedToolsMock . mockReturnValue ( {
316+ agentId : "main" ,
317+ tools : [
318+ {
319+ name : "message" ,
320+ description : "send a message" ,
321+ parameters : { type : "object" , properties : { } } ,
322+ execute : async ( ) => ( {
323+ content : [ { type : "text" , text : "ok" } ] ,
324+ } ) ,
325+ } ,
326+ {
327+ name : "cron" ,
328+ description : "manage schedules" ,
329+ parameters : { type : "object" , properties : { } } ,
330+ execute : async ( ) => ( {
331+ content : [ { type : "text" , text : "cron" } ] ,
332+ } ) ,
333+ } ,
334+ ] ,
335+ } ) ;
336+ server = await startMcpLoopbackServer ( 0 ) ;
337+ const runtime = getActiveMcpLoopbackRuntime ( ) ;
338+
339+ const response = await sendRaw ( {
340+ port : server . port ,
341+ token : runtime ? resolveMcpLoopbackBearerToken ( runtime , true ) : undefined ,
342+ headers : {
343+ "content-type" : "application/json" ,
344+ "x-session-key" : "agent:main:main" ,
345+ } ,
346+ body : JSON . stringify ( { jsonrpc : "2.0" , id : 1 , method : "tools/list" } ) ,
347+ } ) ;
348+ const payload = ( await response . json ( ) ) as {
349+ result ?: { tools ?: Array < { name : string } > } ;
350+ } ;
351+ const names = ( payload . result ?. tools ?? [ ] ) . map ( ( tool ) => tool . name ) ;
352+
353+ expect ( response . status ) . toBe ( 200 ) ;
354+ expect ( names ) . toContain ( "message" ) ;
355+ expect ( names ) . toContain ( "cron" ) ;
356+ } ) ;
357+
358+ it ( "does not execute owner-only tools for non-owner callers" , async ( ) => {
359+ const cronExecute = vi . fn ( async ( ) => ( {
360+ content : [ { type : "text" , text : "CRON_EXECUTED" } ] ,
361+ } ) ) ;
362+ resolveGatewayScopedToolsMock . mockReturnValue ( {
363+ agentId : "main" ,
364+ tools : [
365+ {
366+ name : "message" ,
367+ description : "send a message" ,
368+ parameters : { type : "object" , properties : { } } ,
369+ execute : async ( ) => ( {
370+ content : [ { type : "text" , text : "ok" } ] ,
371+ } ) ,
372+ } ,
373+ {
374+ name : "cron" ,
375+ description : "manage schedules" ,
376+ parameters : { type : "object" , properties : { } } ,
377+ execute : cronExecute ,
378+ } ,
379+ ] ,
380+ } ) ;
381+ server = await startMcpLoopbackServer ( 0 ) ;
382+ const runtime = getActiveMcpLoopbackRuntime ( ) ;
383+
384+ const response = await sendRaw ( {
385+ port : server . port ,
386+ token : runtime ? resolveMcpLoopbackBearerToken ( runtime , false ) : undefined ,
387+ headers : {
388+ "content-type" : "application/json" ,
389+ "x-session-key" : "agent:main:main" ,
390+ } ,
391+ body : JSON . stringify ( {
392+ jsonrpc : "2.0" ,
393+ id : 1 ,
394+ method : "tools/call" ,
395+ params : { name : "cron" , arguments : { } } ,
396+ } ) ,
397+ } ) ;
398+ const payload = ( await response . json ( ) ) as {
399+ result ?: { content ?: Array < { text ?: string } > ; isError ?: boolean } ;
400+ } ;
401+
402+ expect ( response . status ) . toBe ( 200 ) ;
403+ expect ( cronExecute ) . not . toHaveBeenCalled ( ) ;
404+ expect ( payload . result ?. isError ) . toBe ( true ) ;
405+ expect ( payload . result ?. content ?. [ 0 ] ?. text ) . toBe ( "Tool not available: cron" ) ;
406+ } ) ;
407+
408+ it ( "honors before-tool-call hook blocks before loopback tool execution" , async ( ) => {
409+ const execute = vi . fn ( async ( ) => ( {
410+ content : [ { type : "text" , text : "EXECUTED" } ] ,
411+ } ) ) ;
412+ runBeforeToolCallHookMock . mockResolvedValueOnce ( {
413+ blocked : true ,
414+ reason : "blocked by hook" ,
415+ } ) ;
416+ resolveGatewayScopedToolsMock . mockReturnValue ( {
417+ agentId : "main" ,
418+ tools : [
419+ {
420+ name : "message" ,
421+ description : "send a message" ,
422+ parameters : { type : "object" , properties : { } } ,
423+ execute,
424+ } ,
425+ ] ,
426+ } ) ;
427+ server = await startMcpLoopbackServer ( 0 ) ;
428+ const runtime = getActiveMcpLoopbackRuntime ( ) ;
429+
430+ const response = await sendRaw ( {
431+ port : server . port ,
432+ token : runtime ? resolveMcpLoopbackBearerToken ( runtime , false ) : undefined ,
433+ headers : {
434+ "content-type" : "application/json" ,
435+ "x-session-key" : "agent:main:main" ,
436+ } ,
437+ body : JSON . stringify ( {
438+ jsonrpc : "2.0" ,
439+ id : 1 ,
440+ method : "tools/call" ,
441+ params : { name : "message" , arguments : { body : "hello" } } ,
442+ } ) ,
443+ } ) ;
444+ const payload = ( await response . json ( ) ) as {
445+ result ?: { content ?: Array < { text ?: string } > ; isError ?: boolean } ;
446+ } ;
447+
448+ expect ( response . status ) . toBe ( 200 ) ;
449+ expect ( runBeforeToolCallHookMock ) . toHaveBeenCalledWith (
450+ expect . objectContaining ( {
451+ toolName : "message" ,
452+ params : { body : "hello" } ,
453+ ctx : expect . objectContaining ( {
454+ agentId : "main" ,
455+ sessionKey : "agent:main:main" ,
456+ } ) ,
457+ signal : expect . any ( AbortSignal ) ,
458+ } ) ,
459+ ) ;
460+ expect ( execute ) . not . toHaveBeenCalled ( ) ;
461+ expect ( payload . result ?. isError ) . toBe ( true ) ;
462+ expect ( payload . result ?. content ?. [ 0 ] ?. text ) . toBe ( "blocked by hook" ) ;
463+ } ) ;
464+
465+ it ( "forwards the request abort signal to loopback tool execution" , async ( ) => {
466+ const execute = vi . fn ( async ( ) => ( {
467+ content : [ { type : "text" , text : "EXECUTED" } ] ,
468+ } ) ) ;
469+ resolveGatewayScopedToolsMock . mockReturnValue ( {
470+ agentId : "main" ,
471+ tools : [
472+ {
473+ name : "message" ,
474+ description : "send a message" ,
475+ parameters : { type : "object" , properties : { } } ,
476+ execute,
477+ } ,
478+ ] ,
479+ } ) ;
480+ server = await startMcpLoopbackServer ( 0 ) ;
481+ const runtime = getActiveMcpLoopbackRuntime ( ) ;
482+
483+ const response = await sendRaw ( {
484+ port : server . port ,
485+ token : runtime ? resolveMcpLoopbackBearerToken ( runtime , false ) : undefined ,
486+ headers : {
487+ "content-type" : "application/json" ,
488+ "x-session-key" : "agent:main:main" ,
489+ } ,
490+ body : JSON . stringify ( {
491+ jsonrpc : "2.0" ,
492+ id : 1 ,
493+ method : "tools/call" ,
494+ params : { name : "message" , arguments : { body : "hello" } } ,
495+ } ) ,
496+ } ) ;
497+ const payload = ( await response . json ( ) ) as {
498+ result ?: { isError ?: boolean } ;
499+ } ;
500+
501+ expect ( response . status ) . toBe ( 200 ) ;
502+ expect ( payload . result ?. isError ) . toBe ( false ) ;
503+ expect ( execute ) . toHaveBeenCalledWith (
504+ expect . stringMatching ( / ^ m c p - / ) ,
505+ { body : "hello" } ,
506+ expect . any ( AbortSignal ) ,
507+ ) ;
508+ } ) ;
509+
234510 it ( "tracks the active runtime only while the server is running" , async ( ) => {
235511 server = await startMcpLoopbackServer ( 0 ) ;
236512 const active = getActiveMcpLoopbackRuntime ( ) ;
0 commit comments