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"));
109                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "New labels (replaces existing)"));
110                s.add_property("assignees", PropertySchema::array(PropertySchema::string("assignee"), "New assignees"));
111                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."));
112                s.add_property("markdown", PropertySchema::boolean("Whether the description is markdown (default: true). When true, ClickUp renders formatted text."));
113                // Jira-specific slots are added dynamically by
114                // `JiraSchemaEnricher` — see the create_issue
115                // schema comment above.
116                s.set_required("key", true);
117                s
118            },
119        },
120        ToolDefinition {
121            name: "add_issue_comment".into(),
122            description: "Add a comment to an issue with optional file attachments (ClickUp only).".into(),
123            category: ToolCategory::IssueTracker,
124            input_schema: {
125                let mut s = ToolSchema::new();
126                s.add_property("key", PropertySchema::string("Issue key"));
127                s.add_property("body", PropertySchema::string("Comment text"));
128                s.add_property("attachments", PropertySchema::array(
129                    PropertySchema::string("Attachment object with fileData (base64) and filename"),
130                    "File attachments (ClickUp only, max 10MB per file). Each: {fileData: base64, filename: string}",
131                ));
132                s.set_required("key", true);
133                s.set_required("body", true);
134                s
135            },
136        },
137
138        // MR/PR tools
139        ToolDefinition {
140            name: "get_merge_requests".into(),
141            description: "Get merge requests / pull requests from configured provider.".into(),
142            category: ToolCategory::GitRepository,
143            input_schema: {
144                let mut s = ToolSchema::new();
145                s.add_property("state", PropertySchema::string_enum(&["open", "closed", "merged", "all"], "Filter by state (default: open)"));
146                s.add_property("author", PropertySchema::string("Filter by author username"));
147                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Filter by label names"));
148                s.add_property("source_branch", PropertySchema::string("Filter by source branch"));
149                s.add_property("target_branch", PropertySchema::string("Filter by target branch"));
150                s.add_property("limit", PropertySchema::integer("Maximum results (default: 20)", Some(1.0), Some(100.0)));
151                s
152            },
153        },
154        ToolDefinition {
155            name: "get_merge_request".into(),
156            description: "Get a single merge request by key (e.g., 'pr#123', 'mr#456').".into(),
157            category: ToolCategory::GitRepository,
158            input_schema: {
159                let mut s = ToolSchema::new();
160                s.add_property("key", PropertySchema::string("MR/PR key"));
161                s.set_required("key", true);
162                s
163            },
164        },
165        ToolDefinition {
166            name: "get_merge_request_discussions".into(),
167            description: "Get discussions/review comments for a merge request with code positions.".into(),
168            category: ToolCategory::GitRepository,
169            input_schema: {
170                let mut s = ToolSchema::new();
171                s.add_property("key", PropertySchema::string("MR/PR key"));
172                s.add_property("limit", PropertySchema::integer("Max discussions (default: 20)", Some(1.0), Some(100.0)));
173                s.add_property("offset", PropertySchema::integer("Skip N discussions (default: 0)", Some(0.0), None));
174                s.set_required("key", true);
175                s
176            },
177        },
178        ToolDefinition {
179            name: "get_merge_request_diffs".into(),
180            description: "Get file diffs for a merge request.".into(),
181            category: ToolCategory::GitRepository,
182            input_schema: {
183                let mut s = ToolSchema::new();
184                s.add_property("key", PropertySchema::string("MR/PR key"));
185                s.set_required("key", true);
186                s
187            },
188        },
189        ToolDefinition {
190            name: "create_merge_request".into(),
191            description: "Create a new merge request (GitLab) or pull request (GitHub).".into(),
192            category: ToolCategory::GitRepository,
193            input_schema: {
194                let mut s = ToolSchema::new();
195                s.add_property("title", PropertySchema::string("MR/PR title"));
196                s.add_property("description", PropertySchema::string("MR/PR description"));
197                s.add_property("source_branch", PropertySchema::string("Source branch"));
198                s.add_property("target_branch", PropertySchema::string("Target branch"));
199                s.add_property("draft", PropertySchema::boolean("Create as draft (default: false)"));
200                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels"));
201                s.add_property("reviewers", PropertySchema::array(PropertySchema::string("reviewer"), "Reviewers"));
202                s.set_required("title", true);
203                s.set_required("source_branch", true);
204                s.set_required("target_branch", true);
205                s
206            },
207        },
208        ToolDefinition {
209            name: "create_merge_request_comment".into(),
210            description: "Add a comment to a merge request. Can be general or inline code review.".into(),
211            category: ToolCategory::GitRepository,
212            input_schema: {
213                let mut s = ToolSchema::new();
214                s.add_property("key", PropertySchema::string("MR/PR key"));
215                s.add_property("body", PropertySchema::string("Comment text"));
216                s.add_property("file_path", PropertySchema::string("File path for inline comment"));
217                s.add_property("line", PropertySchema::integer("Line number for inline comment", None, None));
218                s.add_property("line_type", PropertySchema::string_enum(&["old", "new"], "Line type (default: new)"));
219                s.add_property("commit_sha", PropertySchema::string("Commit SHA for inline comment"));
220                s.add_property("discussion_id", PropertySchema::string("Reply to existing discussion"));
221                s.set_required("key", true);
222                s.set_required("body", true);
223                s
224            },
225        },
226
227        // Pipeline tools
228        ToolDefinition {
229            name: "get_pipeline".into(),
230            description: "Get CI/CD pipeline status for branch or MR/PR with job details.".into(),
231            category: ToolCategory::GitRepository,
232            input_schema: {
233                let mut s = ToolSchema::new();
234                s.add_property("branch", PropertySchema::string("Branch name (default: main)"));
235                s.add_property("mrKey", PropertySchema::string("MR/PR key (priority over branch)"));
236                s.add_property("includeFailedLogs", PropertySchema::boolean("Include error extraction for failed jobs (default: true)"));
237                s
238            },
239        },
240        ToolDefinition {
241            name: "get_job_logs".into(),
242            description: "Get CI/CD job logs. Modes: smart (auto errors), search (pattern), paginated, full.".into(),
243            category: ToolCategory::GitRepository,
244            input_schema: {
245                let mut s = ToolSchema::new();
246                s.add_property("jobId", PropertySchema::string("Job ID from get_pipeline"));
247                s.add_property("pattern", PropertySchema::string("Regex/keyword search pattern"));
248                s.add_property("context", PropertySchema::integer("Context lines around match (default: 5)", None, None));
249                s.add_property("maxMatches", PropertySchema::integer("Max search results (default: 20)", None, None));
250                s.add_property("offset", PropertySchema::integer("Start line for paginated mode", None, None));
251                s.add_property("limit", PropertySchema::integer("Lines to return (default: 200, max: 1000)", Some(1.0), Some(1000.0)));
252                s.add_property("full", PropertySchema::boolean("Return entire log"));
253                s.set_required("jobId", true);
254                s
255            },
256        },
257
258        // Status / user / link / epic tools
259        ToolDefinition {
260            name: "get_available_statuses".into(),
261            description: "Get available statuses for the issue tracker.".into(),
262            category: ToolCategory::IssueTracker,
263            input_schema: ToolSchema::new(),
264        },
265        ToolDefinition {
266            name: "get_users".into(),
267            description: "Get users from the issue tracker (Jira). Search by name, project, or ID.".into(),
268            category: ToolCategory::IssueTracker,
269            input_schema: {
270                let mut s = ToolSchema::new();
271                s.add_property("userId", PropertySchema::string("Get specific user by ID"));
272                s.add_property("projectKey", PropertySchema::string("Get assignable users for project"));
273                s.add_property("search", PropertySchema::string("Search by name or email"));
274                s.add_property("maxResults", PropertySchema::integer("Max results (default: 50)", Some(1.0), Some(1000.0)));
275                s
276            },
277        },
278        ToolDefinition {
279            name: "link_issues".into(),
280            description: "Link two issues together (blocks, relates_to, etc.).".into(),
281            category: ToolCategory::IssueTracker,
282            input_schema: {
283                let mut s = ToolSchema::new();
284                s.add_property("sourceIssueKey", PropertySchema::string("Source issue key"));
285                s.add_property("targetIssueKey", PropertySchema::string("Target issue key"));
286                s.add_property("linkType", PropertySchema::string(
287                    "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.",
288                ));
289                s.set_required("sourceIssueKey", true);
290                s.set_required("targetIssueKey", true);
291                s.set_required("linkType", true);
292                s
293            },
294        },
295        ToolDefinition {
296            name: "unlink_issues".into(),
297            description: "Remove a link between two issues.".into(),
298            category: ToolCategory::IssueTracker,
299            input_schema: {
300                let mut s = ToolSchema::new();
301                s.add_property("sourceIssueKey", PropertySchema::string("Source issue key"));
302                s.add_property("targetIssueKey", PropertySchema::string("Target issue key"));
303                s.add_property("linkType", PropertySchema::string(
304                    "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.",
305                ));
306                s.set_required("sourceIssueKey", true);
307                s.set_required("targetIssueKey", true);
308                s.set_required("linkType", true);
309                s
310            },
311        },
312        ToolDefinition {
313            name: "get_epics".into(),
314            description: "Get epics (high-level tasks) from the issue tracker.".into(),
315            category: ToolCategory::Epics,
316            input_schema: {
317                let mut s = ToolSchema::new();
318                s.add_property("search", PropertySchema::string("Search in epic title"));
319                s.add_property("limit", PropertySchema::integer("Max results (default: 50)", Some(1.0), Some(100.0)));
320                s.add_property("offset", PropertySchema::integer("Skip N results (default: 0)", Some(0.0), None));
321                s
322            },
323        },
324        ToolDefinition {
325            name: "create_epic".into(),
326            description: "Create a new epic.".into(),
327            category: ToolCategory::Epics,
328            input_schema: {
329                let mut s = ToolSchema::new();
330                s.add_property("title", PropertySchema::string("Epic title"));
331                s.add_property("description", PropertySchema::string("Epic description"));
332                s.set_required("title", true);
333                s
334            },
335        },
336        ToolDefinition {
337            name: "update_epic".into(),
338            description: "Update an existing epic.".into(),
339            category: ToolCategory::Epics,
340            input_schema: {
341                let mut s = ToolSchema::new();
342                s.add_property("epicKey", PropertySchema::string("Epic key (e.g., 'CU-abc', 'DEV-123')"));
343                s.add_property("title", PropertySchema::string("New title"));
344                s.add_property("description", PropertySchema::string("New description"));
345                s.add_property("state", PropertySchema::string("New epic state"));
346                s.add_property("goalId", PropertySchema::string("Goal ID (G1-G9) to associate with the epic"));
347                s.add_property("priority", PropertySchema::string("New priority (urgent/high/normal/low)"));
348                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to set"));
349                s.add_property("assignees", PropertySchema::array(PropertySchema::string("assignee"), "Assignees to set"));
350                s.set_required("epicKey", true);
351                s
352            },
353        },
354        // Meeting notes tools
355        ToolDefinition {
356            name: "get_meeting_notes".into(),
357            description: "Get meeting notes and transcripts with optional filters (date range, participants, host).".into(),
358            category: ToolCategory::MeetingNotes,
359            input_schema: {
360                let mut s = ToolSchema::new();
361                s.add_property("from_date", PropertySchema::string("Filter from date (ISO 8601, e.g., '2025-01-01T00:00:00Z')"));
362                s.add_property("to_date", PropertySchema::string("Filter to date (ISO 8601)"));
363                s.add_property("participants", PropertySchema::array(PropertySchema::string("email"), "Filter by participant email addresses"));
364                s.add_property("host_email", PropertySchema::string("Filter by host email"));
365                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 50)", Some(1.0), Some(50.0)));
366                s.add_property("offset", PropertySchema::integer("Number of results to skip (default: 0)", Some(0.0), None));
367                s
368            },
369        },
370        ToolDefinition {
371            name: "get_meeting_transcript".into(),
372            description: "Get the full transcript for a meeting. Returns speaker-attributed sentences with timestamps.".into(),
373            category: ToolCategory::MeetingNotes,
374            input_schema: {
375                let mut s = ToolSchema::new();
376                s.add_property("meeting_id", PropertySchema::string("Meeting ID from get_meeting_notes"));
377                s.set_required("meeting_id", true);
378                s
379            },
380        },
381        ToolDefinition {
382            name: "search_meeting_notes".into(),
383            description: "Search across meetings by keywords, topics, or action items, with optional filters (date range, participants, host).".into(),
384            category: ToolCategory::MeetingNotes,
385            input_schema: {
386                let mut s = ToolSchema::new();
387                s.add_property("query", PropertySchema::string("Search query"));
388                s.add_property("from_date", PropertySchema::string("Filter from date (ISO 8601)"));
389                s.add_property("to_date", PropertySchema::string("Filter to date (ISO 8601)"));
390                s.add_property("participants", PropertySchema::array(PropertySchema::string("email"), "Filter by participant email addresses"));
391                s.add_property("host_email", PropertySchema::string("Filter by host email"));
392                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 50)", Some(1.0), Some(50.0)));
393                s.add_property("offset", PropertySchema::integer("Number of results to skip (default: 0)", Some(0.0), None));
394                s.set_required("query", true);
395                s
396            },
397        },
398        // Knowledge base tools
399        ToolDefinition {
400            name: "get_knowledge_base_spaces".into(),
401            description: "List available knowledge base spaces.".into(),
402            category: ToolCategory::KnowledgeBase,
403            input_schema: ToolSchema::new(),
404        },
405        ToolDefinition {
406            name: "list_knowledge_base_pages".into(),
407            description: "List pages in a knowledge base space with pagination.".into(),
408            category: ToolCategory::KnowledgeBase,
409            input_schema: {
410                let mut s = ToolSchema::new();
411                s.add_property("spaceKey", PropertySchema::string("Space key to list pages from"));
412                s.add_property("limit", PropertySchema::integer("Maximum number of results (default: 25)", Some(1.0), Some(100.0)));
413                s.add_property("offset", PropertySchema::integer("Number of results to skip when offset pagination is supported", Some(0.0), None));
414                s.add_property("cursor", PropertySchema::string("Provider pagination cursor/token"));
415                s.add_property("search", PropertySchema::string("Optional free-text title/content filter"));
416                s.add_property("parentId", PropertySchema::string("Optional ancestor/parent page ID to scope the listing"));
417                s.set_required("spaceKey", true);
418                s
419            },
420        },
421        ToolDefinition {
422            name: "get_knowledge_base_page".into(),
423            description: "Get a knowledge base page with content, labels, and ancestors.".into(),
424            category: ToolCategory::KnowledgeBase,
425            input_schema: {
426                let mut s = ToolSchema::new();
427                s.add_property("pageId", PropertySchema::string("Knowledge base page ID"));
428                s.set_required("pageId", true);
429                s
430            },
431        },
432        ToolDefinition {
433            name: "create_knowledge_base_page".into(),
434            description: "Create a knowledge base page in a space.".into(),
435            category: ToolCategory::KnowledgeBase,
436            input_schema: {
437                let mut s = ToolSchema::new();
438                s.add_property("spaceKey", PropertySchema::string("Target space key"));
439                s.add_property("title", PropertySchema::string("Page title"));
440                s.add_property("content", PropertySchema::string("Page body content"));
441                s.add_property("contentType", PropertySchema::string_enum(&["markdown", "html", "storage"], "Content representation supplied by the caller"));
442                s.add_property("parentId", PropertySchema::string("Optional parent page ID"));
443                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to set on the page"));
444                s.set_required("spaceKey", true);
445                s.set_required("title", true);
446                s.set_required("content", true);
447                s
448            },
449        },
450        ToolDefinition {
451            name: "update_knowledge_base_page".into(),
452            description: "Update a knowledge base page title, content, metadata, or labels.".into(),
453            category: ToolCategory::KnowledgeBase,
454            input_schema: {
455                let mut s = ToolSchema::new();
456                s.add_property("pageId", PropertySchema::string("Knowledge base page ID"));
457                s.add_property("title", PropertySchema::string("New page title"));
458                s.add_property("content", PropertySchema::string("New page body content"));
459                s.add_property("contentType", PropertySchema::string_enum(&["markdown", "html", "storage"], "Content representation supplied by the caller"));
460                s.add_property("version", PropertySchema::integer("Expected current version for optimistic locking", Some(1.0), None));
461                s.add_property("parentId", PropertySchema::string("Optional new parent page ID"));
462                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "Labels to replace on the page"));
463                s.set_required("pageId", true);
464                s
465            },
466        },
467        ToolDefinition {
468            name: "search_knowledge_base".into(),
469            description: "Search knowledge base pages across spaces using free text or provider-native syntax such as CQL.".into(),
470            category: ToolCategory::KnowledgeBase,
471            input_schema: {
472                let mut s = ToolSchema::new();
473                s.add_property("query", PropertySchema::string("Free-text query or provider-native search expression"));
474                s.add_property("spaceKey", PropertySchema::string("Restrict search to a specific space key"));
475                s.add_property("cursor", PropertySchema::string("Provider pagination cursor/token"));
476                s.add_property("limit", PropertySchema::integer("Maximum number of matches to return", Some(1.0), Some(100.0)));
477                s.add_property("rawQuery", PropertySchema::boolean("Whether `query` should be treated as raw provider-native syntax"));
478                s.set_required("query", true);
479                s
480            },
481        },
482        ToolDefinition {
483            name: "update_merge_request".into(),
484            description: "Update a merge request / pull request (title, description, state, labels, draft).".into(),
485            category: ToolCategory::GitRepository,
486            input_schema: {
487                let mut s = ToolSchema::new();
488                s.add_property("key", PropertySchema::string("MR key (e.g. 'mr#1', 'pr#42')"));
489                s.add_property("title", PropertySchema::string("New title"));
490                s.add_property("description", PropertySchema::string("New description / body (supports markdown)"));
491                s.add_property("state", PropertySchema::string_enum(&["close", "reopen"], "Change MR state"));
492                s.add_property("labels", PropertySchema::array(PropertySchema::string("label"), "New labels (replaces existing)"));
493                s.set_required("key", true);
494                s
495            },
496        },
497        // =====================================================================
498        // Asset tools
499        // =====================================================================
500        ToolDefinition {
501            name: "get_assets".into(),
502            description: "List file attachments for an issue or merge request.".into(),
503            category: ToolCategory::IssueTracker,
504            input_schema: {
505                let mut s = ToolSchema::new();
506                s.add_property("context_type", PropertySchema::string_enum(&["issue", "mr"], "Context type: 'issue' or 'mr' (merge request / pull request)"));
507                s.add_property("key", PropertySchema::string("Issue key (e.g. 'DEV-123', 'gitlab#42') or MR key (e.g. 'mr#42', 'pr#42')"));
508                s.set_required("context_type", true);
509                s.set_required("key", true);
510                s
511            },
512        },
513        ToolDefinition {
514            name: "upload_asset".into(),
515            description: "Upload a file attachment to an issue. Returns the download URL.".into(),
516            category: ToolCategory::IssueTracker,
517            input_schema: {
518                let mut s = ToolSchema::new();
519                s.add_property("context_type", PropertySchema::string_enum(&["issue"], "Context type (currently only 'issue' is supported for uploads)"));
520                s.add_property("key", PropertySchema::string("Issue key (e.g. 'DEV-123')"));
521                s.add_property("filename", PropertySchema::string("Original filename (e.g. 'screenshot.png')"));
522                s.add_property("fileData", PropertySchema::string("Base64-encoded file content"));
523                s.set_required("context_type", true);
524                s.set_required("key", true);
525                s.set_required("filename", true);
526                s.set_required("fileData", true);
527                s
528            },
529        },
530        ToolDefinition {
531            name: "download_asset".into(),
532            description: "Download a file attachment to local cache. Returns local file path when cache is available, base64-encoded content as fallback.".into(),
533            category: ToolCategory::IssueTracker,
534            input_schema: {
535                let mut s = ToolSchema::new();
536                s.add_property("context_type", PropertySchema::string_enum(&["issue", "mr"], "Context type: 'issue' or 'mr'"));
537                s.add_property("key", PropertySchema::string("Issue key or MR key"));
538                s.add_property("asset_id", PropertySchema::string("Asset identifier from get_assets response"));
539                s.set_required("context_type", true);
540                s.set_required("key", true);
541                s.set_required("asset_id", true);
542                s
543            },
544        },
545        // =====================================================================
546        // Messenger tools
547        // =====================================================================
548        ToolDefinition {
549            name: "get_messenger_chats".into(),
550            description: "List available messenger chats, channels, groups, or direct messages.".into(),
551            category: ToolCategory::Messenger,
552            input_schema: {
553                let mut s = ToolSchema::new();
554                s.add_property("search", PropertySchema::string("Optional chat name search"));
555                s.add_property("chat_type", PropertySchema::string_enum(&["direct", "group", "channel"], "Optional chat type filter"));
556                s.add_property("limit", PropertySchema::integer("Maximum number of chats to return", Some(1.0), Some(1000.0)));
557                s.add_property("cursor", PropertySchema::string("Provider pagination cursor"));
558                s.add_property("include_inactive", PropertySchema::boolean("Include archived or inactive chats"));
559                s
560            },
561        },
562        ToolDefinition {
563            name: "get_chat_messages".into(),
564            description: "Get message history for a chat or fetch replies for a specific thread.".into(),
565            category: ToolCategory::Messenger,
566            input_schema: {
567                let mut s = ToolSchema::new();
568                s.add_property("chat_id", PropertySchema::string("Messenger chat ID"));
569                s.add_property("limit", PropertySchema::integer("Maximum number of messages to return", Some(1.0), Some(1000.0)));
570                s.add_property("cursor", PropertySchema::string("Provider pagination cursor"));
571                s.add_property("thread_id", PropertySchema::string("Thread identifier to fetch replies for"));
572                s.add_property("since", PropertySchema::string("Only include messages after this provider timestamp"));
573                s.add_property("until", PropertySchema::string("Only include messages before this provider timestamp"));
574                s.set_required("chat_id", true);
575                s
576            },
577        },
578        ToolDefinition {
579            name: "search_chat_messages".into(),
580            description: "Search messages across accessible chats or within a specific chat.".into(),
581            category: ToolCategory::Messenger,
582            input_schema: {
583                let mut s = ToolSchema::new();
584                s.add_property("query", PropertySchema::string("Message search query"));
585                s.add_property("chat_id", PropertySchema::string("Optional chat ID to scope the search"));
586                s.add_property("limit", PropertySchema::integer("Maximum number of matches to return", Some(1.0), Some(1000.0)));
587                s.add_property("cursor", PropertySchema::string("Provider pagination cursor"));
588                s.add_property("since", PropertySchema::string("Only include messages after this provider timestamp"));
589                s.add_property("until", PropertySchema::string("Only include messages before this provider timestamp"));
590                s.set_required("query", true);
591                s
592            },
593        },
594        ToolDefinition {
595            name: "send_message".into(),
596            description: "Send a message to a chat or as a threaded reply.".into(),
597            category: ToolCategory::Messenger,
598            input_schema: {
599                let mut s = ToolSchema::new();
600                s.add_property("chat_id", PropertySchema::string("Messenger chat ID"));
601                s.add_property("text", PropertySchema::string("Message body"));
602                s.add_property("thread_id", PropertySchema::string("Thread identifier to post as a threaded reply"));
603                s.add_property("reply_to_id", PropertySchema::string("Direct parent message ID when supported"));
604                s.set_required("chat_id", true);
605                s.set_required("text", true);
606                s
607            },
608        },
609        ToolDefinition {
610            name: "delete_asset".into(),
611            description: "Delete a file attachment from an issue. Not all providers support this — check asset_capabilities first.".into(),
612            category: ToolCategory::IssueTracker,
613            input_schema: {
614                let mut s = ToolSchema::new();
615                s.add_property("key", PropertySchema::string("Issue key (e.g. 'PROJ-123')"));
616                s.add_property("asset_id", PropertySchema::string("Asset identifier to delete"));
617                s.set_required("key", true);
618                s.set_required("asset_id", true);
619                s
620            },
621        },
622        // Jira Structure plugin tools
623        ToolDefinition {
624            name: "get_structures".into(),
625            description: "List all available Jira Structures. Returns structure ID, name, and description. Requires Jira with Structure plugin.".into(),
626            category: ToolCategory::JiraStructure,
627            input_schema: ToolSchema::new(),
628        },
629        ToolDefinition {
630            name: "get_structure_forest".into(),
631            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(),
632            category: ToolCategory::JiraStructure,
633            input_schema: {
634                let mut s = ToolSchema::new();
635                s.add_property("structureId", PropertySchema::integer("Structure ID. Use get_structures to find it.", None, None));
636                s.add_property("offset", PropertySchema::integer("Offset for pagination (default: 0)", Some(0.0), None));
637                s.add_property("limit", PropertySchema::integer("Max rows to return (default: 200)", Some(1.0), Some(10000.0)));
638                s.set_required("structureId", true);
639                s
640            },
641        },
642        ToolDefinition {
643            name: "add_structure_rows".into(),
644            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(),
645            category: ToolCategory::JiraStructure,
646            input_schema: {
647                let mut s = ToolSchema::new();
648                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
649                s.add_property("items", PropertySchema::array(
650                    PropertySchema::string("Item: Jira issue key (e.g. 'PROJ-123') or JSON {\"itemId\":\"PROJ-123\",\"itemType\":\"issue\"}"),
651                    "Items to add",
652                ));
653                s.add_property("under", PropertySchema::integer("Parent row ID — items become children of this row", None, None));
654                s.add_property("after", PropertySchema::integer("Sibling row ID — items placed after this row", None, None));
655                s.add_property("forestVersion", PropertySchema::integer("Forest version for optimistic locking (from get_structure_forest)", None, None));
656                s.set_required("structureId", true);
657                s.set_required("items", true);
658                s
659            },
660        },
661        ToolDefinition {
662            name: "move_structure_rows".into(),
663            description: "Move rows within a Jira Structure hierarchy. Specify new position with under (new parent) and/or after (sibling).".into(),
664            category: ToolCategory::JiraStructure,
665            input_schema: {
666                let mut s = ToolSchema::new();
667                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
668                s.add_property("rowIds", PropertySchema::array(
669                    PropertySchema::integer("Row ID", None, None),
670                    "Row IDs to move (from get_structure_forest)",
671                ));
672                s.add_property("under", PropertySchema::integer("New parent row ID", None, None));
673                s.add_property("after", PropertySchema::integer("Sibling row ID to place after", None, None));
674                s.add_property("forestVersion", PropertySchema::integer("Forest version for optimistic locking", None, None));
675                s.set_required("structureId", true);
676                s.set_required("rowIds", true);
677                s
678            },
679        },
680        ToolDefinition {
681            name: "remove_structure_row".into(),
682            description: "Remove a row from a Jira Structure. Only removes from the structure hierarchy — the underlying Jira issue is NOT deleted.".into(),
683            category: ToolCategory::JiraStructure,
684            input_schema: {
685                let mut s = ToolSchema::new();
686                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
687                s.add_property("rowId", PropertySchema::integer("Row ID to remove (from get_structure_forest)", None, None));
688                s.set_required("structureId", true);
689                s.set_required("rowId", true);
690                s
691            },
692        },
693        ToolDefinition {
694            name: "get_structure_values".into(),
695            description: "Read column values (including Expr formulas like SUM, PROGRESS, COUNT) for specific rows in a Jira Structure. Values are computed server-side.".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("rows", PropertySchema::array(
701                    PropertySchema::integer("Row ID", None, None),
702                    "Row IDs to read values for",
703                ));
704                s.add_property("columns", PropertySchema::array(
705                    PropertySchema::string("Column spec: field name (e.g. 'summary'), or JSON {\"field\":\"status\"} or {\"formula\":\"SUM(\\\"Story Points\\\")\"}"),
706                    "Columns to read",
707                ));
708                s.set_required("structureId", true);
709                s.set_required("rows", true);
710                s.set_required("columns", true);
711                s
712            },
713        },
714        ToolDefinition {
715            name: "get_structure_views".into(),
716            description: "Get views for a Jira Structure. Without viewId: lists all views. With viewId: returns full view configuration (columns, grouping, sorting, filter).".into(),
717            category: ToolCategory::JiraStructure,
718            input_schema: {
719                let mut s = ToolSchema::new();
720                s.add_property("structureId", PropertySchema::integer("Structure ID", None, None));
721                s.add_property("viewId", PropertySchema::integer("View ID for full config (optional — omit to list all views)", None, None));
722                s.set_required("structureId", true);
723                s
724            },
725        },
726        ToolDefinition {
727            name: "save_structure_view".into(),
728            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(),
729            category: ToolCategory::JiraStructure,
730            input_schema: {
731                let mut s = ToolSchema::new();
732                s.add_property("id", PropertySchema::integer("View ID to update (omit to create new)", None, None));
733                s.add_property("structureId", PropertySchema::integer("Structure ID this view belongs to", None, None));
734                s.add_property("name", PropertySchema::string("View name"));
735                s.add_property("columns", PropertySchema::array(
736                    PropertySchema::string("Column spec: JSON {\"field\":\"summary\"} or {\"formula\":\"SUM(\\\"Story Points\\\")\",\"width\":100}"),
737                    "Column definitions",
738                ));
739                s.add_property("groupBy", PropertySchema::string("Field name to group by"));
740                s.add_property("sortBy", PropertySchema::string("Field name to sort by"));
741                s.add_property("filter", PropertySchema::string("JQL filter expression"));
742                s.set_required("structureId", true);
743                s.set_required("name", true);
744                s
745            },
746        },
747        ToolDefinition {
748            name: "create_structure".into(),
749            description: "Create a new Jira Structure. After creation, use add_structure_rows to populate and save_structure_view to configure columns.".into(),
750            category: ToolCategory::JiraStructure,
751            input_schema: {
752                let mut s = ToolSchema::new();
753                s.add_property("name", PropertySchema::string("Structure name"));
754                s.add_property("description", PropertySchema::string("Structure description"));
755                s.set_required("name", true);
756                s
757            },
758        },
759
760        // Project versions / fixVersion targets (issue #238).
761        // Two-tool surface — list returns a rich payload so a per-id GET
762        // is unnecessary, upsert is name-keyed so the LLM never deals
763        // with numeric ids. See `docs/research/paper-3-context-enrichment.md`.
764        ToolDefinition {
765            name: "list_project_versions".into(),
766            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(),
767            category: ToolCategory::IssueTracker,
768            input_schema: {
769                let mut s = ToolSchema::new();
770                s.add_property("project", PropertySchema::string("Jira project key (e.g., \"PROJ\"). Defaults to the configured project."));
771                s.add_property("released", PropertySchema::string_enum(&["true", "false", "all"], "Filter by release state: \"true\" → only released, \"false\" → only unreleased, \"all\" → both (default: \"all\")"));
772                s.add_property("archived", PropertySchema::string_enum(&["true", "false", "all"], "Filter by archived flag (default: \"false\" — hides archival noise)"));
773                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)));
774                s.add_property("includeIssueCount", PropertySchema::boolean("Fetch issue counts per version via Cloud `?expand=issuesstatus` (default: false). Adds latency on large projects."));
775                s
776            },
777        },
778        ToolDefinition {
779            name: "upsert_project_version".into(),
780            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(),
781            category: ToolCategory::IssueTracker,
782            input_schema: {
783                let mut s = ToolSchema::new();
784                s.add_property("project", PropertySchema::string("Jira project key (e.g., \"PROJ\"). Defaults to the configured project."));
785                s.add_property("name", PropertySchema::string("Version name — both the lookup key and, on create, the value (e.g., \"3.18.0\")."));
786                s.add_property("description", PropertySchema::string("Release notes / version description. Markdown-style text is preserved on Server/DC; Cloud accepts plain text."));
787                s.add_property("startDate", PropertySchema::string("Planned start date as ISO 8601 calendar date (`YYYY-MM-DD`)."));
788                s.add_property("releaseDate", PropertySchema::string("Planned or actual release date (`YYYY-MM-DD`)."));
789                s.add_property("released", PropertySchema::boolean("Mark released (true) / unreleased (false). Pair with `releaseDate` when closing a release."));
790                s.add_property("archived", PropertySchema::boolean("Archive (true) / unarchive (false) the version."));
791                s.set_required("name", true);
792                s
793            },
794        },
795        // Agile / Sprint (issue #198). Pairs with the `sprintId` slot on
796        // create_issue / update_issue: `get_board_sprints` is how callers
797        // discover available sprint ids on a board.
798        ToolDefinition {
799            name: "get_board_sprints".into(),
800            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(),
801            category: ToolCategory::IssueTracker,
802            input_schema: {
803                let mut s = ToolSchema::new();
804                s.add_property(
805                    "boardId",
806                    PropertySchema::integer(
807                        "Numeric Jira board id. The Agile / Boards REST endpoint returns sprints scoped to one board — there is no global sprint list",
808                        Some(0.0),
809                        None,
810                    ),
811                );
812                s.add_property(
813                    "state",
814                    PropertySchema::string_enum(
815                        &["active", "future", "closed", "all"],
816                        "Filter by sprint state. Default `all` returns every sprint on the board",
817                    ),
818                );
819                s.set_required("boardId", true);
820                s
821            },
822        },
823        ToolDefinition {
824            name: "assign_to_sprint".into(),
825            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(),
826            category: ToolCategory::IssueTracker,
827            input_schema: {
828                let mut s = ToolSchema::new();
829                s.add_property(
830                    "sprintId",
831                    PropertySchema::integer(
832                        "Numeric sprint id. Use `get_board_sprints` to discover ids on a board",
833                        Some(0.0),
834                        None,
835                    ),
836                );
837                s.add_property(
838                    "issueKeys",
839                    PropertySchema::array(
840                        PropertySchema::string("issue key (e.g., \"PROJ-1\")"),
841                        "Issue keys to move onto the sprint. Must contain at least one key.",
842                    ),
843                );
844                s.set_required("sprintId", true);
845                s.set_required("issueKeys", true);
846                s
847            },
848        },
849        // Custom-field discovery — pairs with the `epicKey` / `sprintId` /
850        // `epicName` slots on create/update_issue and the raw `customFields`
851        // escape hatch. Returns a name → id mapping so agents stop guessing
852        // `customfield_*` numbers.
853        ToolDefinition {
854            name: "get_custom_fields".into(),
855            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(),
856            category: ToolCategory::IssueTracker,
857            input_schema: {
858                let mut s = ToolSchema::new();
859                s.add_property(
860                    "project",
861                    PropertySchema::string(
862                        "Optional project key. Reserved for providers that scope custom fields per project; ignored on Jira's global `/field` endpoint.",
863                    ),
864                );
865                s.add_property(
866                    "issueType",
867                    PropertySchema::string(
868                        "Optional issue type. Reserved for providers that scope custom fields per create-screen context.",
869                    ),
870                );
871                s.add_property(
872                    "search",
873                    PropertySchema::string(
874                        "Case-insensitive substring filter on the field name (e.g. `\"Epic\"` to find `Epic Link` and `Epic Name`).",
875                    ),
876                );
877                s.add_property(
878                    "limit",
879                    PropertySchema::integer(
880                        "Max fields to return after filtering (default 50). Sorted by name asc",
881                        Some(1.0),
882                        Some(200.0),
883                    ),
884                );
885                s
886            },
887        },
888    ]
889}
890
891/// Always-available MCP tools that don't belong to a provider category.
892///
893/// These are surfaced by `devboy-mcp` on every `tools/list` regardless of
894/// which providers are configured. They live here (not in the MCP crate)
895/// so the published reference doc can render them from a single source.
896#[derive(Debug, Clone)]
897pub struct McpOnlyTool {
898    pub name: String,
899    pub description: String,
900    pub input_schema: ToolSchema,
901}
902
903/// MCP-only tools (context management). Always advertised by the server.
904pub fn mcp_only_tools() -> Vec<McpOnlyTool> {
905    vec![
906        McpOnlyTool {
907            name: "list_contexts".into(),
908            description: "List configured contexts and indicate the active context.".into(),
909            input_schema: ToolSchema::new(),
910        },
911        McpOnlyTool {
912            name: "use_context".into(),
913            description: "Switch active context at runtime.".into(),
914            input_schema: {
915                let mut s = ToolSchema::new();
916                s.add_property("name", PropertySchema::string("Context name to activate"));
917                s.set_required("name", true);
918                s
919            },
920        },
921        McpOnlyTool {
922            name: "get_current_context".into(),
923            description: "Get current active context name.".into(),
924            input_schema: ToolSchema::new(),
925        },
926    ]
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    #[test]
934    fn test_base_definitions_count() {
935        let tools = base_tool_definitions();
936        assert_eq!(tools.len(), 54);
937    }
938
939    #[test]
940    fn test_all_tools_have_names() {
941        for tool in base_tool_definitions() {
942            assert!(!tool.name.is_empty());
943            assert!(!tool.description.is_empty());
944        }
945    }
946
947    #[test]
948    fn test_tool_categories() {
949        let tools = base_tool_definitions();
950
951        let issue_tracker_tools = [
952            "get_issues",
953            "get_issue",
954            "get_issue_comments",
955            "get_issue_relations",
956            "create_issue",
957            "update_issue",
958            "add_issue_comment",
959            "get_available_statuses",
960            "get_users",
961            "link_issues",
962            "unlink_issues",
963            "get_assets",
964            "upload_asset",
965            "download_asset",
966            "delete_asset",
967            "list_project_versions",
968            "upsert_project_version",
969            "get_board_sprints",
970            "assign_to_sprint",
971            "get_custom_fields",
972        ];
973        let git_repository_tools = [
974            "get_merge_requests",
975            "get_merge_request",
976            "get_merge_request_discussions",
977            "get_merge_request_diffs",
978            "create_merge_request",
979            "create_merge_request_comment",
980            "update_merge_request",
981            "get_pipeline",
982            "get_job_logs",
983        ];
984        let epics_tools = ["get_epics", "create_epic", "update_epic"];
985        let meeting_notes_tools = [
986            "get_meeting_notes",
987            "get_meeting_transcript",
988            "search_meeting_notes",
989        ];
990        let knowledge_base_tools = [
991            "get_knowledge_base_spaces",
992            "list_knowledge_base_pages",
993            "get_knowledge_base_page",
994            "create_knowledge_base_page",
995            "update_knowledge_base_page",
996            "search_knowledge_base",
997        ];
998        let messenger_tools = [
999            "get_messenger_chats",
1000            "get_chat_messages",
1001            "search_chat_messages",
1002            "send_message",
1003        ];
1004        let jira_structure_tools = [
1005            "get_structures",
1006            "get_structure_forest",
1007            "add_structure_rows",
1008            "move_structure_rows",
1009            "remove_structure_row",
1010            "get_structure_values",
1011            "get_structure_views",
1012            "save_structure_view",
1013            "create_structure",
1014        ];
1015
1016        for tool in &tools {
1017            if issue_tracker_tools.contains(&tool.name.as_str()) {
1018                assert_eq!(
1019                    tool.category,
1020                    ToolCategory::IssueTracker,
1021                    "tool {} should be IssueTracker",
1022                    tool.name
1023                );
1024            } else if git_repository_tools.contains(&tool.name.as_str()) {
1025                assert_eq!(
1026                    tool.category,
1027                    ToolCategory::GitRepository,
1028                    "tool {} should be GitRepository",
1029                    tool.name
1030                );
1031            } else if epics_tools.contains(&tool.name.as_str()) {
1032                assert_eq!(
1033                    tool.category,
1034                    ToolCategory::Epics,
1035                    "tool {} should be Epics",
1036                    tool.name
1037                );
1038            } else if meeting_notes_tools.contains(&tool.name.as_str()) {
1039                assert_eq!(
1040                    tool.category,
1041                    ToolCategory::MeetingNotes,
1042                    "tool {} should be MeetingNotes",
1043                    tool.name
1044                );
1045            } else if knowledge_base_tools.contains(&tool.name.as_str()) {
1046                assert_eq!(
1047                    tool.category,
1048                    ToolCategory::KnowledgeBase,
1049                    "tool {} should be KnowledgeBase",
1050                    tool.name
1051                );
1052            } else if messenger_tools.contains(&tool.name.as_str()) {
1053                assert_eq!(
1054                    tool.category,
1055                    ToolCategory::Messenger,
1056                    "tool {} should be Messenger",
1057                    tool.name
1058                );
1059            } else if jira_structure_tools.contains(&tool.name.as_str()) {
1060                assert_eq!(
1061                    tool.category,
1062                    ToolCategory::JiraStructure,
1063                    "tool {} should be JiraStructure",
1064                    tool.name
1065                );
1066            } else {
1067                panic!("tool {} has no expected category mapping", tool.name);
1068            }
1069        }
1070    }
1071
1072    #[test]
1073    fn test_required_params() {
1074        let tools = base_tool_definitions();
1075        let get_issue = tools.iter().find(|t| t.name == "get_issue").unwrap();
1076        assert!(get_issue.input_schema.required.contains(&"key".to_string()));
1077
1078        let create_mr = tools
1079            .iter()
1080            .find(|t| t.name == "create_merge_request")
1081            .unwrap();
1082        assert!(
1083            create_mr
1084                .input_schema
1085                .required
1086                .contains(&"title".to_string())
1087        );
1088        assert!(
1089            create_mr
1090                .input_schema
1091                .required
1092                .contains(&"source_branch".to_string())
1093        );
1094    }
1095
1096    // --- ToolDefinition serialization ---
1097
1098    #[test]
1099    fn test_tool_definition_serializes_to_json() {
1100        let tools = base_tool_definitions();
1101        let tool = &tools[0]; // get_issues
1102        let json = serde_json::to_string(tool).unwrap();
1103
1104        assert!(json.contains("\"name\":\"get_issues\""));
1105        assert!(json.contains("\"description\""));
1106        assert!(json.contains("\"category\""));
1107        assert!(json.contains("\"input_schema\""));
1108
1109        // Should be valid JSON
1110        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1111        assert_eq!(value["name"], "get_issues");
1112    }
1113
1114    #[test]
1115    fn test_all_tool_definitions_serialize() {
1116        let tools = base_tool_definitions();
1117        for tool in &tools {
1118            let json = serde_json::to_string(tool);
1119            assert!(
1120                json.is_ok(),
1121                "tool '{}' failed to serialize: {:?}",
1122                tool.name,
1123                json.err()
1124            );
1125        }
1126    }
1127
1128    #[test]
1129    fn test_tool_definition_json_contains_properties() {
1130        let tools = base_tool_definitions();
1131        let get_issues = tools.iter().find(|t| t.name == "get_issues").unwrap();
1132        let json = serde_json::to_string_pretty(get_issues).unwrap();
1133
1134        // get_issues should have state, search, labels, assignee, limit, offset, sort_by, sort_order, projectKey, nativeQuery
1135        assert!(json.contains("state"));
1136        assert!(json.contains("search"));
1137        assert!(json.contains("labels"));
1138        assert!(json.contains("assignee"));
1139        assert!(json.contains("limit"));
1140        assert!(json.contains("projectKey"));
1141        assert!(json.contains("nativeQuery"));
1142    }
1143
1144    #[test]
1145    fn test_tool_definition_required_fields_in_json() {
1146        let tools = base_tool_definitions();
1147        let add_comment = tools
1148            .iter()
1149            .find(|t| t.name == "add_issue_comment")
1150            .unwrap();
1151        let json_val: serde_json::Value = serde_json::to_value(add_comment).unwrap();
1152
1153        let required = json_val["input_schema"]["required"]
1154            .as_array()
1155            .expect("required should be an array");
1156        let required_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
1157        assert!(required_strs.contains(&"key"));
1158        assert!(required_strs.contains(&"body"));
1159    }
1160
1161    #[test]
1162    fn test_tool_names_are_unique() {
1163        let tools = base_tool_definitions();
1164        let mut names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
1165        let original_len = names.len();
1166        names.sort();
1167        names.dedup();
1168        assert_eq!(names.len(), original_len, "tool names should all be unique");
1169    }
1170
1171    #[test]
1172    fn test_link_issues_required_params() {
1173        let tools = base_tool_definitions();
1174        let link = tools.iter().find(|t| t.name == "link_issues").unwrap();
1175        assert!(
1176            link.input_schema
1177                .required
1178                .contains(&"sourceIssueKey".to_string())
1179        );
1180        assert!(
1181            link.input_schema
1182                .required
1183                .contains(&"targetIssueKey".to_string())
1184        );
1185        assert!(link.input_schema.required.contains(&"linkType".to_string()));
1186    }
1187
1188    #[test]
1189    fn test_get_available_statuses_has_empty_schema() {
1190        let tools = base_tool_definitions();
1191        let statuses = tools
1192            .iter()
1193            .find(|t| t.name == "get_available_statuses")
1194            .unwrap();
1195        assert!(statuses.input_schema.required.is_empty());
1196        assert!(statuses.input_schema.properties.is_empty());
1197    }
1198
1199    #[test]
1200    fn test_epic_tools_exist() {
1201        let tools = base_tool_definitions();
1202        let epic_names: Vec<&str> = tools
1203            .iter()
1204            .filter(|t| t.category == ToolCategory::Epics)
1205            .map(|t| t.name.as_str())
1206            .collect();
1207        assert!(epic_names.contains(&"get_epics"));
1208        assert!(epic_names.contains(&"create_epic"));
1209        assert!(epic_names.contains(&"update_epic"));
1210    }
1211}