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.5 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 visibility: opt_str(&args, "visibility")
259 .as_deref()
260 .map(str::parse)
261 .transpose()?,
262 };
263 crate::core::repo::create(state, identity, spec)
264 .await?
265 .to_value()
266 }
267 _ => {
268 let state = Arc::clone(state);
269 let identity = identity.clone();
270 // The rest is git and filesystem work, which must not run on a runtime
271 // worker.
272 crate::git::exec::blocking(move || run(&name, &args, &state, &identity)).await?
273 }
274 };
275
276 // Structured output, plus a text rendering for clients that only read content.
277 Ok(json!({
278 "content": [{ "type": "text", "text": serde_json::to_string_pretty(&value)
279 .unwrap_or_else(|_| value.to_string()) }],
280 "structuredContent": value,
281 "isError": false,
282 }))
283}
284
285fn run(name: &str, args: &Value, state: &AppState, identity: &Identity) -> Result<Value> {
286 match name {
287 "repo_list" => {
288 Ok(json!({ "items": crate::core::repo::list(state, identity)?, "truncated": false }))
289 }
290 "repo_get" => {
291 let repo = validate::name(&req_str(args, "repo")?)?;
292 Ok(crate::core::repo::get(state, identity, &identity.account, &repo)?.to_value())
293 }
294 "repo_delete" => {
295 let repo = validate::name(&req_str(args, "repo")?)?;
296 crate::core::repo::delete(state, identity, &identity.account, &repo)?;
297 Ok(json!({ "deleted": repo.as_str() }))
298 }
299 "key_add" => key_add(args, state, identity),
300 "key_list" => key_list(state, identity),
301 "key_remove" => key_remove(args, state, identity),
302 "ref_list" => {
303 let (path, _) = readable(args, state, identity)?;
304 discover::refs(&path, opt_str(args, "type").as_deref())
305 }
306 "tree_list" => {
307 let (path, reference) = readable_at(args, state, identity)?;
308 let sub = opt_path(args, "path")?;
309 discover::tree(&path, &reference, sub.as_deref())
310 }
311 "file_read" => {
312 let (path, reference) = readable_at(args, state, identity)?;
313 let file = req_path(args, "path")?;
314 let blob = discover::blob(&path, &reference, &file, state.config.read_limits())?;
315 let text = String::from_utf8(blob.bytes).map_err(|_| {
316 Error::invalid(
317 "binary-file",
318 "this file is not UTF-8 text; fetch it with git",
319 )
320 })?;
321 Ok(json!({ "path": file, "sha": blob.sha, "content": text }))
322 }
323 "search" => {
324 let (path, reference) = readable_at(args, state, identity)?;
325 let q = req_str(args, "q")?;
326 let sub = opt_path(args, "path")?;
327 discover::search(&path, &reference, &q, sub.as_deref(), limit(args, state)?)
328 }
329 "commit_log" => {
330 let (path, reference) = readable_at(args, state, identity)?;
331 let sub = opt_path(args, "path")?;
332 discover::log(
333 &path,
334 &reference,
335 limit(args, state)?,
336 sub.as_deref(),
337 state.config.read_limits(),
338 )
339 }
340 "commit_get" => {
341 let (path, _) = readable(args, state, identity)?;
342 let sha = req_str(args, "sha")?;
343 if !discover::is_object_id(&sha) {
344 return Err(super::invalid_params(
345 "sha must be a full 40- or 64-character object id",
346 ));
347 }
348 discover::commit(&path, &sha)
349 }
350 "compare" => {
351 let (path, _) = readable(args, state, identity)?;
352 let base = discover::revision(&req_str(args, "base")?)?;
353 let head = discover::revision(&req_str(args, "head")?)?;
354 discover::compare(&path, &base, &head)
355 }
356 "run_list" => {
357 readable(args, state, identity)?;
358 let repo = validate::name(&req_str(args, "repo")?)?;
359 let mut runs = state.runs.list(&identity.account, &repo)?;
360 if let Some(wanted) = opt_str(args, "status") {
361 let status: crate::ci::run::Status =
362 serde_json::from_value(Value::String(wanted.clone()))
363 .map_err(|_| super::invalid_params(format!("unknown status {wanted:?}")))?;
364 runs.retain(|r| r.status == status);
365 }
366 let truncated = runs.len() > state.config.max_page_limit;
367 runs.truncate(state.config.max_page_limit);
368 Ok(json!({ "items": runs, "truncated": truncated }))
369 }
370 "run_get" => {
371 readable(args, state, identity)?;
372 let repo = validate::name(&req_str(args, "repo")?)?;
373 let run = state
374 .runs
375 .get(&identity.account, &repo, &req_str(args, "id")?)?
376 .ok_or(Error::NotFound("run"))?;
377 Ok(serde_json::to_value(run).unwrap_or(Value::Null))
378 }
379 "run_logs" => {
380 readable(args, state, identity)?;
381 let repo = validate::name(&req_str(args, "repo")?)?;
382 let id = req_str(args, "id")?;
383 let run = state
384 .runs
385 .get(&identity.account, &repo, &id)?
386 .ok_or(Error::NotFound("run"))?;
387 let log = state.runs.read_log(&identity.account, &repo, &id)?;
388 Ok(json!({
389 "id": run.id,
390 "status": run.status,
391 "logs": String::from_utf8_lossy(&log),
392 }))
393 }
394 "blame" => {
395 let (path, reference) = readable_at(args, state, identity)?;
396 let file = req_path(args, "path")?;
397 discover::blame(&path, &reference, &file)
398 }
399 other => Err(super::invalid_params(format!("unknown tool {other:?}"))),
400 }
401}
402
403// ── administration ─────────────────────────────────────────────────────────
404
405fn key_add(args: &Value, state: &AppState, identity: &Identity) -> Result<Value> {
406 identity.require_account_admin(&identity.account)?;
407
408 let title = req_str(args, "title")?;
409 if title.trim().is_empty() || title.len() > 128 {
410 return Err(super::invalid_params(
411 "title must be between 1 and 128 characters",
412 ));
413 }
414 let (algorithm, blob) = key::parse_authorized_key(&req_str(args, "key")?)?;
415 let public_key = key::encode_public_key(&algorithm, &blob);
416
417 let repos = args
418 .get("repos")
419 .and_then(Value::as_array)
420 .map(|list| {
421 list.iter()
422 .filter_map(Value::as_str)
423 .map(|r| validate::name(r).map(|n| n.as_str().to_string()))
424 .collect::<Result<Vec<_>>>()
425 })
426 .transpose()?
427 .unwrap_or_default();
428
429 let mut keys = state.ssh_keys();
430 if keys.keys.iter().any(|k| k.public_key == public_key) {
431 return Err(Error::conflict(
432 "key-exists",
433 "that public key is already registered",
434 ));
435 }
436
437 let record = KeyRecord {
438 id: uuid::Uuid::new_v4().simple().to_string(),
439 account: identity.account.as_str().to_string(),
440 title: title.trim().to_string(),
441 fingerprint: key::fingerprint(&blob),
442 public_key,
443 read_only: args
444 .get("read_only")
445 .and_then(Value::as_bool)
446 .unwrap_or(false),
447 repos,
448 created_at: now_secs(),
449 last_used_at: None,
450 };
451 let view = key_view(&record);
452 keys.keys.push(record);
453 state.put_ssh_keys(keys)?;
454 Ok(view)
455}
456
457fn key_list(state: &AppState, identity: &Identity) -> Result<Value> {
458 identity.require_account_admin(&identity.account)?;
459 let keys = state.ssh_keys();
460 Ok(json!({
461 "items": keys.keys.iter()
462 .filter(|k| k.account == identity.account.as_str())
463 .map(key_view)
464 .collect::<Vec<_>>(),
465 "truncated": false,
466 }))
467}
468
469fn key_remove(args: &Value, state: &AppState, identity: &Identity) -> Result<Value> {
470 identity.require_account_admin(&identity.account)?;
471 let id = req_str(args, "id")?;
472
473 let mut keys = state.ssh_keys();
474 // A key belonging to another account reads as absent, so this cannot be used to
475 // probe which ids exist.
476 if !keys
477 .keys
478 .iter()
479 .any(|k| k.id == id && k.account == identity.account.as_str())
480 {
481 return Err(Error::NotFound("key"));
482 }
483 keys.keys.retain(|k| k.id != id);
484 state.put_ssh_keys(keys)?;
485 Ok(json!({ "removed": id }))
486}
487
488// ── shared ─────────────────────────────────────────────────────────────────
489
490fn key_view(record: &KeyRecord) -> Value {
491 json!({
492 "id": record.id,
493 "title": record.title,
494 "fingerprint": record.fingerprint,
495 "read_only": record.read_only,
496 "repos": record.repos,
497 "created_at": record.created_at,
498 })
499}
500
501/// Resolve `repo` and check read access.
502fn readable(args: &Value, state: &AppState, identity: &Identity) -> Result<(PathBuf, RepoRecord)> {
503 let repo = validate::name(&req_str(args, "repo")?)?;
504 let (record, path) = state.open_repo(
505 identity,
506 &identity.account,
507 &repo,
508 crate::core::Access::Read,
509 )?;
510 Ok((path, record))
511}
512
513/// As [`readable`], also resolving `ref` against the repository's default branch.
514fn readable_at(args: &Value, state: &AppState, identity: &Identity) -> Result<(PathBuf, String)> {
515 let (path, record) = readable(args, state, identity)?;
516 let reference = match opt_str(args, "ref") {
517 Some(value) => discover::revision(&value)?,
518 None => record.default_branch,
519 };
520 Ok((path, reference))
521}
522
523fn limit(args: &Value, state: &AppState) -> Result<usize> {
524 let requested = args
525 .get("limit")
526 .and_then(Value::as_u64)
527 .map(|n| n as usize);
528 state.config.page_limit(requested)
529}
530
531fn req_str(args: &Value, key: &str) -> Result<String> {
532 args.get(key)
533 .and_then(Value::as_str)
534 .map(String::from)
535 .ok_or_else(|| super::invalid_params(format!("{key} is required")))
536}
537
538fn opt_str(args: &Value, key: &str) -> Option<String> {
539 args.get(key).and_then(Value::as_str).map(String::from)
540}
541
542fn req_path(args: &Value, key: &str) -> Result<String> {
543 let value = req_str(args, key)?;
544 validate::tree_path(&value)?;
545 Ok(value)
546}
547
548fn opt_path(args: &Value, key: &str) -> Result<Option<String>> {
549 match opt_str(args, key) {
550 None => Ok(None),
551 Some(value) if value.is_empty() => Ok(None),
552 Some(value) => {
553 validate::tree_path(&value)?;
554 Ok(Some(value))
555 }
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn every_tool_has_a_description_and_an_object_schema() {
565 let tools = catalogue();
566 assert!(tools.len() >= 18);
567
568 for entry in &tools {
569 let name = entry["name"].as_str().expect("tool has a name");
570 assert!(
571 entry["description"].as_str().is_some_and(|d| d.len() > 20),
572 "{name} needs a description a model can act on"
573 );
574 assert_eq!(
575 entry["inputSchema"]["type"], "object",
576 "{name} schema must be an object"
577 );
578 }
579 }
580
581 #[test]
582 fn tool_names_are_unique() {
583 let tools = catalogue();
584 let mut names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
585 let total = names.len();
586 names.sort_unstable();
587 names.dedup();
588 assert_eq!(names.len(), total, "duplicate tool name");
589 }
590
591 #[test]
592 fn no_tool_writes_to_a_repository() {
593 // The boundary is the product's core decision: git writes, tools read and
594 // administer. A tool named like a write is almost certainly a mistake.
595 for entry in catalogue() {
596 let name = entry["name"].as_str().unwrap();
597 for banned in [
598 "file_write",
599 "commit_create",
600 "push",
601 "ref_set",
602 "file_delete",
603 ] {
604 assert_ne!(name, banned, "{banned} would be a second write path");
605 }
606 }
607 }
608
609 #[test]
610 fn required_arguments_are_reported_rather_than_defaulted() {
611 let empty = json!({});
612 assert_eq!(req_str(&empty, "repo").unwrap_err().status(), 400);
613 assert_eq!(opt_str(&empty, "ref"), None);
614 }
615
616 #[test]
617 fn path_arguments_are_validated() {
618 assert!(req_path(&json!({ "path": "../escape" }), "path").is_err());
619 assert!(req_path(&json!({ "path": ".git/config" }), "path").is_err());
620 assert_eq!(
621 req_path(&json!({ "path": "src/main.rs" }), "path").unwrap(),
622 "src/main.rs"
623 );
624 assert_eq!(opt_path(&json!({ "path": "" }), "path").unwrap(), None);
625 }
626}