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