From 4e5f9d07c43e2a04bbdb3b69e782a5fae4204322 Mon Sep 17 00:00:00 2001 From: Solomon Neas Date: Mon, 27 Jul 2026 23:50:18 -0400 Subject: [PATCH] feat(mcp): add access-tier registry Co-authored-by: Codex --- src/index.ts | 7 + src/registry.ts | 229 +++++++++++++++++ tests/fixtures/tier-registry-fixture.ts | 67 +++++ tests/tier-registry.test.ts | 313 ++++++++++++++++++++++++ 4 files changed, 616 insertions(+) create mode 100644 src/registry.ts create mode 100644 tests/fixtures/tier-registry-fixture.ts create mode 100644 tests/tier-registry.test.ts diff --git a/src/index.ts b/src/index.ts index 75ba030..3f7b516 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { registerMemoryFlowTools } from "./tools/memory-flows.js"; import { registerAlbumFlowTools } from "./tools/album-flows.js"; import { registerTrashTools } from "./tools/trash.js"; import { registerJobTools } from "./tools/jobs.js"; +import { finalizeToolRegistry } from "./registry.js"; /** * Build the stdio MCP server and connect it. Extracted from the former @@ -56,6 +57,12 @@ export async function serve(): Promise { registerTrashTools(server, config); registerJobTools(server, config); + // Stamp each registered tool's access-tier annotations from the registry, + // AFTER all registrations and BEFORE the transport is created/connected. + // assertNoDrift fails closed if a tool was added/removed/renamed without + // updating src/registry.ts. + finalizeToolRegistry(server); + const transport = new StdioServerTransport(); await server.connect(transport); } diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..566a5ef --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,229 @@ +// Access-tier registry for the Immich MCP server. +// +// SAFETY INVARIANT: the tier is a SECOND surface of a fact the executor already +// enforces through its own gates (requireConfirm / requireWrites in +// src/tools/_util.ts). It is descriptive metadata, never the enforcement +// point. A tool is safe because its executor calls the gate, not because this +// map labels it. +// +// The duplication is only safe because tests/tier-registry.test.ts derives each +// tool's tier INDEPENDENTLY from the executor source and asserts this map +// agrees. Do not edit these tiers by hand without re-running that test; a tier +// map that silently disagrees with the gates is worse than no tier map. +// +// The createToolRegistry pattern is inlined here because +// @lidless-labs/mcp-dynamic-tools is unpublished and must not be added as a +// dependency. + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export type ToolTier = "read" | "safe-write" | "destructive"; + +// Standard MCP tool annotations. Hints only: clients must not rely on them +// for security. Clients implementing human-in-the-loop approval read +// destructiveHint to decide which calls need a human, and readOnlyHint to +// skip approval on pure reads. +export interface ToolAnnotations { + readOnlyHint?: boolean; + destructiveHint?: boolean; +} + +// Projected from the tier as a pure function, so annotations can never +// disagree with the tier. +export function annotationsForTier(tier: ToolTier): ToolAnnotations { + switch (tier) { + case "read": + return { readOnlyHint: true }; + case "safe-write": + return { readOnlyHint: false, destructiveHint: false }; + case "destructive": + return { readOnlyHint: false, destructiveHint: true }; + } +} + +export interface ToolRegistry { + readonly tiers: Readonly>; + /** Throws when the name has no declared tier, so a new tool cannot ship untiered. */ + tierFor(name: string): ToolTier; + annotationsFor(name: string): ToolAnnotations; + /** + * Fail-closed startup guard: every registered tool must declare a tier, and + * the registry must not name a tool that is not registered. Call this once + * at server start, before serving any request. + */ + assertNoDrift(registeredNames: readonly string[]): void; +} + +export function createToolRegistry(tiers: Record): ToolRegistry { + const frozen = Object.freeze({ ...tiers }); + const tierFor = (name: string): ToolTier => { + const tier = frozen[name]; + if (tier === undefined) { + throw new Error(`registry: no access tier declared for tool "${name}"`); + } + return tier; + }; + return { + tiers: frozen, + tierFor, + annotationsFor: (name) => annotationsForTier(tierFor(name)), + assertNoDrift(registeredNames) { + const registered = new Set(registeredNames); + const declared = new Set(Object.keys(frozen)); + const missing = [...registered].filter((n) => !declared.has(n)); + const extra = [...declared].filter((n) => !registered.has(n)); + if (missing.length > 0 || extra.length > 0) { + throw new Error( + `registry/tool drift: missing tier for [${missing.join(", ")}]; ` + + `tier declared for unregistered [${extra.join(", ")}]`, + ); + } + }, + }; +} + +// Every Immich MCP tool, conservatively tiered from the actual executor gates +// in src/tools/*.ts: +// requireConfirm => destructive +// requireWrites => safe-write (unless requireConfirm also present) +// neither => read +// `immich_delete_asset`, `immich_resolve_duplicates`, `immich_restore_by_query`, +// `immich_run_job`, and `immich_resolve_with_keep_strategy` gate only some +// execution paths on confirmation, but are tiered at their conservative maximum. +const IMMICH_TOOL_TIERS: Record = { + // system.ts + immich_ping: "read", + immich_get_server_info: "read", + immich_get_server_statistics: "read", + immich_get_capabilities: "read", + immich_get_storage: "read", + + // assets.ts + immich_list_assets: "read", + immich_get_asset: "read", + immich_get_asset_exif: "read", + immich_download_asset_original: "read", + immich_download_asset_thumbnail: "read", + immich_get_asset_statistics: "read", + immich_upload_asset_from_path: "safe-write", + immich_update_asset: "safe-write", + immich_bulk_update_assets: "destructive", + immich_delete_asset: "destructive", + immich_restore_from_trash: "safe-write", + + // search.ts + immich_search_metadata: "read", + immich_search_smart: "read", + immich_search_explore: "read", + + // albums.ts + immich_list_albums: "read", + immich_get_album: "read", + immich_get_album_statistics: "read", + immich_create_album: "safe-write", + immich_update_album: "safe-write", + immich_delete_album: "destructive", + immich_add_assets_to_album: "safe-write", + immich_remove_assets_from_album: "safe-write", + + // people.ts + immich_list_people: "read", + immich_get_person: "read", + immich_get_person_assets: "read", + immich_update_person: "safe-write", + immich_hide_person: "safe-write", + immich_merge_people: "destructive", + immich_suggest_face_names: "read", + + // tags.ts + immich_list_tags: "read", + immich_get_tag: "read", + immich_create_tag: "safe-write", + immich_update_tag: "safe-write", + immich_delete_tag: "destructive", + immich_add_tag_to_assets: "safe-write", + immich_remove_tag_from_assets: "safe-write", + + // shared-links.ts + immich_list_shared_links: "read", + immich_get_shared_link: "read", + immich_create_shared_link: "safe-write", + immich_update_shared_link: "safe-write", + immich_delete_shared_link: "destructive", + + // activities.ts + immich_list_activities: "read", + immich_create_activity: "safe-write", + immich_delete_activity: "destructive", + immich_get_activity_statistics: "read", + + // memories.ts + immich_list_memories: "read", + immich_get_memory: "read", + + // duplicates.ts + immich_list_duplicates: "read", + immich_resolve_duplicates: "destructive", + + // stacks.ts + immich_list_stacks: "read", + immich_create_stack: "safe-write", + immich_update_stack: "safe-write", + immich_delete_stack: "destructive", + + // duplicate-flows.ts + immich_categorize_duplicates: "read", + immich_find_byte_dupes: "read", + immich_resolve_with_keep_strategy: "destructive", + immich_explain_duplicate_group: "read", + immich_find_clip_dupes: "read", + immich_compare_assets: "read", + immich_audit_active: "read", + immich_audit_trash: "safe-write", + + // memory-flows.ts + immich_memories_today: "read", + immich_daily_digest: "read", + + // album-flows.ts + immich_search_then_album: "safe-write", + + // trash.ts + immich_list_trash: "read", + immich_restore_by_query: "destructive", + immich_empty_trash: "destructive", + + // jobs.ts + immich_list_jobs: "read", + immich_run_job: "destructive", +}; + +export const TOOL_REGISTRY: ToolRegistry = createToolRegistry(IMMICH_TOOL_TIERS); + +/** + * Stamp every registered tool's annotations from the registry. Call once at + * server start, AFTER all register*Tools calls and BEFORE the transport is + * created/connected. Fails closed: asserts no drift between the declared + * tiers and the actual registered tool names BEFORE any annotation update, + * so a new tool cannot ship untiered and a stale tier cannot survive a + * rename/removal silently. + */ +export function finalizeToolRegistry(server: McpServer): void { + const registered = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + if (!registered || typeof registered !== "object") { + throw new Error("registry: server has no _registeredTools map to finalize"); + } + const names = Object.keys(registered); + TOOL_REGISTRY.assertNoDrift(names); + for (const name of names) { + const tool = registered[name]; + if (!tool || typeof tool.update !== "function") { + throw new Error(`registry: registered tool "${name}" has no update() hook`); + } + tool.update({ annotations: TOOL_REGISTRY.annotationsFor(name) }); + } +} diff --git a/tests/fixtures/tier-registry-fixture.ts b/tests/fixtures/tier-registry-fixture.ts new file mode 100644 index 0000000..5d8ed5f --- /dev/null +++ b/tests/fixtures/tier-registry-fixture.ts @@ -0,0 +1,67 @@ +// Fixture for tests/tier-registry.test.ts: proves the AST scanner recurses +// into locally declared helpers when deriving a server.tool handler's access +// tier. +// +// This file is included by tsconfig.json (`tests/**/*.ts`) so the TypeScript +// program exposes it as a SourceFile, but it is NOT under src/tools, so the +// production scan in deriveTiers() skips it (keeping the production total at +// 74). The scanner is purely syntactic; nothing here executes. +// +// Patterns covered: +// - fixture_write_tool delegates to a local arrow-function helper that calls +// requireWrites. The scanner must recurse into the helper to derive +// "safe-write". +// - fixture_confirm_tool delegates to a local function helper that gates on +// an inline `confirm !== true` guard AND calls requireConfirm. The scanner +// must recurse into the helper to derive "destructive". + +type ToolCb = (args?: { confirm?: boolean }) => Promise; + +interface FixtureServer { + tool(name: string, description: string, schema: Record, cb: ToolCb): void; +} + +const server: FixtureServer = { + tool(_name, _description, _schema, _cb) { + // no-op: the scanner never invokes handlers. + }, +}; + +function requireWrites(): void { + // stub of src/tools/_util.ts requireWrites +} + +function requireConfirm(_toolName: string, _confirm: boolean | undefined): void { + // stub of src/tools/_util.ts requireConfirm +} + +// Local helper that gates on writes. The handler delegates to it, so the +// scanner must recurse into the helper body to find requireWrites. +const writeHelper = async (): Promise => { + requireWrites(); + return null; +}; + +// Local helper that conditionally gates on confirm. The handler delegates to +// it, so the scanner must recurse into the helper body to find the inline +// `confirm !== true` guard and the requireConfirm call. +function confirmHelper(confirm: boolean | undefined): Promise { + if (confirm !== true) { + requireConfirm("fixture_confirm_tool", confirm); + } + return Promise.resolve(null); +} + +server.tool( + "fixture_write_tool", + "Delegates to a local helper that calls requireWrites.", + {}, + async () => writeHelper(), +); + +server.tool( + "fixture_confirm_tool", + "Delegates to a local helper that conditionally calls requireConfirm.", + {}, + async (args) => confirmHelper(args?.confirm), +); diff --git a/tests/tier-registry.test.ts b/tests/tier-registry.test.ts new file mode 100644 index 0000000..5877dd8 --- /dev/null +++ b/tests/tier-registry.test.ts @@ -0,0 +1,313 @@ +// Immich Runbook Step 2 proof: the access-tier registry must agree with the +// executor gates. This test derives each tool's tier INDEPENDENTLY from the +// TypeScript source of every server.tool handler (scanning requireConfirm, +// inline confirm:true guards, requireWrites, and locally declared helpers), then +// asserts that TOOL_REGISTRY agrees, that drift detection fires, and that +// finalizeToolRegistry stamps the real registered tools' annotations. +// +import { describe, expect, it } from "vitest"; +import { installFakeSdk } from "./_fake-sdk.js"; + +installFakeSdk(); + +import * as path from "node:path"; +import * as url from "node:url"; +import { API } from "typescript/unstable/sync"; +import * as ts from "typescript/unstable/ast"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerSystemTools } from "../src/tools/system.js"; +import { registerAssetTools } from "../src/tools/assets.js"; +import { registerSearchTools } from "../src/tools/search.js"; +import { registerAlbumTools } from "../src/tools/albums.js"; +import { registerPeopleTools } from "../src/tools/people.js"; +import { registerTagTools } from "../src/tools/tags.js"; +import { registerSharedLinkTools } from "../src/tools/shared-links.js"; +import { registerActivityTools } from "../src/tools/activities.js"; +import { registerMemoryTools } from "../src/tools/memories.js"; +import { registerDuplicateTools } from "../src/tools/duplicates.js"; +import { registerStackTools } from "../src/tools/stacks.js"; +import { registerDuplicateFlowTools } from "../src/tools/duplicate-flows.js"; +import { registerMemoryFlowTools } from "../src/tools/memory-flows.js"; +import { registerAlbumFlowTools } from "../src/tools/album-flows.js"; +import { registerTrashTools } from "../src/tools/trash.js"; +import { registerJobTools } from "../src/tools/jobs.js"; +import { TOOL_REGISTRY, finalizeToolRegistry } from "../src/registry.js"; +import type { Config } from "../src/config.js"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const toolsDir = path.join(root, "src", "tools"); +const fixturePath = path.join(root, "tests", "fixtures", "tier-registry-fixture.ts"); + +const testConfig: Config = { + baseUrl: "https://photos.example.com/api", + apiKey: "k", + allowWrites: false, + verifySsl: true, +}; + +type Tier = "read" | "safe-write" | "destructive"; +type Annotations = { readOnlyHint?: boolean; destructiveHint?: boolean }; + +const rank = (t: Tier): number => (t === "read" ? 0 : t === "safe-write" ? 1 : 2); +const bump = (a: Tier, b: Tier): Tier => (rank(a) >= rank(b) ? a : b); + +// Independently derive the maximum tier of every server.tool handler in a +// single SourceFile by scanning its body for requireConfirm or an inline +// confirm:true guard (destructive), requireWrites (safe-write), and locally +// declared helper calls (recursing into the helper body). Extracted from +// deriveTiers() so the local-helper recursion can be proven against a +// compiler-included fixture without disturbing the production src/tools scan. +function deriveTiersFromSourceFile(sf: ts.SourceFile): Map { + const out = new Map(); + const locals = new Map(); + const collectLocals = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name) { + locals.set(node.name.text, node); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + locals.set(node.name.text, node.initializer); + } + node.forEachChild(collectLocals); + }; + collectLocals(sf); + + const gateOf = (name: string): Tier | null => + name === "requireConfirm" + ? "destructive" + : name === "requireWrites" + ? "safe-write" + : null; + const calleeName = (expression: ts.Expression): string | null => + ts.isIdentifier(expression) ? expression.text : null; + const isConfirmReference = (node: ts.Node): boolean => + (ts.isIdentifier(node) && node.text === "confirm") || + (ts.isPropertyAccessExpression(node) && node.name.text === "confirm"); + const isInlineConfirmGuard = (node: ts.Node): boolean => + ts.isBinaryExpression(node) && + (node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken || + node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken) && + ((isConfirmReference(node.left) && node.right.kind === ts.SyntaxKind.TrueKeyword) || + (node.left.kind === ts.SyntaxKind.TrueKeyword && isConfirmReference(node.right))); + + const scan = (node: ts.Node, seen: Set): Tier => { + let tier: Tier = "read"; + const visit = (child: ts.Node): void => { + if (isInlineConfirmGuard(child)) tier = bump(tier, "destructive"); + if (ts.isCallExpression(child)) { + const name = calleeName(child.expression); + if (name) { + const gate = gateOf(name); + if (gate) { + tier = bump(tier, gate); + } else if (locals.has(name) && !seen.has(name)) { + const next = new Set(seen); + next.add(name); + const helper = locals.get(name); + if (helper) tier = bump(tier, scan(helper, next)); + } + } + } + child.forEachChild(visit); + }; + visit(node); + return tier; + }; + + const findToolCalls = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "tool" && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === "server" + ) { + const args = node.arguments; + if (args.length >= 2) { + const nameArg = args[0]; + if (nameArg && ts.isStringLiteral(nameArg)) { + const callback = args[args.length - 1]!; + const tier = scan(callback, new Set()); + const prior = out.get(nameArg.text); + out.set(nameArg.text, prior ? bump(prior, tier) : tier); + } + } + } + node.forEachChild(findToolCalls); + }; + findToolCalls(sf); + return out; +} + +// Independently derive the maximum tier of every server.tool handler across the +// production src/tools/*.ts files (excluding _util.ts). The scan is restricted +// to src/tools so compiler-included fixtures under tests/fixtures/ do not +// inflate the production total. +function deriveTiers(): Map { + const out = new Map(); + const api = new API({ cwd: root }); + const snapshot = api.updateSnapshot({ + openProjects: [path.join(root, "tsconfig.json")], + }); + const project = snapshot.getProject(path.join(root, "tsconfig.json")); + if (!project) throw new Error("TypeScript did not load the Immich tsconfig"); + + try { + for (const file of project.program.getSourceFileNames()) { + if (!file.startsWith(`${toolsDir}${path.sep}`) || !file.endsWith(".ts") || file.endsWith("_util.ts")) { + continue; + } + const sf = project.program.getSourceFile(file); + if (!sf) throw new Error(`TypeScript did not load ${file}`); + for (const [name, tier] of deriveTiersFromSourceFile(sf)) { + const prior = out.get(name); + out.set(name, prior ? bump(prior, tier) : tier); + } + } + } finally { + snapshot.dispose(); + api.close(); + } + return out; +} + +// Register every production Immich MCP tool onto a real McpServer. Centralized +// so the finalizeToolRegistry tests (positive and fail-closed) register the +// identical 74-tool set the production server does. +function registerAllTools(server: McpServer, config: Config): void { + registerSystemTools(server, config); + registerAssetTools(server, config); + registerSearchTools(server, config); + registerAlbumTools(server, config); + registerPeopleTools(server, config); + registerTagTools(server, config); + registerSharedLinkTools(server, config); + registerActivityTools(server, config); + registerMemoryTools(server, config); + registerDuplicateTools(server, config); + registerStackTools(server, config); + registerDuplicateFlowTools(server, config); + registerMemoryFlowTools(server, config); + registerAlbumFlowTools(server, config); + registerTrashTools(server, config); + registerJobTools(server, config); +} + +const derived = deriveTiers(); + +describe("tier-registry (Immich Runbook Step 2)", () => { + it("derives a tier for every one of the 74 server.tool handlers", () => { + expect(derived.size).toBe(74); + }); + + it("every derived tier matches TOOL_REGISTRY.tierFor", () => { + for (const [name, tier] of derived) { + expect(TOOL_REGISTRY.tierFor(name), name).toBe(tier); + } + }); + + it("TOOL_REGISTRY.tierFor throws for an unknown tool name", () => { + expect(() => TOOL_REGISTRY.tierFor("immich_does_not_exist")).toThrow(); + }); + + it("TOOL_REGISTRY.assertNoDrift passes for the derived name set", () => { + expect(() => TOOL_REGISTRY.assertNoDrift([...derived.keys()])).not.toThrow(); + }); + + it("TOOL_REGISTRY.assertNoDrift throws on a missing (undeclared) registered name", () => { + expect(() => + TOOL_REGISTRY.assertNoDrift([...derived.keys(), "immich_bogus"]), + ).toThrow(); + }); + + it("TOOL_REGISTRY.assertNoDrift throws on a stale (declared but unregistered) name", () => { + expect(() => + TOOL_REGISTRY.assertNoDrift([...derived.keys()].slice(1)), + ).toThrow(); + }); + + it("finalizeToolRegistry stamps every registered tool's annotations from the registry", () => { + const server = new McpServer({ name: "immich-mcp", version: "0.0.0-test" }); + registerAllTools(server, testConfig); + + finalizeToolRegistry(server); + + const reg = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + const names = Object.keys(reg); + expect(names).toHaveLength(74); + expect([...names].sort()).toEqual([...derived.keys()].sort()); + for (const name of names) { + expect(reg[name]!.annotations).toEqual(TOOL_REGISTRY.annotationsFor(name)); + } + }); + + it("finalizeToolRegistry fails closed when a registered tool has no declared tier, before any annotations are stamped", () => { + const server = new McpServer({ name: "immich-mcp", version: "0.0.0-test" }); + registerAllTools(server, testConfig); + + const reg = ( + server as unknown as { + _registeredTools: Record; + } + )._registeredTools; + const realNames = Object.keys(reg); + expect(realNames).toHaveLength(74); + + // Snapshot annotations BEFORE finalize for every real registered tool. The + // SDK leaves annotations undefined when a tool is registered without an + // annotations argument, so a fail-closed throw must leave them all untouched. + const before: Record = {}; + for (const name of realNames) { + before[name] = reg[name]!.annotations; + expect(before[name], name).toBeUndefined(); + } + + // Inject an extra registered tool name that has NO declared tier. This + // simulates a new tool shipping without a registry entry: assertNoDrift + // must catch it before any annotation stamping runs. + server.tool( + "immich_untiered_extra", + "Registered but intentionally untiered to prove finalize fails closed.", + {}, + async () => ({ content: [] }), + ); + expect(Object.keys(reg)).toHaveLength(75); + + expect(() => finalizeToolRegistry(server)).toThrow(); + + // No real registered tool received annotations before the throw: the + // fail-closed guard runs the drift check BEFORE the stamping loop. + for (const name of realNames) { + expect(reg[name]!.annotations, name).toBe(before[name]); + expect(reg[name]!.annotations, name).toBeUndefined(); + } + }); + + it("AST scanner recurses into local helpers (fixture: safe-write + destructive)", () => { + const api = new API({ cwd: root }); + const snapshot = api.updateSnapshot({ + openProjects: [path.join(root, "tsconfig.json")], + }); + const project = snapshot.getProject(path.join(root, "tsconfig.json")); + if (!project) throw new Error("TypeScript did not load the Immich tsconfig"); + try { + const sf = project.program.getSourceFile(fixturePath); + if (!sf) throw new Error(`TypeScript did not load fixture ${fixturePath}`); + const tiers = deriveTiersFromSourceFile(sf); + expect(tiers.size).toBe(2); + expect(tiers.get("fixture_write_tool")).toBe("safe-write"); + expect(tiers.get("fixture_confirm_tool")).toBe("destructive"); + } finally { + snapshot.dispose(); + api.close(); + } + }); +});