Skip to content

g1t/apps/api/src/tools.rs

702 lines34,792 bytesCodeBlame
1//! 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, change their settings, and see and dismiss their security alerts (secrets and vulnerable dependencies). 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, "Branch protection: required checks, approvals, how pull requests merge"),
68 a("update_settings", Op::UpdateRepoSettings, "Change branch protection and how pull requests merge"),
69 a("check_names", Op::ListCheckNames, "Check names reported lately, to require on the default branch"),
70 a("list_labels", Op::ListLabels, "Labels in use"),
71 a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"),
72 a("rename_branch", Op::RenameBranch, "Rename a branch"),
73 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
74 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
75 a("archive", Op::ArchiveRepo, "Make it read-only"),
76 a("unarchive", Op::UnarchiveRepo, "Make it writable again"),
77 a("set_visibility", Op::SetRepoVisibility, "Make it public or private"),
78 a("delete", Op::DeleteRepo, "Delete it; restorable for 30 days"),
79 a("list_deleted", Op::ListDeletedRepos, "A workspace's deleted repositories"),
80 a("restore", Op::RestoreRepo, "Restore a deleted one"),
81 a("purge", Op::PurgeRepo, "Remove a deleted one for good"),
82 a("security_alerts", Op::ListSecurityAlerts, "Secret and dependency alerts, filtered by state"),
83 a("dismiss_alert", Op::DismissSecurityAlert, "Dismiss an alert with a reason"),
84 a("reopen_alert", Op::ReopenSecurityAlert, "Reopen a dismissed alert"),
85 ],
86 },
87 Tool {
88 name: "issue",
89 title: "Issues",
90 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.",
91 default_action: None,
92 actions: &[
93 a("list", Op::ListIssues, "Issues on a repository, newest first"),
94 a("get", Op::GetIssue, "One issue with comments and its pull requests"),
95 a("create", Op::CreateIssue, "Open an issue"),
96 a("update", Op::UpdateIssue, "Change title, body, labels or assignees"),
97 a("close", Op::CloseIssue, "Close it without a pull request"),
98 a("reopen", Op::ReopenIssue, "Reopen it"),
99 a("comment", Op::AddComment, "Comment on an issue or pull request; path and line for one line of a change"),
100 a("import", Op::ImportIssue, "Open an issue from a Jira, Linear or Sentry item"),
101 ],
102 },
103 Tool {
104 name: "pull_request",
105 title: "Pull requests",
106 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.",
107 default_action: None,
108 actions: &[
109 a("list", Op::ListPullRequests, "Pull requests on a repository, newest first"),
110 a("get", Op::GetPullRequest, "Status, checks and required checks, reviews, overlaps, whether it is behind"),
111 a("changes", Op::GetPullRequestChanges, "Files and line-by-line diff"),
112 a("create", Op::CreatePullRequest, "Start a draft with its own fork to push to, or open one from a pushed branch"),
113 a("record_session", Op::RecordSession, "Append prompt, reasoning and tool entries to its session"),
114 a("read_session", Op::ReadSession, "Its recorded session"),
115 a("ready", Op::MarkPullRequestReady, "Mark a draft ready, with a summary"),
116 a("review", Op::ReviewPullRequest, "Approve or request changes"),
117 a("close", Op::ClosePullRequest, "Close without merging"),
118 a("merge", Op::MergePullRequest, "Land it, or join the merge queue"),
119 a("merge_queue", Op::GetMergeQueue, "The repository's merge queue"),
120 ],
121 },
122 Tool {
123 name: "agent",
124 title: "g1t agents",
125 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.",
126 default_action: None,
127 actions: &[
128 a("delegate", Op::Delegate, "Open an issue and put an agent on it in one step"),
129 a("assign", Op::AssignIssue, "Put an agent on an existing issue"),
130 a("message", Op::MessageAgent, "Tell the agent on a pull request something, or ask another agent"),
131 a("answer", Op::AnswerMessage, "Answer a question or handoff sent to you"),
132 a("take_messages", Op::TakeMessages, "For a g1t agent: messages not seen yet"),
133 ],
134 },
135 Tool {
136 name: "plan",
137 title: "Plans",
138 description: "Turn an outcome into issues: an agent proposes them with what done means and their dependencies; nothing opens until you apply the plan.",
139 default_action: None,
140 actions: &[
141 a("create", Op::PlanWork, "Ask an agent for a plan; read it with get until ready"),
142 a("get", Op::GetPlan, "A plan and the issues it proposes"),
143 a("apply", Op::ApplyPlan, "Open its issues; with assign, agents start in dependency order"),
144 ],
145 },
146 Tool {
147 name: "memory",
148 title: "Memory",
149 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.",
150 default_action: None,
151 actions: &[
152 a("recall", Op::Recall, "Search memory, or list it all"),
153 a("remember", Op::Remember, "Save one fact"),
154 ],
155 },
156 Tool {
157 name: "workflow",
158 title: "Workflows",
159 description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
160 default_action: None,
161 actions: &[
162 a("list", Op::ListWorkflows, "Workflows on the default branch"),
163 a("list_runs", Op::ListWorkflowRuns, "Runs, newest first"),
164 a("get_run", Op::GetWorkflowRun, "One run with its jobs and steps"),
165 a("job_logs", Op::GetJobLogs, "A job's log after a sequence number"),
166 a("dispatch", Op::DispatchWorkflow, "Run a workflow_dispatch workflow"),
167 a("cancel", Op::CancelWorkflowRun, "Cancel a run"),
168 a("rerun", Op::RerunWorkflowRun, "Run a finished run again"),
169 a("update", Op::UpdateWorkflow, "Turn a workflow on or off"),
170 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
171 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
172 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
173 a("list_runner_groups", Op::ListRunnerGroups, "A workspace's runner groups"),
174 a("create_runner_group", Op::CreateRunnerGroup, "Make a group, for some repositories"),
175 a("update_runner_group", Op::UpdateRunnerGroup, "Rename a group or change its repositories"),
176 a("delete_runner_group", Op::DeleteRunnerGroup, "Delete a group; its runners join the default"),
177 a("get_runner_settings", Op::GetRunnerSettings, "Where agent work runs; whether forks may use runners"),
178 a("update_runner_settings", Op::UpdateRunnerSettings, "Change them"),
179 ],
180 },
181 Tool {
182 name: "secret",
183 title: "Secrets and variables",
184 description: "A repository's or workspace's secrets and variables, read by workflows and deployments. Secret values are never returned.",
185 default_action: None,
186 actions: &[
187 a("list_secrets", Op::ListActionsSecrets, "Secrets, without values"),
188 a("set_secret", Op::SetActionsSecret, "Add or change a secret"),
189 a("delete_secret", Op::DeleteActionsSecret, "Remove a secret"),
190 a("list_variables", Op::ListActionsVariables, "Variables, with values"),
191 a("set_variable", Op::SetActionsVariable, "Add or change a variable"),
192 a("delete_variable", Op::DeleteActionsVariable, "Remove a variable"),
193 ],
194 },
195 Tool {
196 name: "webhook",
197 title: "Webhooks",
198 description: "HTTPS addresses sent signed events as they happen, for a repository or a whole workspace.",
199 default_action: None,
200 actions: &[
201 a("list", Op::ListWebhooks, "Webhooks, without secrets"),
202 a("create", Op::CreateWebhook, "Register one; a ping is sent"),
203 a("update", Op::UpdateWebhook, "Change address, events or active"),
204 a("delete", Op::DeleteWebhook, "Remove one"),
205 a("ping", Op::PingWebhook, "Send a ping"),
206 a("list_deliveries", Op::ListWebhookDeliveries, "Latest deliveries"),
207 a("redeliver", Op::RedeliverWebhook, "Send a delivery again"),
208 ],
209 },
210 Tool {
211 name: "access",
212 title: "Who has access",
213 description: "Who has access to a repository and with which role (read, triage, write, maintain, admin), outside collaborators, and a workspace's base permission.",
214 default_action: None,
215 actions: &[
216 a("list_collaborators", Op::ListCollaborators, "Everyone with a role, and pending invitations"),
217 a("get_permission", Op::GetCollaboratorPermission, "One person's role and capabilities"),
218 a("add_collaborator", Op::AddCollaborator, "Give someone a role, by username or email"),
219 a("update_collaborator", Op::UpdateCollaborator, "Change a direct role"),
220 a("remove_collaborator", Op::RemoveCollaborator, "Take away a direct role"),
221 a("list_invitations", Op::ListRepoInvitations, "Pending invitations to a repository"),
222 a("revoke_invitation", Op::RevokeRepoInvitation, "Withdraw one"),
223 a("set_base_permission", Op::SetBasePermission, "What every member gets on each repository"),
224 a("list_outside_collaborators", Op::ListOutsideCollaborators, "People with roles who are not members"),
225 ],
226 },
227 Tool {
228 name: "workspace",
229 title: "Workspaces",
230 description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, and connect integrations and model providers.",
231 default_action: None,
232 actions: &[
233 a("create", Op::CreateWorkspace, "Create a workspace"),
234 a("delete", Op::DeleteWorkspace, "Delete a workspace and everything in it (support can restore it for 30 days)"),
235 a("update", Op::UpdateWorkspace, "Change its name, description or base permission"),
236 a("list_invites", Op::ListWorkspaceInvites, "Its invites"),
237 a("invite_member", Op::InviteMember, "Invite an email address"),
238 a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"),
239 a("list_integrations", Op::ListIntegrations, "Model providers, alert sources, trackers"),
240 a("connect_integration", Op::ConnectIntegration, "Connect one"),
241 a("disconnect_integration", Op::DisconnectIntegration, "Remove one"),
242 a("test_integration", Op::TestIntegration, "Check its credentials"),
243 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
244 a("set_model_routes", Op::SetModelRoutes, "Replace them"),
245 ],
246 },
247 Tool {
248 name: "notifications",
249 title: "Notifications",
250 description: "Your inbox: what needs you, and what you follow. One thread per issue, pull request, workflow or deployment, with why you were told (`reason`): an agent waiting on you, a review asked of you, an assignment, a mention, your work's checks, or what you subscribe to and watch. Mark threads read or done once handled, and choose what you hear of with subscribe, unsubscribe and watch. Your own: a personal token.",
251 default_action: Some("list"),
252 actions: &[
253 a("list", Op::ListNotifications, "Unread threads, latest first; all, a view, a reason, a repository"),
254 a("get", Op::GetNotificationThread, "One thread with its recent activity and your subscription"),
255 a("mark_read", Op::MarkThreadRead, "Mark a thread read, or unread"),
256 a("mark_all_read", Op::MarkNotificationsRead, "Mark everything read up to a time, or one repository's"),
257 a("done", Op::MarkThreadDone, "Mark a thread done; new activity brings it back"),
258 a("save", Op::SaveThread, "Save a thread, or unsave it"),
259 a("snooze", Op::SnoozeThread, "Snooze a thread until a time, or bring it back"),
260 a("subscription", Op::GetThreadSubscription, "Your subscription to an issue or pull request"),
261 a("subscribe", Op::SetThreadSubscription, "Subscribe to an issue or pull request, or ignore it"),
262 a("unsubscribe", Op::DeleteThreadSubscription, "Unsubscribe until you comment or are mentioned"),
263 a("watching", Op::GetRepoSubscription, "How you watch a repository"),
264 a("watch", Op::SetRepoSubscription, "Watch a repository: participating, all, ignore or custom"),
265 a("unwatch", Op::DeleteRepoSubscription, "Stop watching a repository"),
266 a("watched", Op::ListWatchedRepos, "Repositories you watch other than the default way"),
267 ],
268 },
269 Tool {
270 name: "account",
271 title: "Your account",
272 description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.",
273 default_action: Some("whoami"),
274 actions: &[
275 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
276 a("list_emails", Op::ListEmails, "Your addresses"),
277 a("add_email", Op::AddEmail, "Add an address"),
278 a("remove_email", Op::RemoveEmail, "Remove an address"),
279 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
280 a("list_invites", Op::ListInvites, "Your invites to g1t"),
281 a("create_invite", Op::CreateInvite, "Make an invite"),
282 a("revoke_invite", Op::RevokeInvite, "Revoke one"),
283 a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"),
284 a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"),
285 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
286 ],
287 },
288];
289
290/// Operations that cannot be undone, or reach beyond g1t's own records:
291/// clients ask before running a tool that has any of them.
292fn destructive(op: Op) -> bool {
293 matches!(
294 op,
295 Op::DeleteWorkspace
296 | Op::UpdateWorkspace
297 | Op::DeleteRepo
298 | Op::PurgeRepo
299 | Op::TransferRepo
300 | Op::SetRepoVisibility
301 | Op::RemoveEmail
302 | Op::RemoveCollaborator
303 | Op::DisconnectIntegration
304 | Op::DeleteWebhook
305 | Op::DeleteActionsSecret
306 | Op::DeleteActionsVariable
307 | Op::SetActionsSecret
308 | Op::SetActionsVariable
309 | Op::SetModelRoutes
310 | Op::SetBasePermission
311 | Op::MergePullRequest
312 | Op::RemoveRunner
313 | Op::DeleteRunnerGroup
314 | Op::UpdateRunnerSettings
315 )
316}
317
318/// Whether an operation only reads.
319pub fn reads_only(op: Op) -> bool {
320 NO_SCOPE.contains(&op.name())
321 || scope_for(op.name()).is_some_and(|scope| scope.level() == Level::Read)
322}
323
324/// What decides which actions a caller sees.
325pub enum Gate<'a> {
326 /// No limit beyond the person's own role.
327 Everything,
328 /// A g1t agent's token: the operations its run lists.
329 Agent(&'a AgentScope),
330 /// An access token with scopes.
331 Token(&'a TokenAccess),
332}
333
334impl Gate<'_> {
335 pub fn allows(&self, op: Op) -> bool {
336 match self {
337 Gate::Everything => true,
338 Gate::Agent(scope) => op.allowed_by(scope) && !NEVER.contains(&op.name()),
339 Gate::Token(access) => {
340 if NO_SCOPE.contains(&op.name()) {
341 return true;
342 }
343 match scope_for(op.name()) {
344 Some(scope) => access.allows(scope),
345 None => access.scopes.is_none(),
346 }
347 }
348 }
349 }
350}
351
352impl Tool {
353 pub fn by_name(name: &str) -> Option<&'static Tool> {
354 TOOLS.iter().find(|tool| tool.name == name)
355 }
356
357 pub fn action(&self, name: &str) -> Option<&'static Action> {
358 // The tools are 'static; find through TOOLS to keep the lifetime.
359 TOOLS
360 .iter()
361 .find(|tool| tool.name == self.name)
362 .and_then(|tool| tool.actions.iter().find(|action| action.name == name))
363 }
364
365 pub fn visible(&self, gate: &Gate) -> Vec<&'static Action> {
366 TOOLS
367 .iter()
368 .find(|tool| tool.name == self.name)
369 .map(|tool| tool.actions.iter().filter(|action| gate.allows(action.op)).collect())
370 .unwrap_or_default()
371 }
372
373 /// The flat input schema of the actions given.
374 pub fn input_schema(&self, actions: &[&Action]) -> Value {
375 let mut properties = Map::new();
376 let lines: Vec<String> = actions
377 .iter()
378 .map(|action| {
379 let required: Vec<String> = action.op.required();
380 if required.is_empty() {
381 format!("{}: {}.", action.name, action.summary)
382 } else {
383 format!("{} ({}): {}.", action.name, required.join(", "), action.summary)
384 }
385 })
386 .collect();
387 let mut action_schema = json!({
388 "type": "string",
389 "enum": actions.iter().map(|action| action.name).collect::<Vec<_>>(),
390 "description": lines.join("\n"),
391 });
392 if let Some(default) = self.default_action.filter(|name| actions.iter().any(|action| action.name == *name)) {
393 action_schema["default"] = json!(default);
394 }
395 properties.insert("action".to_owned(), action_schema);
396 for action in actions {
397 for (name, schema) in action.op.properties() {
398 merge_property(&mut properties, name, schema);
399 }
400 }
401 let mut required = vec![];
402 if self.default_action.is_none() {
403 required.push("action");
404 }
405 let mut schema = json!({ "type": "object", "properties": properties });
406 if !required.is_empty() {
407 schema["required"] = json!(required);
408 }
409 schema
410 }
411
412 /// The input schema keyed by action: one `oneOf` branch per action,
413 /// each with its own fields and the ones it needs.
414 pub fn discriminated(&self, actions: &[&Action]) -> Value {
415 let branches: Vec<Value> = actions
416 .iter()
417 .map(|action| {
418 let mut properties = Map::new();
419 properties.insert("action".to_owned(), json!({ "const": action.name }));
420 properties.extend(action.op.properties());
421 let mut required = vec![Value::String("action".to_owned())];
422 // The default action may leave `action` out.
423 if self.default_action == Some(action.name) {
424 required.clear();
425 }
426 required.extend(action.op.required().into_iter().map(Value::String));
427 json!({
428 "title": action.name,
429 "description": action.summary,
430 "type": "object",
431 "properties": properties,
432 "required": required,
433 })
434 })
435 .collect();
436 json!({ "type": "object", "oneOf": branches })
437 }
438
439 /// MCP's hints about the actions given: whether the tool only reads,
440 /// whether it can destroy something, and whether calling it twice is
441 /// the same as once.
442 pub fn annotations(&self, actions: &[&Action]) -> Value {
443 let read_only = actions.iter().all(|action| reads_only(action.op));
444 json!({
445 "title": self.title,
446 "readOnlyHint": read_only,
447 "destructiveHint": !read_only && actions.iter().any(|action| destructive(action.op)),
448 "idempotentHint": read_only,
449 "openWorldHint": false,
450 })
451 }
452
453 /// The tool as `tools/list` gives it, for a caller behind `gate`, or
454 /// `None` when it may use none of its actions.
455 pub fn listed(&self, gate: &Gate) -> Option<Value> {
456 let actions = self.visible(gate);
457 if actions.is_empty() {
458 return None;
459 }
460 Some(json!({
461 "name": self.name,
462 "title": self.title,
463 "description": self.description,
464 "inputSchema": self.input_schema(&actions),
465 "annotations": self.annotations(&actions),
466 }))
467 }
468}
469
470/// Adds a property to a tool's flat schema. The first action to use a name
471/// describes it; a later one with other allowed values adds them.
472fn merge_property(properties: &mut Map<String, Value>, name: String, schema: Value) {
473 match properties.get_mut(&name) {
474 None => {
475 properties.insert(name, schema);
476 }
477 Some(existing) => {
478 if let (Some(Value::Array(had)), Some(Value::Array(more))) =
479 (existing.get("enum").cloned(), schema.get("enum"))
480 {
481 let mut merged = had;
482 for value in more {
483 if !merged.contains(value) {
484 merged.push(value.clone());
485 }
486 }
487 existing["enum"] = Value::Array(merged);
488 }
489 // Different kinds of value under one name: say less, accept both.
490 if existing.get("type") != schema.get("type")
491 && let Some(fields) = existing.as_object_mut()
492 {
493 fields.remove("type");
494 fields.remove("items");
495 }
496 }
497 }
498}
499
500/// What a call to a tool runs: the operation its action names, or why not.
501pub fn resolve(tool: &Tool, arguments: &Value) -> Result<Op, String> {
502 let names = || {
503 tool.actions
504 .iter()
505 .map(|action| action.name)
506 .collect::<Vec<_>>()
507 .join(", ")
508 };
509 let Some(name) = arguments["action"].as_str().or(tool.default_action) else {
510 return Err(format!("Give an action: one of {}.", names()));
511 };
512 let Some(action) = tool.action(name) else {
513 return Err(format!("{} has no action {name}. Its actions: {}.", tool.name, names()));
514 };
515 let missing: Vec<String> = action
516 .op
517 .required()
518 .into_iter()
519 .filter(|field| arguments.get(field).is_none_or(Value::is_null))
520 .collect();
521 if !missing.is_empty() {
522 return Err(format!("{}.{name} needs {}.", tool.name, missing.join(", ")));
523 }
524 Ok(action.op)
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use g1t_contracts::scopes::{Preset, Scope};
531
532 fn listed(gate: &Gate) -> Vec<Value> {
533 TOOLS.iter().filter_map(|tool| tool.listed(gate)).collect()
534 }
535
536 fn token(scopes: Option<Vec<Scope>>) -> TokenAccess {
537 TokenAccess {
538 token_id: "tok_1".to_owned(),
539 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
540 legacy: false,
541 }
542 }
543
544 #[test]
545 fn every_operation_is_exactly_one_action_of_one_tool() {
546 for op in Op::ALL {
547 let count = TOOLS
548 .iter()
549 .flat_map(|tool| tool.actions.iter())
550 .filter(|action| action.op == op)
551 .count();
552 assert_eq!(count, 1, "{} is {count} actions", op.name());
553 }
554 for tool in TOOLS {
555 let mut names = std::collections::HashSet::new();
556 for action in tool.actions {
557 assert!(names.insert(action.name), "{}.{} twice", tool.name, action.name);
558 }
559 if let Some(default) = tool.default_action {
560 assert!(tool.action(default).is_some(), "{}", tool.name);
561 }
562 }
563 assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len());
564 }
565
566 #[test]
567 fn every_operation_needs_exactly_one_scope_or_none() {
568 use g1t_contracts::scopes::OPERATIONS;
569 for op in Op::ALL {
570 let mapped = OPERATIONS.iter().filter(|(name, _)| *name == op.name()).count();
571 let free = NO_SCOPE.contains(&op.name());
572 assert_eq!(mapped + usize::from(free), 1, "{}", op.name());
573 }
574 for (name, _) in OPERATIONS {
575 assert!(Op::by_name(name).is_some(), "{name} is not an operation");
576 }
577 }
578
579 #[test]
580 fn each_tool_schema_is_valid_with_one_branch_per_action() {
581 for tool in TOOLS {
582 let actions: Vec<&Action> = tool.actions.iter().collect();
583 let flat = tool.input_schema(&actions);
584 assert_eq!(flat["type"], "object");
585 assert!(flat.get("oneOf").is_none(), "no oneOf at the top level");
586 let listed: Vec<&str> = flat["properties"]["action"]["enum"]
587 .as_array()
588 .unwrap()
589 .iter()
590 .map(|name| name.as_str().unwrap())
591 .collect();
592 assert_eq!(listed, tool.actions.iter().map(|action| action.name).collect::<Vec<_>>());
593 for action in tool.actions {
594 for field in action.op.required() {
595 assert!(flat["properties"].get(&field).is_some(), "{}.{}: {field}", tool.name, action.name);
596 }
597 }
598 let keyed = tool.discriminated(&actions);
599 let branches = keyed["oneOf"].as_array().unwrap();
600 assert_eq!(branches.len(), tool.actions.len());
601 for (branch, action) in branches.iter().zip(tool.actions) {
602 assert_eq!(branch["properties"]["action"]["const"], action.name);
603 for field in branch["required"].as_array().unwrap() {
604 assert!(branch["properties"].get(field.as_str().unwrap()).is_some(), "{}.{}: {field}", tool.name, action.name);
605 }
606 }
607 // A well-formed JSON Schema object throughout.
608 let text = serde_json::to_string(&flat).unwrap();
609 assert!(serde_json::from_str::<Value>(&text).is_ok());
610 }
611 }
612
613 #[test]
614 fn a_read_only_token_sees_read_actions_only() {
615 let access = token(Preset::ReadOnly.scopes());
616 let gate = Gate::Token(&access);
617 for tool in TOOLS {
618 for action in tool.visible(&gate) {
619 assert!(reads_only(action.op), "{}.{}", tool.name, action.name);
620 }
621 }
622 let tools = listed(&gate);
623 for tool in &tools {
624 assert_eq!(tool["annotations"]["readOnlyHint"], true, "{}", tool["name"]);
625 assert_eq!(tool["annotations"]["destructiveHint"], false);
626 }
627 let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap();
628 assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get"]));
629 // Nothing of the agent tool is a read.
630 assert!(!tools.iter().any(|tool| tool["name"] == "agent"));
631 }
632
633 #[test]
634 fn a_narrow_token_sees_only_its_tools() {
635 let access = token(Some(vec![Scope::IssuesWrite]));
636 let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect();
637 assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]);
638 // Notifications are a resource of their own: reading them lists
639 // only what reads.
640 let reader = token(Some(vec![Scope::NotificationsRead]));
641 let tools = listed(&Gate::Token(&reader));
642 let notifications = tools.iter().find(|tool| tool["name"] == "notifications").unwrap();
643 assert_eq!(
644 notifications["inputSchema"]["properties"]["action"]["enum"],
645 json!(["list", "get", "subscription", "watching", "watched"])
646 );
647 assert_eq!(notifications["annotations"]["readOnlyHint"], true);
648 let full = token(None);
649 assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len());
650 assert_eq!(listed(&Gate::Everything).len(), TOOLS.len());
651 }
652
653 #[test]
654 fn a_tool_that_can_destroy_says_so() {
655 let tools = listed(&Gate::Everything);
656 let repository = tools.iter().find(|tool| tool["name"] == "repository").unwrap();
657 assert_eq!(repository["annotations"]["destructiveHint"], true);
658 assert_eq!(repository["annotations"]["readOnlyHint"], false);
659 let memory = tools.iter().find(|tool| tool["name"] == "memory").unwrap();
660 assert_eq!(memory["annotations"]["destructiveHint"], false);
661 }
662
663 #[test]
664 fn calls_resolve_to_their_operation_or_say_what_is_missing() {
665 let issue = Tool::by_name("issue").unwrap();
666 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b", "number": 1 })), Ok(Op::GetIssue));
667 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b" })), Err("issue.get needs number.".to_owned()));
668 assert!(resolve(issue, &json!({})).unwrap_err().starts_with("Give an action"));
669 assert!(resolve(issue, &json!({ "action": "explode" })).unwrap_err().contains("no action explode"));
670 let search = Tool::by_name("search").unwrap();
671 assert_eq!(resolve(search, &json!({ "query": "x" })), Ok(Op::Search));
672 let account = Tool::by_name("account").unwrap();
673 assert_eq!(resolve(account, &json!({})), Ok(Op::Whoami));
674 }
675
676 /// How much smaller `tools/list` is than one tool per operation. Run
677 /// with `--nocapture` to see the numbers.
678 #[test]
679 fn the_tool_list_is_much_smaller_than_one_tool_per_operation() {
680 let before: Vec<Value> = Op::ALL
681 .into_iter()
682 .map(|op| json!({ "name": op.name(), "description": op.description(), "inputSchema": op.input() }))
683 .collect();
684 let after = listed(&Gate::Everything);
685 let before_bytes = serde_json::to_string(&json!({ "tools": before })).unwrap().len();
686 let after_bytes = serde_json::to_string(&json!({ "tools": after })).unwrap().len();
687 let agent = token(Preset::Agent.scopes());
688 let agent_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&agent)) })).unwrap().len();
689 let read = token(Preset::ReadOnly.scopes());
690 let read_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&read)) })).unwrap().len();
691 println!(
692 "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)",
693 before.len(),
694 before_bytes / 4,
695 after.len(),
696 after_bytes / 4,
697 agent_bytes / 4,
698 read_bytes / 4,
699 );
700 assert!(after_bytes * 2 < before_bytes, "{after_bytes} vs {before_bytes}");
701 }
702}