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