Public: anyone can read this; the crew can change it.

A test pins who may call an MCP tool: `visible` hides admin tools, and `plansOnly` lets a read-only token only read or plan #6662

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6660-a-test-pins-who-may-call-an-mcp-tool-vis → main 1 commit fork

  • Merged. scout/6660-a-test-pins-who-may-call-an-mcp-tool-vis was merged into main as eae1ecd.

Review

Sign in to review this change.
1 file changed +111 −0
test/mcp-shared-gate.test.ts added +111
@@ -0,0 +1,111 @@
1 import { describe, expect, test } from "bun:test";
2 import { lean, plansOnly, visible, type Tool } from "../src/http/mcp/shared.ts";
3 import type { Ctx } from "../src/http/context.ts";
4 import type { User } from "../src/core/users.ts";
5
6 const tool = (over: Partial<Tool> = {}): Tool => ({
7 name: "t",
8 description: "",
9 inputSchema: {},
10 writes: true,
11 run: () => ({ ok: true, value: null }),
12 ...over,
13 });
14 const ctxOf = (scope: "ro" | "rw" | "admin"): Ctx => ({ auth: { scope } }) as unknown as Ctx;
15 const userOf = (isAdmin: boolean): User => ({ isAdmin }) as unknown as User;
16
17 describe("visible", () => {
18 test("a plain tool is visible to everyone", () => {
19 expect(visible(tool(), ctxOf("ro"), userOf(false))).toBe(true);
20 });
21 test("adminOnly is hidden from a non-admin and shown to an admin", () => {
22 const t = tool({ adminOnly: true });
23 expect(visible(t, ctxOf("admin"), userOf(false))).toBe(false);
24 expect(visible(t, ctxOf("ro"), userOf(true))).toBe(true);
25 });
26 test("needsAdminScope wants an admin token, whoever the user is", () => {
27 const t = tool({ needsAdminScope: true });
28 expect(visible(t, ctxOf("ro"), userOf(true))).toBe(false);
29 expect(visible(t, ctxOf("rw"), userOf(true))).toBe(false);
30 expect(visible(t, ctxOf("admin"), userOf(true))).toBe(true);
31 });
32 test("both flags need both", () => {
33 const t = tool({ adminOnly: true, needsAdminScope: true });
34 expect(visible(t, ctxOf("admin"), userOf(false))).toBe(false);
35 expect(visible(t, ctxOf("ro"), userOf(true))).toBe(false);
36 expect(visible(t, ctxOf("admin"), userOf(true))).toBe(true);
37 });
38 test("an unset scope counts as ro", () => {
39 const t = tool({ needsAdminScope: true });
40 expect(visible(t, { auth: {} } as unknown as Ctx, userOf(true))).toBe(false);
41 });
42 });
43
44 describe("plansOnly", () => {
45 test("a plain write is not a plan", () => {
46 expect(plansOnly(tool(), {})).toBe(false);
47 });
48 test("dryByDefault plans unless dryRun is false", () => {
49 const t = tool({ dryByDefault: true });
50 expect(plansOnly(t, {})).toBe(true);
51 expect(plansOnly(t, { dryRun: true })).toBe(true);
52 expect(plansOnly(t, { dryRun: "false" })).toBe(true);
53 expect(plansOnly(t, { dryRun: false })).toBe(false);
54 });
55 test("readsWithout reads only while the key is absent", () => {
56 const t = tool({ readsWithout: "x" });
57 expect(plansOnly(t, {})).toBe(true);
58 expect(plansOnly(t, { y: 1 })).toBe(true);
59 expect(plansOnly(t, { x: 1 })).toBe(false);
60 expect(plansOnly(t, { x: null })).toBe(false);
61 });
62 test("readsWithout as an array needs every key absent", () => {
63 const t = tool({ readsWithout: ["x", "y"] });
64 expect(plansOnly(t, {})).toBe(true);
65 expect(plansOnly(t, { x: 1 })).toBe(false);
66 expect(plansOnly(t, { y: 1 })).toBe(false);
67 expect(plansOnly(t, { x: 1, y: 1 })).toBe(false);
68 });
69 test("readsWhen is honoured", () => {
70 const t = tool({ readsWhen: (a) => a["action"] === "list" });
71 expect(plansOnly(t, { action: "list" })).toBe(true);
72 expect(plansOnly(t, { action: "delete" })).toBe(false);
73 });
74 });
75
76 describe("lean", () => {
77 test("strips length hints at every depth and keeps the rest", () => {
78 const schema = {
79 type: "object",
80 required: ["a"],
81 maxLength: 1,
82 properties: {
83 a: { type: "string", minLength: 1, maxLength: 9, enum: ["x"], description: "d" },
84 b: {
85 type: "array",
86 minItems: 1,
87 maxItems: 5,
88 items: { type: "object", properties: { c: { type: "string", maxLength: 3 } } },
89 },
90 d: { anyOf: [{ type: "string", minLength: 2 }, { type: "number", minimum: 0 }] },
91 },
92 };
93 expect(lean(schema)).toEqual({
94 type: "object",
95 required: ["a"],
96 properties: {
97 a: { type: "string", enum: ["x"], description: "d" },
98 b: { type: "array", items: { type: "object", properties: { c: { type: "string" } } } },
99 d: { anyOf: [{ type: "string" }, { type: "number", minimum: 0 }] },
100 },
101 });
102 });
103 test("scalars and null pass through", () => {
104 expect(lean(null)).toBe(null);
105 expect(lean(5)).toBe(5);
106 expect(lean("maxLength")).toBe("maxLength");
107 });
108 test("a hint key at the top level is stripped too", () => {
109 expect(lean({ maxItems: 3, keep: 1 })).toEqual({ keep: 1 });
110 });
111 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs