This is page 8 of 11. Use http://codebase.md/tuananh/hyper-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursor
│ └── rules
│ └── print-ctx-size.mdc
├── .dockerignore
├── .github
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── nightly.yml
│ └── release.yml
├── .gitignore
├── .gitmodules
├── .hadolint.yaml
├── .pre-commit-config.yaml
├── .windsurf
│ └── rules
│ ├── print-ctx-size.md
│ └── think.md
├── assets
│ ├── cursor-mcp-1.png
│ ├── cursor-mcp.png
│ ├── eval-py.jpg
│ └── logo.png
├── Cargo.lock
├── Cargo.toml
├── config.example.json
├── config.example.yaml
├── CREATING_PLUGINS.md
├── DEPLOYMENT.md
├── Dockerfile
├── examples
│ └── plugins
│ ├── v1
│ │ ├── arxiv
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── context7
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── crates-io
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── crypto-price
│ │ │ ├── Dockerfile
│ │ │ ├── go.mod
│ │ │ ├── go.sum
│ │ │ ├── main.go
│ │ │ ├── pdk.gen.go
│ │ │ └── README.md
│ │ ├── eval-py
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── fetch
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── fs
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── github
│ │ │ ├── .gitignore
│ │ │ ├── branches.go
│ │ │ ├── Dockerfile
│ │ │ ├── files.go
│ │ │ ├── gists.go
│ │ │ ├── go.mod
│ │ │ ├── go.sum
│ │ │ ├── issues.go
│ │ │ ├── main.go
│ │ │ ├── pdk.gen.go
│ │ │ ├── README.md
│ │ │ └── repo.go
│ │ ├── gitlab
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── gomodule
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── hash
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── maven
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── meme-generator
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── generate_embedded.py
│ │ │ ├── README.md
│ │ │ ├── src
│ │ │ │ ├── embedded.rs
│ │ │ │ ├── lib.rs
│ │ │ │ └── pdk.rs
│ │ │ └── templates.json
│ │ ├── memory
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── myip
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── qdrant
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ ├── pdk.rs
│ │ │ └── qdrant_client.rs
│ │ ├── qr-code
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.lock
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── serper
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── sqlite
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── think
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ └── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ ├── time
│ │ │ ├── .cargo
│ │ │ │ └── config.toml
│ │ │ ├── .gitignore
│ │ │ ├── Cargo.toml
│ │ │ ├── Dockerfile
│ │ │ ├── README.md
│ │ │ ├── src
│ │ │ │ ├── lib.rs
│ │ │ │ └── pdk.rs
│ │ │ └── time.wasm
│ │ └── tool-list-changed
│ │ ├── .gitignore
│ │ ├── Cargo.toml
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── lib.rs
│ │ │ └── pdk.rs
│ │ └── tool_list_changed.wasm
│ └── v2
│ └── rstime
│ ├── .cargo
│ │ └── config.toml
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── Dockerfile
│ ├── README.md
│ ├── rstime.wasm
│ └── src
│ ├── lib.rs
│ └── pdk
│ ├── exports.rs
│ ├── imports.rs
│ ├── mod.rs
│ └── types.rs
├── iac
│ ├── .terraform.lock.hcl
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── justfile
├── LICENSE
├── README.md
├── RUNTIME_CONFIG.md
├── rust-toolchain.toml
├── server.json
├── SKIP_TOOLS_GUIDE.md
├── src
│ ├── cli.rs
│ ├── config.rs
│ ├── https_auth.rs
│ ├── logging.rs
│ ├── main.rs
│ ├── naming.rs
│ ├── plugin.rs
│ ├── service.rs
│ └── wasm
│ ├── http.rs
│ ├── mod.rs
│ ├── oci.rs
│ └── s3.rs
├── templates
│ └── plugins
│ ├── go
│ │ ├── .gitignore
│ │ ├── Dockerfile
│ │ ├── exports.go
│ │ ├── go.mod
│ │ ├── go.sum
│ │ ├── imports.go
│ │ ├── main.go
│ │ ├── README.md
│ │ └── types.go
│ ├── README.md
│ └── rust
│ ├── .cargo
│ │ └── config.toml
│ ├── .gitignore
│ ├── Cargo.toml
│ ├── Dockerfile
│ ├── README.md
│ └── src
│ ├── lib.rs
│ └── pdk
│ ├── exports.rs
│ ├── imports.rs
│ ├── mod.rs
│ └── types.rs
├── tests
│ └── fixtures
│ ├── config_with_auths.json
│ ├── config_with_auths.yaml
│ ├── documentation_example.json
│ ├── documentation_example.yaml
│ ├── invalid_auth_config.yaml
│ ├── invalid_plugin_name.yaml
│ ├── invalid_structure.yaml
│ ├── invalid_url.yaml
│ ├── keyring_auth_config.yaml
│ ├── skip_tools_examples.yaml
│ ├── unsupported_config.txt
│ ├── valid_config.json
│ └── valid_config.yaml
└── xtp-plugin-schema.json
```
# Files
--------------------------------------------------------------------------------
/examples/plugins/v1/gitlab/src/lib.rs:
--------------------------------------------------------------------------------
```rust
1 | mod pdk;
2 |
3 | use std::collections::BTreeMap;
4 |
5 | use base64::prelude::*;
6 | use extism_pdk::*;
7 | use json::Value;
8 | use pdk::types::{
9 | CallToolRequest, CallToolResult, Content, ContentType, ListToolsResult, ToolDescription,
10 | };
11 | use serde_json::json;
12 | use termtree::Tree;
13 |
14 | // Helper struct for deserializing GitLab API response
15 | // https://docs.gitlab.com/api/repositories/#list-repository-tree
16 | #[derive(serde::Deserialize)]
17 | struct GitLabRepoEntry {
18 | id: String,
19 | name: String,
20 | r#type: String, // "tree" or "blob"
21 | path: String,
22 | mode: String,
23 | }
24 |
25 | // Helper struct for building the tree
26 | #[derive(Debug)]
27 | struct FileTreeNode {
28 | name: String,
29 | children: BTreeMap<String, FileTreeNode>,
30 | }
31 |
32 | impl FileTreeNode {
33 | fn new(name: &str) -> Self {
34 | FileTreeNode {
35 | name: name.to_string(),
36 | children: BTreeMap::new(),
37 | }
38 | }
39 |
40 | fn insert_path(&mut self, path_segments: &[&str]) {
41 | if path_segments.is_empty() {
42 | return;
43 | }
44 | let current_segment = path_segments[0];
45 | let node = self
46 | .children
47 | .entry(current_segment.to_string())
48 | .or_insert_with(|| FileTreeNode::new(current_segment));
49 |
50 | if path_segments.len() > 1 {
51 | node.insert_path(&path_segments[1..]);
52 | }
53 | }
54 | }
55 |
56 | // Renamed and modified function to convert FileTreeNode to termtree::Tree<String>
57 | fn convert_file_tree_to_termtree(file_node: &FileTreeNode) -> Tree<String> {
58 | let mut tree_node = Tree::new(file_node.name.clone());
59 | for child_file_node in file_node.children.values() {
60 | // Iterate over sorted children
61 | tree_node.push(convert_file_tree_to_termtree(child_file_node));
62 | }
63 | tree_node
64 | }
65 |
66 | // New function to build and format the tree
67 | fn build_and_format_tree_from_entries(
68 | entries: Vec<GitLabRepoEntry>,
69 | requested_path_opt: Option<&str>,
70 | project_id_str: &str,
71 | ) -> Result<String, String> {
72 | if entries.is_empty() {
73 | return Ok("Repository tree is empty or path not found.".to_string());
74 | }
75 |
76 | let root_display_name = match requested_path_opt {
77 | Some(req_path) if !req_path.is_empty() => req_path
78 | .split('/')
79 | .next_back()
80 | .unwrap_or("root")
81 | .to_string(),
82 | _ => project_id_str
83 | .split('/')
84 | .next_back()
85 | .unwrap_or("root")
86 | .to_string(),
87 | };
88 |
89 | let mut root_node = FileTreeNode::new(&root_display_name);
90 |
91 | for entry in entries {
92 | let effective_path = match requested_path_opt {
93 | Some(base_path_val)
94 | if !base_path_val.is_empty() && entry.path.starts_with(base_path_val) =>
95 | {
96 | entry
97 | .path
98 | .strip_prefix(base_path_val)
99 | .unwrap_or(&entry.path)
100 | .trim_start_matches('/')
101 | .to_string()
102 | }
103 | _ => entry.path.clone(),
104 | };
105 |
106 | if effective_path.is_empty() {
107 | continue;
108 | }
109 |
110 | let path_segments: Vec<&str> = effective_path
111 | .split('/')
112 | .filter(|s| !s.is_empty())
113 | .collect();
114 | if !path_segments.is_empty() {
115 | root_node.insert_path(&path_segments);
116 | }
117 | }
118 |
119 | let termtree_root = convert_file_tree_to_termtree(&root_node); // Use the new conversion function
120 | Ok(termtree_root.to_string())
121 | }
122 |
123 | fn get_gitlab_config() -> Result<(String, String), Error> {
124 | let token = config::get("GITLAB_TOKEN")?
125 | .ok_or_else(|| Error::msg("GITLAB_TOKEN configuration is required but not set"))?;
126 |
127 | let url = config::get("GITLAB_URL")?.unwrap_or_else(|| "https://gitlab.com/api/v4".to_string());
128 |
129 | Ok((token, url))
130 | }
131 |
132 | /// Helper function to check if an HTTP status code represents success (200-299)
133 | fn is_success_status(status_code: u16) -> bool {
134 | (200..300).contains(&status_code)
135 | }
136 |
137 | fn urlencode_if_needed(input: &str) -> String {
138 | if input.contains("/") {
139 | urlencoding::encode(input).to_string()
140 | } else {
141 | input.to_string()
142 | }
143 | }
144 |
145 | pub(crate) fn call(input: CallToolRequest) -> Result<CallToolResult, Error> {
146 | info!("call: {:?}", input);
147 | match input.params.name.as_str() {
148 | // Issues
149 | "gl_create_issue" => create_issue(input),
150 | "gl_get_issue" => get_issue(input),
151 | "gl_update_issue" => update_issue(input),
152 | "gl_add_issue_comment" => add_issue_comment(input),
153 | "gl_list_issues" => gl_list_issues(input),
154 |
155 | // Files
156 | "gl_get_file_contents" => get_file_contents(input),
157 | "gl_create_or_update_file" => create_or_update_file(input),
158 | "gl_delete_file" => delete_file(input),
159 |
160 | // Branches
161 | "gl_create_branch" => create_branch(input),
162 | "gl_list_branches" => gl_list_branches(input),
163 | "gl_create_merge_request" => create_merge_request(input),
164 | "gl_update_merge_request" => update_merge_request(input),
165 | "gl_get_merge_request" => gl_get_merge_request(input),
166 |
167 | // Snippets (GitLab equivalent of Gists)
168 | "gl_create_snippet" => create_snippet(input),
169 | "gl_update_snippet" => update_snippet(input),
170 | "gl_get_snippet" => get_snippet(input),
171 | "gl_delete_snippet" => delete_snippet(input),
172 |
173 | // Repository tree
174 | "gl_get_repo_tree" => gl_get_repo_tree(input),
175 |
176 | // Repository members
177 | "gl_get_repo_members" => gl_get_repo_members(input),
178 |
179 | _ => Ok(CallToolResult {
180 | is_error: Some(true),
181 | content: vec![Content {
182 | annotations: None,
183 | text: Some(format!("Unknown operation: {}", input.params.name)),
184 | mime_type: None,
185 | r#type: ContentType::Text,
186 | data: None,
187 | }],
188 | }),
189 | }
190 | }
191 |
192 | fn create_issue(input: CallToolRequest) -> Result<CallToolResult, Error> {
193 | let args = input.params.arguments.clone().unwrap_or_default();
194 | let (token, gitlab_url) = get_gitlab_config()?;
195 |
196 | if let (
197 | Some(Value::String(project_id)),
198 | Some(Value::String(title)),
199 | Some(Value::String(description)),
200 | ) = (
201 | args.get("project_id"),
202 | args.get("title"),
203 | args.get("description"),
204 | ) {
205 | let url = format!(
206 | "{}/projects/{}/issues",
207 | gitlab_url,
208 | urlencode_if_needed(project_id)
209 | );
210 | let mut body = json!({
211 | "title": title,
212 | "description": description,
213 | });
214 |
215 | // Add labels if provided
216 | if let Some(Value::String(labels)) = args.get("labels") {
217 | body.as_object_mut()
218 | .unwrap()
219 | .insert("labels".to_string(), Value::String(labels.clone()));
220 | }
221 |
222 | let mut headers = BTreeMap::new();
223 | headers.insert("PRIVATE-TOKEN".to_string(), token);
224 | headers.insert("Content-Type".to_string(), "application/json".to_string());
225 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
226 |
227 | let req = HttpRequest {
228 | url,
229 | headers,
230 | method: Some("POST".to_string()),
231 | };
232 |
233 | let res = http::request(&req, Some(&body.to_string()))?;
234 |
235 | if is_success_status(res.status_code()) {
236 | Ok(CallToolResult {
237 | is_error: None,
238 | content: vec![Content {
239 | annotations: None,
240 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
241 | mime_type: Some("application/json".to_string()),
242 | r#type: ContentType::Text,
243 | data: None,
244 | }],
245 | })
246 | } else {
247 | Ok(CallToolResult {
248 | is_error: Some(true),
249 | content: vec![Content {
250 | annotations: None,
251 | text: Some(format!("Failed to create issue: {}", res.status_code())),
252 | mime_type: None,
253 | r#type: ContentType::Text,
254 | data: None,
255 | }],
256 | })
257 | }
258 | } else {
259 | Ok(CallToolResult {
260 | is_error: Some(true),
261 | content: vec![Content {
262 | annotations: None,
263 | text: Some("Please provide project_id, title, and description".into()),
264 | mime_type: None,
265 | r#type: ContentType::Text,
266 | data: None,
267 | }],
268 | })
269 | }
270 | }
271 |
272 | fn get_issue(input: CallToolRequest) -> Result<CallToolResult, Error> {
273 | let args = input.params.arguments.clone().unwrap_or_default();
274 | let (token, gitlab_url) = get_gitlab_config()?;
275 |
276 | if let (Some(Value::String(project_id)), Some(Value::String(issue_iid))) =
277 | (args.get("project_id"), args.get("issue_iid"))
278 | {
279 | let url = format!(
280 | "{}/projects/{}/issues/{}",
281 | gitlab_url,
282 | urlencode_if_needed(project_id),
283 | issue_iid
284 | );
285 |
286 | let mut headers = BTreeMap::new();
287 | headers.insert("PRIVATE-TOKEN".to_string(), token);
288 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
289 |
290 | let req = HttpRequest {
291 | url,
292 | headers,
293 | method: Some("GET".to_string()),
294 | };
295 |
296 | let res = http::request::<()>(&req, None)?;
297 |
298 | if is_success_status(res.status_code()) {
299 | Ok(CallToolResult {
300 | is_error: None,
301 | content: vec![Content {
302 | annotations: None,
303 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
304 | mime_type: Some("application/json".to_string()),
305 | r#type: ContentType::Text,
306 | data: None,
307 | }],
308 | })
309 | } else {
310 | Ok(CallToolResult {
311 | is_error: Some(true),
312 | content: vec![Content {
313 | annotations: None,
314 | text: Some(format!("Failed to get issue: {}", res.status_code())),
315 | mime_type: None,
316 | r#type: ContentType::Text,
317 | data: None,
318 | }],
319 | })
320 | }
321 | } else {
322 | Ok(CallToolResult {
323 | is_error: Some(true),
324 | content: vec![Content {
325 | annotations: None,
326 | text: Some("Please provide project_id and issue_iid".into()),
327 | mime_type: None,
328 | r#type: ContentType::Text,
329 | data: None,
330 | }],
331 | })
332 | }
333 | }
334 |
335 | fn update_issue(input: CallToolRequest) -> Result<CallToolResult, Error> {
336 | let args = input.params.arguments.clone().unwrap_or_default();
337 | let (token, gitlab_url) = get_gitlab_config()?;
338 |
339 | if let (Some(Value::String(project_id)), Some(Value::String(issue_iid))) =
340 | (args.get("project_id"), args.get("issue_iid"))
341 | {
342 | let url = format!(
343 | "{}/projects/{}/issues/{}",
344 | gitlab_url,
345 | urlencode_if_needed(project_id),
346 | issue_iid
347 | );
348 |
349 | let mut body_map = serde_json::Map::new();
350 | if let Some(Value::String(title)) = args.get("title") {
351 | body_map.insert("title".to_string(), json!(title));
352 | }
353 | if let Some(Value::String(description)) = args.get("description") {
354 | body_map.insert("description".to_string(), json!(description));
355 | }
356 | if let Some(Value::String(add_labels)) = args.get("add_labels") {
357 | body_map.insert("add_labels".to_string(), json!(add_labels));
358 | }
359 | if let Some(Value::String(remove_labels)) = args.get("remove_labels") {
360 | body_map.insert("remove_labels".to_string(), json!(remove_labels));
361 | }
362 | if let Some(Value::String(due_date)) = args.get("due_date") {
363 | body_map.insert("due_date".to_string(), json!(due_date));
364 | }
365 |
366 | if body_map.is_empty() {
367 | return Ok(CallToolResult {
368 | is_error: Some(true),
369 | content: vec![Content {
370 | annotations: None,
371 | text: Some("Please provide at least one field to update (e.g., title, description, add_labels, remove_labels, due_date)".into()),
372 | mime_type: None,
373 | r#type: ContentType::Text,
374 | data: None,
375 | }],
376 | });
377 | }
378 |
379 | let body = Value::Object(body_map);
380 |
381 | let mut headers = BTreeMap::new();
382 | headers.insert("PRIVATE-TOKEN".to_string(), token);
383 | headers.insert("Content-Type".to_string(), "application/json".to_string());
384 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
385 |
386 | let req = HttpRequest {
387 | url,
388 | headers,
389 | method: Some("PUT".to_string()),
390 | };
391 |
392 | let res = http::request(&req, Some(&body.to_string()))?;
393 |
394 | if is_success_status(res.status_code()) {
395 | Ok(CallToolResult {
396 | is_error: None,
397 | content: vec![Content {
398 | annotations: None,
399 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
400 | mime_type: Some("application/json".to_string()),
401 | r#type: ContentType::Text,
402 | data: None,
403 | }],
404 | })
405 | } else {
406 | Ok(CallToolResult {
407 | is_error: Some(true),
408 | content: vec![Content {
409 | annotations: None,
410 | text: Some(format!(
411 | "Failed to update issue: {} - Response: {}",
412 | res.status_code(),
413 | String::from_utf8_lossy(&res.body())
414 | )),
415 | mime_type: None,
416 | r#type: ContentType::Text,
417 | data: None,
418 | }],
419 | })
420 | }
421 | } else {
422 | Ok(CallToolResult {
423 | is_error: Some(true),
424 | content: vec![Content {
425 | annotations: None,
426 | text: Some("Please provide project_id, issue_iid, and at least one field to update (title, description, add_labels, remove_labels, or due_date)".into()),
427 | mime_type: None,
428 | r#type: ContentType::Text,
429 | data: None,
430 | }],
431 | })
432 | }
433 | }
434 |
435 | fn add_issue_comment(input: CallToolRequest) -> Result<CallToolResult, Error> {
436 | let args = input.params.arguments.clone().unwrap_or_default();
437 | let (token, gitlab_url) = get_gitlab_config()?;
438 |
439 | if let (
440 | Some(Value::String(project_id)),
441 | Some(Value::String(issue_iid)),
442 | Some(Value::String(comment)),
443 | ) = (
444 | args.get("project_id"),
445 | args.get("issue_iid"),
446 | args.get("comment"),
447 | ) {
448 | let url = format!(
449 | "{}/projects/{}/issues/{}/notes",
450 | gitlab_url,
451 | urlencode_if_needed(project_id),
452 | issue_iid
453 | );
454 | let body = json!({
455 | "body": comment,
456 | });
457 |
458 | let mut headers = BTreeMap::new();
459 | headers.insert("PRIVATE-TOKEN".to_string(), token);
460 | headers.insert("Content-Type".to_string(), "application/json".to_string());
461 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
462 |
463 | let req = HttpRequest {
464 | url,
465 | headers,
466 | method: Some("POST".to_string()),
467 | };
468 |
469 | let res = http::request(&req, Some(&body.to_string()))?;
470 |
471 | if is_success_status(res.status_code()) {
472 | Ok(CallToolResult {
473 | is_error: None,
474 | content: vec![Content {
475 | annotations: None,
476 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
477 | mime_type: Some("application/json".to_string()),
478 | r#type: ContentType::Text,
479 | data: None,
480 | }],
481 | })
482 | } else {
483 | Ok(CallToolResult {
484 | is_error: Some(true),
485 | content: vec![Content {
486 | annotations: None,
487 | text: Some(format!("Failed to add comment: {}", res.status_code())),
488 | mime_type: None,
489 | r#type: ContentType::Text,
490 | data: None,
491 | }],
492 | })
493 | }
494 | } else {
495 | Ok(CallToolResult {
496 | is_error: Some(true),
497 | content: vec![Content {
498 | annotations: None,
499 | text: Some("Please provide project_id, issue_iid, and comment".into()),
500 | mime_type: None,
501 | r#type: ContentType::Text,
502 | data: None,
503 | }],
504 | })
505 | }
506 | }
507 |
508 | fn get_file_contents(input: CallToolRequest) -> Result<CallToolResult, Error> {
509 | let args = input.params.arguments.clone().unwrap_or_default();
510 | let (token, gitlab_url) = get_gitlab_config()?;
511 |
512 | if let (Some(Value::String(project_id)), Some(Value::String(file_path))) =
513 | (args.get("project_id"), args.get("file_path"))
514 | {
515 | let ref_name = args.get("ref").and_then(|v| v.as_str()).unwrap_or("HEAD");
516 |
517 | let url = format!(
518 | "{}/projects/{}/repository/files/{}?ref={}",
519 | gitlab_url,
520 | urlencode_if_needed(project_id),
521 | urlencode_if_needed(file_path),
522 | ref_name
523 | );
524 |
525 | let mut headers = BTreeMap::new();
526 | headers.insert("PRIVATE-TOKEN".to_string(), token);
527 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
528 |
529 | let req = HttpRequest {
530 | url: url.clone(),
531 | headers,
532 | method: Some("GET".to_string()),
533 | };
534 |
535 | let res = http::request::<()>(&req, None)?;
536 |
537 | if is_success_status(res.status_code()) {
538 | // Parse the response to get the file content from the "content" field
539 | if let Ok(json) = serde_json::from_slice::<Value>(&res.body()) {
540 | if let Some(content) = json.get("content").and_then(|v| v.as_str()) {
541 | // Decode base64 content
542 | match BASE64_STANDARD.decode(content.as_bytes()) {
543 | Ok(decoded_bytes) => {
544 | if let Ok(decoded_content) = String::from_utf8(decoded_bytes) {
545 | return Ok(CallToolResult {
546 | is_error: None,
547 | content: vec![Content {
548 | annotations: None,
549 | text: Some(decoded_content),
550 | mime_type: Some("text/plain".to_string()),
551 | r#type: ContentType::Text,
552 | data: None,
553 | }],
554 | });
555 | }
556 | }
557 | Err(e) => {
558 | return Ok(CallToolResult {
559 | is_error: Some(true),
560 | content: vec![Content {
561 | annotations: None,
562 | text: Some(format!("Failed to decode base64 content: {}", e)),
563 | mime_type: None,
564 | r#type: ContentType::Text,
565 | data: None,
566 | }],
567 | });
568 | }
569 | }
570 | }
571 | }
572 |
573 | Ok(CallToolResult {
574 | is_error: Some(true),
575 | content: vec![Content {
576 | annotations: None,
577 | text: Some("Failed to parse file contents from response".into()),
578 | mime_type: None,
579 | r#type: ContentType::Text,
580 | data: None,
581 | }],
582 | })
583 | } else {
584 | Ok(CallToolResult {
585 | is_error: Some(true),
586 | content: vec![Content {
587 | annotations: None,
588 | text: Some(format!(
589 | "Failed to get file contents: {} {}",
590 | url.clone(),
591 | res.status_code()
592 | )),
593 | mime_type: None,
594 | r#type: ContentType::Text,
595 | data: None,
596 | }],
597 | })
598 | }
599 | } else {
600 | Ok(CallToolResult {
601 | is_error: Some(true),
602 | content: vec![Content {
603 | annotations: None,
604 | text: Some("Please provide project_id and file_path".into()),
605 | mime_type: None,
606 | r#type: ContentType::Text,
607 | data: None,
608 | }],
609 | })
610 | }
611 | }
612 |
613 | fn delete_file(input: CallToolRequest) -> Result<CallToolResult, Error> {
614 | let args = input.params.arguments.clone().unwrap_or_default();
615 | let (token, gitlab_url) = get_gitlab_config()?;
616 |
617 | if let (
618 | Some(Value::String(project_id)),
619 | Some(Value::String(file_path)),
620 | Some(Value::String(branch)),
621 | ) = (
622 | args.get("project_id"),
623 | args.get("file_path"),
624 | args.get("branch"),
625 | ) {
626 | let commit_message = args
627 | .get("commit_message")
628 | .and_then(|v| v.as_str())
629 | .unwrap_or("Delete file via API")
630 | .to_string();
631 |
632 | let url = format!(
633 | "{}/projects/{}/repository/files/{}",
634 | gitlab_url,
635 | urlencode_if_needed(project_id),
636 | urlencode_if_needed(file_path)
637 | );
638 |
639 | let mut headers = BTreeMap::new();
640 | headers.insert("PRIVATE-TOKEN".to_string(), token);
641 | headers.insert("Content-Type".to_string(), "application/json".to_string());
642 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
643 |
644 | let mut body_map = serde_json::Map::new();
645 | body_map.insert("branch".to_string(), json!(branch));
646 | body_map.insert("commit_message".to_string(), json!(commit_message));
647 | if let Some(Value::String(author_email)) = args.get("author_email") {
648 | body_map.insert("author_email".to_string(), json!(author_email));
649 | }
650 | if let Some(Value::String(author_name)) = args.get("author_name") {
651 | body_map.insert("author_name".to_string(), json!(author_name));
652 | }
653 | let body = Value::Object(body_map);
654 |
655 | let req = HttpRequest {
656 | url,
657 | headers,
658 | method: Some("DELETE".to_string()),
659 | };
660 |
661 | let res = http::request(&req, Some(&body.to_string()))?;
662 |
663 | if is_success_status(res.status_code()) {
664 | Ok(CallToolResult {
665 | is_error: None,
666 | content: vec![Content {
667 | annotations: None,
668 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
669 | mime_type: Some("application/json".to_string()),
670 | r#type: ContentType::Text,
671 | data: None,
672 | }],
673 | })
674 | } else {
675 | Ok(CallToolResult {
676 | is_error: Some(true),
677 | content: vec![Content {
678 | annotations: None,
679 | text: Some(format!(
680 | "Failed to delete file (status {}): {}",
681 | res.status_code(),
682 | String::from_utf8_lossy(&res.body())
683 | )),
684 | mime_type: None,
685 | r#type: ContentType::Text,
686 | data: None,
687 | }],
688 | })
689 | }
690 | } else {
691 | Ok(CallToolResult {
692 | is_error: Some(true),
693 | content: vec![Content {
694 | annotations: None,
695 | text: Some("Please provide project_id, file_path, and branch".into()),
696 | mime_type: None,
697 | r#type: ContentType::Text,
698 | data: None,
699 | }],
700 | })
701 | }
702 | }
703 |
704 | fn create_or_update_file(input: CallToolRequest) -> Result<CallToolResult, Error> {
705 | let args = input.params.arguments.clone().unwrap_or_default();
706 | let (token, gitlab_url) = get_gitlab_config()?;
707 |
708 | if let (
709 | Some(Value::String(project_id)),
710 | Some(Value::String(file_path)),
711 | Some(Value::String(content)),
712 | Some(Value::String(branch)),
713 | ) = (
714 | args.get("project_id"),
715 | args.get("file_path"),
716 | args.get("content"),
717 | args.get("branch"),
718 | ) {
719 | let commit_message = args
720 | .get("commit_message")
721 | .and_then(|v| v.as_str())
722 | .unwrap_or("Update file via API")
723 | .to_string();
724 |
725 | // URL for checking file existence. Note: GitLab GET file API needs ref in query.
726 | let check_file_url = format!(
727 | "{}/projects/{}/repository/files/{}?ref={}",
728 | gitlab_url,
729 | urlencode_if_needed(project_id),
730 | urlencode_if_needed(file_path),
731 | branch
732 | );
733 |
734 | let mut headers_check = BTreeMap::new();
735 | headers_check.insert("PRIVATE-TOKEN".to_string(), token.clone());
736 | headers_check.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
737 |
738 | let check_req = HttpRequest {
739 | url: check_file_url,
740 | headers: headers_check,
741 | method: Some("GET".to_string()),
742 | };
743 |
744 | let check_res = http::request::<()>(&check_req, None)?;
745 |
746 | let http_method = match check_res.status_code() {
747 | 200 => "PUT", // File exists, so update
748 | 404 => "POST", // File does not exist, so create
749 | _ => {
750 | return Ok(CallToolResult {
751 | is_error: Some(true),
752 | content: vec![Content {
753 | annotations: None,
754 | text: Some(format!(
755 | "Failed to check file existence (status {} on GET {}): {}",
756 | check_res.status_code(),
757 | check_req.url,
758 | String::from_utf8_lossy(&check_res.body())
759 | )),
760 | mime_type: None,
761 | r#type: ContentType::Text,
762 | data: None,
763 | }],
764 | });
765 | }
766 | };
767 |
768 | // URL for POST/PUT operations (does not have ref in query string, ref is in body via 'branch' parameter)
769 | // Ensure file_path is URL encoded for this URL as well.
770 | let operation_url = format!(
771 | "{}/projects/{}/repository/files/{}",
772 | gitlab_url,
773 | urlencode_if_needed(project_id),
774 | urlencode_if_needed(file_path)
775 | );
776 |
777 | let mut body_map = serde_json::Map::new();
778 | body_map.insert("branch".to_string(), json!(branch));
779 | body_map.insert("content".to_string(), json!(content));
780 | body_map.insert("commit_message".to_string(), json!(commit_message));
781 |
782 | if let Some(Value::String(author_email)) = args.get("author_email") {
783 | body_map.insert("author_email".to_string(), json!(author_email));
784 | }
785 | if let Some(Value::String(author_name)) = args.get("author_name") {
786 | body_map.insert("author_name".to_string(), json!(author_name));
787 | }
788 | // Note: For 'POST' (create), 'encoding' can be 'base64'.
789 | // GitLab API often expects content to be base64 encoded for new files if not plain text.
790 | // For simplicity, we assume content is plain text, and GitLab handles it.
791 | // If issues arise with binary or special characters, 'content' might need explicit base64 encoding
792 | // and adding "encoding": "base64" to body_map.
793 |
794 | let body = Value::Object(body_map);
795 |
796 | let mut headers_op = BTreeMap::new();
797 | headers_op.insert("PRIVATE-TOKEN".to_string(), token);
798 | headers_op.insert("Content-Type".to_string(), "application/json".to_string());
799 | headers_op.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
800 |
801 | let req = HttpRequest {
802 | url: operation_url.clone(),
803 | headers: headers_op,
804 | method: Some(http_method.to_string()),
805 | };
806 |
807 | let res = http::request(&req, Some(&body.to_string()))?;
808 |
809 | if is_success_status(res.status_code()) {
810 | Ok(CallToolResult {
811 | is_error: None,
812 | content: vec![Content {
813 | annotations: None,
814 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
815 | mime_type: Some("application/json".to_string()),
816 | r#type: ContentType::Text,
817 | data: None,
818 | }],
819 | })
820 | } else {
821 | Ok(CallToolResult {
822 | is_error: Some(true),
823 | content: vec![Content {
824 | annotations: None,
825 | text: Some(format!(
826 | "Failed to {} file (method {}, status {} on {}): Response: {}",
827 | if http_method == "POST" {
828 | "create"
829 | } else {
830 | "update"
831 | },
832 | http_method,
833 | res.status_code(),
834 | req.url,
835 | String::from_utf8_lossy(&res.body())
836 | )),
837 | mime_type: None,
838 | r#type: ContentType::Text,
839 | data: None,
840 | }],
841 | })
842 | }
843 | } else {
844 | Ok(CallToolResult {
845 | is_error: Some(true),
846 | content: vec![Content {
847 | annotations: None,
848 | text: Some("Please provide project_id, file_path, content, and branch".into()),
849 | mime_type: None,
850 | r#type: ContentType::Text,
851 | data: None,
852 | }],
853 | })
854 | }
855 | }
856 |
857 | fn create_branch(input: CallToolRequest) -> Result<CallToolResult, Error> {
858 | let args = input.params.arguments.clone().unwrap_or_default();
859 | let (token, gitlab_url) = get_gitlab_config()?;
860 |
861 | if let (
862 | Some(Value::String(project_id)),
863 | Some(Value::String(branch_name)),
864 | Some(Value::String(ref_name)),
865 | ) = (
866 | args.get("project_id"),
867 | args.get("branch_name"),
868 | args.get("ref"),
869 | ) {
870 | let url = format!(
871 | "{}/projects/{}/repository/branches",
872 | gitlab_url,
873 | urlencode_if_needed(project_id)
874 | );
875 | let body = json!({
876 | "branch": branch_name,
877 | "ref": ref_name
878 | });
879 |
880 | let mut headers = BTreeMap::new();
881 | headers.insert("PRIVATE-TOKEN".to_string(), token);
882 | headers.insert("Content-Type".to_string(), "application/json".to_string());
883 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
884 |
885 | let req = HttpRequest {
886 | url,
887 | headers,
888 | method: Some("POST".to_string()),
889 | };
890 |
891 | let res = http::request(&req, Some(&body.to_string()))?;
892 |
893 | if is_success_status(res.status_code()) {
894 | Ok(CallToolResult {
895 | is_error: None,
896 | content: vec![Content {
897 | annotations: None,
898 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
899 | mime_type: Some("application/json".to_string()),
900 | r#type: ContentType::Text,
901 | data: None,
902 | }],
903 | })
904 | } else {
905 | Ok(CallToolResult {
906 | is_error: Some(true),
907 | content: vec![Content {
908 | annotations: None,
909 | text: Some(format!("Failed to create branch: {}", res.status_code())),
910 | mime_type: None,
911 | r#type: ContentType::Text,
912 | data: None,
913 | }],
914 | })
915 | }
916 | } else {
917 | Ok(CallToolResult {
918 | is_error: Some(true),
919 | content: vec![Content {
920 | annotations: None,
921 | text: Some("Please provide project_id, branch_name, and ref".into()),
922 | mime_type: None,
923 | r#type: ContentType::Text,
924 | data: None,
925 | }],
926 | })
927 | }
928 | }
929 |
930 | fn create_merge_request(input: CallToolRequest) -> Result<CallToolResult, Error> {
931 | let args = input.params.arguments.clone().unwrap_or_default();
932 | let (token, gitlab_url) = get_gitlab_config()?;
933 |
934 | if let (
935 | Some(Value::String(project_id)),
936 | Some(Value::String(source_branch)),
937 | Some(Value::String(target_branch)),
938 | ) = (
939 | args.get("project_id"),
940 | args.get("source_branch"),
941 | args.get("target_branch"),
942 | ) {
943 | let url = format!(
944 | "{}/projects/{}/merge_requests",
945 | gitlab_url,
946 | urlencode_if_needed(project_id)
947 | );
948 |
949 | // Use provided title if present, otherwise use default format
950 | let title = args
951 | .get("title")
952 | .and_then(|t| t.as_str())
953 | .map(|t| t.to_string())
954 | .unwrap_or_else(|| format!("Merge {} into {}", source_branch, target_branch));
955 |
956 | let body = json!({
957 | "source_branch": source_branch,
958 | "target_branch": target_branch,
959 | "title": title,
960 | });
961 |
962 | let mut headers = BTreeMap::new();
963 | headers.insert("PRIVATE-TOKEN".to_string(), token);
964 | headers.insert("Content-Type".to_string(), "application/json".to_string());
965 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
966 |
967 | let req = HttpRequest {
968 | url,
969 | headers,
970 | method: Some("POST".to_string()),
971 | };
972 |
973 | let res = http::request(&req, Some(&body.to_string()))?;
974 |
975 | if is_success_status(res.status_code()) {
976 | Ok(CallToolResult {
977 | is_error: None,
978 | content: vec![Content {
979 | annotations: None,
980 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
981 | mime_type: Some("application/json".to_string()),
982 | r#type: ContentType::Text,
983 | data: None,
984 | }],
985 | })
986 | } else {
987 | Ok(CallToolResult {
988 | is_error: Some(true),
989 | content: vec![Content {
990 | annotations: None,
991 | text: Some(format!(
992 | "Failed to create merge request: {}",
993 | res.status_code()
994 | )),
995 | mime_type: None,
996 | r#type: ContentType::Text,
997 | data: None,
998 | }],
999 | })
1000 | }
1001 | } else {
1002 | Ok(CallToolResult {
1003 | is_error: Some(true),
1004 | content: vec![Content {
1005 | annotations: None,
1006 | text: Some("Please provide project_id, source_branch, and target_branch".into()),
1007 | mime_type: None,
1008 | r#type: ContentType::Text,
1009 | data: None,
1010 | }],
1011 | })
1012 | }
1013 | }
1014 |
1015 | fn update_merge_request(input: CallToolRequest) -> Result<CallToolResult, Error> {
1016 | let args = input.params.arguments.clone().unwrap_or_default();
1017 | let (token, gitlab_url) = get_gitlab_config()?;
1018 |
1019 | if let (
1020 | Some(Value::String(project_id)),
1021 | Some(Value::String(merge_request_iid)),
1022 | Some(Value::String(title)),
1023 | Some(Value::String(description)),
1024 | ) = (
1025 | args.get("project_id"),
1026 | args.get("merge_request_iid"),
1027 | args.get("title"),
1028 | args.get("description"),
1029 | ) {
1030 | let url = format!(
1031 | "{}/projects/{}/merge_requests/{}",
1032 | gitlab_url,
1033 | urlencode_if_needed(project_id),
1034 | merge_request_iid
1035 | );
1036 |
1037 | let mut body_map = serde_json::Map::new();
1038 | body_map.insert("title".to_string(), json!(title));
1039 | body_map.insert("description".to_string(), json!(description));
1040 |
1041 | let body = Value::Object(body_map);
1042 |
1043 | let mut headers = BTreeMap::new();
1044 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1045 | headers.insert("Content-Type".to_string(), "application/json".to_string());
1046 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1047 |
1048 | let req = HttpRequest {
1049 | url,
1050 | headers,
1051 | method: Some("PUT".to_string()),
1052 | };
1053 |
1054 | let res = http::request(&req, Some(&body.to_string()))?;
1055 |
1056 | if is_success_status(res.status_code()) {
1057 | Ok(CallToolResult {
1058 | is_error: None,
1059 | content: vec![Content {
1060 | annotations: None,
1061 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1062 | mime_type: Some("application/json".to_string()),
1063 | r#type: ContentType::Text,
1064 | data: None,
1065 | }],
1066 | })
1067 | } else {
1068 | Ok(CallToolResult {
1069 | is_error: Some(true),
1070 | content: vec![Content {
1071 | annotations: None,
1072 | text: Some(format!(
1073 | "Failed to update merge request: {} - Response: {}",
1074 | res.status_code(),
1075 | String::from_utf8_lossy(&res.body())
1076 | )),
1077 | mime_type: None,
1078 | r#type: ContentType::Text,
1079 | data: None,
1080 | }],
1081 | })
1082 | }
1083 | } else {
1084 | Ok(CallToolResult {
1085 | is_error: Some(true),
1086 | content: vec![Content {
1087 | annotations: None,
1088 | text: Some(
1089 | "Please provide project_id, merge_request_iid, title, and description".into(),
1090 | ),
1091 | mime_type: None,
1092 | r#type: ContentType::Text,
1093 | data: None,
1094 | }],
1095 | })
1096 | }
1097 | }
1098 |
1099 | fn gl_get_merge_request(input: CallToolRequest) -> Result<CallToolResult, Error> {
1100 | let args = input.params.arguments.clone().unwrap_or_default();
1101 | let (token, gitlab_url) = get_gitlab_config()?;
1102 |
1103 | if let (Some(Value::String(project_id)), Some(Value::String(merge_request_iid))) =
1104 | (args.get("project_id"), args.get("merge_request_iid"))
1105 | {
1106 | let url = format!(
1107 | "{}/projects/{}/merge_requests/{}",
1108 | gitlab_url,
1109 | urlencode_if_needed(project_id),
1110 | merge_request_iid
1111 | );
1112 |
1113 | let mut headers = BTreeMap::new();
1114 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1115 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1116 |
1117 | let req = HttpRequest {
1118 | url,
1119 | headers,
1120 | method: Some("GET".to_string()),
1121 | };
1122 |
1123 | let res = http::request::<()>(&req, None)?;
1124 |
1125 | if is_success_status(res.status_code()) {
1126 | Ok(CallToolResult {
1127 | is_error: None,
1128 | content: vec![Content {
1129 | annotations: None,
1130 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1131 | mime_type: Some("application/json".to_string()),
1132 | r#type: ContentType::Text,
1133 | data: None,
1134 | }],
1135 | })
1136 | } else {
1137 | Ok(CallToolResult {
1138 | is_error: Some(true),
1139 | content: vec![Content {
1140 | annotations: None,
1141 | text: Some(format!(
1142 | "Failed to get merge request: {} - Response: {}",
1143 | res.status_code(),
1144 | String::from_utf8_lossy(&res.body())
1145 | )),
1146 | mime_type: None,
1147 | r#type: ContentType::Text,
1148 | data: None,
1149 | }],
1150 | })
1151 | }
1152 | } else {
1153 | Ok(CallToolResult {
1154 | is_error: Some(true),
1155 | content: vec![Content {
1156 | annotations: None,
1157 | text: Some("Please provide project_id and merge_request_iid".into()),
1158 | mime_type: None,
1159 | r#type: ContentType::Text,
1160 | data: None,
1161 | }],
1162 | })
1163 | }
1164 | }
1165 |
1166 | fn create_snippet(input: CallToolRequest) -> Result<CallToolResult, Error> {
1167 | let args = input.params.arguments.clone().unwrap_or_default();
1168 | let (token, gitlab_url) = get_gitlab_config()?;
1169 |
1170 | if let (Some(Value::String(title)), Some(Value::String(content))) =
1171 | (args.get("title"), args.get("content"))
1172 | {
1173 | let url = format!("{}/snippets", gitlab_url);
1174 |
1175 | // Get visibility from args or default to "private"
1176 | let visibility = args
1177 | .get("visibility")
1178 | .and_then(|v| v.as_str())
1179 | .unwrap_or("private");
1180 |
1181 | let body = json!({
1182 | "title": title,
1183 | "file_name": format!("{}.txt", title.to_lowercase().replace(" ", "_")),
1184 | "content": content,
1185 | "visibility": visibility
1186 | });
1187 |
1188 | let mut headers = BTreeMap::new();
1189 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1190 | headers.insert("Content-Type".to_string(), "application/json".to_string());
1191 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1192 |
1193 | let req = HttpRequest {
1194 | url,
1195 | headers,
1196 | method: Some("POST".to_string()),
1197 | };
1198 |
1199 | let res = http::request(&req, Some(&body.to_string()))?;
1200 |
1201 | if is_success_status(res.status_code()) {
1202 | Ok(CallToolResult {
1203 | is_error: None,
1204 | content: vec![Content {
1205 | annotations: None,
1206 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1207 | mime_type: Some("application/json".to_string()),
1208 | r#type: ContentType::Text,
1209 | data: None,
1210 | }],
1211 | })
1212 | } else {
1213 | Ok(CallToolResult {
1214 | is_error: Some(true),
1215 | content: vec![Content {
1216 | annotations: None,
1217 | text: Some(format!("Failed to create snippet: {}", res.status_code())),
1218 | mime_type: None,
1219 | r#type: ContentType::Text,
1220 | data: None,
1221 | }],
1222 | })
1223 | }
1224 | } else {
1225 | Ok(CallToolResult {
1226 | is_error: Some(true),
1227 | content: vec![Content {
1228 | annotations: None,
1229 | text: Some("Please provide title and content".into()),
1230 | mime_type: None,
1231 | r#type: ContentType::Text,
1232 | data: None,
1233 | }],
1234 | })
1235 | }
1236 | }
1237 |
1238 | fn update_snippet(input: CallToolRequest) -> Result<CallToolResult, Error> {
1239 | let args = input.params.arguments.clone().unwrap_or_default();
1240 | let (token, gitlab_url) = get_gitlab_config()?;
1241 |
1242 | if let (
1243 | Some(Value::String(snippet_id)),
1244 | Some(Value::String(title)),
1245 | Some(Value::String(content)),
1246 | ) = (
1247 | args.get("snippet_id"),
1248 | args.get("title"),
1249 | args.get("content"),
1250 | ) {
1251 | let url = format!("{}/snippets/{}", gitlab_url, snippet_id);
1252 | let body = json!({
1253 | "title": title,
1254 | "file_name": format!("{}.txt", title.to_lowercase().replace(" ", "_")),
1255 | "content": content,
1256 | });
1257 |
1258 | let mut headers = BTreeMap::new();
1259 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1260 | headers.insert("Content-Type".to_string(), "application/json".to_string());
1261 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1262 |
1263 | let req = HttpRequest {
1264 | url,
1265 | headers,
1266 | method: Some("PUT".to_string()),
1267 | };
1268 |
1269 | let res = http::request(&req, Some(&body.to_string()))?;
1270 |
1271 | if is_success_status(res.status_code()) {
1272 | Ok(CallToolResult {
1273 | is_error: None,
1274 | content: vec![Content {
1275 | annotations: None,
1276 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1277 | mime_type: Some("application/json".to_string()),
1278 | r#type: ContentType::Text,
1279 | data: None,
1280 | }],
1281 | })
1282 | } else {
1283 | Ok(CallToolResult {
1284 | is_error: Some(true),
1285 | content: vec![Content {
1286 | annotations: None,
1287 | text: Some(format!("Failed to update snippet: {}", res.status_code())),
1288 | mime_type: None,
1289 | r#type: ContentType::Text,
1290 | data: None,
1291 | }],
1292 | })
1293 | }
1294 | } else {
1295 | Ok(CallToolResult {
1296 | is_error: Some(true),
1297 | content: vec![Content {
1298 | annotations: None,
1299 | text: Some("Please provide snippet_id, title, and content".into()),
1300 | mime_type: None,
1301 | r#type: ContentType::Text,
1302 | data: None,
1303 | }],
1304 | })
1305 | }
1306 | }
1307 |
1308 | fn get_snippet(input: CallToolRequest) -> Result<CallToolResult, Error> {
1309 | let args = input.params.arguments.clone().unwrap_or_default();
1310 | let (token, gitlab_url) = get_gitlab_config()?;
1311 |
1312 | if let Some(Value::String(snippet_id)) = args.get("snippet_id") {
1313 | let url = format!("{}/snippets/{}", gitlab_url, snippet_id);
1314 |
1315 | let mut headers = BTreeMap::new();
1316 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1317 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1318 |
1319 | let req = HttpRequest {
1320 | url,
1321 | headers,
1322 | method: Some("GET".to_string()),
1323 | };
1324 |
1325 | let res = http::request::<()>(&req, None)?;
1326 |
1327 | if is_success_status(res.status_code()) {
1328 | Ok(CallToolResult {
1329 | is_error: None,
1330 | content: vec![Content {
1331 | annotations: None,
1332 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1333 | mime_type: Some("application/json".to_string()),
1334 | r#type: ContentType::Text,
1335 | data: None,
1336 | }],
1337 | })
1338 | } else {
1339 | Ok(CallToolResult {
1340 | is_error: Some(true),
1341 | content: vec![Content {
1342 | annotations: None,
1343 | text: Some(format!("Failed to get snippet: {}", res.status_code())),
1344 | mime_type: None,
1345 | r#type: ContentType::Text,
1346 | data: None,
1347 | }],
1348 | })
1349 | }
1350 | } else {
1351 | Ok(CallToolResult {
1352 | is_error: Some(true),
1353 | content: vec![Content {
1354 | annotations: None,
1355 | text: Some("Please provide snippet_id".into()),
1356 | mime_type: None,
1357 | r#type: ContentType::Text,
1358 | data: None,
1359 | }],
1360 | })
1361 | }
1362 | }
1363 |
1364 | fn delete_snippet(input: CallToolRequest) -> Result<CallToolResult, Error> {
1365 | let args = input.params.arguments.clone().unwrap_or_default();
1366 | let (token, gitlab_url) = get_gitlab_config()?;
1367 |
1368 | if let Some(Value::String(snippet_id)) = args.get("snippet_id") {
1369 | let url = format!("{}/snippets/{}", gitlab_url, snippet_id);
1370 |
1371 | let mut headers = BTreeMap::new();
1372 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1373 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1374 |
1375 | let req = HttpRequest {
1376 | url,
1377 | headers,
1378 | method: Some("DELETE".to_string()),
1379 | };
1380 |
1381 | let res = http::request::<()>(&req, None)?;
1382 |
1383 | if is_success_status(res.status_code()) {
1384 | Ok(CallToolResult {
1385 | is_error: None,
1386 | content: vec![Content {
1387 | annotations: None,
1388 | text: Some("Snippet deleted successfully".into()),
1389 | mime_type: None,
1390 | r#type: ContentType::Text,
1391 | data: None,
1392 | }],
1393 | })
1394 | } else {
1395 | Ok(CallToolResult {
1396 | is_error: Some(true),
1397 | content: vec![Content {
1398 | annotations: None,
1399 | text: Some(format!("Failed to delete snippet: {}", res.status_code())),
1400 | mime_type: None,
1401 | r#type: ContentType::Text,
1402 | data: None,
1403 | }],
1404 | })
1405 | }
1406 | } else {
1407 | Ok(CallToolResult {
1408 | is_error: Some(true),
1409 | content: vec![Content {
1410 | annotations: None,
1411 | text: Some("Please provide snippet_id".into()),
1412 | mime_type: None,
1413 | r#type: ContentType::Text,
1414 | data: None,
1415 | }],
1416 | })
1417 | }
1418 | }
1419 |
1420 | fn gl_list_branches(input: CallToolRequest) -> Result<CallToolResult, Error> {
1421 | let args = input.params.arguments.clone().unwrap_or_default();
1422 | let (token, gitlab_url) = get_gitlab_config()?;
1423 |
1424 | if let Some(Value::String(project_id)) = args.get("project_id") {
1425 | let url = format!(
1426 | "{}/projects/{}/repository/branches",
1427 | gitlab_url,
1428 | urlencode_if_needed(project_id)
1429 | );
1430 |
1431 | let mut headers = BTreeMap::new();
1432 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1433 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1434 |
1435 | let req = HttpRequest {
1436 | url,
1437 | headers,
1438 | method: Some("GET".to_string()),
1439 | };
1440 |
1441 | let res = http::request::<()>(&req, None)?;
1442 |
1443 | if is_success_status(res.status_code()) {
1444 | Ok(CallToolResult {
1445 | is_error: None,
1446 | content: vec![Content {
1447 | annotations: None,
1448 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1449 | mime_type: Some("application/json".to_string()),
1450 | r#type: ContentType::Text,
1451 | data: None,
1452 | }],
1453 | })
1454 | } else {
1455 | Ok(CallToolResult {
1456 | is_error: Some(true),
1457 | content: vec![Content {
1458 | annotations: None,
1459 | text: Some(format!("Failed to list branches: {}", res.status_code())),
1460 | mime_type: None,
1461 | r#type: ContentType::Text,
1462 | data: None,
1463 | }],
1464 | })
1465 | }
1466 | } else {
1467 | Ok(CallToolResult {
1468 | is_error: Some(true),
1469 | content: vec![Content {
1470 | annotations: None,
1471 | text: Some("Please provide project_id".into()),
1472 | mime_type: None,
1473 | r#type: ContentType::Text,
1474 | data: None,
1475 | }],
1476 | })
1477 | }
1478 | }
1479 |
1480 | fn gl_list_issues(input: CallToolRequest) -> Result<CallToolResult, Error> {
1481 | let args = input.params.arguments.clone().unwrap_or_default();
1482 | let (token, gitlab_url) = get_gitlab_config()?;
1483 |
1484 | if let Some(Value::String(project_id)) = args.get("project_id") {
1485 | let mut url_params = vec![];
1486 |
1487 | if let Some(Value::String(state)) = args.get("state") {
1488 | url_params.push(format!("state={}", state));
1489 | }
1490 | if let Some(Value::String(labels)) = args.get("labels") {
1491 | url_params.push(format!("labels={}", urlencoding::encode(labels)));
1492 | }
1493 |
1494 | let query_string = if url_params.is_empty() {
1495 | "".to_string()
1496 | } else {
1497 | format!("?{}", url_params.join("&"))
1498 | };
1499 |
1500 | let url = format!(
1501 | "{}/projects/{}/issues{}",
1502 | gitlab_url,
1503 | urlencode_if_needed(project_id),
1504 | query_string
1505 | );
1506 |
1507 | let mut headers = BTreeMap::new();
1508 | headers.insert("PRIVATE-TOKEN".to_string(), token);
1509 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1510 |
1511 | let req = HttpRequest {
1512 | url,
1513 | headers,
1514 | method: Some("GET".to_string()),
1515 | };
1516 |
1517 | let res = http::request::<()>(&req, None)?;
1518 |
1519 | if is_success_status(res.status_code()) {
1520 | Ok(CallToolResult {
1521 | is_error: None,
1522 | content: vec![Content {
1523 | annotations: None,
1524 | text: Some(String::from_utf8_lossy(&res.body()).to_string()),
1525 | mime_type: Some("application/json".to_string()),
1526 | r#type: ContentType::Text,
1527 | data: None,
1528 | }],
1529 | })
1530 | } else {
1531 | Ok(CallToolResult {
1532 | is_error: Some(true),
1533 | content: vec![Content {
1534 | annotations: None,
1535 | text: Some(format!("Failed to list issues: {}", res.status_code())),
1536 | mime_type: None,
1537 | r#type: ContentType::Text,
1538 | data: None,
1539 | }],
1540 | })
1541 | }
1542 | } else {
1543 | Ok(CallToolResult {
1544 | is_error: Some(true),
1545 | content: vec![Content {
1546 | annotations: None,
1547 | text: Some("Please provide project_id".into()),
1548 | mime_type: None,
1549 | r#type: ContentType::Text,
1550 | data: None,
1551 | }],
1552 | })
1553 | }
1554 | }
1555 |
1556 | fn gl_get_repo_tree(input: CallToolRequest) -> Result<CallToolResult, Error> {
1557 | let args = input.params.arguments.clone().unwrap_or_default();
1558 | let (token, gitlab_url) = get_gitlab_config()?;
1559 |
1560 | if let Some(Value::String(project_id_val)) = args.get("project_id") {
1561 | let project_id = project_id_val.as_str();
1562 | let requested_path_opt = args.get("path").and_then(|v| v.as_str());
1563 | let ref_name_opt = args.get("ref").and_then(|v| v.as_str());
1564 | let recursive_opt = args.get("recursive").and_then(|v| v.as_bool());
1565 |
1566 | let mut all_entries: Vec<GitLabRepoEntry> = Vec::new();
1567 | let mut current_page_number: u32 = 1;
1568 | const PER_PAGE_COUNT: u32 = 100; // GitLab's typical max per_page
1569 | const MAX_PAGES: u32 = 100; // Safety break: 100 pages * 100 items/page = 10,000 items
1570 |
1571 | loop {
1572 | if current_page_number > MAX_PAGES {
1573 | // Log this or return a partial result with a warning if desired
1574 | // For now, just break and use what we have.
1575 | // Consider returning an error if this limit is hit.
1576 | break;
1577 | }
1578 |
1579 | let mut url_params = vec![
1580 | format!("per_page={}", PER_PAGE_COUNT),
1581 | format!("page={}", current_page_number),
1582 | ];
1583 |
1584 | if let Some(path_str) = requested_path_opt {
1585 | if !path_str.is_empty() {
1586 | url_params.push(format!("path={}", urlencoding::encode(path_str)));
1587 | }
1588 | }
1589 | if let Some(ref_name_str) = ref_name_opt {
1590 | url_params.push(format!("ref={}", urlencoding::encode(ref_name_str)));
1591 | }
1592 | if let Some(recursive_bool) = recursive_opt {
1593 | if recursive_bool {
1594 | url_params.push("recursive=true".to_string());
1595 | }
1596 | }
1597 |
1598 | let query_string = format!("?{}", url_params.join("&"));
1599 | let url = format!(
1600 | "{}/projects/{}/repository/tree{}",
1601 | gitlab_url,
1602 | urlencode_if_needed(project_id),
1603 | query_string
1604 | );
1605 |
1606 | let mut headers = BTreeMap::new();
1607 | headers.insert("PRIVATE-TOKEN".to_string(), token.clone()); // Clone token for loop
1608 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1609 |
1610 | let req = HttpRequest {
1611 | url: url.clone(),
1612 | headers,
1613 | method: Some("GET".to_string()),
1614 | };
1615 |
1616 | let res = http::request::<()>(&req, None)?;
1617 |
1618 | if !is_success_status(res.status_code()) {
1619 | return Ok(CallToolResult {
1620 | is_error: Some(true),
1621 | content: vec![Content {
1622 | annotations: None,
1623 | text: Some(format!(
1624 | "Failed to get repository tree page {} from {}: {} - Response: {}",
1625 | current_page_number,
1626 | req.url,
1627 | res.status_code(),
1628 | String::from_utf8_lossy(&res.body())
1629 | )),
1630 | mime_type: None,
1631 | r#type: ContentType::Text,
1632 | data: None,
1633 | }],
1634 | });
1635 | }
1636 |
1637 | match serde_json::from_slice::<Vec<GitLabRepoEntry>>(&res.body()) {
1638 | Ok(page_entries) => {
1639 | let num_fetched = page_entries.len();
1640 | all_entries.extend(page_entries);
1641 |
1642 | if num_fetched < PER_PAGE_COUNT as usize {
1643 | break; // Last page fetched
1644 | }
1645 | }
1646 | Err(e) => {
1647 | return Ok(CallToolResult {
1648 | is_error: Some(true),
1649 | content: vec![Content {
1650 | annotations: None,
1651 | text: Some(format!(
1652 | "Failed to parse repository tree data from GitLab API (page {}): {}",
1653 | current_page_number, e
1654 | )),
1655 | mime_type: None,
1656 | r#type: ContentType::Text,
1657 | data: None,
1658 | }],
1659 | });
1660 | }
1661 | }
1662 | current_page_number += 1;
1663 | }
1664 |
1665 | // Proceed with building the tree from all_entries
1666 | match build_and_format_tree_from_entries(all_entries, requested_path_opt, project_id) {
1667 | Ok(tree_string) => Ok(CallToolResult {
1668 | is_error: None,
1669 | content: vec![Content {
1670 | annotations: None,
1671 | text: Some(tree_string),
1672 | mime_type: Some("text/plain".to_string()),
1673 | r#type: ContentType::Text,
1674 | data: None,
1675 | }],
1676 | }),
1677 | Err(e_str) => Ok(CallToolResult {
1678 | is_error: Some(true),
1679 | content: vec![Content {
1680 | annotations: None,
1681 | text: Some(e_str),
1682 | mime_type: None,
1683 | r#type: ContentType::Text,
1684 | data: None,
1685 | }],
1686 | }),
1687 | }
1688 | } else {
1689 | Ok(CallToolResult {
1690 | is_error: Some(true),
1691 | content: vec![Content {
1692 | annotations: None,
1693 | text: Some("Please provide project_id".into()),
1694 | mime_type: None,
1695 | r#type: ContentType::Text,
1696 | data: None,
1697 | }],
1698 | })
1699 | }
1700 | }
1701 |
1702 | fn gl_get_repo_members(input: CallToolRequest) -> Result<CallToolResult, Error> {
1703 | let args = input.params.arguments.clone().unwrap_or_default();
1704 | let (token, gitlab_url) = get_gitlab_config()?;
1705 |
1706 | if let Some(Value::String(project_id_val)) = args.get("project_id") {
1707 | let project_id = project_id_val.as_str();
1708 | let include_inherited = args
1709 | .get("include_inherited_members")
1710 | .and_then(|v| v.as_bool())
1711 | .unwrap_or(false);
1712 | let query_opt = args.get("query").and_then(|v| v.as_str());
1713 |
1714 | let members_path = if include_inherited {
1715 | "members/all"
1716 | } else {
1717 | "members"
1718 | };
1719 |
1720 | let mut all_members_json: Vec<Value> = Vec::new();
1721 | let mut current_page_number: u32 = 1;
1722 | const PER_PAGE_COUNT: u32 = 100; // GitLab's typical max per_page
1723 | const MAX_PAGES: u32 = 100; // Safety break: 100 pages * 100 items/page = 10,000 members
1724 |
1725 | loop {
1726 | if current_page_number > MAX_PAGES {
1727 | // Log this or return a partial result with a warning if desired
1728 | break;
1729 | }
1730 |
1731 | let mut url_params = vec![
1732 | format!("per_page={}", PER_PAGE_COUNT),
1733 | format!("page={}", current_page_number),
1734 | ];
1735 |
1736 | if let Some(query_str) = query_opt {
1737 | url_params.push(format!("query={}", urlencoding::encode(query_str)));
1738 | }
1739 |
1740 | let query_string = format!("?{}", url_params.join("&"));
1741 | let url = format!(
1742 | "{}/projects/{}/{}{}",
1743 | gitlab_url,
1744 | urlencode_if_needed(project_id),
1745 | members_path,
1746 | query_string
1747 | );
1748 |
1749 | let mut headers = BTreeMap::new();
1750 | headers.insert("PRIVATE-TOKEN".to_string(), token.clone());
1751 | headers.insert("User-Agent".to_string(), "hyper-mcp/0.1.0".to_string());
1752 |
1753 | let req = HttpRequest {
1754 | url: url.clone(),
1755 | headers,
1756 | method: Some("GET".to_string()),
1757 | };
1758 |
1759 | let res = http::request::<()>(&req, None)?;
1760 |
1761 | if !is_success_status(res.status_code()) {
1762 | return Ok(CallToolResult {
1763 | is_error: Some(true),
1764 | content: vec![Content {
1765 | annotations: None,
1766 | text: Some(format!(
1767 | "Failed to get repository members page {} from {}: {} - Response: {}",
1768 | current_page_number,
1769 | req.url,
1770 | res.status_code(),
1771 | String::from_utf8_lossy(&res.body())
1772 | )),
1773 | mime_type: None,
1774 | r#type: ContentType::Text,
1775 | data: None,
1776 | }],
1777 | });
1778 | }
1779 |
1780 | match serde_json::from_slice::<Vec<Value>>(&res.body()) {
1781 | Ok(page_members) => {
1782 | let num_fetched = page_members.len();
1783 | all_members_json.extend(page_members);
1784 |
1785 | if num_fetched < PER_PAGE_COUNT as usize {
1786 | break; // Last page fetched
1787 | }
1788 | }
1789 | Err(e) => {
1790 | return Ok(CallToolResult {
1791 | is_error: Some(true),
1792 | content: vec![Content {
1793 | annotations: None,
1794 | text: Some(format!(
1795 | "Failed to parse repository members data from GitLab API (page {}): {}",
1796 | current_page_number, e
1797 | )),
1798 | mime_type: None,
1799 | r#type: ContentType::Text,
1800 | data: None,
1801 | }],
1802 | });
1803 | }
1804 | }
1805 | current_page_number += 1;
1806 | }
1807 |
1808 | Ok(CallToolResult {
1809 | is_error: None,
1810 | content: vec![Content {
1811 | annotations: None,
1812 | text: Some(serde_json::to_string(&all_members_json)?),
1813 | mime_type: Some("application/json".to_string()),
1814 | r#type: ContentType::Text,
1815 | data: None,
1816 | }],
1817 | })
1818 | } else {
1819 | Ok(CallToolResult {
1820 | is_error: Some(true),
1821 | content: vec![Content {
1822 | annotations: None,
1823 | text: Some("Please provide project_id".into()),
1824 | mime_type: None,
1825 | r#type: ContentType::Text,
1826 | data: None,
1827 | }],
1828 | })
1829 | }
1830 | }
1831 |
1832 | pub(crate) fn describe() -> Result<ListToolsResult, Error> {
1833 | Ok(ListToolsResult {
1834 | tools: vec![
1835 | ToolDescription {
1836 | name: "gl_delete_file".into(),
1837 | description: "Delete a file in a GitLab project repository. Requires project_id, file_path, branch, and optional commit_message.".into(),
1838 | input_schema: json!({
1839 | "type": "object",
1840 | "properties": {
1841 | "project_id": {
1842 | "type": "string",
1843 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
1844 | },
1845 | "file_path": {
1846 | "type": "string",
1847 | "description": "The path to the file in the project",
1848 | },
1849 | "branch": {
1850 | "type": "string",
1851 | "description": "The name of the branch to delete the file from",
1852 | },
1853 | "commit_message": {
1854 | "type": "string",
1855 | "description": "The commit message. Optional, defaults to 'Delete file via API'",
1856 | },
1857 | "author_email": {
1858 | "type": "string",
1859 | "description": "The email of the commit author. Optional.",
1860 | },
1861 | "author_name": {
1862 | "type": "string",
1863 | "description": "The name of the commit author. Optional.",
1864 | },
1865 | },
1866 | "required": ["project_id", "file_path", "branch"],
1867 | })
1868 | .as_object()
1869 | .unwrap()
1870 | .clone(),
1871 | },
1872 | ToolDescription {
1873 | name: "gl_create_issue".into(),
1874 | description: "Create a new issue in a GitLab project".into(),
1875 | input_schema: json!({
1876 | "type": "object",
1877 | "properties": {
1878 | "project_id": {
1879 | "type": "string",
1880 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
1881 | },
1882 | "title": {
1883 | "type": "string",
1884 | "description": "The title of the issue",
1885 | },
1886 | "description": {
1887 | "type": "string",
1888 | "description": "The description of the issue",
1889 | },
1890 | "labels": {
1891 | "type": "string",
1892 | "description": "Comma-separated list of labels",
1893 | },
1894 | },
1895 | "required": ["project_id", "title", "description"],
1896 | })
1897 | .as_object()
1898 | .unwrap()
1899 | .clone(),
1900 | },
1901 | ToolDescription {
1902 | name: "gl_get_issue".into(),
1903 | description: "Get details of a specific issue".into(),
1904 | input_schema: json!({
1905 | "type": "object",
1906 | "properties": {
1907 | "project_id": {
1908 | "type": "string",
1909 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
1910 | },
1911 | "issue_iid": {
1912 | "type": "string",
1913 | "description": "The internal ID of the issue",
1914 | },
1915 | },
1916 | "required": ["project_id", "issue_iid"],
1917 | })
1918 | .as_object()
1919 | .unwrap()
1920 | .clone(),
1921 | },
1922 | ToolDescription {
1923 | name: "gl_update_issue".into(),
1924 | description: "Update an existing issue in a GitLab project".into(),
1925 | input_schema: json!({
1926 | "type": "object",
1927 | "properties": {
1928 | "project_id": {
1929 | "type": "string",
1930 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
1931 | },
1932 | "issue_iid": {
1933 | "type": "string",
1934 | "description": "The internal ID of the issue",
1935 | },
1936 | "title": {
1937 | "type": "string",
1938 | "description": "The new title of the issue",
1939 | },
1940 | "description": {
1941 | "type": "string",
1942 | "description": "The new description of the issue",
1943 | },
1944 | "add_labels": {
1945 | "type": "string",
1946 | "description": "Comma-separated list of labels to add to the issue",
1947 | },
1948 | "remove_labels": {
1949 | "type": "string",
1950 | "description": "Comma-separated list of labels to remove from the issue",
1951 | },
1952 | "due_date": {
1953 | "type": "string",
1954 | "description": "The due date of the issue in YYYY-MM-DD format (e.g., 2024-03-11)",
1955 | },
1956 | },
1957 | "required": ["project_id", "issue_iid"],
1958 | })
1959 | .as_object()
1960 | .unwrap()
1961 | .clone(),
1962 | },
1963 | ToolDescription {
1964 | name: "gl_add_issue_comment".into(),
1965 | description: "Add a comment to an issue in a GitLab project".into(),
1966 | input_schema: json!({
1967 | "type": "object",
1968 | "properties": {
1969 | "project_id": {
1970 | "type": "string",
1971 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
1972 | },
1973 | "issue_iid": {
1974 | "type": "string",
1975 | "description": "The internal ID of the issue",
1976 | },
1977 | "comment": {
1978 | "type": "string",
1979 | "description": "The comment to add to the issue",
1980 | },
1981 | },
1982 | "required": ["project_id", "issue_iid", "comment"],
1983 | })
1984 | .as_object()
1985 | .unwrap()
1986 | .clone(),
1987 | },
1988 | ToolDescription {
1989 | name: "gl_list_issues".into(),
1990 | description: "List issues for a project in GitLab. Supports filtering by state and labels.".into(),
1991 | input_schema: json!({
1992 | "type": "object",
1993 | "properties": {
1994 | "project_id": {
1995 | "type": "string",
1996 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
1997 | },
1998 | "state": {
1999 | "type": "string",
2000 | "description": "Filter by state: 'opened', 'closed', or 'all'. Defaults to 'opened' if not specified by GitLab.",
2001 | },
2002 | "labels": {
2003 | "type": "string",
2004 | "description": "Comma-separated list of label names to filter by.",
2005 | },
2006 | },
2007 | "required": ["project_id"],
2008 | })
2009 | .as_object()
2010 | .unwrap()
2011 | .clone(),
2012 | },
2013 | ToolDescription {
2014 | name: "gl_get_file_contents".into(),
2015 | description: "Get the contents of a file in a GitLab project".into(),
2016 | input_schema: json!({
2017 | "type": "object",
2018 | "properties": {
2019 | "project_id": {
2020 | "type": "string",
2021 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2022 | },
2023 | "file_path": {
2024 | "type": "string",
2025 | "description": "The path to the file in the project",
2026 | },
2027 | "ref": {
2028 | "type": "string",
2029 | "description": "The name of the branch, tag or commit (defaults to HEAD)",
2030 | },
2031 | },
2032 | "required": ["project_id", "file_path"],
2033 | })
2034 | .as_object()
2035 | .unwrap()
2036 | .clone(),
2037 | },
2038 | ToolDescription {
2039 | name: "gl_create_or_update_file".into(),
2040 | description: "Create or update a file in a GitLab project".into(),
2041 | input_schema: json!({
2042 | "type": "object",
2043 | "properties": {
2044 | "project_id": {
2045 | "type": "string",
2046 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2047 | },
2048 | "file_path": {
2049 | "type": "string",
2050 | "description": "The path to the file in the project",
2051 | },
2052 | "content": {
2053 | "type": "string",
2054 | "description": "The content to write to the file",
2055 | },
2056 | "branch": {
2057 | "type": "string",
2058 | "description": "The name of the branch to create or update the file in",
2059 | },
2060 | "author_email": {
2061 | "type": "string",
2062 | "description": "The email of the commit author",
2063 | },
2064 | "author_name": {
2065 | "type": "string",
2066 | "description": "The name of the commit author",
2067 | },
2068 | "commit_message": {
2069 | "type": "string",
2070 | "description": "The commit message. Defaults to 'Update file via API' if not specified.",
2071 | },
2072 | },
2073 | "required": ["project_id", "file_path", "content", "branch"],
2074 | })
2075 | .as_object()
2076 | .unwrap()
2077 | .clone(),
2078 | },
2079 | ToolDescription {
2080 | name: "gl_create_branch".into(),
2081 | description: "Create a new branch in a GitLab project".into(),
2082 | input_schema: json!({
2083 | "type": "object",
2084 | "properties": {
2085 | "project_id": {
2086 | "type": "string",
2087 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2088 | },
2089 | "branch_name": {
2090 | "type": "string",
2091 | "description": "The name of the new branch",
2092 | },
2093 | "ref": {
2094 | "type": "string",
2095 | "description": "The branch name or commit SHA to create the new branch from",
2096 | },
2097 | },
2098 | "required": ["project_id", "branch_name", "ref"],
2099 | })
2100 | .as_object()
2101 | .unwrap()
2102 | .clone(),
2103 | },
2104 | ToolDescription {
2105 | name: "gl_create_merge_request".into(),
2106 | description: "Create a new merge request in a GitLab project".into(),
2107 | input_schema: json!({
2108 | "type": "object",
2109 | "properties": {
2110 | "project_id": {
2111 | "type": "string",
2112 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2113 | },
2114 | "source_branch": {
2115 | "type": "string",
2116 | "description": "The name of the source branch",
2117 | },
2118 | "target_branch": {
2119 | "type": "string",
2120 | "description": "The name of the target branch",
2121 | },
2122 | },
2123 | "required": ["project_id", "source_branch", "target_branch"],
2124 | })
2125 | .as_object()
2126 | .unwrap()
2127 | .clone(),
2128 | },
2129 | ToolDescription {
2130 | name: "gl_update_merge_request".into(),
2131 | description: "Update an existing merge request in a GitLab project.".into(),
2132 | input_schema: json!({
2133 | "type": "object",
2134 | "properties": {
2135 | "project_id": {
2136 | "type": "string",
2137 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2138 | },
2139 | "merge_request_iid": {
2140 | "type": "string",
2141 | "description": "The internal ID (IID) of the merge request to update",
2142 | },
2143 | "title": {
2144 | "type": "string",
2145 | "description": "The new title for the merge request.",
2146 | },
2147 | "description": {
2148 | "type": "string",
2149 | "description": "The new description for the merge request.",
2150 | },
2151 | // Consider adding other common updatable fields like:
2152 | // "target_branch": { "type": "string", "description": "The target branch" },
2153 | // "state_event": { "type": "string", "description": "Event to change MR state (e.g., 'close', 'reopen')" }
2154 | },
2155 | "required": ["project_id", "merge_request_iid", "title", "description"],
2156 | })
2157 | .as_object()
2158 | .unwrap()
2159 | .clone(),
2160 | },
2161 | ToolDescription {
2162 | name: "gl_get_merge_request".into(),
2163 | description: "Get details of a specific merge request in a GitLab project.".into(),
2164 | input_schema: json!({
2165 | "type": "object",
2166 | "properties": {
2167 | "project_id": {
2168 | "type": "string",
2169 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2170 | },
2171 | "merge_request_iid": {
2172 | "type": "string",
2173 | "description": "The internal ID (IID) of the merge request",
2174 | },
2175 | },
2176 | "required": ["project_id", "merge_request_iid"],
2177 | })
2178 | .as_object()
2179 | .unwrap()
2180 | .clone(),
2181 | },
2182 | ToolDescription {
2183 | name: "gl_create_snippet".into(),
2184 | description: "Create a new snippet".into(),
2185 | input_schema: json!({
2186 | "type": "object",
2187 | "properties": {
2188 | "title": {
2189 | "type": "string",
2190 | "description": "The title of the snippet",
2191 | },
2192 | "content": {
2193 | "type": "string",
2194 | "description": "The content of the snippet",
2195 | },
2196 | "visibility": {
2197 | "type": "string",
2198 | "description": "The visibility level of the snippet (private, internal, or public). Defaults to private if not specified.",
2199 | },
2200 | },
2201 | "required": ["title", "content"],
2202 | })
2203 | .as_object()
2204 | .unwrap()
2205 | .clone(),
2206 | },
2207 | ToolDescription {
2208 | name: "gl_update_snippet".into(),
2209 | description: "Update an existing snippet".into(),
2210 | input_schema: json!({
2211 | "type": "object",
2212 | "properties": {
2213 | "snippet_id": {
2214 | "type": "string",
2215 | "description": "The ID of the snippet",
2216 | },
2217 | "title": {
2218 | "type": "string",
2219 | "description": "The new title of the snippet",
2220 | },
2221 | "content": {
2222 | "type": "string",
2223 | "description": "The new content of the snippet",
2224 | },
2225 | },
2226 | "required": ["snippet_id", "title", "content"],
2227 | })
2228 | .as_object()
2229 | .unwrap()
2230 | .clone(),
2231 | },
2232 | ToolDescription {
2233 | name: "gl_get_snippet".into(),
2234 | description: "Get details of a specific snippet".into(),
2235 | input_schema: json!({
2236 | "type": "object",
2237 | "properties": {
2238 | "snippet_id": {
2239 | "type": "string",
2240 | "description": "The ID of the snippet",
2241 | },
2242 | },
2243 | "required": ["snippet_id"],
2244 | })
2245 | .as_object()
2246 | .unwrap()
2247 | .clone(),
2248 | },
2249 | ToolDescription {
2250 | name: "gl_delete_snippet".into(),
2251 | description: "Delete a snippet".into(),
2252 | input_schema: json!({
2253 | "type": "object",
2254 | "properties": {
2255 | "snippet_id": {
2256 | "type": "string",
2257 | "description": "The ID of the snippet",
2258 | },
2259 | },
2260 | "required": ["snippet_id"],
2261 | })
2262 | .as_object()
2263 | .unwrap()
2264 | .clone(),
2265 | },
2266 | ToolDescription {
2267 | name: "gl_list_branches".into(),
2268 | description: "List all branches in a GitLab project".into(),
2269 | input_schema: json!({
2270 | "type": "object",
2271 | "properties": {
2272 | "project_id": {
2273 | "type": "string",
2274 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2275 | },
2276 | },
2277 | "required": ["project_id"],
2278 | })
2279 | .as_object()
2280 | .unwrap()
2281 | .clone(),
2282 | },
2283 | ToolDescription {
2284 | name: "gl_get_repo_tree".into(),
2285 | description: "Get the list of files and directories in a project repository. Handles pagination internally.".into(),
2286 | input_schema: json!({
2287 | "type": "object",
2288 | "properties": {
2289 | "project_id": {
2290 | "type": "string",
2291 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2292 | },
2293 | "path": {
2294 | "type": "string",
2295 | "description": "The path inside the repository. Used to get content of subdirectories. Optional.",
2296 | },
2297 | "ref": {
2298 | "type": "string",
2299 | "description": "The name of a repository branch or tag or if not given the default branch. Optional.",
2300 | },
2301 | "recursive": {
2302 | "type": "boolean",
2303 | "description": "Boolean value used to get a recursive tree. If you want a complete tree, set this to true. Default is false. Optional.",
2304 | },
2305 | },
2306 | "required": ["project_id"],
2307 | })
2308 | .as_object()
2309 | .unwrap()
2310 | .clone(),
2311 | },
2312 | ToolDescription {
2313 | name: "gl_get_repo_members".into(),
2314 | description: "Get a list of members for a GitLab project. Supports fetching direct or inherited members and filtering by query. Handles pagination internally.".into(),
2315 | input_schema: json!({
2316 | "type": "object",
2317 | "properties": {
2318 | "project_id": {
2319 | "type": "string",
2320 | "description": "The project identifier - can be a numeric project ID (e.g. '123') or a URL-encoded path (e.g. 'group%2Fproject')",
2321 | },
2322 | "include_inherited_members": {
2323 | "type": "boolean",
2324 | "description": "Set to true to include inherited members (e.g., from groups). Defaults to false (direct members only). Optional.",
2325 | },
2326 | "query": {
2327 | "type": "string",
2328 | "description": "Filter by username, name, or public email. Optional.",
2329 | },
2330 | },
2331 | "required": ["project_id"],
2332 | })
2333 | .as_object()
2334 | .unwrap()
2335 | .clone(),
2336 | },
2337 | ],
2338 | })
2339 | }
2340 |
```