g1t/apps/api/src/tools.rs

670 lines32,261 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",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily60 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`.",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step61 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"),
Fast pages, required checks on the branch, self-hosted runners, honest incidents67 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"),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step70 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"),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily82 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"),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step85 ],
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"),
Fast pages, required checks on the branch, self-hosted runners, honest incidents94 a("get", Op::GetIssue, "One issue with comments and its pull requests"),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step95 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"),
Fast pages, required checks on the branch, self-hosted runners, honest incidents110 a("get", Op::GetPullRequest, "Status, checks and required checks, reviews, overlaps, whether it is behind"),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step111 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",
Fast pages, required checks on the branch, self-hosted runners, honest incidents138 description: "Turn an outcome into issues: an agent proposes them with what done means and their dependencies; nothing opens until you apply the plan.",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step139 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",
Fast pages, required checks on the branch, self-hosted runners, honest incidents159 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.",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step160 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"),
Fast pages, required checks on the branch, self-hosted runners, honest incidents170 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"),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step179 ],
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",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily230 description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, and connect integrations and model providers.",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step231 default_action: None,
232 actions: &[
233 a("create", Op::CreateWorkspace, "Create a workspace"),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member234 a("delete", Op::DeleteWorkspace, "Delete a workspace and everything in it (support can restore it for 30 days)"),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily235 a("update", Op::UpdateWorkspace, "Change its name, description or base permission"),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step236 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: "account",
249 title: "Your account",
250 description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.",
251 default_action: Some("whoami"),
252 actions: &[
253 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
254 a("list_emails", Op::ListEmails, "Your addresses"),
255 a("add_email", Op::AddEmail, "Add an address"),
256 a("remove_email", Op::RemoveEmail, "Remove an address"),
257 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
258 a("list_invites", Op::ListInvites, "Your invites to g1t"),
259 a("create_invite", Op::CreateInvite, "Make an invite"),
260 a("revoke_invite", Op::RevokeInvite, "Revoke one"),
261 a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"),
262 a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"),
263 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
264 ],
265 },
266];
267
268/// Operations that cannot be undone, or reach beyond g1t's own records:
269/// clients ask before running a tool that has any of them.
270fn destructive(op: Op) -> bool {
271 matches!(
272 op,
273 Op::DeleteWorkspace
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily274 | Op::UpdateWorkspace
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step275 | Op::DeleteRepo
276 | Op::PurgeRepo
277 | Op::TransferRepo
278 | Op::SetRepoVisibility
279 | Op::RemoveEmail
280 | Op::RemoveCollaborator
281 | Op::DisconnectIntegration
282 | Op::DeleteWebhook
283 | Op::DeleteActionsSecret
284 | Op::DeleteActionsVariable
285 | Op::SetActionsSecret
286 | Op::SetActionsVariable
287 | Op::SetModelRoutes
288 | Op::SetBasePermission
289 | Op::MergePullRequest
Fast pages, required checks on the branch, self-hosted runners, honest incidents290 | Op::RemoveRunner
291 | Op::DeleteRunnerGroup
292 | Op::UpdateRunnerSettings
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step293 )
294}
295
296/// Whether an operation only reads.
297pub fn reads_only(op: Op) -> bool {
298 NO_SCOPE.contains(&op.name())
299 || scope_for(op.name()).is_some_and(|scope| scope.level() == Level::Read)
300}
301
302/// What decides which actions a caller sees.
303pub enum Gate<'a> {
304 /// No limit beyond the person's own role.
305 Everything,
306 /// A g1t agent's token: the operations its run lists.
307 Agent(&'a AgentScope),
308 /// An access token with scopes.
309 Token(&'a TokenAccess),
310}
311
312impl Gate<'_> {
313 pub fn allows(&self, op: Op) -> bool {
314 match self {
315 Gate::Everything => true,
316 Gate::Agent(scope) => op.allowed_by(scope) && !NEVER.contains(&op.name()),
317 Gate::Token(access) => {
318 if NO_SCOPE.contains(&op.name()) {
319 return true;
320 }
321 match scope_for(op.name()) {
322 Some(scope) => access.allows(scope),
323 None => access.scopes.is_none(),
324 }
325 }
326 }
327 }
328}
329
330impl Tool {
331 pub fn by_name(name: &str) -> Option<&'static Tool> {
332 TOOLS.iter().find(|tool| tool.name == name)
333 }
334
335 pub fn action(&self, name: &str) -> Option<&'static Action> {
336 // The tools are 'static; find through TOOLS to keep the lifetime.
337 TOOLS
338 .iter()
339 .find(|tool| tool.name == self.name)
340 .and_then(|tool| tool.actions.iter().find(|action| action.name == name))
341 }
342
343 pub fn visible(&self, gate: &Gate) -> Vec<&'static Action> {
344 TOOLS
345 .iter()
346 .find(|tool| tool.name == self.name)
347 .map(|tool| tool.actions.iter().filter(|action| gate.allows(action.op)).collect())
348 .unwrap_or_default()
349 }
350
351 /// The flat input schema of the actions given.
352 pub fn input_schema(&self, actions: &[&Action]) -> Value {
353 let mut properties = Map::new();
354 let lines: Vec<String> = actions
355 .iter()
356 .map(|action| {
357 let required: Vec<String> = action.op.required();
358 if required.is_empty() {
359 format!("{}: {}.", action.name, action.summary)
360 } else {
361 format!("{} ({}): {}.", action.name, required.join(", "), action.summary)
362 }
363 })
364 .collect();
365 let mut action_schema = json!({
366 "type": "string",
367 "enum": actions.iter().map(|action| action.name).collect::<Vec<_>>(),
368 "description": lines.join("\n"),
369 });
370 if let Some(default) = self.default_action.filter(|name| actions.iter().any(|action| action.name == *name)) {
371 action_schema["default"] = json!(default);
372 }
373 properties.insert("action".to_owned(), action_schema);
374 for action in actions {
375 for (name, schema) in action.op.properties() {
376 merge_property(&mut properties, name, schema);
377 }
378 }
379 let mut required = vec![];
380 if self.default_action.is_none() {
381 required.push("action");
382 }
383 let mut schema = json!({ "type": "object", "properties": properties });
384 if !required.is_empty() {
385 schema["required"] = json!(required);
386 }
387 schema
388 }
389
390 /// The input schema keyed by action: one `oneOf` branch per action,
391 /// each with its own fields and the ones it needs.
392 pub fn discriminated(&self, actions: &[&Action]) -> Value {
393 let branches: Vec<Value> = actions
394 .iter()
395 .map(|action| {
396 let mut properties = Map::new();
397 properties.insert("action".to_owned(), json!({ "const": action.name }));
398 properties.extend(action.op.properties());
399 let mut required = vec![Value::String("action".to_owned())];
400 // The default action may leave `action` out.
401 if self.default_action == Some(action.name) {
402 required.clear();
403 }
404 required.extend(action.op.required().into_iter().map(Value::String));
405 json!({
406 "title": action.name,
407 "description": action.summary,
408 "type": "object",
409 "properties": properties,
410 "required": required,
411 })
412 })
413 .collect();
414 json!({ "type": "object", "oneOf": branches })
415 }
416
417 /// MCP's hints about the actions given: whether the tool only reads,
418 /// whether it can destroy something, and whether calling it twice is
419 /// the same as once.
420 pub fn annotations(&self, actions: &[&Action]) -> Value {
421 let read_only = actions.iter().all(|action| reads_only(action.op));
422 json!({
423 "title": self.title,
424 "readOnlyHint": read_only,
425 "destructiveHint": !read_only && actions.iter().any(|action| destructive(action.op)),
426 "idempotentHint": read_only,
427 "openWorldHint": false,
428 })
429 }
430
431 /// The tool as `tools/list` gives it, for a caller behind `gate`, or
432 /// `None` when it may use none of its actions.
433 pub fn listed(&self, gate: &Gate) -> Option<Value> {
434 let actions = self.visible(gate);
435 if actions.is_empty() {
436 return None;
437 }
438 Some(json!({
439 "name": self.name,
440 "title": self.title,
441 "description": self.description,
442 "inputSchema": self.input_schema(&actions),
443 "annotations": self.annotations(&actions),
444 }))
445 }
446}
447
448/// Adds a property to a tool's flat schema. The first action to use a name
449/// describes it; a later one with other allowed values adds them.
450fn merge_property(properties: &mut Map<String, Value>, name: String, schema: Value) {
451 match properties.get_mut(&name) {
452 None => {
453 properties.insert(name, schema);
454 }
455 Some(existing) => {
456 if let (Some(Value::Array(had)), Some(Value::Array(more))) =
457 (existing.get("enum").cloned(), schema.get("enum"))
458 {
459 let mut merged = had;
460 for value in more {
461 if !merged.contains(value) {
462 merged.push(value.clone());
463 }
464 }
465 existing["enum"] = Value::Array(merged);
466 }
467 // Different kinds of value under one name: say less, accept both.
468 if existing.get("type") != schema.get("type")
469 && let Some(fields) = existing.as_object_mut()
470 {
471 fields.remove("type");
472 fields.remove("items");
473 }
474 }
475 }
476}
477
478/// What a call to a tool runs: the operation its action names, or why not.
479pub fn resolve(tool: &Tool, arguments: &Value) -> Result<Op, String> {
480 let names = || {
481 tool.actions
482 .iter()
483 .map(|action| action.name)
484 .collect::<Vec<_>>()
485 .join(", ")
486 };
487 let Some(name) = arguments["action"].as_str().or(tool.default_action) else {
488 return Err(format!("Give an action: one of {}.", names()));
489 };
490 let Some(action) = tool.action(name) else {
491 return Err(format!("{} has no action {name}. Its actions: {}.", tool.name, names()));
492 };
493 let missing: Vec<String> = action
494 .op
495 .required()
496 .into_iter()
497 .filter(|field| arguments.get(field).is_none_or(Value::is_null))
498 .collect();
499 if !missing.is_empty() {
500 return Err(format!("{}.{name} needs {}.", tool.name, missing.join(", ")));
501 }
502 Ok(action.op)
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use g1t_contracts::scopes::{Preset, Scope};
509
510 fn listed(gate: &Gate) -> Vec<Value> {
511 TOOLS.iter().filter_map(|tool| tool.listed(gate)).collect()
512 }
513
514 fn token(scopes: Option<Vec<Scope>>) -> TokenAccess {
515 TokenAccess {
516 token_id: "tok_1".to_owned(),
517 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
518 legacy: false,
519 }
520 }
521
522 #[test]
523 fn every_operation_is_exactly_one_action_of_one_tool() {
524 for op in Op::ALL {
525 let count = TOOLS
526 .iter()
527 .flat_map(|tool| tool.actions.iter())
528 .filter(|action| action.op == op)
529 .count();
530 assert_eq!(count, 1, "{} is {count} actions", op.name());
531 }
532 for tool in TOOLS {
533 let mut names = std::collections::HashSet::new();
534 for action in tool.actions {
535 assert!(names.insert(action.name), "{}.{} twice", tool.name, action.name);
536 }
537 if let Some(default) = tool.default_action {
538 assert!(tool.action(default).is_some(), "{}", tool.name);
539 }
540 }
541 assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len());
542 }
543
544 #[test]
545 fn every_operation_needs_exactly_one_scope_or_none() {
546 use g1t_contracts::scopes::OPERATIONS;
547 for op in Op::ALL {
548 let mapped = OPERATIONS.iter().filter(|(name, _)| *name == op.name()).count();
549 let free = NO_SCOPE.contains(&op.name());
550 assert_eq!(mapped + usize::from(free), 1, "{}", op.name());
551 }
552 for (name, _) in OPERATIONS {
553 assert!(Op::by_name(name).is_some(), "{name} is not an operation");
554 }
555 }
556
557 #[test]
558 fn each_tool_schema_is_valid_with_one_branch_per_action() {
559 for tool in TOOLS {
560 let actions: Vec<&Action> = tool.actions.iter().collect();
561 let flat = tool.input_schema(&actions);
562 assert_eq!(flat["type"], "object");
563 assert!(flat.get("oneOf").is_none(), "no oneOf at the top level");
564 let listed: Vec<&str> = flat["properties"]["action"]["enum"]
565 .as_array()
566 .unwrap()
567 .iter()
568 .map(|name| name.as_str().unwrap())
569 .collect();
570 assert_eq!(listed, tool.actions.iter().map(|action| action.name).collect::<Vec<_>>());
571 for action in tool.actions {
572 for field in action.op.required() {
573 assert!(flat["properties"].get(&field).is_some(), "{}.{}: {field}", tool.name, action.name);
574 }
575 }
576 let keyed = tool.discriminated(&actions);
577 let branches = keyed["oneOf"].as_array().unwrap();
578 assert_eq!(branches.len(), tool.actions.len());
579 for (branch, action) in branches.iter().zip(tool.actions) {
580 assert_eq!(branch["properties"]["action"]["const"], action.name);
581 for field in branch["required"].as_array().unwrap() {
582 assert!(branch["properties"].get(field.as_str().unwrap()).is_some(), "{}.{}: {field}", tool.name, action.name);
583 }
584 }
585 // A well-formed JSON Schema object throughout.
586 let text = serde_json::to_string(&flat).unwrap();
587 assert!(serde_json::from_str::<Value>(&text).is_ok());
588 }
589 }
590
591 #[test]
592 fn a_read_only_token_sees_read_actions_only() {
593 let access = token(Preset::ReadOnly.scopes());
594 let gate = Gate::Token(&access);
595 for tool in TOOLS {
596 for action in tool.visible(&gate) {
597 assert!(reads_only(action.op), "{}.{}", tool.name, action.name);
598 }
599 }
600 let tools = listed(&gate);
601 for tool in &tools {
602 assert_eq!(tool["annotations"]["readOnlyHint"], true, "{}", tool["name"]);
603 assert_eq!(tool["annotations"]["destructiveHint"], false);
604 }
605 let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap();
606 assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get"]));
607 // Nothing of the agent tool is a read.
608 assert!(!tools.iter().any(|tool| tool["name"] == "agent"));
609 }
610
611 #[test]
612 fn a_narrow_token_sees_only_its_tools() {
613 let access = token(Some(vec![Scope::IssuesWrite]));
614 let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect();
615 assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]);
616 let full = token(None);
617 assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len());
618 assert_eq!(listed(&Gate::Everything).len(), TOOLS.len());
619 }
620
621 #[test]
622 fn a_tool_that_can_destroy_says_so() {
623 let tools = listed(&Gate::Everything);
624 let repository = tools.iter().find(|tool| tool["name"] == "repository").unwrap();
625 assert_eq!(repository["annotations"]["destructiveHint"], true);
626 assert_eq!(repository["annotations"]["readOnlyHint"], false);
627 let memory = tools.iter().find(|tool| tool["name"] == "memory").unwrap();
628 assert_eq!(memory["annotations"]["destructiveHint"], false);
629 }
630
631 #[test]
632 fn calls_resolve_to_their_operation_or_say_what_is_missing() {
633 let issue = Tool::by_name("issue").unwrap();
634 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b", "number": 1 })), Ok(Op::GetIssue));
635 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b" })), Err("issue.get needs number.".to_owned()));
636 assert!(resolve(issue, &json!({})).unwrap_err().starts_with("Give an action"));
637 assert!(resolve(issue, &json!({ "action": "explode" })).unwrap_err().contains("no action explode"));
638 let search = Tool::by_name("search").unwrap();
639 assert_eq!(resolve(search, &json!({ "query": "x" })), Ok(Op::Search));
640 let account = Tool::by_name("account").unwrap();
641 assert_eq!(resolve(account, &json!({})), Ok(Op::Whoami));
642 }
643
644 /// How much smaller `tools/list` is than one tool per operation. Run
645 /// with `--nocapture` to see the numbers.
646 #[test]
647 fn the_tool_list_is_much_smaller_than_one_tool_per_operation() {
648 let before: Vec<Value> = Op::ALL
649 .into_iter()
650 .map(|op| json!({ "name": op.name(), "description": op.description(), "inputSchema": op.input() }))
651 .collect();
652 let after = listed(&Gate::Everything);
653 let before_bytes = serde_json::to_string(&json!({ "tools": before })).unwrap().len();
654 let after_bytes = serde_json::to_string(&json!({ "tools": after })).unwrap().len();
655 let agent = token(Preset::Agent.scopes());
656 let agent_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&agent)) })).unwrap().len();
657 let read = token(Preset::ReadOnly.scopes());
658 let read_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&read)) })).unwrap().len();
659 println!(
660 "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)",
661 before.len(),
662 before_bytes / 4,
663 after.len(),
664 after_bytes / 4,
665 agent_bytes / 4,
666 read_bytes / 4,
667 );
668 assert!(after_bytes * 2 < before_bytes, "{after_bytes} vs {before_bytes}");
669 }
670}