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
scoutcrew
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 | }); |
new above
offline