Skip to content

g1t/apps/api/src/tools.rs

706 lines35,216 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, connect integrations and model providers, and keep your own pinned projects at the top of its sidebar.",
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 a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"),
246 a("pin_project", Op::PinProject, "Pin a project, at a position or the end"),
247 a("unpin_project", Op::UnpinProject, "Unpin a project"),
248 a("reorder_pinned_projects", Op::ReorderPinnedProjects, "Put your pins in a new order"),
249 ],
250 },
251 Tool {
252 name: "notifications",
253 title: "Notifications",
254 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.",
255 default_action: Some("list"),
256 actions: &[
257 a("list", Op::ListNotifications, "Unread threads, latest first; all, a view, a reason, a repository"),
258 a("get", Op::GetNotificationThread, "One thread with its recent activity and your subscription"),
259 a("mark_read", Op::MarkThreadRead, "Mark a thread read, or unread"),
260 a("mark_all_read", Op::MarkNotificationsRead, "Mark everything read up to a time, or one repository's"),
261 a("done", Op::MarkThreadDone, "Mark a thread done; new activity brings it back"),
262 a("save", Op::SaveThread, "Save a thread, or unsave it"),
263 a("snooze", Op::SnoozeThread, "Snooze a thread until a time, or bring it back"),
264 a("subscription", Op::GetThreadSubscription, "Your subscription to an issue or pull request"),
265 a("subscribe", Op::SetThreadSubscription, "Subscribe to an issue or pull request, or ignore it"),
266 a("unsubscribe", Op::DeleteThreadSubscription, "Unsubscribe until you comment or are mentioned"),
267 a("watching", Op::GetRepoSubscription, "How you watch a repository"),
268 a("watch", Op::SetRepoSubscription, "Watch a repository: participating, all, ignore or custom"),
269 a("unwatch", Op::DeleteRepoSubscription, "Stop watching a repository"),
270 a("watched", Op::ListWatchedRepos, "Repositories you watch other than the default way"),
271 ],
272 },
273 Tool {
274 name: "account",
275 title: "Your account",
276 description: "Who this token acts as and its workspaces (`whoami`), your email addresses, your invites, and invitations to repositories waiting for you.",
277 default_action: Some("whoami"),
278 actions: &[
279 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
280 a("list_emails", Op::ListEmails, "Your addresses"),
281 a("add_email", Op::AddEmail, "Add an address"),
282 a("remove_email", Op::RemoveEmail, "Remove an address"),
283 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
284 a("list_invites", Op::ListInvites, "Your invites to g1t"),
285 a("create_invite", Op::CreateInvite, "Make an invite"),
286 a("revoke_invite", Op::RevokeInvite, "Revoke one"),
287 a("list_repository_invitations", Op::ListMyRepoInvitations, "Invitations to repositories for you"),
288 a("accept_repository_invitation", Op::AcceptRepoInvitation, "Accept one"),
289 a("decline_repository_invitation", Op::DeclineRepoInvitation, "Decline one"),
290 ],
291 },
292];
293
294/// Operations that cannot be undone, or reach beyond g1t's own records:
295/// clients ask before running a tool that has any of them.
296fn destructive(op: Op) -> bool {
297 matches!(
298 op,
299 Op::DeleteWorkspace
300 | Op::UpdateWorkspace
301 | Op::DeleteRepo
302 | Op::PurgeRepo
303 | Op::TransferRepo
304 | Op::SetRepoVisibility
305 | Op::RemoveEmail
306 | Op::RemoveCollaborator
307 | Op::DisconnectIntegration
308 | Op::DeleteWebhook
309 | Op::DeleteActionsSecret
310 | Op::DeleteActionsVariable
311 | Op::SetActionsSecret
312 | Op::SetActionsVariable
313 | Op::SetModelRoutes
314 | Op::SetBasePermission
315 | Op::MergePullRequest
316 | Op::RemoveRunner
317 | Op::DeleteRunnerGroup
318 | Op::UpdateRunnerSettings
319 )
320}
321
322/// Whether an operation only reads.
323pub fn reads_only(op: Op) -> bool {
324 NO_SCOPE.contains(&op.name())
325 || scope_for(op.name()).is_some_and(|scope| scope.level() == Level::Read)
326}
327
328/// What decides which actions a caller sees.
329pub enum Gate<'a> {
330 /// No limit beyond the person's own role.
331 Everything,
332 /// A g1t agent's token: the operations its run lists.
333 Agent(&'a AgentScope),
334 /// An access token with scopes.
335 Token(&'a TokenAccess),
336}
337
338impl Gate<'_> {
339 pub fn allows(&self, op: Op) -> bool {
340 match self {
341 Gate::Everything => true,
342 Gate::Agent(scope) => op.allowed_by(scope) && !NEVER.contains(&op.name()),
343 Gate::Token(access) => {
344 if NO_SCOPE.contains(&op.name()) {
345 return true;
346 }
347 match scope_for(op.name()) {
348 Some(scope) => access.allows(scope),
349 None => access.scopes.is_none(),
350 }
351 }
352 }
353 }
354}
355
356impl Tool {
357 pub fn by_name(name: &str) -> Option<&'static Tool> {
358 TOOLS.iter().find(|tool| tool.name == name)
359 }
360
361 pub fn action(&self, name: &str) -> Option<&'static Action> {
362 // The tools are 'static; find through TOOLS to keep the lifetime.
363 TOOLS
364 .iter()
365 .find(|tool| tool.name == self.name)
366 .and_then(|tool| tool.actions.iter().find(|action| action.name == name))
367 }
368
369 pub fn visible(&self, gate: &Gate) -> Vec<&'static Action> {
370 TOOLS
371 .iter()
372 .find(|tool| tool.name == self.name)
373 .map(|tool| tool.actions.iter().filter(|action| gate.allows(action.op)).collect())
374 .unwrap_or_default()
375 }
376
377 /// The flat input schema of the actions given.
378 pub fn input_schema(&self, actions: &[&Action]) -> Value {
379 let mut properties = Map::new();
380 let lines: Vec<String> = actions
381 .iter()
382 .map(|action| {
383 let required: Vec<String> = action.op.required();
384 if required.is_empty() {
385 format!("{}: {}.", action.name, action.summary)
386 } else {
387 format!("{} ({}): {}.", action.name, required.join(", "), action.summary)
388 }
389 })
390 .collect();
391 let mut action_schema = json!({
392 "type": "string",
393 "enum": actions.iter().map(|action| action.name).collect::<Vec<_>>(),
394 "description": lines.join("\n"),
395 });
396 if let Some(default) = self.default_action.filter(|name| actions.iter().any(|action| action.name == *name)) {
397 action_schema["default"] = json!(default);
398 }
399 properties.insert("action".to_owned(), action_schema);
400 for action in actions {
401 for (name, schema) in action.op.properties() {
402 merge_property(&mut properties, name, schema);
403 }
404 }
405 let mut required = vec![];
406 if self.default_action.is_none() {
407 required.push("action");
408 }
409 let mut schema = json!({ "type": "object", "properties": properties });
410 if !required.is_empty() {
411 schema["required"] = json!(required);
412 }
413 schema
414 }
415
416 /// The input schema keyed by action: one `oneOf` branch per action,
417 /// each with its own fields and the ones it needs.
418 pub fn discriminated(&self, actions: &[&Action]) -> Value {
419 let branches: Vec<Value> = actions
420 .iter()
421 .map(|action| {
422 let mut properties = Map::new();
423 properties.insert("action".to_owned(), json!({ "const": action.name }));
424 properties.extend(action.op.properties());
425 let mut required = vec![Value::String("action".to_owned())];
426 // The default action may leave `action` out.
427 if self.default_action == Some(action.name) {
428 required.clear();
429 }
430 required.extend(action.op.required().into_iter().map(Value::String));
431 json!({
432 "title": action.name,
433 "description": action.summary,
434 "type": "object",
435 "properties": properties,
436 "required": required,
437 })
438 })
439 .collect();
440 json!({ "type": "object", "oneOf": branches })
441 }
442
443 /// MCP's hints about the actions given: whether the tool only reads,
444 /// whether it can destroy something, and whether calling it twice is
445 /// the same as once.
446 pub fn annotations(&self, actions: &[&Action]) -> Value {
447 let read_only = actions.iter().all(|action| reads_only(action.op));
448 json!({
449 "title": self.title,
450 "readOnlyHint": read_only,
451 "destructiveHint": !read_only && actions.iter().any(|action| destructive(action.op)),
452 "idempotentHint": read_only,
453 "openWorldHint": false,
454 })
455 }
456
457 /// The tool as `tools/list` gives it, for a caller behind `gate`, or
458 /// `None` when it may use none of its actions.
459 pub fn listed(&self, gate: &Gate) -> Option<Value> {
460 let actions = self.visible(gate);
461 if actions.is_empty() {
462 return None;
463 }
464 Some(json!({
465 "name": self.name,
466 "title": self.title,
467 "description": self.description,
468 "inputSchema": self.input_schema(&actions),
469 "annotations": self.annotations(&actions),
470 }))
471 }
472}
473
474/// Adds a property to a tool's flat schema. The first action to use a name
475/// describes it; a later one with other allowed values adds them.
476fn merge_property(properties: &mut Map<String, Value>, name: String, schema: Value) {
477 match properties.get_mut(&name) {
478 None => {
479 properties.insert(name, schema);
480 }
481 Some(existing) => {
482 if let (Some(Value::Array(had)), Some(Value::Array(more))) =
483 (existing.get("enum").cloned(), schema.get("enum"))
484 {
485 let mut merged = had;
486 for value in more {
487 if !merged.contains(value) {
488 merged.push(value.clone());
489 }
490 }
491 existing["enum"] = Value::Array(merged);
492 }
493 // Different kinds of value under one name: say less, accept both.
494 if existing.get("type") != schema.get("type")
495 && let Some(fields) = existing.as_object_mut()
496 {
497 fields.remove("type");
498 fields.remove("items");
499 }
500 }
501 }
502}
503
504/// What a call to a tool runs: the operation its action names, or why not.
505pub fn resolve(tool: &Tool, arguments: &Value) -> Result<Op, String> {
506 let names = || {
507 tool.actions
508 .iter()
509 .map(|action| action.name)
510 .collect::<Vec<_>>()
511 .join(", ")
512 };
513 let Some(name) = arguments["action"].as_str().or(tool.default_action) else {
514 return Err(format!("Give an action: one of {}.", names()));
515 };
516 let Some(action) = tool.action(name) else {
517 return Err(format!("{} has no action {name}. Its actions: {}.", tool.name, names()));
518 };
519 let missing: Vec<String> = action
520 .op
521 .required()
522 .into_iter()
523 .filter(|field| arguments.get(field).is_none_or(Value::is_null))
524 .collect();
525 if !missing.is_empty() {
526 return Err(format!("{}.{name} needs {}.", tool.name, missing.join(", ")));
527 }
528 Ok(action.op)
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use g1t_contracts::scopes::{Preset, Scope};
535
536 fn listed(gate: &Gate) -> Vec<Value> {
537 TOOLS.iter().filter_map(|tool| tool.listed(gate)).collect()
538 }
539
540 fn token(scopes: Option<Vec<Scope>>) -> TokenAccess {
541 TokenAccess {
542 token_id: "tok_1".to_owned(),
543 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
544 legacy: false,
545 }
546 }
547
548 #[test]
549 fn every_operation_is_exactly_one_action_of_one_tool() {
550 for op in Op::ALL {
551 let count = TOOLS
552 .iter()
553 .flat_map(|tool| tool.actions.iter())
554 .filter(|action| action.op == op)
555 .count();
556 assert_eq!(count, 1, "{} is {count} actions", op.name());
557 }
558 for tool in TOOLS {
559 let mut names = std::collections::HashSet::new();
560 for action in tool.actions {
561 assert!(names.insert(action.name), "{}.{} twice", tool.name, action.name);
562 }
563 if let Some(default) = tool.default_action {
564 assert!(tool.action(default).is_some(), "{}", tool.name);
565 }
566 }
567 assert!(TOOLS.len() <= 16, "{} tools", TOOLS.len());
568 }
569
570 #[test]
571 fn every_operation_needs_exactly_one_scope_or_none() {
572 use g1t_contracts::scopes::OPERATIONS;
573 for op in Op::ALL {
574 let mapped = OPERATIONS.iter().filter(|(name, _)| *name == op.name()).count();
575 let free = NO_SCOPE.contains(&op.name());
576 assert_eq!(mapped + usize::from(free), 1, "{}", op.name());
577 }
578 for (name, _) in OPERATIONS {
579 assert!(Op::by_name(name).is_some(), "{name} is not an operation");
580 }
581 }
582
583 #[test]
584 fn each_tool_schema_is_valid_with_one_branch_per_action() {
585 for tool in TOOLS {
586 let actions: Vec<&Action> = tool.actions.iter().collect();
587 let flat = tool.input_schema(&actions);
588 assert_eq!(flat["type"], "object");
589 assert!(flat.get("oneOf").is_none(), "no oneOf at the top level");
590 let listed: Vec<&str> = flat["properties"]["action"]["enum"]
591 .as_array()
592 .unwrap()
593 .iter()
594 .map(|name| name.as_str().unwrap())
595 .collect();
596 assert_eq!(listed, tool.actions.iter().map(|action| action.name).collect::<Vec<_>>());
597 for action in tool.actions {
598 for field in action.op.required() {
599 assert!(flat["properties"].get(&field).is_some(), "{}.{}: {field}", tool.name, action.name);
600 }
601 }
602 let keyed = tool.discriminated(&actions);
603 let branches = keyed["oneOf"].as_array().unwrap();
604 assert_eq!(branches.len(), tool.actions.len());
605 for (branch, action) in branches.iter().zip(tool.actions) {
606 assert_eq!(branch["properties"]["action"]["const"], action.name);
607 for field in branch["required"].as_array().unwrap() {
608 assert!(branch["properties"].get(field.as_str().unwrap()).is_some(), "{}.{}: {field}", tool.name, action.name);
609 }
610 }
611 // A well-formed JSON Schema object throughout.
612 let text = serde_json::to_string(&flat).unwrap();
613 assert!(serde_json::from_str::<Value>(&text).is_ok());
614 }
615 }
616
617 #[test]
618 fn a_read_only_token_sees_read_actions_only() {
619 let access = token(Preset::ReadOnly.scopes());
620 let gate = Gate::Token(&access);
621 for tool in TOOLS {
622 for action in tool.visible(&gate) {
623 assert!(reads_only(action.op), "{}.{}", tool.name, action.name);
624 }
625 }
626 let tools = listed(&gate);
627 for tool in &tools {
628 assert_eq!(tool["annotations"]["readOnlyHint"], true, "{}", tool["name"]);
629 assert_eq!(tool["annotations"]["destructiveHint"], false);
630 }
631 let issue = tools.iter().find(|tool| tool["name"] == "issue").unwrap();
632 assert_eq!(issue["inputSchema"]["properties"]["action"]["enum"], json!(["list", "get"]));
633 // Nothing of the agent tool is a read.
634 assert!(!tools.iter().any(|tool| tool["name"] == "agent"));
635 }
636
637 #[test]
638 fn a_narrow_token_sees_only_its_tools() {
639 let access = token(Some(vec![Scope::IssuesWrite]));
640 let names: Vec<Value> = listed(&Gate::Token(&access)).into_iter().map(|tool| tool["name"].clone()).collect();
641 assert_eq!(names, vec![json!("issue"), json!("plan"), json!("account")]);
642 // Notifications are a resource of their own: reading them lists
643 // only what reads.
644 let reader = token(Some(vec![Scope::NotificationsRead]));
645 let tools = listed(&Gate::Token(&reader));
646 let notifications = tools.iter().find(|tool| tool["name"] == "notifications").unwrap();
647 assert_eq!(
648 notifications["inputSchema"]["properties"]["action"]["enum"],
649 json!(["list", "get", "subscription", "watching", "watched"])
650 );
651 assert_eq!(notifications["annotations"]["readOnlyHint"], true);
652 let full = token(None);
653 assert_eq!(listed(&Gate::Token(&full)).len(), TOOLS.len());
654 assert_eq!(listed(&Gate::Everything).len(), TOOLS.len());
655 }
656
657 #[test]
658 fn a_tool_that_can_destroy_says_so() {
659 let tools = listed(&Gate::Everything);
660 let repository = tools.iter().find(|tool| tool["name"] == "repository").unwrap();
661 assert_eq!(repository["annotations"]["destructiveHint"], true);
662 assert_eq!(repository["annotations"]["readOnlyHint"], false);
663 let memory = tools.iter().find(|tool| tool["name"] == "memory").unwrap();
664 assert_eq!(memory["annotations"]["destructiveHint"], false);
665 }
666
667 #[test]
668 fn calls_resolve_to_their_operation_or_say_what_is_missing() {
669 let issue = Tool::by_name("issue").unwrap();
670 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b", "number": 1 })), Ok(Op::GetIssue));
671 assert_eq!(resolve(issue, &json!({ "action": "get", "repo": "a/b" })), Err("issue.get needs number.".to_owned()));
672 assert!(resolve(issue, &json!({})).unwrap_err().starts_with("Give an action"));
673 assert!(resolve(issue, &json!({ "action": "explode" })).unwrap_err().contains("no action explode"));
674 let search = Tool::by_name("search").unwrap();
675 assert_eq!(resolve(search, &json!({ "query": "x" })), Ok(Op::Search));
676 let account = Tool::by_name("account").unwrap();
677 assert_eq!(resolve(account, &json!({})), Ok(Op::Whoami));
678 }
679
680 /// How much smaller `tools/list` is than one tool per operation. Run
681 /// with `--nocapture` to see the numbers.
682 #[test]
683 fn the_tool_list_is_much_smaller_than_one_tool_per_operation() {
684 let before: Vec<Value> = Op::ALL
685 .into_iter()
686 .map(|op| json!({ "name": op.name(), "description": op.description(), "inputSchema": op.input() }))
687 .collect();
688 let after = listed(&Gate::Everything);
689 let before_bytes = serde_json::to_string(&json!({ "tools": before })).unwrap().len();
690 let after_bytes = serde_json::to_string(&json!({ "tools": after })).unwrap().len();
691 let agent = token(Preset::Agent.scopes());
692 let agent_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&agent)) })).unwrap().len();
693 let read = token(Preset::ReadOnly.scopes());
694 let read_bytes = serde_json::to_string(&json!({ "tools": listed(&Gate::Token(&read)) })).unwrap().len();
695 println!(
696 "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)",
697 before.len(),
698 before_bytes / 4,
699 after.len(),
700 after_bytes / 4,
701 agent_bytes / 4,
702 read_bytes / 4,
703 );
704 assert!(after_bytes * 2 < before_bytes, "{after_bytes} vs {before_bytes}");
705 }
706}