Skip to main content

devboy_executor/
tools.rs

1//! Base tool definitions for all provider tools.
2//!
3//! These are the "generic" schemas before enrichment.
4//! Provider enrichers modify them based on capabilities and metadata.
5
6use devboy_core::{PropertySchema, ToolCategory, ToolSchema};
7
8/// A tool definition with name, description, category, and input schema.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct ToolDefinition {
11    pub name: String,
12    pub description: String,
13    pub category: ToolCategory,
14    pub input_schema: ToolSchema,
15}
16
17/// Get all base tool definitions (before enrichment).
18pub fn base_tool_definitions() -> Vec<ToolDefinition> {
19    vec![
20        // Issue tools
21        ToolDefinition {
22            name: "get_issues".into(),
23            description: "Get issues from configured provider. Returns a list with filters.".into(),
24            category: ToolCategory::IssueTracker,
25            input_schema: {
26                let mut s = ToolSchema::new();
27                s.add_property("state", PropertySchema::string_enum(&["open", "closed", "all"], "Filter by issue state (default: open)"));
28                s.add_property("search", PropertySchema::string("Search query for title and description"));
29                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Filter by label names"));
30                s.add_property("assignee", PropertySchema::string("Filter by assignee username"));
31                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 20)", Some(1.0), Some(100.0)));
32                s.add_property("offset", PropertySchema::integer("Number of results to skip (default: 0)", Some(0.0), None));
33                s.add_property("sort_by", PropertySchema::string_enum(&["created_at", "updated_at"], "Sort by field (default: updated_at)"));
34                s.add_property("sort_order", PropertySchema::string_enum(&["asc", "desc"], "Sort order (default: desc)"));
35                s.add_property("projectKey", PropertySchema::string("Project key to filter issues (e.g., \"PROJ\"). Overrides default project. Removed by providers that don't support it."));
36                s.add_property("nativeQuery", PropertySchema::string("Native query passed directly to provider (e.g., Jira JQL). Replaces auto-generated filters. If the query omits a project clause, the default project is auto-injected."));
37                s
38            },
39        },
40        ToolDefinition {
41            name: "get_issue".into(),
42            description: "Get a single issue by key with optional comments and relations.".into(),
43            category: ToolCategory::IssueTracker,
44            input_schema: {
45                let mut s = ToolSchema::new();
46                s.add_property("key", PropertySchema::string("Issue key (e.g., 'gh#123', 'gitlab#456', 'CU-abc', 'DEV-42', 'jira#PROJ-123')"));
47                s.add_property("includeComments", PropertySchema::boolean("Include issue comments (default: true)"));
48                s.add_property("includeRelations", PropertySchema::boolean("Include issue relations — parent, subtasks, dependencies (default: true)"));
49                s.set_required("key", true);
50                s
51            },
52        },
53        ToolDefinition {
54            name: "get_issue_comments".into(),
55            description: "Get comments for an issue.".into(),
56            category: ToolCategory::IssueTracker,
57            input_schema: {
58                let mut s = ToolSchema::new();
59                s.add_property("key", PropertySchema::string("Issue key"));
60                s.set_required("key", true);
61                s
62            },
63        },
64        ToolDefinition {
65            name: "get_issue_relations".into(),
66            description: "Get relations for an issue (parent, subtasks, linked issues).".into(),
67            category: ToolCategory::IssueTracker,
68            input_schema: {
69                let mut s = ToolSchema::new();
70                s.add_property("key", PropertySchema::string("Issue key"));
71                s.set_required("key", true);
72                s
73            },
74        },
75        ToolDefinition {
76            name: "create_issue".into(),
77            description: "Create a new issue in the configured provider.".into(),
78            category: ToolCategory::IssueTracker,
79            input_schema: {
80                let mut s = ToolSchema::new();
81                s.add_property("title", PropertySchema::string("Issue title"));
82                s.add_property("description", PropertySchema::string("Issue description/body"));
83                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to add"));
84                s.add_property("assignees", PropertySchema::array(PropertySchema::string("assignee"), "Assignee usernames"));
85                s.add_property("parentId", PropertySchema::string("Parent issue key to create a subtask (e.g., 'CU-abc123' or 'DEV-42'). Only supported by ClickUp."));
86                s.add_property("markdown", PropertySchema::boolean("Whether the description is markdown (default: true). When true, ClickUp renders formatted text."));
87                s.add_property("projectId", PropertySchema::string("Jira project key (not numeric ID) for issue creation (e.g., \"PROJ\"). Optional — overrides the default project."));
88                s.add_property("issueType", PropertySchema::string("Issue type (e.g., \"Task\", \"Bug\", \"Story\"). Default: \"Task\". Removed by providers that don't support it."));
89                // Jira-specific slots (`components`, `fixVersions`,
90                // `epicKey`, `sprintId`, `epicName`) are *not* in
91                // the base schema — `JiraSchemaEnricher` adds them
92                // dynamically so non-Jira providers
93                // (GitHub/GitLab/ClickUp) don't see them and can't
94                // think they're applicable.
95                s.set_required("title", true);
96                s
97            },
98        },
99        ToolDefinition {
100            name: "update_issue".into(),
101            description: "Update an existing issue. Only provided fields will be changed.".into(),
102            category: ToolCategory::IssueTracker,
103            input_schema: {
104                let mut s = ToolSchema::new();
105                s.add_property("key", PropertySchema::string("Issue key"));
106                s.add_property("title", PropertySchema::string("New title"));
107                s.add_property("description", PropertySchema::string("New description"));
108                s.add_property("state", PropertySchema::string_enum(&["open", "closed"], "New state (generic open/closed). For ClickUp custom statuses (\"in progress\", \"review\", \"to do\", …) use `status` instead — `get_available_statuses` lists valid names"));
109                s.add_property("status", PropertySchema::string("Provider-specific status name. ClickUp: any custom status from `get_available_statuses` (e.g. \"in progress\", \"review\"). Other providers: ignored. Takes precedence over `state` when both are set."));
110                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "New labels (replaces existing)"));
111                s.add_property("assignees", PropertySchema::array(PropertySchema::string("assignee"), "New assignees"));
112                s.add_property("parentId", PropertySchema::string("Parent issue key to move task as subtask (e.g., 'CU-abc123' or 'DEV-42'). Only supported by ClickUp."));
113                s.add_property("markdown", PropertySchema::boolean("Whether the description is markdown (default: true). When true, ClickUp renders formatted text."));
114                // Jira-specific slots are added dynamically by
115                // `JiraSchemaEnricher` — see the create_issue
116                // schema comment above.
117                s.set_required("key", true);
118                s
119            },
120        },
121        ToolDefinition {
122            name: "add_issue_comment".into(),
123            description: "Add a comment to an issue with optional file attachments (ClickUp only).".into(),
124            category: ToolCategory::IssueTracker,
125            input_schema: {
126                let mut s = ToolSchema::new();
127                s.add_property("key", PropertySchema::string("Issue key"));
128                s.add_property("body", PropertySchema::string("Comment text"));
129                s.add_property("attachments", PropertySchema::array(
130                    PropertySchema::string("Attachment object with fileData (base64) and filename"),
131                    "File attachments (ClickUp only, max 10MB per file). Each: {fileData: base64, filename: string}",
132                ));
133                s.set_required("key", true);
134                s.set_required("body", true);
135                s
136            },
137        },
138
139        // MR/PR tools
140        ToolDefinition {
141            name: "get_merge_requests".into(),
142            description: "Get merge requests / pull requests from configured provider.".into(),
143            category: ToolCategory::GitRepository,
144            input_schema: {
145                let mut s = ToolSchema::new();
146                s.add_property("state", PropertySchema::string_enum(&["open", "closed", "merged", "all"], "Filter by state (default: open)"));
147                s.add_property("author", PropertySchema::string("Filter by author username"));
148                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Filter by label names"));
149                s.add_property("source_branch", PropertySchema::string("Filter by source branch"));
150                s.add_property("target_branch", PropertySchema::string("Filter by target branch"));
151                s.add_property("limit", PropertySchema::integer("Maximum results (default: 20)", Some(1.0), Some(100.0)));
152                s
153            },
154        },
155        ToolDefinition {
156            name: "get_merge_request".into(),
157            description: "Get a single merge request by key (e.g., 'pr#123', 'mr#456').".into(),
158            category: ToolCategory::GitRepository,
159            input_schema: {
160                let mut s = ToolSchema::new();
161                s.add_property("key", PropertySchema::string("MR/PR key"));
162                s.set_required("key", true);
163                s
164            },
165        },
166        ToolDefinition {
167            name: "get_merge_request_discussions".into(),
168            description: "Get discussions/review comments for a merge request with code positions.".into(),
169            category: ToolCategory::GitRepository,
170            input_schema: {
171                let mut s = ToolSchema::new();
172                s.add_property("key", PropertySchema::string("MR/PR key"));
173                s.add_property("limit", PropertySchema::integer("Max discussions to return (default: all)", Some(1.0), None));
174                s.add_property("offset", PropertySchema::integer("Skip N discussions (default: 0)", Some(0.0), None));
175                s.set_required("key", true);
176                s
177            },
178        },
179        ToolDefinition {
180            name: "get_merge_request_diffs".into(),
181            description: "Get file diffs for a merge request.".into(),
182            category: ToolCategory::GitRepository,
183            input_schema: {
184                let mut s = ToolSchema::new();
185                s.add_property("key", PropertySchema::string("MR/PR key"));
186                s.set_required("key", true);
187                s
188            },
189        },
190        ToolDefinition {
191            name: "create_merge_request".into(),
192            description: "Create a new merge request (GitLab) or pull request (GitHub).".into(),
193            category: ToolCategory::GitRepository,
194            input_schema: {
195                let mut s = ToolSchema::new();
196                s.add_property("title", PropertySchema::string("MR/PR title"));
197                s.add_property("description", PropertySchema::string("MR/PR description"));
198                s.add_property("source_branch", PropertySchema::string("Source branch"));
199                s.add_property("target_branch", PropertySchema::string("Target branch"));
200                s.add_property("draft", PropertySchema::boolean("Create as draft (default: false)"));
201                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels"));
202                s.add_property("reviewers", PropertySchema::array(PropertySchema::string("reviewer"), "Reviewers"));
203                s.set_required("title", true);
204                s.set_required("source_branch", true);
205                s.set_required("target_branch", true);
206                s
207            },
208        },
209        ToolDefinition {
210            name: "create_merge_request_comment".into(),
211            description: "Add a comment to a merge request. Can be general or inline code review.".into(),
212            category: ToolCategory::GitRepository,
213            input_schema: {
214                let mut s = ToolSchema::new();
215                s.add_property("key", PropertySchema::string("MR/PR key"));
216                s.add_property("body", PropertySchema::string("Comment text"));
217                s.add_property("file_path", PropertySchema::string("File path for inline comment"));
218                s.add_property("line", PropertySchema::integer("Line number for inline comment", None, None));
219                s.add_property("line_type", PropertySchema::string_enum(&["old", "new"], "Line type (default: new)"));
220                s.add_property("commit_sha", PropertySchema::string("Commit SHA for inline comment"));
221                s.add_property("discussion_id", PropertySchema::string("Reply to existing discussion"));
222                s.set_required("key", true);
223                s.set_required("body", true);
224                s
225            },
226        },
227
228        // Pipeline tools
229        ToolDefinition {
230            name: "get_pipeline".into(),
231            description: "Get CI/CD pipeline status for branch or MR/PR with job details.".into(),
232            category: ToolCategory::GitRepository,
233            input_schema: {
234                let mut s = ToolSchema::new();
235                s.add_property("branch", PropertySchema::string("Branch name (default: main)"));
236                s.add_property("mrKey", PropertySchema::string("MR/PR key (priority over branch)"));
237                s.add_property("includeFailedLogs", PropertySchema::boolean("Include error extraction for failed jobs (default: true)"));
238                s
239            },
240        },
241        ToolDefinition {
242            name: "get_job_logs".into(),
243            description: "Get CI/CD job logs. Modes: smart (auto errors), search (pattern), paginated, full.".into(),
244            category: ToolCategory::GitRepository,
245            input_schema: {
246                let mut s = ToolSchema::new();
247                s.add_property("jobId", PropertySchema::string("Job ID from get_pipeline"));
248                s.add_property("pattern", PropertySchema::string("Regex/keyword search pattern"));
249                s.add_property("context", PropertySchema::integer("Context lines around match (default: 5)", None, None));
250                s.add_property("maxMatches", PropertySchema::integer("Max search results (default: 20)", None, None));
251                s.add_property("offset", PropertySchema::integer("Start line for paginated mode", None, None));
252                s.add_property("limit", PropertySchema::integer("Lines to return (default: 200, max: 1000)", Some(1.0), Some(1000.0)));
253                s.add_property("full", PropertySchema::boolean("Return entire log"));
254                s.set_required("jobId", true);
255                s
256            },
257        },
258        ToolDefinition {
259            name: "run_pipeline_job".into(),
260            description: "Run an existing manual GitLab CI job after verifying that it belongs to the supplied pipeline and is still manual.".into(),
261            category: ToolCategory::GitRepository,
262            input_schema: {
263                let mut s = ToolSchema::new();
264                s.add_property(
265                    "pipelineId",
266                    PropertySchema::string("Pipeline ID returned by get_pipeline"),
267                );
268                s.add_property(
269                    "jobId",
270                    PropertySchema::string("Manual job ID returned by get_pipeline"),
271                );
272                s.add_property(
273                    "variables",
274                    PropertySchema::object(
275                        "Optional CI variables as an object whose values are strings",
276                    ),
277                );
278                s.add_property(
279                    "jobInputs",
280                    PropertySchema::object(
281                        "Optional typed GitLab job inputs as an object of JSON values",
282                    ),
283                );
284                s.set_required("pipelineId", true);
285                s.set_required("jobId", true);
286                s
287            },
288        },
289
290        // Status / user / link / epic tools
291        ToolDefinition {
292            name: "get_available_statuses".into(),
293            description: "Get available statuses for the issue tracker.".into(),
294            category: ToolCategory::IssueTracker,
295            input_schema: ToolSchema::new(),
296        },
297        ToolDefinition {
298            name: "get_users".into(),
299            description: "Get users from the issue tracker (Jira). Search by name, project, or ID.".into(),
300            category: ToolCategory::IssueTracker,
301            input_schema: {
302                let mut s = ToolSchema::new();
303                s.add_property("userId", PropertySchema::string("Get specific user by ID"));
304                s.add_property("projectKey", PropertySchema::string("Get assignable users for project"));
305                s.add_property("search", PropertySchema::string("Search by name or email"));
306                s.add_property("maxResults", PropertySchema::integer("Max results (default: 50)", Some(1.0), Some(1000.0)));
307                s
308            },
309        },
310        ToolDefinition {
311            name: "link_issues".into(),
312            description: "Link two issues together (blocks, relates_to, etc.).".into(),
313            category: ToolCategory::IssueTracker,
314            input_schema: {
315                let mut s = ToolSchema::new();
316                s.add_property("sourceIssueKey", PropertySchema::string("Source issue key"));
317                s.add_property("targetIssueKey", PropertySchema::string("Target issue key"));
318                s.add_property("linkType", PropertySchema::string(
319                    "Issue link type. Accepts canonical Jira names (`Blocks`, `Relates`, `Causes`, `Implements`, `Created By`, `Duplicate`, `Cloners`) and snake_case aliases (`blocks`, `blocked_by`, `relates_to`, `causes`, `caused_by`, `implements`, `implemented_by`, `created_by`, `creates`, `duplicates`, `duplicated_by`, `clones`, `cloned_by`). The `*_by` variants flip direction. Custom link types configured on the instance also work — pass the exact name. GitHub/GitLab providers ignore this field.",
320                ));
321                s.set_required("sourceIssueKey", true);
322                s.set_required("targetIssueKey", true);
323                s.set_required("linkType", true);
324                s
325            },
326        },
327        ToolDefinition {
328            name: "unlink_issues".into(),
329            description: "Remove a link between two issues.".into(),
330            category: ToolCategory::IssueTracker,
331            input_schema: {
332                let mut s = ToolSchema::new();
333                s.add_property("sourceIssueKey", PropertySchema::string("Source issue key"));
334                s.add_property("targetIssueKey", PropertySchema::string("Target issue key"));
335                s.add_property("linkType", PropertySchema::string(
336                    "Issue link type to remove. Accepts the same canonical names and snake_case aliases as `link_issues` (`Blocks`, `Causes`, `Implements`, `Created By`, `Duplicate`, `Cloners`, plus `*_by` direction flips and `subtask`). Custom link types pass through as-is.",
337                ));
338                s.set_required("sourceIssueKey", true);
339                s.set_required("targetIssueKey", true);
340                s.set_required("linkType", true);
341                s
342            },
343        },
344        ToolDefinition {
345            name: "get_epics".into(),
346            description: "Get epics (high-level tasks) from the issue tracker.".into(),
347            category: ToolCategory::Epics,
348            input_schema: {
349                let mut s = ToolSchema::new();
350                s.add_property("search", PropertySchema::string("Search in epic title"));
351                s.add_property("limit", PropertySchema::integer("Max results (default: 50)", Some(1.0), Some(100.0)));
352                s.add_property("offset", PropertySchema::integer("Skip N results (default: 0)", Some(0.0), None));
353                s
354            },
355        },
356        ToolDefinition {
357            name: "create_epic".into(),
358            description: "Create a new epic.".into(),
359            category: ToolCategory::Epics,
360            input_schema: {
361                let mut s = ToolSchema::new();
362                s.add_property("title", PropertySchema::string("Epic title"));
363                s.add_property("description", PropertySchema::string("Epic description"));
364                s.set_required("title", true);
365                s
366            },
367        },
368        ToolDefinition {
369            name: "update_epic".into(),
370            description: "Update an existing epic.".into(),
371            category: ToolCategory::Epics,
372            input_schema: {
373                let mut s = ToolSchema::new();
374                s.add_property("epicKey", PropertySchema::string("Epic key (e.g., 'CU-abc', 'DEV-123')"));
375                s.add_property("title", PropertySchema::string("New title"));
376                s.add_property("description", PropertySchema::string("New description"));
377                s.add_property("state", PropertySchema::string("New epic state (generic open/closed). For ClickUp custom statuses use `status`."));
378                s.add_property("status", PropertySchema::string("Provider-specific status name. Same as `update_issue.status` — for ClickUp, any custom status from `get_available_statuses`. Takes precedence over `state`."));
379                s.add_property("goalId", PropertySchema::string("Goal ID (G1-G9) to associate with the epic"));
380                s.add_property("priority", PropertySchema::string("New priority (urgent/high/normal/low)"));
381                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to set"));
382                s.add_property("assignees", PropertySchema::array(PropertySchema::string("assignee"), "Assignees to set"));
383                s.set_required("epicKey", true);
384                s
385            },
386        },
387        // Meeting notes tools
388        ToolDefinition {
389            name: "get_meeting_notes".into(),
390            description: "Get meeting notes and transcripts with optional filters (date range, participants, host).".into(),
391            category: ToolCategory::MeetingNotes,
392            input_schema: {
393                let mut s = ToolSchema::new();
394                s.add_property("from_date", PropertySchema::string("Filter from date (ISO 8601, e.g., '2025-01-01T00:00:00Z')"));
395                s.add_property("to_date", PropertySchema::string("Filter to date (ISO 8601)"));
396                s.add_property("participants", PropertySchema::array(PropertySchema::string("email"), "Filter by participant email addresses"));
397                s.add_property("host_email", PropertySchema::string("Filter by host email"));
398                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 50)", Some(1.0), Some(50.0)));
399                s.add_property("offset", PropertySchema::integer("Number of results to skip (default: 0)", Some(0.0), None));
400                s
401            },
402        },
403        ToolDefinition {
404            name: "get_meeting_transcript".into(),
405            description: "Get the full transcript for a meeting. Returns speaker-attributed sentences with timestamps.".into(),
406            category: ToolCategory::MeetingNotes,
407            input_schema: {
408                let mut s = ToolSchema::new();
409                s.add_property("meeting_id", PropertySchema::string("Meeting ID from get_meeting_notes"));
410                s.set_required("meeting_id", true);
411                // gh#291: restore pagination + filter + format params dropped
412                // during the NestJS→Rust migration. Backend dispatcher
413                // (`TranscriptArgs` in consumer monorepo) already parses
414                // these — the schema gap left agents unable to paginate
415                // through long transcripts (1000+ sentences) or discover
416                // grouped output / speaker / text filters.
417                s.add_property("offset", PropertySchema::integer("Number of sentences to skip for pagination (default: 0)", Some(0.0), None));
418                s.add_property("limit", PropertySchema::integer("Maximum number of sentences (default: 50, max: 500)", Some(1.0), Some(500.0)));
419                s.add_property("speaker_filter", PropertySchema::string("Filter sentences by speaker name (case-insensitive substring match)"));
420                s.add_property("search_text", PropertySchema::string("Search sentence text (case-insensitive substring match)"));
421                s.add_property("format", PropertySchema::string_enum(&["flat", "grouped"], "Output format: 'flat' (default, per-sentence) or 'grouped' (same-speaker runs collapsed)"));
422                s
423            },
424        },
425        ToolDefinition {
426            name: "search_meeting_notes".into(),
427            description: "Search across meetings by keywords, topics, or action items, with optional filters (date range, participants, host).".into(),
428            category: ToolCategory::MeetingNotes,
429            input_schema: {
430                let mut s = ToolSchema::new();
431                s.add_property("query", PropertySchema::string("Search query"));
432                s.add_property("from_date", PropertySchema::string("Filter from date (ISO 8601)"));
433                s.add_property("to_date", PropertySchema::string("Filter to date (ISO 8601)"));
434                s.add_property("participants", PropertySchema::array(PropertySchema::string("email"), "Filter by participant email addresses"));
435                s.add_property("host_email", PropertySchema::string("Filter by host email"));
436                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 50)", Some(1.0), Some(50.0)));
437                s.add_property("offset", PropertySchema::integer("Number of results to skip (default: 0)", Some(0.0), None));
438                s.set_required("query", true);
439                s
440            },
441        },
442        // Knowledge base tools
443        ToolDefinition {
444            name: "get_knowledge_base_spaces".into(),
445            description: "List available knowledge base spaces.".into(),
446            category: ToolCategory::KnowledgeBase,
447            input_schema: ToolSchema::new(),
448        },
449        ToolDefinition {
450            name: "list_knowledge_base_pages".into(),
451            description: "List pages in a knowledge base space with pagination.".into(),
452            category: ToolCategory::KnowledgeBase,
453            input_schema: {
454                let mut s = ToolSchema::new();
455                s.add_property("spaceKey", PropertySchema::string("Space key to list pages from"));
456                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 25)", Some(1.0), Some(100.0)));
457                s.add_property("offset", PropertySchema::integer("Number of results to skip when offset pagination is supported", Some(0.0), None));
458                s.add_property("cursor", PropertySchema::string("Provider pagination cursor/token"));
459                s.add_property("search", PropertySchema::string("Optional free-text title/content filter"));
460                s.add_property("parentId", PropertySchema::string("Optional ancestor/parent page ID to scope the listing"));
461                s.set_required("spaceKey", true);
462                s
463            },
464        },
465        ToolDefinition {
466            name: "get_knowledge_base_page".into(),
467            description: "Get a knowledge base page with content, labels, and ancestors.".into(),
468            category: ToolCategory::KnowledgeBase,
469            input_schema: {
470                let mut s = ToolSchema::new();
471                s.add_property("pageId", PropertySchema::string("Knowledge base page ID"));
472                s.set_required("pageId", true);
473                s
474            },
475        },
476        ToolDefinition {
477            name: "create_knowledge_base_page".into(),
478            description: "Create a knowledge base page in a space.".into(),
479            category: ToolCategory::KnowledgeBase,
480            input_schema: {
481                let mut s = ToolSchema::new();
482                s.add_property("spaceKey", PropertySchema::string("Target space key"));
483                s.add_property("title", PropertySchema::string("Page title"));
484                s.add_property("content", PropertySchema::string("Page body content"));
485                s.add_property("contentType", PropertySchema::string_enum(&["markdown", "html", "storage"], "Content representation supplied by the caller"));
486                s.add_property("parentId", PropertySchema::string("Optional parent page ID"));
487                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to set on the page"));
488                s.set_required("spaceKey", true);
489                s.set_required("title", true);
490                s.set_required("content", true);
491                s
492            },
493        },
494        ToolDefinition {
495            name: "update_knowledge_base_page".into(),
496            description: "Update a knowledge base page title, content, metadata, or labels.".into(),
497            category: ToolCategory::KnowledgeBase,
498            input_schema: {
499                let mut s = ToolSchema::new();
500                s.add_property("pageId", PropertySchema::string("Knowledge base page ID"));
501                s.add_property("title", PropertySchema::string("New page title"));
502                s.add_property("content", PropertySchema::string("New page body content"));
503                s.add_property("contentType", PropertySchema::string_enum(&["markdown", "html", "storage"], "Content representation supplied by the caller"));
504                s.add_property("version", PropertySchema::integer("Expected current version for optimistic locking", Some(1.0), None));
505                s.add_property("parentId", PropertySchema::string("Optional new parent page ID"));
506                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to replace on the page"));
507                s.set_required("pageId", true);
508                s
509            },
510        },
511        ToolDefinition {
512            name: "search_knowledge_base".into(),
513            description: "Search knowledge base pages across spaces using free text or provider-native syntax such as CQL.".into(),
514            category: ToolCategory::KnowledgeBase,
515            input_schema: {
516                let mut s = ToolSchema::new();
517                s.add_property("query", PropertySchema::string("Free-text query or provider-native search expression"));
518                s.add_property("spaceKey", PropertySchema::string("Restrict search to a specific space key"));
519                s.add_property("cursor", PropertySchema::string("Provider pagination cursor/token"));
520                s.add_property("limit", PropertySchema::integer("Maximum number of matches to return", Some(1.0), Some(100.0)));
521                s.add_property("rawQuery", PropertySchema::boolean("Whether `query` should be treated as raw provider-native syntax"));
522                s.set_required("query", true);
523                s
524            },
525        },
526        ToolDefinition {
527            name: "update_merge_request".into(),
528            description: "Update a merge request / pull request (title, description, state, labels, draft).".into(),
529            category: ToolCategory::GitRepository,
530            input_schema: {
531                let mut s = ToolSchema::new();
532                s.add_property("key", PropertySchema::string("MR key (e.g. 'mr#1', 'pr#42')"));
533                s.add_property("title", PropertySchema::string("New title"));
534                s.add_property("description", PropertySchema::string("New description / body (supports markdown)"));
535                s.add_property("state", PropertySchema::string_enum(&["close", "reopen"], "Change MR state"));
536                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "New labels (replaces existing)"));
537                s.set_required("key", true);
538                s
539            },
540        },
541        // =====================================================================
542        // Asset tools
543        // =====================================================================
544        ToolDefinition {
545            name: "get_assets".into(),
546            description: "List file attachments for an issue or merge request.".into(),
547            category: ToolCategory::IssueTracker,
548            input_schema: {
549                let mut s = ToolSchema::new();
550                s.add_property("context_type", PropertySchema::string_enum(&["issue", "mr"], "Context type: 'issue' or 'mr' (merge request / pull request)"));
551                s.add_property("key", PropertySchema::string("Issue key (e.g. 'DEV-123', 'gitlab#42') or MR key (e.g. 'mr#42', 'pr#42')"));
552                s.set_required("context_type", true);
553                s.set_required("key", true);
554                s
555            },
556        },
557        ToolDefinition {
558            name: "upload_asset".into(),
559            description: "Upload a file attachment to an issue. Returns the download URL.".into(),
560            category: ToolCategory::IssueTracker,
561            input_schema: {
562                let mut s = ToolSchema::new();
563                s.add_property("context_type", PropertySchema::string_enum(&["issue"], "Context type (currently only 'issue' is supported for uploads)"));
564                s.add_property("key", PropertySchema::string("Issue key (e.g. 'DEV-123')"));
565                s.add_property("filename", PropertySchema::string("Original filename (e.g. 'screenshot.png')"));
566                s.add_property("fileData", PropertySchema::string("Base64-encoded file content"));
567                s.set_required("context_type", true);
568                s.set_required("key", true);
569                s.set_required("filename", true);
570                s.set_required("fileData", true);
571                s
572            },
573        },
574        ToolDefinition {
575            name: "download_asset".into(),
576            description: "Download a file attachment to local cache. Returns local file path when cache is available, base64-encoded content as fallback.".into(),
577            category: ToolCategory::IssueTracker,
578            input_schema: {
579                let mut s = ToolSchema::new();
580                s.add_property("context_type", PropertySchema::string_enum(&["issue", "mr"], "Context type: 'issue' or 'mr'"));
581                s.add_property("key", PropertySchema::string("Issue key or MR key"));
582                s.add_property("asset_id", PropertySchema::string("Asset identifier from get_assets response"));
583                s.set_required("context_type", true);
584                s.set_required("key", true);
585                s.set_required("asset_id", true);
586                s
587            },
588        },
589        // =====================================================================
590        // Messenger tools
591        // =====================================================================
592        ToolDefinition {
593            name: "get_messenger_chats".into(),
594            description: "List available messenger chats, channels, groups, or direct messages.".into(),
595            category: ToolCategory::Messenger,
596            input_schema: {
597                let mut s = ToolSchema::new();
598                s.add_property("search", PropertySchema::string("Optional chat name search"));
599                s.add_property("chat_type", PropertySchema::string_enum(&["direct", "group", "channel"], "Optional chat type filter"));
600                s.add_property("limit", PropertySchema::integer("Maximum number of chats to return", Some(1.0), Some(1000.0)));
601                s.add_property("cursor", PropertySchema::string("Provider pagination cursor"));
602                s.add_property("include_inactive", PropertySchema::boolean("Include archived or inactive chats"));
603                s
604            },
605        },
606        ToolDefinition {
607            name: "get_chat_messages".into(),
608            description: "Get message history for a chat or fetch replies for a specific thread.".into(),
609            category: ToolCategory::Messenger,
610            input_schema: {
611                let mut s = ToolSchema::new();
612                s.add_property("chat_id", PropertySchema::string("Messenger chat ID"));
613                s.add_property("limit", PropertySchema::integer("Maximum number of messages to return", Some(1.0), Some(1000.0)));
614                s.add_property("cursor", PropertySchema::string("Provider pagination cursor"));
615                s.add_property("thread_id", PropertySchema::string("Thread identifier to fetch replies for"));
616                s.add_property("since", PropertySchema::string("Only include messages after this provider timestamp"));
617                s.add_property("until", PropertySchema::string("Only include messages before this provider timestamp"));
618                s.set_required("chat_id", true);
619                s
620            },
621        },
622        ToolDefinition {
623            name: "search_chat_messages".into(),
624            description: "Search messages across accessible chats or within a specific chat.".into(),
625            category: ToolCategory::Messenger,
626            input_schema: {
627                let mut s = ToolSchema::new();
628                s.add_property("query", PropertySchema::string("Message search query"));
629                s.add_property("chat_id", PropertySchema::string("Optional chat ID to scope the search"));
630                s.add_property("limit", PropertySchema::integer("Maximum number of matches to return", Some(1.0), Some(1000.0)));
631                s.add_property("cursor", PropertySchema::string("Provider pagination cursor"));
632                s.add_property("since", PropertySchema::string("Only include messages after this provider timestamp"));
633                s.add_property("until", PropertySchema::string("Only include messages before this provider timestamp"));
634                s.set_required("query", true);
635                s
636            },
637        },
638        ToolDefinition {
639            name: "send_message".into(),
640            description: "Send a message to a chat or as a threaded reply.".into(),
641            category: ToolCategory::Messenger,
642            input_schema: {
643                let mut s = ToolSchema::new();
644                s.add_property("chat_id", PropertySchema::string("Messenger chat ID"));
645                s.add_property("text", PropertySchema::string("Message body"));
646                s.add_property("thread_id", PropertySchema::string("Thread identifier to post as a threaded reply"));
647                s.add_property("reply_to_id", PropertySchema::string("Direct parent message ID when supported"));
648                s.set_required("chat_id", true);
649                s.set_required("text", true);
650                s
651            },
652        },
653        ToolDefinition {
654            name: "delete_asset".into(),
655            description: "Delete a file attachment from an issue. Not all providers support this — check asset_capabilities first.".into(),
656            category: ToolCategory::IssueTracker,
657            input_schema: {
658                let mut s = ToolSchema::new();
659                s.add_property("key", PropertySchema::string("Issue key (e.g. 'PROJ-123')"));
660                s.add_property("asset_id", PropertySchema::string("Asset identifier to delete"));
661                s.set_required("key", true);
662                s.set_required("asset_id", true);
663                s
664            },
665        },
666        // Jira Structure plugin tools
667        ToolDefinition {
668            name: "get_structures".into(),
669            description: "List all available Jira Structures. Returns structure ID, name, and description. Requires Jira with Structure plugin.".into(),
670            category: ToolCategory::JiraStructure,
671            input_schema: ToolSchema::new(),
672        },
673        ToolDefinition {
674            name: "get_structure_forest".into(),
675            description: "Get the hierarchy tree of a Jira Structure. Returns nested tree with rowId, itemId (Jira issue key), itemType, and children. Supports pagination for large structures.".into(),
676            category: ToolCategory::JiraStructure,
677            input_schema: {
678                let mut s = ToolSchema::new();
679                s.add_property("structureId", PropertySchema::integer("Structure ID. Use get_structures to find it.", None, None));
680                s.add_property("offset", PropertySchema::integer("Offset for pagination (default: 0)", Some(0.0), None));
681                s.add_property("limit", PropertySchema::integer("Max rows to return (default: 200)", Some(1.0), Some(10000.0)));
682                s.set_required("structureId", true);
683                s
684            },
685        },
686        ToolDefinition {
687            name: "add_structure_rows".into(),
688            description: "Add items (Jira issues or folders) to a Structure. Specify position with under (parent row) and/or after (sibling row). Use forestVersion for optimistic concurrency.".into(),
689            category: ToolCategory::JiraStructure,
690            input_schema: {
691                let mut s = ToolSchema::new();
692                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
693                s.add_property("items", PropertySchema::array(
694                    PropertySchema::string("Item: Jira issue key (e.g. 'PROJ-123') or JSON {\"itemId\":\"PROJ-123\",\"itemType\":\"issue\"}"),
695                    "Items to add",
696                ));
697                s.add_property("under", PropertySchema::integer("Parent row ID — items become children of this row", None, None));
698                s.add_property("after", PropertySchema::integer("Sibling row ID — items placed after this row", None, None));
699                s.add_property("forestVersion", PropertySchema::integer("Forest version for optimistic locking (from get_structure_forest)", None, None));
700                s.set_required("structureId", true);
701                s.set_required("items", true);
702                s
703            },
704        },
705        ToolDefinition {
706            name: "move_structure_rows".into(),
707            description: "Move rows within a Jira Structure hierarchy. Specify new position with under (new parent) and/or after (sibling).".into(),
708            category: ToolCategory::JiraStructure,
709            input_schema: {
710                let mut s = ToolSchema::new();
711                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
712                s.add_property("rowIds", PropertySchema::array(
713                    PropertySchema::integer("Row ID", None, None),
714                    "Row IDs to move (from get_structure_forest)",
715                ));
716                s.add_property("under", PropertySchema::integer("New parent row ID", None, None));
717                s.add_property("after", PropertySchema::integer("Sibling row ID to place after", None, None));
718                s.add_property("forestVersion", PropertySchema::integer("Forest version for optimistic locking", None, None));
719                s.set_required("structureId", true);
720                s.set_required("rowIds", true);
721                s
722            },
723        },
724        ToolDefinition {
725            name: "remove_structure_row".into(),
726            description: "Remove a row from a Jira Structure. Only removes from the structure hierarchy — the underlying Jira issue is NOT deleted.".into(),
727            category: ToolCategory::JiraStructure,
728            input_schema: {
729                let mut s = ToolSchema::new();
730                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
731                s.add_property("rowId", PropertySchema::integer("Row ID to remove (from get_structure_forest)", None, None));
732                s.set_required("structureId", true);
733                s.set_required("rowId", true);
734                s
735            },
736        },
737        ToolDefinition {
738            name: "get_structure_values".into(),
739            description: "Read column values (including Expr formulas like SUM, PROGRESS, COUNT) for specific rows in a Jira Structure. Values are computed server-side.".into(),
740            category: ToolCategory::JiraStructure,
741            input_schema: {
742                let mut s = ToolSchema::new();
743                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
744                s.add_property("rows", PropertySchema::array(
745                    PropertySchema::integer("Row ID", None, None),
746                    "Row IDs to read values for",
747                ));
748                s.add_property("columns", PropertySchema::array(
749                    PropertySchema::string("Column spec: field name (e.g. 'summary'), or JSON {\"field\":\"status\"} or {\"formula\":\"SUM(\\\"Story Points\\\")\"}"),
750                    "Columns to read",
751                ));
752                s.set_required("structureId", true);
753                s.set_required("rows", true);
754                s.set_required("columns", true);
755                s
756            },
757        },
758        ToolDefinition {
759            name: "get_structure_views".into(),
760            description: "Get views for a Jira Structure. Without viewId: lists all views. With viewId: returns full view configuration (columns, grouping, sorting, filter).".into(),
761            category: ToolCategory::JiraStructure,
762            input_schema: {
763                let mut s = ToolSchema::new();
764                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
765                s.add_property("viewId", PropertySchema::integer("View ID for full config (optional — omit to list all views)", None, None));
766                s.set_required("structureId", true);
767                s
768            },
769        },
770        ToolDefinition {
771            name: "save_structure_view".into(),
772            description: "Create or update a Jira Structure view. Views define column layout (fields and formulas), grouping, sorting, and filters. Omit id to create new.".into(),
773            category: ToolCategory::JiraStructure,
774            input_schema: {
775                let mut s = ToolSchema::new();
776                s.add_property("id", PropertySchema::integer("View ID to update (omit to create new)", None, None));
777                s.add_property("structureId", PropertySchema::integer("Structure ID this view belongs to", None, None));
778                s.add_property("name", PropertySchema::string("View name"));
779                s.add_property("columns", PropertySchema::array(
780                    PropertySchema::string("Column spec: JSON {\"field\":\"summary\"} or {\"formula\":\"SUM(\\\"Story Points\\\")\",\"width\":100}"),
781                    "Column definitions",
782                ));
783                s.add_property("groupBy", PropertySchema::string("Field name to group by"));
784                s.add_property("sortBy", PropertySchema::string("Field name to sort by"));
785                s.add_property("filter", PropertySchema::string("JQL filter expression"));
786                s.set_required("structureId", true);
787                s.set_required("name", true);
788                s
789            },
790        },
791        ToolDefinition {
792            name: "create_structure".into(),
793            description: "Create a new Jira Structure. After creation, use add_structure_rows to populate and save_structure_view to configure columns.".into(),
794            category: ToolCategory::JiraStructure,
795            input_schema: {
796                let mut s = ToolSchema::new();
797                s.add_property("name", PropertySchema::string("Structure name"));
798                s.add_property("description", PropertySchema::string("Structure description"));
799                s.set_required("name", true);
800                s
801            },
802        },
803
804        // Project versions / fixVersion targets (issue #238).
805        // Two-tool surface — list returns a rich payload so a per-id GET
806        // is unnecessary, upsert is name-keyed so the LLM never deals
807        // with numeric ids. See `docs/research/paper-3-context-enrichment.md`.
808        ToolDefinition {
809            name: "list_project_versions".into(),
810            description: "List Jira project versions / fixVersion targets (releases). Returns rich per-version payload (description, dates, released/archived flags, optional issue counts). Default filter hides archived versions and limits to 20 most recent (unreleased first, then released by releaseDate desc). For issue-level details on a release, follow up with `get_issues` and a JQL `nativeQuery` such as `fixVersion = \"<name>\"` — there is no per-id get tool by design.".into(),
811            category: ToolCategory::IssueTracker,
812            input_schema: {
813                let mut s = ToolSchema::new();
814                s.add_property("project", PropertySchema::string("Jira project key (e.g., \"PROJ\"). Defaults to the configured project."));
815                s.add_property("released", PropertySchema::string_enum(&["true", "false", "all"], "Filter by release state: \"true\" → only released, \"false\" → only unreleased, \"all\" → both (default: \"all\")"));
816                s.add_property("archived", PropertySchema::string_enum(&["true", "false", "all"], "Filter by archived flag (default: \"false\" — hides archival noise)"));
817                s.add_property("limit", PropertySchema::integer("Max versions to return (default: 20). Sorted by releaseDate desc; oldest archival entries trimmed first", Some(1.0), Some(200.0)));
818                s.add_property("includeIssueCount", PropertySchema::boolean("Fetch issue counts per version via Cloud `?expand=issuesstatus` (default: false). Adds latency on large projects."));
819                s
820            },
821        },
822        ToolDefinition {
823            name: "upsert_project_version".into(),
824            description: "Create or partially update a Jira project version, keyed by `(project, name)`. If a version with this name exists, fields you supply are updated and unspecified fields are preserved. If not, a new version is created. Useful for writing release notes (`description`) or closing a release (`released: true`, `releaseDate`).".into(),
825            category: ToolCategory::IssueTracker,
826            input_schema: {
827                let mut s = ToolSchema::new();
828                s.add_property("project", PropertySchema::string("Jira project key (e.g., \"PROJ\"). Defaults to the configured project."));
829                s.add_property("name", PropertySchema::string("Version name — both the lookup key and, on create, the value (e.g., \"3.18.0\")."));
830                s.add_property("description", PropertySchema::string("Release notes / version description. Markdown-style text is preserved on Server/DC; Cloud accepts plain text."));
831                s.add_property("startDate", PropertySchema::string("Planned start date as ISO 8601 calendar date (`YYYY-MM-DD`)."));
832                s.add_property("releaseDate", PropertySchema::string("Planned or actual release date (`YYYY-MM-DD`)."));
833                s.add_property("released", PropertySchema::boolean("Mark released (true) / unreleased (false). Pair with `releaseDate` when closing a release."));
834                s.add_property("archived", PropertySchema::boolean("Archive (true) / unarchive (false) the version."));
835                s.set_required("name", true);
836                s
837            },
838        },
839        // Agile / Sprint (issue #198). Pairs with the `sprintId` slot on
840        // create_issue / update_issue: `get_board_sprints` is how callers
841        // discover available sprint ids on a board.
842        ToolDefinition {
843            name: "get_board_sprints".into(),
844            description: "List sprints visible on a Jira agile board. Use to discover the numeric `sprintId` accepted by `create_issue` / `update_issue` and `assign_to_sprint`. Returns name, state (active/future/closed), planned start/end, and goal — enough for the agent to pick the right sprint without a follow-up call.".into(),
845            category: ToolCategory::IssueTracker,
846            input_schema: {
847                let mut s = ToolSchema::new();
848                s.add_property(
849                    "boardId",
850                    PropertySchema::integer(
851                        "Numeric Jira board id. The Agile / Boards REST endpoint returns sprints scoped to one board — there is no global sprint list",
852                        Some(0.0),
853                        None,
854                    ),
855                );
856                s.add_property(
857                    "state",
858                    PropertySchema::string_enum(
859                        &["active", "future", "closed", "all"],
860                        "Filter by sprint state. Default `all` returns every sprint on the board",
861                    ),
862                );
863                s.set_required("boardId", true);
864                s
865            },
866        },
867        ToolDefinition {
868            name: "assign_to_sprint".into(),
869            description: "Move one or more issues onto a Jira sprint. Pair with `get_board_sprints` to look up the numeric `sprintId`. Issues already on a sprint are silently moved.".into(),
870            category: ToolCategory::IssueTracker,
871            input_schema: {
872                let mut s = ToolSchema::new();
873                s.add_property(
874                    "sprintId",
875                    PropertySchema::integer(
876                        "Numeric sprint id. Use `get_board_sprints` to discover ids on a board",
877                        Some(0.0),
878                        None,
879                    ),
880                );
881                s.add_property(
882                    "issueKeys",
883                    PropertySchema::array(
884                        PropertySchema::string("issue key (e.g., \"PROJ-1\")"),
885                        "Issue keys to move onto the sprint. Must contain at least one key.",
886                    ),
887                );
888                s.set_required("sprintId", true);
889                s.set_required("issueKeys", true);
890                s
891            },
892        },
893        // Custom-field discovery — pairs with the `epicKey` / `sprintId` /
894        // `epicName` slots on create/update_issue and the raw `customFields`
895        // escape hatch. Returns a name → id mapping so agents stop guessing
896        // `customfield_*` numbers.
897        ToolDefinition {
898            name: "get_custom_fields".into(),
899            description: "List custom fields available on the issue tracker, with their id, name, and field type. Use to discover the `customfield_*` id of a Jira instance — names like `Epic Link`, `Sprint`, `Epic Name` map to different ids on every deployment. Pair with `customFields: { \"<id>\": <value> }` on `create_issue` / `update_issue` for fields not yet exposed as first-class params.".into(),
900            category: ToolCategory::IssueTracker,
901            input_schema: {
902                let mut s = ToolSchema::new();
903                s.add_property(
904                    "project",
905                    PropertySchema::string(
906                        "Optional project key. Reserved for providers that scope custom fields per project; ignored on Jira's global `/field` endpoint.",
907                    ),
908                );
909                s.add_property(
910                    "issueType",
911                    PropertySchema::string(
912                        "Optional issue type. Reserved for providers that scope custom fields per create-screen context.",
913                    ),
914                );
915                s.add_property(
916                    "search",
917                    PropertySchema::string(
918                        "Case-insensitive substring filter on the field name (e.g. `\"Epic\"` to find `Epic Link` and `Epic Name`).",
919                    ),
920                );
921                s.add_property(
922                    "limit",
923                    PropertySchema::integer(
924                        "Max fields to return after filtering (default 50). Sorted by name asc",
925                        Some(1.0),
926                        Some(200.0),
927                    ),
928                );
929                s
930            },
931        },
932    ]
933}
934
935/// Always-available MCP tools that don't belong to a provider category.
936///
937/// These are surfaced by `devboy-mcp` on every `tools/list` regardless of
938/// which providers are configured. They live here (not in the MCP crate)
939/// so the published reference doc can render them from a single source.
940#[derive(Debug, Clone)]
941pub struct McpOnlyTool {
942    pub name: String,
943    pub description: String,
944    pub input_schema: ToolSchema,
945}
946
947/// MCP-only tools (context management). Always advertised by the server.
948pub fn mcp_only_tools() -> Vec<McpOnlyTool> {
949    vec![
950        McpOnlyTool {
951            name: "list_contexts".into(),
952            description: "List configured contexts and indicate the active context.".into(),
953            input_schema: ToolSchema::new(),
954        },
955        McpOnlyTool {
956            name: "use_context".into(),
957            description: "Switch active context at runtime.".into(),
958            input_schema: {
959                let mut s = ToolSchema::new();
960                s.add_property("name", PropertySchema::string("Context name to activate"));
961                s.set_required("name", true);
962                s
963            },
964        },
965        McpOnlyTool {
966            name: "get_current_context".into(),
967            description: "Get current active context name.".into(),
968            input_schema: ToolSchema::new(),
969        },
970        McpOnlyTool {
971            name: "secrets_list".into(),
972            description: "List secrets the active context's manifest declares. \
973                Returns metadata only — values are never included. \
974                Optional filter narrows by path substring, scope, status, or \
975                whether to include framework-internal paths."
976                .into(),
977            input_schema: {
978                let mut s = ToolSchema::new();
979                s.add_property(
980                    "path_contains",
981                    PropertySchema::string("Substring to match against the full ADR-020 path."),
982                );
983                s.add_property(
984                    "scope",
985                    PropertySchema::string(
986                        "Exact match against the first path segment (e.g. team / personal).",
987                    ),
988                );
989                s.add_property(
990                    "status",
991                    PropertySchema::string_enum(
992                        &["registered", "expiring", "expired"],
993                        "Lifecycle status filter computed from expires_at.",
994                    ),
995                );
996                s.add_property(
997                    "include_internal",
998                    PropertySchema::boolean("Include framework-internal paths (default: false)."),
999                );
1000                s
1001            },
1002        },
1003        McpOnlyTool {
1004            name: "secrets_describe".into(),
1005            description: "Describe one secret by ADR-020 path. Returns the same \
1006                metadata fields as `secrets_list` plus description, retrieval URL, \
1007                rotation method, last rotated date, rotation cadence, and pattern \
1008                ID. The value is never returned."
1009                .into(),
1010            input_schema: {
1011                let mut s = ToolSchema::new();
1012                s.add_property(
1013                    "path",
1014                    PropertySchema::string("Full ADR-020 path to describe."),
1015                );
1016                s.set_required("path", true);
1017                s
1018            },
1019        },
1020        McpOnlyTool {
1021            name: "secrets_request_provision".into(),
1022            description: "Open the provisioning UI dialog for the given ADR-020 \
1023                path. The dialog hands the user-entered value directly to the \
1024                local daemon — the agent never sees it. Returns a `request_id` \
1025                that can be polled with `secrets_poll_status`. Mode defaults to \
1026                `provision`; pass `rotation` to surface the destructive-confirm \
1027                checkbox. Pending requests expire 5 minutes after issuance."
1028                .into(),
1029            input_schema: {
1030                let mut s = ToolSchema::new();
1031                s.add_property(
1032                    "path",
1033                    PropertySchema::string("Full ADR-020 path to provision."),
1034                );
1035                s.add_property(
1036                    "mode",
1037                    PropertySchema::string_enum(
1038                        &["provision", "rotation"],
1039                        "Dialog mode (default: provision).",
1040                    ),
1041                );
1042                s.set_required("path", true);
1043                s
1044            },
1045        },
1046        McpOnlyTool {
1047            name: "secrets_poll_status".into(),
1048            description: "Poll a provisioning or rotation request issued by \
1049                `secrets_request_provision` / `secrets_request_rotation`. Returns \
1050                one of pending / ok / cancelled / expired / failed plus the \
1051                request's age in seconds and the path it was opened for."
1052                .into(),
1053            input_schema: {
1054                let mut s = ToolSchema::new();
1055                s.add_property(
1056                    "request_id",
1057                    PropertySchema::string(
1058                        "Opaque id returned by request_provision / request_rotation.",
1059                    ),
1060                );
1061                s.set_required("request_id", true);
1062                s
1063            },
1064        },
1065        McpOnlyTool {
1066            name: "secrets_request_rotation".into(),
1067            description: "Open the rotation UI dialog for the given ADR-020 \
1068                path. Same lifecycle as `secrets_request_provision` but the \
1069                dialog surfaces the destructive-confirm checkbox so the user \
1070                explicitly acknowledges that the existing value is being \
1071                overwritten. Reuses `secrets_poll_status` for status. Pending \
1072                requests expire 5 minutes after issuance."
1073                .into(),
1074            input_schema: {
1075                let mut s = ToolSchema::new();
1076                s.add_property(
1077                    "path",
1078                    PropertySchema::string("Full ADR-020 path to rotate."),
1079                );
1080                s.set_required("path", true);
1081                s
1082            },
1083        },
1084        McpOnlyTool {
1085            name: "secrets_request_use_approval".into(),
1086            description: "Open the use-approval dialog for an ADR-020 path \
1087                whose `approve_on_use` is set to `session` or `per-call`. \
1088                The agent supplies a short human-facing `reason` that the \
1089                dialog renders verbatim alongside the path; the user picks \
1090                `once`, `session`, or `denied`. Returns a `request_id` to \
1091                poll via `secrets_poll_status`. Pending requests expire 5 \
1092                minutes after issuance; `ttl_seconds` may shorten the \
1093                window but never extend it. The agent never sees the \
1094                secret — only whether the user approved its use."
1095                .into(),
1096            input_schema: {
1097                let mut s = ToolSchema::new();
1098                s.add_property(
1099                    "path",
1100                    PropertySchema::string("Full ADR-020 path the agent intends to resolve."),
1101                );
1102                s.add_property(
1103                    "reason",
1104                    PropertySchema::string(
1105                        "Short human-facing reason rendered in the dialog \
1106                         (e.g. 'pushing image to staging registry').",
1107                    ),
1108                );
1109                s.add_property(
1110                    "ttl_seconds",
1111                    PropertySchema {
1112                        schema_type: "integer".into(),
1113                        description: Some(
1114                            "Optional lifetime in seconds; capped at the \
1115                             registry-wide TTL (5 minutes)."
1116                                .into(),
1117                        ),
1118                        ..Default::default()
1119                    },
1120                );
1121                s.set_required("path", true);
1122                s.set_required("reason", true);
1123                s
1124            },
1125        },
1126        McpOnlyTool {
1127            name: "secrets_propose_metadata".into(),
1128            description: "Suggest metadata edits for an existing ADR-020 \
1129                path. The dialog renders the manifest's current values as the \
1130                diff baseline (read straight from the index — agent strings \
1131                never replace trusted fields, mitigating prompt-injection). \
1132                The user picks which proposed fields to accept. Reuses \
1133                `secrets_poll_status` for status. Pending requests expire 5 \
1134                minutes after issuance."
1135                .into(),
1136            input_schema: {
1137                let mut s = ToolSchema::new();
1138                s.add_property("path", PropertySchema::string("Full ADR-020 path to edit."));
1139                s.add_property(
1140                    "fields",
1141                    PropertySchema {
1142                        schema_type: "object".into(),
1143                        description: Some(
1144                            "Proposed field overrides (description, retrieval_url, \
1145                             rotate_every_days, expires_at, pattern_id). Omitted \
1146                             fields are not proposed for change."
1147                                .into(),
1148                        ),
1149                        ..Default::default()
1150                    },
1151                );
1152                s.set_required("path", true);
1153                s.set_required("fields", true);
1154                s
1155            },
1156        },
1157        McpOnlyTool {
1158            name: "secrets_propose_new_path".into(),
1159            description: "Suggest registering a new secret at the given path. \
1160                The dialog opens with the suggested path editable and the \
1161                proposed metadata visible in a diff column for review. The \
1162                user has the final say on the path and the metadata that \
1163                lands in the manifest. Reuses `secrets_poll_status` for status. \
1164                Pending requests expire 5 minutes after issuance."
1165                .into(),
1166            input_schema: {
1167                let mut s = ToolSchema::new();
1168                s.add_property(
1169                    "suggested_path",
1170                    PropertySchema::string("Suggested ADR-020 path; user may edit."),
1171                );
1172                s.add_property(
1173                    "metadata",
1174                    PropertySchema {
1175                        schema_type: "object".into(),
1176                        description: Some("Proposed metadata fields for the new entry.".into()),
1177                        ..Default::default()
1178                    },
1179                );
1180                s.set_required("suggested_path", true);
1181                s.set_required("metadata", true);
1182                s
1183            },
1184        },
1185    ]
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    #[test]
1193    fn test_base_definitions_count() {
1194        let tools = base_tool_definitions();
1195        assert_eq!(tools.len(), 55);
1196    }
1197
1198    #[test]
1199    fn test_all_tools_have_names() {
1200        for tool in base_tool_definitions() {
1201            assert!(!tool.name.is_empty());
1202            assert!(!tool.description.is_empty());
1203        }
1204    }
1205
1206    #[test]
1207    fn test_tool_categories() {
1208        let tools = base_tool_definitions();
1209
1210        let issue_tracker_tools = [
1211            "get_issues",
1212            "get_issue",
1213            "get_issue_comments",
1214            "get_issue_relations",
1215            "create_issue",
1216            "update_issue",
1217            "add_issue_comment",
1218            "get_available_statuses",
1219            "get_users",
1220            "link_issues",
1221            "unlink_issues",
1222            "get_assets",
1223            "upload_asset",
1224            "download_asset",
1225            "delete_asset",
1226            "list_project_versions",
1227            "upsert_project_version",
1228            "get_board_sprints",
1229            "assign_to_sprint",
1230            "get_custom_fields",
1231        ];
1232        let git_repository_tools = [
1233            "get_merge_requests",
1234            "get_merge_request",
1235            "get_merge_request_discussions",
1236            "get_merge_request_diffs",
1237            "create_merge_request",
1238            "create_merge_request_comment",
1239            "update_merge_request",
1240            "get_pipeline",
1241            "get_job_logs",
1242            "run_pipeline_job",
1243        ];
1244        let epics_tools = ["get_epics", "create_epic", "update_epic"];
1245        let meeting_notes_tools = [
1246            "get_meeting_notes",
1247            "get_meeting_transcript",
1248            "search_meeting_notes",
1249        ];
1250        let knowledge_base_tools = [
1251            "get_knowledge_base_spaces",
1252            "list_knowledge_base_pages",
1253            "get_knowledge_base_page",
1254            "create_knowledge_base_page",
1255            "update_knowledge_base_page",
1256            "search_knowledge_base",
1257        ];
1258        let messenger_tools = [
1259            "get_messenger_chats",
1260            "get_chat_messages",
1261            "search_chat_messages",
1262            "send_message",
1263        ];
1264        let jira_structure_tools = [
1265            "get_structures",
1266            "get_structure_forest",
1267            "add_structure_rows",
1268            "move_structure_rows",
1269            "remove_structure_row",
1270            "get_structure_values",
1271            "get_structure_views",
1272            "save_structure_view",
1273            "create_structure",
1274        ];
1275
1276        for tool in &tools {
1277            if issue_tracker_tools.contains(&tool.name.as_str()) {
1278                assert_eq!(
1279                    tool.category,
1280                    ToolCategory::IssueTracker,
1281                    "tool {} should be IssueTracker",
1282                    tool.name
1283                );
1284            } else if git_repository_tools.contains(&tool.name.as_str()) {
1285                assert_eq!(
1286                    tool.category,
1287                    ToolCategory::GitRepository,
1288                    "tool {} should be GitRepository",
1289                    tool.name
1290                );
1291            } else if epics_tools.contains(&tool.name.as_str()) {
1292                assert_eq!(
1293                    tool.category,
1294                    ToolCategory::Epics,
1295                    "tool {} should be Epics",
1296                    tool.name
1297                );
1298            } else if meeting_notes_tools.contains(&tool.name.as_str()) {
1299                assert_eq!(
1300                    tool.category,
1301                    ToolCategory::MeetingNotes,
1302                    "tool {} should be MeetingNotes",
1303                    tool.name
1304                );
1305            } else if knowledge_base_tools.contains(&tool.name.as_str()) {
1306                assert_eq!(
1307                    tool.category,
1308                    ToolCategory::KnowledgeBase,
1309                    "tool {} should be KnowledgeBase",
1310                    tool.name
1311                );
1312            } else if messenger_tools.contains(&tool.name.as_str()) {
1313                assert_eq!(
1314                    tool.category,
1315                    ToolCategory::Messenger,
1316                    "tool {} should be Messenger",
1317                    tool.name
1318                );
1319            } else if jira_structure_tools.contains(&tool.name.as_str()) {
1320                assert_eq!(
1321                    tool.category,
1322                    ToolCategory::JiraStructure,
1323                    "tool {} should be JiraStructure",
1324                    tool.name
1325                );
1326            } else {
1327                panic!("tool {} has no expected category mapping", tool.name);
1328            }
1329        }
1330    }
1331
1332    #[test]
1333    fn test_required_params() {
1334        let tools = base_tool_definitions();
1335        let get_issue = tools.iter().find(|t| t.name == "get_issue").unwrap();
1336        assert!(get_issue.input_schema.required.contains(&"key".to_string()));
1337
1338        let create_mr = tools
1339            .iter()
1340            .find(|t| t.name == "create_merge_request")
1341            .unwrap();
1342        assert!(
1343            create_mr
1344                .input_schema
1345                .required
1346                .contains(&"title".to_string())
1347        );
1348        assert!(
1349            create_mr
1350                .input_schema
1351                .required
1352                .contains(&"source_branch".to_string())
1353        );
1354
1355        let run_job = tools
1356            .iter()
1357            .find(|tool| tool.name == "run_pipeline_job")
1358            .unwrap();
1359        assert_eq!(
1360            run_job.input_schema.required,
1361            vec!["pipelineId".to_string(), "jobId".to_string()]
1362        );
1363        assert_eq!(
1364            run_job.input_schema.properties["variables"].schema_type,
1365            "object"
1366        );
1367        assert_eq!(
1368            run_job.input_schema.properties["jobInputs"].schema_type,
1369            "object"
1370        );
1371    }
1372
1373    // --- ToolDefinition serialization ---
1374
1375    #[test]
1376    fn test_tool_definition_serializes_to_json() {
1377        let tools = base_tool_definitions();
1378        let tool = &tools[0]; // get_issues
1379        let json = serde_json::to_string(tool).unwrap();
1380
1381        assert!(json.contains("\"name\":\"get_issues\""));
1382        assert!(json.contains("\"description\""));
1383        assert!(json.contains("\"category\""));
1384        assert!(json.contains("\"input_schema\""));
1385
1386        // Should be valid JSON
1387        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1388        assert_eq!(value["name"], "get_issues");
1389    }
1390
1391    #[test]
1392    fn test_all_tool_definitions_serialize() {
1393        let tools = base_tool_definitions();
1394        for tool in &tools {
1395            let json = serde_json::to_string(tool);
1396            assert!(
1397                json.is_ok(),
1398                "tool '{}' failed to serialize: {:?}",
1399                tool.name,
1400                json.err()
1401            );
1402        }
1403    }
1404
1405    #[test]
1406    fn test_tool_definition_json_contains_properties() {
1407        let tools = base_tool_definitions();
1408        let get_issues = tools.iter().find(|t| t.name == "get_issues").unwrap();
1409        let json = serde_json::to_string_pretty(get_issues).unwrap();
1410
1411        // get_issues should have state, search, labels, assignee, limit, offset, sort_by, sort_order, projectKey, nativeQuery
1412        assert!(json.contains("state"));
1413        assert!(json.contains("search"));
1414        assert!(json.contains("labels"));
1415        assert!(json.contains("assignee"));
1416        assert!(json.contains("limit"));
1417        assert!(json.contains("projectKey"));
1418        assert!(json.contains("nativeQuery"));
1419    }
1420
1421    #[test]
1422    fn test_tool_definition_required_fields_in_json() {
1423        let tools = base_tool_definitions();
1424        let add_comment = tools
1425            .iter()
1426            .find(|t| t.name == "add_issue_comment")
1427            .unwrap();
1428        let json_val: serde_json::Value = serde_json::to_value(add_comment).unwrap();
1429
1430        let required = json_val["input_schema"]["required"]
1431            .as_array()
1432            .expect("required should be an array");
1433        let required_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
1434        assert!(required_strs.contains(&"key"));
1435        assert!(required_strs.contains(&"body"));
1436    }
1437
1438    #[test]
1439    fn test_tool_names_are_unique() {
1440        let tools = base_tool_definitions();
1441        let mut names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
1442        let original_len = names.len();
1443        names.sort();
1444        names.dedup();
1445        assert_eq!(names.len(), original_len, "tool names should all be unique");
1446    }
1447
1448    #[test]
1449    fn test_link_issues_required_params() {
1450        let tools = base_tool_definitions();
1451        let link = tools.iter().find(|t| t.name == "link_issues").unwrap();
1452        assert!(
1453            link.input_schema
1454                .required
1455                .contains(&"sourceIssueKey".to_string())
1456        );
1457        assert!(
1458            link.input_schema
1459                .required
1460                .contains(&"targetIssueKey".to_string())
1461        );
1462        assert!(link.input_schema.required.contains(&"linkType".to_string()));
1463    }
1464
1465    #[test]
1466    fn test_get_available_statuses_has_empty_schema() {
1467        let tools = base_tool_definitions();
1468        let statuses = tools
1469            .iter()
1470            .find(|t| t.name == "get_available_statuses")
1471            .unwrap();
1472        assert!(statuses.input_schema.required.is_empty());
1473        assert!(statuses.input_schema.properties.is_empty());
1474    }
1475
1476    /// gh#291: `get_meeting_transcript` schema must advertise pagination
1477    /// and filter params, otherwise MCP agents cannot paginate through
1478    /// long transcripts (default limit=50, max=500 in dispatcher) or
1479    /// discover speaker/text filters or the grouped output format.
1480    #[test]
1481    fn test_get_meeting_transcript_exposes_pagination_and_filters() {
1482        let tools = base_tool_definitions();
1483        let t = tools
1484            .iter()
1485            .find(|t| t.name == "get_meeting_transcript")
1486            .expect("get_meeting_transcript tool must exist");
1487        // `meeting_id` is the only required param.
1488        assert_eq!(t.input_schema.required, vec!["meeting_id".to_string()]);
1489        // Optional params for pagination, filtering, output format —
1490        // dropped during NestJS→Rust migration, restored in gh#291.
1491        for prop in [
1492            "meeting_id",
1493            "offset",
1494            "limit",
1495            "speaker_filter",
1496            "search_text",
1497            "format",
1498        ] {
1499            assert!(
1500                t.input_schema.properties.contains_key(prop),
1501                "schema must advertise `{prop}` so MCP agents can use it"
1502            );
1503        }
1504    }
1505
1506    #[test]
1507    fn test_epic_tools_exist() {
1508        let tools = base_tool_definitions();
1509        let epic_names: Vec<&str> = tools
1510            .iter()
1511            .filter(|t| t.category == ToolCategory::Epics)
1512            .map(|t| t.name.as_str())
1513            .collect();
1514        assert!(epic_names.contains(&"get_epics"));
1515        assert!(epic_names.contains(&"create_epic"));
1516        assert!(epic_names.contains(&"update_epic"));
1517    }
1518}