g1t/apps/api/src/tools.rs

652 lines30,335 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1//! The MCP server's tools: a few resource tools, each with an `action`.
2//!
3//! Every operation is one action of one tool. A call is dispatched to the
4//! operation it names, so permissions, the audit log, billing and outcomes
5//! are exactly those of the REST API. A token sees only the actions its
6//! scopes allow, and a tool none of whose actions it may use is not listed.
7//!
8//! The listed input schema is one flat object: `action`, then every field
9//! any of its actions takes. Which fields each action needs is in the
10//! `action` field's description and checked on every call. Claude's API,
11//! and so most MCP clients, refuse a tool whose input schema has `oneOf`
12//! at its top level, so the schema keyed by action, with each action's
13//! required fields, is [`discriminated`], published on the server's card
14//! and in the docs.
15
16use g1t_contracts::credentials::NEVER;
17use g1t_contracts::identity::AgentScope;
18use g1t_contracts::scopes::{Level, NO_SCOPE, TokenAccess, scope_for};
19use serde_json::{Map, Value, json};
20
21use crate::operations::Op;
22
23pub struct Action {
24 pub name: &'static str,
25 pub op: Op,
26 /// One line, for the `action` field's description.
27 pub summary: &'static str,
28}
29
30pub struct Tool {
31 pub name: &'static str,
32 pub title: &'static str,
33 /// What it is for, in a sentence or two.
34 pub description: &'static str,
35 pub actions: &'static [Action],
36 /// The action a call without one runs.
37 pub default_action: Option<&'static str>,
38}
39
40const fn a(name: &'static str, op: Op, summary: &'static str) -> Action {
41 Action { name, op, summary }
42}
43
44pub const TOOLS: &[Tool] = &[
45 Tool {
46 name: "search",
47 title: "Search",
48 description: "Find things. `code` (the default) searches all of g1t you can see: repositories, code, issues, pull requests and people, with qualifiers like repo:owner/name, language:rust, is:issue. `context` searches one workspace's catalog, docs, issues and memory by meaning.",
49 default_action: Some("code"),
50 actions: &[
51 a("code", Op::Search, "Search all of g1t: repositories, code, issues, pull requests, people"),
52 a("context", Op::SearchContext, "Search a workspace's context hub by meaning"),
53 a("entity", Op::GetEntity, "One catalog entry and its relations"),
54 a("ticket", Op::GetContext, "A Jira, Linear or Sentry item the work refers to, as it is now"),
55 ],
56 },
57 Tool {
58 name: "repository",
59 title: "Repositories",
60 description: "Repositories: find, read and create them, and change their settings. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
61 default_action: None,
62 actions: &[
63 a("list", Op::ListRepos, "Repositories you can see"),
64 a("get", Op::GetRepo, "One repository"),
65 a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL"),
66 a("update", Op::UpdateRepo, "Change description, website, topics, default branch, protection"),
67 a("get_settings", Op::GetRepoSettings, "How pull requests merge"),
68 a("update_settings", Op::UpdateRepoSettings, "Change how pull requests merge"),
69 a("list_labels", Op::ListLabels, "Labels in use"),
70 a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"),
71 a("rename_branch", Op::RenameBranch, "Rename a branch"),
72 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
73 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
74 a("archive", Op::ArchiveRepo, "Make it read-only"),
75 a("unarchive", Op::UnarchiveRepo, "Make it writable again"),
76 a("set_visibility", Op::SetRepoVisibility, "Make it public or private"),
77 a("delete", Op::DeleteRepo, "Delete it; restorable for 30 days"),
78 a("list_deleted", Op::ListDeletedRepos, "A workspace's deleted repositories"),
79 a("restore", Op::RestoreRepo, "Restore a deleted one"),
80 a("purge", Op::PurgeRepo, "Remove a deleted one for good"),
81 ],
82 },
83 Tool {
84 name: "issue",
85 title: "Issues",
86 description: "Issues: what should change. Read one before working on it to see the pull requests already made for it. Issues and pull requests share numbers; `comment` works on either.",
87 default_action: None,
88 actions: &[
89 a("list", Op::ListIssues, "Issues on a repository, newest first"),
90 a("get", Op::GetIssue, "One issue with comments, checks and its pull requests"),
91 a("create", Op::CreateIssue, "Open an issue"),
92 a("update", Op::UpdateIssue, "Change title, body, labels or assignees"),
93 a("close", Op::CloseIssue, "Close it without a pull request"),
94 a("reopen", Op::ReopenIssue, "Reopen it"),
95 a("comment", Op::AddComment, "Comment on an issue or pull request; path and line for one line of a change"),
96 a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"),
97 ],
98 },
99 Tool {
100 name: "pull_request",
101 title: "Pull requests",
102 description: "Pull requests: start a change for an issue, record your session, mark it ready, review and merge. Read `overlaps` and `behind` on `get` before going far.",
103 default_action: None,
104 actions: &[
105 a("list", Op::ListPullRequests, "Pull requests on a repository, newest first"),
106 a("get", Op::GetPullRequest, "Status, checks, reviews, overlaps and whether it is behind"),
107 a("changes", Op::GetPullRequestChanges, "Files and line-by-line diff"),
108 a("create", Op::CreatePullRequest, "Start a draft with its own fork to push to, or open one from a pushed branch"),
109 a("record_session", Op::RecordSession, "Append prompt, reasoning and tool entries to its session"),
110 a("read_session", Op::ReadSession, "Its recorded session"),
111 a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"),
112 a("review", Op::ReviewPullRequest, "Approve or request changes"),
113 a("close", Op::ClosePullRequest, "Close without merging"),
114 a("merge", Op::MergePullRequest, "Land it, or join the merge queue"),
115 a("merge_queue", Op::GetMergeQueue, "The repository's merge queue"),
116 ],
117 },
118 Tool {
119 name: "agent",
120 title: "g1t agents",
121 description: "Put g1t's agent to work and talk to it. One agent per issue; to do more at once, use more issues. Starting an agent uses the workspace's money.",
122 default_action: None,
123 actions: &[
124 a("delegate", Op::Delegate, "Open an issue and put an agent on it in one step"),
125 a("assign", Op::AssignIssue, "Put an agent on an existing issue"),
126 a("message", Op::MessageAgent, "Tell the agent on a pull request something, or ask another agent"),
127 a("answer", Op::AnswerMessage, "Answer a question or handoff sent to you"),
128 a("take_messages", Op::TakeMessages, "For a g1t agent: messages not seen yet"),
129 ],
130 },
131 Tool {
132 name: "plan",
133 title: "Plans",
134 description: "Turn an outcome into issues: an agent proposes them with checks and dependencies; nothing opens until you apply the plan.",
135 default_action: None,
136 actions: &[
137 a("create", Op::PlanWork, "Ask an agent for a plan; read it with get until ready"),
138 a("get", Op::GetPlan, "A plan and the issues it proposes"),
139 a("apply", Op::ApplyPlan, "Open its issues; with assign, agents start in dependency order"),
140 ],
141 },
142 Tool {
143 name: "memory",
144 title: "Memory",
145 description: "What the project and its workspace remember for the next agent: how to build, conventions, decisions, traps. Recall before you start; remember one short fact at a time, never a secret.",
146 default_action: None,
147 actions: &[
148 a("recall", Op::Recall, "Search memory, or list it all"),
149 a("remember", Op::Remember, "Save one fact"),
150 ],
151 },
152 Tool {
153 name: "workflow",
154 title: "Workflows",
155 description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them.",
156 default_action: None,
157 actions: &[
158 a("list", Op::ListWorkflows, "Workflows on the default branch"),
159 a("list_runs", Op::ListWorkflowRuns, "Runs, newest first"),
160 a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps"),
161 a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"),
162 a("dispatch", Op::DispatchWorkflow, "Run a workflow_dispatch workflow"),
163 a("cancel", Op::CancelWorkflowRun, "Cancel a run"),
164 a("rerun", Op::RerunWorkflowRun, "Run a finished run again"),
165 a("update", Op::UpdateWorkflow, "Turn a workflow on or off"),
166 ],
167 },
168 Tool {
169 name: "secret",
170 title: "Secrets and variables",
171 description: "A repository's or workspace's secrets and variables, read by workflows and deployments. Secret values are never returned.",
172 default_action: None,
173 actions: &[
174 a("list_secrets", Op::ListActionsSecrets, "Secrets, without values"),
175 a("set_secret", Op::SetActionsSecret, "Add or change a secret"),
176 a("delete_secret", Op::DeleteActionsSecret, "Remove a secret"),
177 a("list_variables", Op::ListActionsVariables, "Variables, with values"),
178 a("set_variable", Op::SetActionsVariable, "Add or change a variable"),
179 a("delete_variable", Op::DeleteActionsVariable, "Remove a variable"),
180 ],
181 },
182 Tool {
183 name: "webhook",
184 title: "Webhooks",
185 description: "HTTPS addresses sent signed events as they happen, for a repository or a whole workspace.",
186 default_action: None,
187 actions: &[
188 a("list", Op::ListWebhooks, "Webhooks, without secrets"),
189 a("create", Op::CreateWebhook, "Register one; a ping is sent"),
190 a("update", Op::UpdateWebhook, "Change address, events or active"),
191 a("delete", Op::DeleteWebhook, "Remove one"),
192 a("ping", Op::PingWebhook, "Send a ping"),
193 a("list_deliveries", Op::ListWebhookDeliveries, "Latest deliveries"),
194 a("redeliver", Op::RedeliverWebhook, "Send a delivery again"),
195 ],
196 },
197 Tool {
198 name: "access",
199 title: "Who has access",
200 description: "Who has access to a repository and with which role (read, triage, write, maintain, admin), outside collaborators, and a workspace's base permission.",
201 default_action: None,
202 actions: &[
203 a("list_collaborators", Op::ListCollaborators, "Everyone with a role, and pending invitations"),
204 a("get_permission", Op::GetCollaboratorPermission, "One person's role and capabilities"),
205 a("add_collaborator", Op::AddCollaborator, "Give someone a role, by username or email"),
206 a("update_collaborator", Op::UpdateCollaborator, "Change a direct role"),
207 a("remove_collaborator", Op::RemoveCollaborator, "Take away a direct role"),
208 a("list_invitations", Op::ListRepoInvitations, "Pending invitations to a repository"),
209 a("revoke_invitation", Op::RevokeRepoInvitation, "Withdraw one"),
210 a("set_base_permission", Op::SetBasePermission, "What every member gets on each repository"),
211 a("list_outside_collaborators", Op::ListOutsideCollaborators, "People with roles who are not members"),
212 ],
213 },
214 Tool {
215 name: "workspace",
216 title: "Workspaces",
217 description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create or delete one, invite members, and connect integrations and model providers.",
218 default_action: None,
219 actions: &[
220 a("create", Op::CreateWorkspace, "Create a workspace"),
221 a("delete", Op::DeleteWorkspace, "Delete an empty workspace"),
222 a("list_invites", Op::ListWorkspaceInvites, "Its invites"),
223 a("invite_member", Op::InviteMember, "Invite an email address"),
224 a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"),
225 a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"),
226 a("connect_integration", Op::ConnectIntegration, "Connect one"),
227 a("disconnect_integration", Op::DisconnectIntegration, "Remove one"),
228 a("test_integration", Op::TestIntegration, "Check its credentials"),
229 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
230 a("set_model_routes", Op::SetModelRoutes, "Replace them"),
231 ],
232 },
233 Tool {
234 name: "account",
235 title: "Your account",
236 description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.",
237 default_action: Some("whoami"),
238 actions: &[
239 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
240 a("list_emails", Op::ListEmails, "Your addresses"),
241 a("add_email", Op::AddEmail, "Add an address"),
242 a("remove_email", Op::RemoveEmail, "Remove an address"),
243 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
244 a("list_invites", Op::ListInvites, "Your invites to g1t"),
245 a("create_invite", Op::CreateInvite, "Make an invite"),
246 a("revoke_invite", Op::RevokeInvite, "Revoke one"),
247 a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"),
248 a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"),
249 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
250 ],
251 },
252];
253
254/// Operations that cannot be undone, or reach beyond g1t's own records:
255/// clients ask before running a tool that has any of them.
256fn destructive(op: Op) -> bool {
257 matches!(
258 op,
259 Op::DeleteWorkspace
260 | Op::DeleteRepo
261 | Op::PurgeRepo
262 | Op::TransferRepo
263 | Op::SetRepoVisibility
264 | Op::RemoveEmail
265 | Op::RemoveCollaborator
266 | Op::DisconnectIntegration
267 | Op::DeleteWebhook
268 | Op::DeleteActionsSecret
269 | Op::DeleteActionsVariable
270 | Op::SetActionsSecret
271 | Op::SetActionsVariable
272 | Op::SetModelRoutes
273 | Op::SetBasePermission
274 | Op::MergePullRequest
275 )
276}
277
278/// Whether an operation only reads.
279pub fn reads_only(op: Op) -> bool {
280 NO_SCOPE.contains(&op.name())
281 || scope_for(op.name()).is_some_and(|scope| scope.level() == Level::Read)
282}
283
284/// What decides which actions a caller sees.
285pub enum Gate<'a> {
286 /// No limit beyond the person's own role.
287 Everything,
288 /// A g1t agent's token: the operations its run lists.
289 Agent(&'a AgentScope),
290 /// An access token with scopes.
291 Token(&'a TokenAccess),
292}
293
294impl Gate<'_> {
295 pub fn allows(&self, op: Op) -> bool {
296 match self {
297 Gate::Everything => true,
298 Gate::Agent(scope) => op.allowed_by(scope) && !NEVER.contains(&op.name()),
299 Gate::Token(access) => {
300 if NO_SCOPE.contains(&op.name()) {
301 return true;
302 }
303 match scope_for(op.name()) {
304 Some(scope) => access.allows(scope),
305 None => access.scopes.is_none(),
306 }
307 }
308 }
309 }
310}
311
312impl Tool {
313 pub fn by_name(name: &str) -> Option<&'static Tool> {
314 TOOLS.iter().find(|tool| tool.name == name)
315 }
316
317 pub fn action(&self, name: &str) -> Option<&'static Action> {
318 // The tools are 'static; find through TOOLS to keep the lifetime.
319 TOOLS
320 .iter()
321 .find(|tool| tool.name == self.name)
322 .and_then(|tool| tool.actions.iter().find(|action| action.name == name))
323 }
324
325 pub fn visible(&self, gate: &Gate) -> Vec<&'static Action> {
326 TOOLS
327 .iter()
328 .find(|tool| tool.name == self.name)
329 .map(|tool| tool.actions.iter().filter(|action| gate.allows(action.op)).collect())
330 .unwrap_or_default()
331 }
332
333 /// The flat input schema of the actions given.
334 pub fn input_schema(&self, actions: &[&Action]) -> Value {
335 let mut properties = Map::new();
336 let lines: Vec<String> = actions
337 .iter()
338 .map(|action| {
339 let required: Vec<String> = action.op.required();
340 if required.is_empty() {
341 format!("{}: {}.", action.name, action.summary)
342 } else {
343 format!("{} ({}): {}.", action.name, required.join(", "), action.summary)
344 }
345 })
346 .collect();
347 let mut action_schema = json!({
348 "type": "string",
349 "enum": actions.iter().map(|action| action.name).collect::<Vec<_>>(),
350 "description": lines.join("\n"),
351 });
352 if let Some(default) = self.default_action.filter(|name| actions.iter().any(|action| action.name == *name)) {
353 action_schema["default"] = json!(default);
354 }
355 properties.insert("action".to_owned(), action_schema);
356 for action in actions {
357 for (name, schema) in action.op.properties() {
358 merge_property(&mut properties, name, schema);
359 }
360 }
361 let mut required = vec![];
362 if self.default_action.is_none() {
363 required.push("action");
364 }
365 let mut schema = json!({ "type": "object", "properties": properties });
366 if !required.is_empty() {
367 schema["required"] = json!(required);
368 }
369 schema
370 }
371
372 /// The input schema keyed by action: one `oneOf` branch per action,
373 /// each with its own fields and the ones it needs.
374 pub fn discriminated(&self, actions: &[&Action]) -> Value {
375 let branches: Vec<Value> = actions
376 .iter()
377 .map(|action| {
378 let mut properties = Map::new();
379 properties.insert("action".to_owned(), json!({ "const": action.name }));
380 properties.extend(action.op.properties());
381 let mut required = vec![Value::String("action".to_owned())];
382 // The default action may leave `action` out.
383 if self.default_action == Some(action.name) {
384 required.clear();
385 }
386 required.extend(action.op.required().into_iter().map(Value::String));
387 json!({
388 "title": action.name,
389 "description": action.summary,
390 "type": "object",
391 "properties": properties,
392 "required": required,
393 })
394 })
395 .collect();
396 json!({ "type": "object", "oneOf": branches })
397 }
398
399 /// MCP's hints about the actions given: whether the tool only reads,
400 /// whether it can destroy something, and whether calling it twice is
401 /// the same as once.
402 pub fn annotations(&self, actions: &[&Action]) -> Value {
403 let read_only = actions.iter().all(|action| reads_only(action.op));
404 json!({
405 "title": self.title,
406 "readOnlyHint": read_only,
407 "destructiveHint": !read_only && actions.iter().any(|action| destructive(action.op)),
408 "idempotentHint": read_only,
409 "openWorldHint": false,
410 })
411 }
412
413 /// The tool as `tools/list` gives it, for a caller behind `gate`, or
414 /// `None` when it may use none of its actions.
415 pub fn listed(&self, gate: &Gate) -> Option<Value> {
416 let actions = self.visible(gate);
417 if actions.is_empty() {
418 return None;
419 }
420 Some(json!({
421 "name": self.name,
422 "title": self.title,
423 "description": self.description,
424 "inputSchema": self.input_schema(&actions),
425 "annotations": self.annotations(&actions),
426 }))
427 }
428}
429
430/// Adds a property to a tool's flat schema. The first action to use a name
431/// describes it; a later one with other allowed values adds them.
432fn merge_property(properties: &mut Map<String, Value>, name: String, schema: Value) {
433 match properties.get_mut(&name) {
434 None => {
435 properties.insert(name, schema);
436 }
437 Some(existing) => {
438 if let (Some(Value::Array(had)), Some(Value::Array(more))) =
439 (existing.get("enum").cloned(), schema.get("enum"))
440 {
441 let mut merged = had;
442 for value in more {
443 if !merged.contains(value) {
444 merged.push(value.clone());
445 }
446 }
447 existing["enum"] = Value::Array(merged);
448 }
449 // Different kinds of value under one name: say less, accept both.
450 if existing.get("type") != schema.get("type")
451 && let Some(fields) = existing.as_object_mut()
452 {
453 fields.remove("type");
454 fields.remove("items");
455 }
456 }
457 }
458}
459
460/// What a call to a tool runs: the operation its action names, or why not.
461pub fn resolve(tool: &Tool, arguments: &Value) -> Result<Op, String> {
462 let names = || {
463 tool.actions
464 .iter()
465 .map(|action| action.name)
466 .collect::<Vec<_>>()
467 .join(", ")
468 };
469 let Some(name) = arguments["action"].as_str().or(tool.default_action) else {
470 return Err(format!("Give an action: one of {}.", names()));
471 };
472 let Some(action) = tool.action(name) else {
473 return Err(format!("{} has no action {name}. Its actions: {}.", tool.name, names()));
474 };
475 let missing: Vec<String> = action
476 .op
477 .required()
478 .into_iter()
479 .filter(|field| arguments.get(field).is_none_or(Value::is_null))
480 .collect();
481 if !missing.is_empty() {
482 return Err(format!("{}.{name} needs {}.", tool.name, missing.join(", ")));
483 }
484 Ok(action.op)
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use g1t_contracts::scopes::{Preset, Scope};
491
492 fn listed(gate: &Gate) -> Vec<Value> {
493 TOOLS.iter().filter_map(|tool| tool.listed(gate)).collect()
494 }
495
496 fn token(scopes: Option<Vec<Scope>>) -> TokenAccess {
497 TokenAccess {
498 token_id: "tok_1".to_owned(),
499 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
500 legacy: false,
501 }
502 }
503
504 #[test]
505 fn every_operation_is_exactly_one_action_of_one_tool() {
506 for op in Op::ALL {
507 let count = TOOLS
508 .iter()
509 .flat_map(|tool| tool.actions.iter())
510 .filter(|action| action.op == op)
511 .count();
512 assert_eq!(count, 1, "{} is {count} actions", op.name());
513 }
514 for tool in TOOLS {
515 let mut names = std::collections::HashSet::new();
516 for action in tool.actions {
517 assert!(names.insert(action.name), "{}.{} twice", tool.name, action.name);
518 }
519 if let Some(default) = tool.default_action {
520 assert!(tool.action(default).is_some(), "{}", tool.name);
521 }
522 }
523 assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len());
524 }
525
526 #[test]
527 fn every_operation_needs_exactly_one_scope_or_none() {
528 use g1t_contracts::scopes::OPERATIONS;
529 for op in Op::ALL {
530 let mapped = OPERATIONS.iter().filter(|(name, _)| *name == op.name()).count();
531 let free = NO_SCOPE.contains(&op.name());
532 assert_eq!(mapped + usize::from(free), 1, "{}", op.name());
533 }
534 for (name, _) in OPERATIONS {
535 assert!(Op::by_name(name).is_some(), "{name} is not an operation");
536 }
537 }
538
539 #[test]
540 fn each_tool_schema_is_valid_with_one_branch_per_action() {
541 for tool in TOOLS {
542 let actions: Vec<&Action> = tool.actions.iter().collect();
543 let flat = tool.input_schema(&actions);
544 assert_eq!(flat["type"], "object");
545 assert!(flat.get("oneOf").is_none(), "no oneOf at the top level");
546 let listed: Vec<&str> = flat["properties"]["action"]["enum"]
547 .as_array()
548 .unwrap()
549 .iter()
550 .map(|name| name.as_str().unwrap())
551 .collect();
552 assert_eq!(listed, tool.actions.iter().map(|action| action.name).collect::<Vec<_>>());
553 for action in tool.actions {
554 for field in action.op.required() {
555 assert!(flat["properties"].get(&field).is_some(), "{}.{}: {field}", tool.name, action.name);
556 }
557 }
558 let keyed = tool.discriminated(&actions);
559 let branches = keyed["oneOf"].as_array().unwrap();
560 assert_eq!(branches.len(), tool.actions.len());
561 for (branch, action) in branches.iter().zip(tool.actions) {
562 assert_eq!(branch["properties"]["action"]["const"], action.name);
563 for field in branch["required"].as_array().unwrap() {
564 assert!(branch["properties"].get(field.as_str().unwrap()).is_some(), "{}.{}: {field}", tool.name, action.name);
565 }
566 }
567 // A well-formed JSON Schema object throughout.
568 let text = serde_json::to_string(&flat).unwrap();
569 assert!(serde_json::from_str::<Value>(&text).is_ok());
570 }
571 }
572
573 #[test]
574 fn a_read_only_token_sees_read_actions_only() {
575 let access = token(Preset::ReadOnly.scopes());
576 let gate = Gate::Token(&access);
577 for tool in TOOLS {
578 for action in tool.visible(&gate) {
579 assert!(reads_only(action.op), "{}.{}", tool.name, action.name);
580 }
581 }
582 let tools = listed(&gate);
583 for tool in &tools {
584 assert_eq!(tool["annotations"]["readOnlyHint"], true, "{}", tool["name"]);
585 assert_eq!(tool["annotations"]["destructiveHint"], false);
586 }
587 let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap();
588 assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get"]));
589 // Nothing of the agent tool is a read.
590 assert!(!tools.iter().any(|tool| tool["name"] == "agent"));
591 }
592
593 #[test]
594 fn a_narrow_token_sees_only_its_tools() {
595 let access = token(Some(vec![Scope::IssuesWrite]));
596 let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect();
597 assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]);
598 let full = token(None);
599 assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len());
600 assert_eq!(listed(&Gate::Everything).len(), TOOLS.len());
601 }
602
603 #[test]
604 fn a_tool_that_can_destroy_says_so() {
605 let tools = listed(&Gate::Everything);
606 let repository = tools.iter().find(|tool| tool["name"] == "repository").unwrap();
607 assert_eq!(repository["annotations"]["destructiveHint"], true);
608 assert_eq!(repository["annotations"]["readOnlyHint"], false);
609 let memory = tools.iter().find(|tool| tool["name"] == "memory").unwrap();
610 assert_eq!(memory["annotations"]["destructiveHint"], false);
611 }
612
613 #[test]
614 fn calls_resolve_to_their_operation_or_say_what_is_missing() {
615 let issue = Tool::by_name("issue").unwrap();
616 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b", "number": 1 })), Ok(Op::GetIssue));
617 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b" })), Err("issue.get needs number.".to_owned()));
618 assert!(resolve(issue, &json!({})).unwrap_err().starts_with("Give an action"));
619 assert!(resolve(issue, &json!({ "action": "explode" })).unwrap_err().contains("no action explode"));
620 let search = Tool::by_name("search").unwrap();
621 assert_eq!(resolve(search, &json!({ "query": "x" })), Ok(Op::Search));
622 let account = Tool::by_name("account").unwrap();
623 assert_eq!(resolve(account, &json!({})), Ok(Op::Whoami));
624 }
625
626 /// How much smaller `tools/list` is than one tool per operation. Run
627 /// with `--nocapture` to see the numbers.
628 #[test]
629 fn the_tool_list_is_much_smaller_than_one_tool_per_operation() {
630 let before: Vec<Value> = Op::ALL
631 .into_iter()
632 .map(|op| json!({ "name": op.name(), "description": op.description(), "inputSchema": op.input() }))
633 .collect();
634 let after = listed(&Gate::Everything);
635 let before_bytes = serde_json::to_string(&json!({ "tools": before })).unwrap().len();
636 let after_bytes = serde_json::to_string(&json!({ "tools": after })).unwrap().len();
637 let agent = token(Preset::Agent.scopes());
638 let agent_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&agent)) })).unwrap().len();
639 let read = token(Preset::ReadOnly.scopes());
640 let read_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&read)) })).unwrap().len();
641 println!(
642 "tools/list: before {} tools, {before_bytes} bytes (~{} tokens); after {} tools, {after_bytes} bytes (~{} tokens); agent preset {agent_bytes} bytes (~{} tokens); read only {read_bytes} bytes (~{} tokens)",
643 before.len(),
644 before_bytes / 4,
645 after.len(),
646 after_bytes / 4,
647 agent_bytes / 4,
648 read_bytes / 4,
649 );
650 assert!(after_bytes * 2 < before_bytes, "{after_bytes} vs {before_bytes}");
651 }
652}