


























@@ -5,6 +5,7 @@ type MockGatewayTool = {
55name: string;
66description: string;
77parameters: Record<string, unknown>;
8+ownerOnly?: boolean;
89execute: (...args: unknown[]) => Promise<{ content: Array<{ type: string; text: string }> }>;
910};
1011@@ -13,6 +14,19 @@ type MockGatewayScopedTools = {
1314tools: MockGatewayTool[];
1415};
151617+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(() =>
1731vi.fn<(...args: unknown[]) => MockGatewayScopedTools>(() => ({
1832agentId: "main",
@@ -37,6 +51,11 @@ vi.mock("../config/sessions.js", () => ({
3751resolveMainSessionKey: () => "agent:main:main",
3852}));
395354+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", () => ({
4160resolveGatewayScopedTools: (...args: Parameters<typeof resolveGatewayScopedToolsMock>) =>
4261resolveGatewayScopedToolsMock(...args),
@@ -71,6 +90,13 @@ async function sendRaw(params: {
71907291beforeEach(() => {
7392resolveGatewayScopedToolsMock.mockClear();
93+runBeforeToolCallHookMock.mockClear();
94+runBeforeToolCallHookMock.mockImplementation(
95+async (args: { params: unknown }): Promise<MockBeforeToolCallHookResult> => ({
96+blocked: false,
97+params: args.params,
98+}),
99+);
74100resolveGatewayScopedToolsMock.mockReturnValue({
75101agentId: "main",
76102tools: [
@@ -231,6 +257,256 @@ describe("mcp loopback server", () => {
231257);
232258});
233259260+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(/^mcp-/),
505+{ body: "hello" },
506+expect.any(AbortSignal),
507+);
508+});
509+234510it("tracks the active runtime only while the server is running", async () => {
235511server = await startMcpLoopbackServer(0);
236512const active = getActiveMcpLoopbackRuntime();
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。