|
| 1 | +import * as z from "zod"; |
| 2 | +import { BrowseSearchSchema } from "./schema-readonly"; |
| 3 | +import { ToolRegistry, EnhancedToolDefinition } from "../../types"; |
| 4 | +import { isActionDenied } from "../../config"; |
| 5 | +import { gitlab, paths, toQuery } from "../../utils/gitlab-api"; |
| 6 | + |
| 7 | +/** |
| 8 | + * Search tools registry - 1 read-only CQRS tool |
| 9 | + * |
| 10 | + * browse_search (Query): global, project, group |
| 11 | + * |
| 12 | + * Search is read-only by design - no manage_search tool needed. |
| 13 | + */ |
| 14 | +export const searchToolRegistry: ToolRegistry = new Map<string, EnhancedToolDefinition>([ |
| 15 | + // ============================================================================ |
| 16 | + // browse_search - CQRS Query Tool (discriminated union schema) |
| 17 | + // TypeScript automatically narrows types in each switch case |
| 18 | + // ============================================================================ |
| 19 | + [ |
| 20 | + "browse_search", |
| 21 | + { |
| 22 | + name: "browse_search", |
| 23 | + description: |
| 24 | + 'SEARCH GitLab resources. Actions: "global" searches entire instance, "project" searches within a project, "group" searches within a group. Scopes: projects, issues, merge_requests, milestones, users, groups, blobs (code), commits, wiki_blobs, notes.', |
| 25 | + inputSchema: z.toJSONSchema(BrowseSearchSchema), |
| 26 | + gate: { envVar: "USE_SEARCH", defaultValue: true }, |
| 27 | + handler: async (args: unknown): Promise<unknown> => { |
| 28 | + const input = BrowseSearchSchema.parse(args); |
| 29 | + |
| 30 | + // Runtime validation: reject denied actions even if they bypass schema filtering |
| 31 | + if (isActionDenied("browse_search", input.action)) { |
| 32 | + throw new Error(`Action '${input.action}' is not allowed for browse_search tool`); |
| 33 | + } |
| 34 | + |
| 35 | + switch (input.action) { |
| 36 | + case "global": { |
| 37 | + // TypeScript knows: input has scope, search, and optional filters |
| 38 | + const { scope, ...params } = input; |
| 39 | + |
| 40 | + // Build query params excluding action (not an API parameter) |
| 41 | + const query = toQuery(params, ["action"]); |
| 42 | + |
| 43 | + // Global search endpoint |
| 44 | + const results = await gitlab.get<unknown[]>("search", { |
| 45 | + query: { ...query, scope }, |
| 46 | + }); |
| 47 | + |
| 48 | + return { |
| 49 | + scope, |
| 50 | + count: results.length, |
| 51 | + results, |
| 52 | + }; |
| 53 | + } |
| 54 | + |
| 55 | + case "project": { |
| 56 | + // TypeScript knows: input has project_id, scope, search, and optional filters |
| 57 | + const { project_id, scope, ref, ...params } = input; |
| 58 | + |
| 59 | + // Build query params excluding action (project_id, scope, ref are already destructured) |
| 60 | + const query = toQuery(params, ["action"]); |
| 61 | + |
| 62 | + // Project-scoped search endpoint |
| 63 | + const results = await gitlab.get<unknown[]>(`${paths.project(project_id)}/search`, { |
| 64 | + query: { ...query, scope, ...(ref && { ref }) }, |
| 65 | + }); |
| 66 | + |
| 67 | + return { |
| 68 | + project_id, |
| 69 | + scope, |
| 70 | + count: results.length, |
| 71 | + results, |
| 72 | + }; |
| 73 | + } |
| 74 | + |
| 75 | + case "group": { |
| 76 | + // TypeScript knows: input has group_id, scope, search, and optional filters |
| 77 | + const { group_id, scope, ...params } = input; |
| 78 | + |
| 79 | + // Build query params excluding action (group_id, scope are already destructured) |
| 80 | + const query = toQuery(params, ["action"]); |
| 81 | + |
| 82 | + // Group-scoped search endpoint |
| 83 | + const results = await gitlab.get<unknown[]>(`${paths.group(group_id)}/search`, { |
| 84 | + query: { ...query, scope }, |
| 85 | + }); |
| 86 | + |
| 87 | + return { |
| 88 | + group_id, |
| 89 | + scope, |
| 90 | + count: results.length, |
| 91 | + results, |
| 92 | + }; |
| 93 | + } |
| 94 | + |
| 95 | + /* istanbul ignore next -- unreachable with Zod discriminatedUnion */ |
| 96 | + default: |
| 97 | + throw new Error(`Unknown action: ${(input as { action: string }).action}`); |
| 98 | + } |
| 99 | + }, |
| 100 | + }, |
| 101 | + ], |
| 102 | +]); |
| 103 | + |
| 104 | +/** |
| 105 | + * Get read-only tool names from the registry |
| 106 | + * Search is entirely read-only, so all tools are read-only |
| 107 | + */ |
| 108 | +export function getSearchReadOnlyToolNames(): string[] { |
| 109 | + return ["browse_search"]; |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * Get all tool definitions from the registry |
| 114 | + */ |
| 115 | +export function getSearchToolDefinitions(): EnhancedToolDefinition[] { |
| 116 | + return Array.from(searchToolRegistry.values()); |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Get filtered tools based on read-only mode |
| 121 | + * Since search is read-only, this always returns all tools |
| 122 | + */ |
| 123 | +export function getFilteredSearchTools(readOnlyMode: boolean = false): EnhancedToolDefinition[] { |
| 124 | + // Search is always read-only, so readOnlyMode doesn't affect filtering |
| 125 | + void readOnlyMode; |
| 126 | + return getSearchToolDefinitions(); |
| 127 | +} |
0 commit comments