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