zuka
zuka/src/mcp/tools.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/mcp/tools.rs
RStools.rs22.6 KBDownload
1// The tool catalogue and its dispatch.
2//
3// Two groups: administration (the part an agent cannot do with git) and discovery
4// (reading a repository without cloning it). Nothing writes to a repository.
5//
6// Every tool resolves its arguments, runs the same access check the REST path runs,
7// and calls the same core function. A tool that reimplements a rule is a bug.
8
9use crate::account::key::{self, KeyRecord};
10use crate::account::token::now_secs;
11use crate::account::Identity;
12use crate::error::{Error, Result};
13use crate::git::discover;
14use crate::git::validate;
15use crate::http::AppState;
16use crate::store::RepoRecord;
17use serde_json::{json, Value};
18use std::path::PathBuf;
19use std::sync::Arc;
20
21/// Tool descriptions the model reads. Written for a model, not for a changelog:
22/// each says what the tool is for and, where it matters, what to use instead.
23pub fn catalogue() -> Vec<Value> {
24 vec![
25 tool(
26 "repo_create",
27 "Create a git repository. Returns clone URLs for HTTP and SSH — clone one \
28 of them with git to add code. When visibility is public the response \
29 also carries web_url, the browser page to hand to a person.",
30 json!({
31 "type": "object",
32 "required": ["name"],
33 "properties": {
34 "name": { "type": "string", "description": "Letters, digits, '.', '_' and '-'. Case-insensitive." },
35 "default_branch": { "type": "string", "description": "Fully qualified, e.g. refs/heads/main." },
36 "description": { "type": "string" }
37 }
38 }),
39 ),
40 tool(
41 "repo_list",
42 "List repositories this account can read.",
43 json!({ "type": "object", "properties": {} }),
44 ),
45 tool(
46 "repo_get",
47 "Read one repository, including its clone URLs and protected refs.",
48 json!({
49 "type": "object",
50 "required": ["repo"],
51 "properties": { "repo": { "type": "string" } }
52 }),
53 ),
54 tool(
55 "repo_delete",
56 "Delete a repository. Recoverable for a short window, but treat as permanent.",
57 json!({
58 "type": "object",
59 "required": ["repo"],
60 "properties": { "repo": { "type": "string" } }
61 }),
62 ),
63 tool(
64 "key_add",
65 "Register an SSH public key so this agent can clone and push over SSH. \
66 Pass the contents of a .pub file.",
67 json!({
68 "type": "object",
69 "required": ["title", "key"],
70 "properties": {
71 "title": { "type": "string" },
72 "key": { "type": "string", "description": "One authorized_keys line, e.g. 'ssh-ed25519 AAAA...'." },
73 "read_only": { "type": "boolean", "description": "Clone and fetch only; cannot push." },
74 "repos": { "type": "array", "items": { "type": "string" }, "description": "Confine the key to these repositories." }
75 }
76 }),
77 ),
78 tool(
79 "key_list",
80 "List registered SSH keys. Public key bodies are never returned.",
81 json!({ "type": "object", "properties": {} }),
82 ),
83 tool(
84 "key_remove",
85 "Remove a registered SSH key by id.",
86 json!({
87 "type": "object",
88 "required": ["id"],
89 "properties": { "id": { "type": "string" } }
90 }),
91 ),
92 tool(
93 "ref_list",
94 "List branches, tags and notes with their tip commits.",
95 json!({
96 "type": "object",
97 "required": ["repo"],
98 "properties": {
99 "repo": { "type": "string" },
100 "type": { "type": "string", "enum": ["branch", "tag", "note"] }
101 }
102 }),
103 ),
104 tool(
105 "tree_list",
106 "List a directory in the repository. Omit path for the root.",
107 json!({
108 "type": "object",
109 "required": ["repo"],
110 "properties": {
111 "repo": { "type": "string" },
112 "path": { "type": "string" },
113 "ref": { "type": "string", "description": "Fully-qualified ref or full commit sha. Defaults to the default branch." }
114 }
115 }),
116 ),
117 tool(
118 "file_read",
119 "Read a text file from the repository without cloning it.",
120 json!({
121 "type": "object",
122 "required": ["repo", "path"],
123 "properties": {
124 "repo": { "type": "string" },
125 "path": { "type": "string" },
126 "ref": { "type": "string" }
127 }
128 }),
129 ),
130 tool(
131 "search",
132 "Search file contents for a fixed string. Use this before reading files \
133 when you do not know where something lives.",
134 json!({
135 "type": "object",
136 "required": ["repo", "q"],
137 "properties": {
138 "repo": { "type": "string" },
139 "q": { "type": "string", "description": "Literal text, not a regular expression." },
140 "path": { "type": "string", "description": "Restrict to a subtree." },
141 "ref": { "type": "string" },
142 "limit": { "type": "integer" }
143 }
144 }),
145 ),
146 tool(
147 "commit_log",
148 "Recent commits, optionally only those touching a path.",
149 json!({
150 "type": "object",
151 "required": ["repo"],
152 "properties": {
153 "repo": { "type": "string" },
154 "path": { "type": "string" },
155 "ref": { "type": "string" },
156 "limit": { "type": "integer" }
157 }
158 }),
159 ),
160 tool(
161 "commit_get",
162 "One commit and the paths it changed.",
163 json!({
164 "type": "object",
165 "required": ["repo", "sha"],
166 "properties": {
167 "repo": { "type": "string" },
168 "sha": { "type": "string", "description": "Full 40- or 64-character object id." }
169 }
170 }),
171 ),
172 tool(
173 "compare",
174 "What changed between two points, from their merge base — the same view as \
175 'git diff base...head'.",
176 json!({
177 "type": "object",
178 "required": ["repo", "base", "head"],
179 "properties": {
180 "repo": { "type": "string" },
181 "base": { "type": "string" },
182 "head": { "type": "string" }
183 }
184 }),
185 ),
186 tool(
187 "run_list",
188 "CI runs for a repository, newest first. Use this after pushing to see \
189 whether the build passed.",
190 json!({
191 "type": "object",
192 "required": ["repo"],
193 "properties": {
194 "repo": { "type": "string" },
195 "status": { "type": "string", "enum": ["queued", "running", "succeeded", "failed", "cancelled", "timed_out"] }
196 }
197 }),
198 ),
199 tool(
200 "run_get",
201 "One CI run: its status, exit code and timings.",
202 json!({
203 "type": "object",
204 "required": ["repo", "id"],
205 "properties": { "repo": { "type": "string" }, "id": { "type": "string" } }
206 }),
207 ),
208 tool(
209 "run_logs",
210 "Captured output of a CI run. Read this when a run failed to find out why.",
211 json!({
212 "type": "object",
213 "required": ["repo", "id"],
214 "properties": { "repo": { "type": "string" }, "id": { "type": "string" } }
215 }),
216 ),
217 tool(
218 "blame",
219 "Who last changed each line of a file, and when.",
220 json!({
221 "type": "object",
222 "required": ["repo", "path"],
223 "properties": {
224 "repo": { "type": "string" },
225 "path": { "type": "string" },
226 "ref": { "type": "string" }
227 }
228 }),
229 ),
230 ]
231}
232
233fn tool(name: &str, description: &str, schema: Value) -> Value {
234 json!({ "name": name, "description": description, "inputSchema": schema })
235}
236
237/// Handle `tools/call`.
238pub async fn call(params: Value, state: &Arc<AppState>, identity: &Identity) -> Result<Value> {
239 let name = params
240 .get("name")
241 .and_then(Value::as_str)
242 .ok_or_else(|| super::invalid_params("tools/call requires a tool name"))?
243 .to_string();
244 let args = params
245 .get("arguments")
246 .cloned()
247 .unwrap_or_else(|| json!({}));
248
249 let value = match name.as_str() {
250 // Creation may fetch a remote, so it stays on the async path and manages
251 // its own blocking work.
252 "repo_create" => {
253 let spec = crate::core::repo::CreateSpec {
254 name: req_str(&args, "name")?,
255 default_branch: opt_str(&args, "default_branch"),
256 description: opt_str(&args, "description"),
257 protected_refs: Vec::new(),
258 import_url: opt_str(&args, "import_url"),
259 visibility: opt_str(&args, "visibility")
260 .as_deref()
261 .map(str::parse)
262 .transpose()?,
263 };
264 crate::core::repo::create(state, identity, spec)
265 .await?
266 .to_value()
267 }
268 _ => {
269 let state = Arc::clone(state);
270 let identity = identity.clone();
271 // The rest is git and filesystem work, which must not run on a runtime
272 // worker.
273 crate::git::exec::blocking(move || run(&name, &args, &state, &identity)).await?
274 }
275 };
276
277 // Structured output, plus a text rendering for clients that only read content.
278 Ok(json!({
279 "content": [{ "type": "text", "text": serde_json::to_string_pretty(&value)
280 .unwrap_or_else(|_| value.to_string()) }],
281 "structuredContent": value,
282 "isError": false,
283 }))
284}
285
286fn run(name: &str, args: &Value, state: &AppState, identity: &Identity) -> Result<Value> {
287 match name {
288 "repo_list" => {
289 Ok(json!({ "items": crate::core::repo::list(state, identity)?, "truncated": false }))
290 }
291 "repo_get" => {
292 let repo = validate::name(&req_str(args, "repo")?)?;
293 Ok(crate::core::repo::get(state, identity, &identity.account, &repo)?.to_value())
294 }
295 "repo_delete" => {
296 let repo = validate::name(&req_str(args, "repo")?)?;
297 crate::core::repo::delete(state, identity, &identity.account, &repo)?;
298 Ok(json!({ "deleted": repo.as_str() }))
299 }
300 "key_add" => key_add(args, state, identity),
301 "key_list" => key_list(state, identity),
302 "key_remove" => key_remove(args, state, identity),
303 "ref_list" => {
304 let (path, _) = readable(args, state, identity)?;
305 discover::refs(&path, opt_str(args, "type").as_deref())
306 }
307 "tree_list" => {
308 let (path, reference) = readable_at(args, state, identity)?;
309 let sub = opt_path(args, "path")?;
310 discover::tree(&path, &reference, sub.as_deref())
311 }
312 "file_read" => {
313 let (path, reference) = readable_at(args, state, identity)?;
314 let file = req_path(args, "path")?;
315 let blob = discover::blob(&path, &reference, &file, state.config.read_limits())?;
316 let text = String::from_utf8(blob.bytes).map_err(|_| {
317 Error::invalid(
318 "binary-file",
319 "this file is not UTF-8 text; fetch it with git",
320 )
321 })?;
322 Ok(json!({ "path": file, "sha": blob.sha, "content": text }))
323 }
324 "search" => {
325 let (path, reference) = readable_at(args, state, identity)?;
326 let q = req_str(args, "q")?;
327 let sub = opt_path(args, "path")?;
328 discover::search(&path, &reference, &q, sub.as_deref(), limit(args, state)?)
329 }
330 "commit_log" => {
331 let (path, reference) = readable_at(args, state, identity)?;
332 let sub = opt_path(args, "path")?;
333 discover::log(
334 &path,
335 &reference,
336 limit(args, state)?,
337 sub.as_deref(),
338 state.config.read_limits(),
339 )
340 }
341 "commit_get" => {
342 let (path, _) = readable(args, state, identity)?;
343 let sha = req_str(args, "sha")?;
344 if !discover::is_object_id(&sha) {
345 return Err(super::invalid_params(
346 "sha must be a full 40- or 64-character object id",
347 ));
348 }
349 discover::commit(&path, &sha)
350 }
351 "compare" => {
352 let (path, _) = readable(args, state, identity)?;
353 let base = discover::revision(&req_str(args, "base")?)?;
354 let head = discover::revision(&req_str(args, "head")?)?;
355 discover::compare(&path, &base, &head)
356 }
357 "run_list" => {
358 readable(args, state, identity)?;
359 let repo = validate::name(&req_str(args, "repo")?)?;
360 let mut runs = state.runs.list(&identity.account, &repo)?;
361 if let Some(wanted) = opt_str(args, "status") {
362 let status: crate::ci::run::Status =
363 serde_json::from_value(Value::String(wanted.clone()))
364 .map_err(|_| super::invalid_params(format!("unknown status {wanted:?}")))?;
365 runs.retain(|r| r.status == status);
366 }
367 let truncated = runs.len() > state.config.max_page_limit;
368 runs.truncate(state.config.max_page_limit);
369 Ok(json!({ "items": runs, "truncated": truncated }))
370 }
371 "run_get" => {
372 readable(args, state, identity)?;
373 let repo = validate::name(&req_str(args, "repo")?)?;
374 let run = state
375 .runs
376 .get(&identity.account, &repo, &req_str(args, "id")?)?
377 .ok_or(Error::NotFound("run"))?;
378 Ok(serde_json::to_value(run).unwrap_or(Value::Null))
379 }
380 "run_logs" => {
381 readable(args, state, identity)?;
382 let repo = validate::name(&req_str(args, "repo")?)?;
383 let id = req_str(args, "id")?;
384 let run = state
385 .runs
386 .get(&identity.account, &repo, &id)?
387 .ok_or(Error::NotFound("run"))?;
388 let log = state.runs.read_log(&identity.account, &repo, &id)?;
389 Ok(json!({
390 "id": run.id,
391 "status": run.status,
392 "logs": String::from_utf8_lossy(&log),
393 }))
394 }
395 "blame" => {
396 let (path, reference) = readable_at(args, state, identity)?;
397 let file = req_path(args, "path")?;
398 discover::blame(&path, &reference, &file)
399 }
400 other => Err(super::invalid_params(format!("unknown tool {other:?}"))),
401 }
402}
403
404// ── administration ─────────────────────────────────────────────────────────
405
406fn key_add(args: &Value, state: &AppState, identity: &Identity) -> Result<Value> {
407 identity.require_account_admin(&identity.account)?;
408
409 let title = req_str(args, "title")?;
410 if title.trim().is_empty() || title.len() > 128 {
411 return Err(super::invalid_params(
412 "title must be between 1 and 128 characters",
413 ));
414 }
415 let (algorithm, blob) = key::parse_authorized_key(&req_str(args, "key")?)?;
416 let public_key = key::encode_public_key(&algorithm, &blob);
417
418 let repos = args
419 .get("repos")
420 .and_then(Value::as_array)
421 .map(|list| {
422 list.iter()
423 .filter_map(Value::as_str)
424 .map(|r| validate::name(r).map(|n| n.as_str().to_string()))
425 .collect::<Result<Vec<_>>>()
426 })
427 .transpose()?
428 .unwrap_or_default();
429
430 let mut keys = state.ssh_keys();
431 if keys.keys.iter().any(|k| k.public_key == public_key) {
432 return Err(Error::conflict(
433 "key-exists",
434 "that public key is already registered",
435 ));
436 }
437
438 let record = KeyRecord {
439 id: uuid::Uuid::new_v4().simple().to_string(),
440 account: identity.account.as_str().to_string(),
441 title: title.trim().to_string(),
442 fingerprint: key::fingerprint(&blob),
443 public_key,
444 read_only: args
445 .get("read_only")
446 .and_then(Value::as_bool)
447 .unwrap_or(false),
448 repos,
449 created_at: now_secs(),
450 last_used_at: None,
451 };
452 let view = key_view(&record);
453 keys.keys.push(record);
454 state.put_ssh_keys(keys)?;
455 Ok(view)
456}
457
458fn key_list(state: &AppState, identity: &Identity) -> Result<Value> {
459 identity.require_account_admin(&identity.account)?;
460 let keys = state.ssh_keys();
461 Ok(json!({
462 "items": keys.keys.iter()
463 .filter(|k| k.account == identity.account.as_str())
464 .map(key_view)
465 .collect::<Vec<_>>(),
466 "truncated": false,
467 }))
468}
469
470fn key_remove(args: &Value, state: &AppState, identity: &Identity) -> Result<Value> {
471 identity.require_account_admin(&identity.account)?;
472 let id = req_str(args, "id")?;
473
474 let mut keys = state.ssh_keys();
475 // A key belonging to another account reads as absent, so this cannot be used to
476 // probe which ids exist.
477 if !keys
478 .keys
479 .iter()
480 .any(|k| k.id == id && k.account == identity.account.as_str())
481 {
482 return Err(Error::NotFound("key"));
483 }
484 keys.keys.retain(|k| k.id != id);
485 state.put_ssh_keys(keys)?;
486 Ok(json!({ "removed": id }))
487}
488
489// ── shared ─────────────────────────────────────────────────────────────────
490
491fn key_view(record: &KeyRecord) -> Value {
492 json!({
493 "id": record.id,
494 "title": record.title,
495 "fingerprint": record.fingerprint,
496 "read_only": record.read_only,
497 "repos": record.repos,
498 "created_at": record.created_at,
499 })
500}
501
502/// Resolve `repo` and check read access.
503fn readable(args: &Value, state: &AppState, identity: &Identity) -> Result<(PathBuf, RepoRecord)> {
504 let repo = validate::name(&req_str(args, "repo")?)?;
505 let (record, path) = state.open_repo(
506 identity,
507 &identity.account,
508 &repo,
509 crate::core::Access::Read,
510 )?;
511 Ok((path, record))
512}
513
514/// As [`readable`], also resolving `ref` against the repository's default branch.
515fn readable_at(args: &Value, state: &AppState, identity: &Identity) -> Result<(PathBuf, String)> {
516 let (path, record) = readable(args, state, identity)?;
517 let reference = match opt_str(args, "ref") {
518 Some(value) => discover::revision(&value)?,
519 None => record.default_branch,
520 };
521 Ok((path, reference))
522}
523
524fn limit(args: &Value, state: &AppState) -> Result<usize> {
525 let requested = args
526 .get("limit")
527 .and_then(Value::as_u64)
528 .map(|n| n as usize);
529 state.config.page_limit(requested)
530}
531
532fn req_str(args: &Value, key: &str) -> Result<String> {
533 args.get(key)
534 .and_then(Value::as_str)
535 .map(String::from)
536 .ok_or_else(|| super::invalid_params(format!("{key} is required")))
537}
538
539fn opt_str(args: &Value, key: &str) -> Option<String> {
540 args.get(key).and_then(Value::as_str).map(String::from)
541}
542
543fn req_path(args: &Value, key: &str) -> Result<String> {
544 let value = req_str(args, key)?;
545 validate::tree_path(&value)?;
546 Ok(value)
547}
548
549fn opt_path(args: &Value, key: &str) -> Result<Option<String>> {
550 match opt_str(args, key) {
551 None => Ok(None),
552 Some(value) if value.is_empty() => Ok(None),
553 Some(value) => {
554 validate::tree_path(&value)?;
555 Ok(Some(value))
556 }
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 #[test]
565 fn every_tool_has_a_description_and_an_object_schema() {
566 let tools = catalogue();
567 assert!(tools.len() >= 18);
568
569 for entry in &tools {
570 let name = entry["name"].as_str().expect("tool has a name");
571 assert!(
572 entry["description"].as_str().is_some_and(|d| d.len() > 20),
573 "{name} needs a description a model can act on"
574 );
575 assert_eq!(
576 entry["inputSchema"]["type"], "object",
577 "{name} schema must be an object"
578 );
579 }
580 }
581
582 #[test]
583 fn tool_names_are_unique() {
584 let tools = catalogue();
585 let mut names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
586 let total = names.len();
587 names.sort_unstable();
588 names.dedup();
589 assert_eq!(names.len(), total, "duplicate tool name");
590 }
591
592 #[test]
593 fn no_tool_writes_to_a_repository() {
594 // The boundary is the product's core decision: git writes, tools read and
595 // administer. A tool named like a write is almost certainly a mistake.
596 for entry in catalogue() {
597 let name = entry["name"].as_str().unwrap();
598 for banned in [
599 "file_write",
600 "commit_create",
601 "push",
602 "ref_set",
603 "file_delete",
604 ] {
605 assert_ne!(name, banned, "{banned} would be a second write path");
606 }
607 }
608 }
609
610 #[test]
611 fn required_arguments_are_reported_rather_than_defaulted() {
612 let empty = json!({});
613 assert_eq!(req_str(&empty, "repo").unwrap_err().status(), 400);
614 assert_eq!(opt_str(&empty, "ref"), None);
615 }
616
617 #[test]
618 fn path_arguments_are_validated() {
619 assert!(req_path(&json!({ "path": "../escape" }), "path").is_err());
620 assert!(req_path(&json!({ "path": ".git/config" }), "path").is_err());
621 assert_eq!(
622 req_path(&json!({ "path": "src/main.rs" }), "path").unwrap(),
623 "src/main.rs"
624 );
625 assert_eq!(opt_path(&json!({ "path": "" }), "path").unwrap(), None);
626 }
627}