| 85 | 85 | | a("contents", Op::GetContents, "A file's text, or a directory's entries and README, at a branch, tag or commit"), |
| 86 | 86 | | a("tarball", Op::Archives(ArchiveOp::DownloadTarball), "A 10-minute link to a branch, tag or commit as a .tar.gz"), |
| 87 | 87 | | a("zipball", Op::Archives(ArchiveOp::DownloadZipball), "A 10-minute link to a branch, tag or commit as a .zip"), |
| 88 | | − | a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL"), |
| 88 | + | a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL; private unless private is false"), |
| 89 | 89 | | a("update", Op::UpdateRepo, "Change description (its project's), website, topics, default branch, protection, forking"), |
| 90 | 90 | | a("get_settings", Op::GetRepoSettings, "How pull requests merge, and the default branch's protection as its rules stack"), |
| 91 | 91 | | a("update_settings", Op::UpdateRepoSettings, "Change how pull requests merge and the default branch protection ruleset"), |
| ⋯ |
| 848 | 848 | | } |
| 849 | 849 | | } |
| 850 | 850 | | |
| 851 | + | /// A call's arguments with the MCP server's own defaults, where they differ |
| 852 | + | /// from the REST API's: a repository an agent creates is private unless it |
| 853 | + | /// says otherwise (`private: false`, or `visibility: "public"`), so nothing |
| 854 | + | /// it starts is public by accident. REST keeps its default, public. |
| 855 | + | pub fn with_defaults(op: Op, arguments: &Value) -> Value { |
| 856 | + | let mut arguments = arguments.clone(); |
| 857 | + | if op == Op::CreateRepo |
| 858 | + | && let Some(fields) = arguments.as_object_mut() |
| 859 | + | && !fields.get("private").is_some_and(Value::is_boolean) |
| 860 | + | { |
| 861 | + | let public = fields.get("visibility").and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case("public")); |
| 862 | + | fields.insert("private".to_owned(), Value::Bool(!public)); |
| 863 | + | } |
| 864 | + | arguments |
| 865 | + | } |
| 866 | + | |
| 851 | 867 | | /// What a call to a tool runs: the operation its action names, or why not. |
| 852 | 868 | | pub fn resolve(tool: &Tool, arguments: &Value) -> Result<Op, String> { |
| 853 | 869 | | let names = || { |
| ⋯ |
| 895 | 911 | | } |
| 896 | 912 | | |
| 897 | 913 | | #[test] |
| 914 | + | fn a_repository_created_over_mcp_is_private_unless_it_says_public() { |
| 915 | + | let made = |arguments: Value| with_defaults(Op::CreateRepo, &arguments)["private"].clone(); |
| 916 | + | assert_eq!(made(json!({ "name": "web" })), json!(true)); |
| 917 | + | assert_eq!(made(json!({ "name": "web", "private": null })), json!(true)); |
| 918 | + | assert_eq!(made(json!({ "name": "web", "private": true })), json!(true)); |
| 919 | + | assert_eq!(made(json!({ "name": "web", "private": false })), json!(false)); |
| 920 | + | assert_eq!(made(json!({ "name": "web", "visibility": "public" })), json!(false)); |
| 921 | + | assert_eq!(made(json!({ "name": "web", "visibility": "private" })), json!(true)); |
| 922 | + | // Other operations' arguments are left as given. |
| 923 | + | assert_eq!(with_defaults(Op::UpdateRepo, &json!({ "repo": "acme/web" })), json!({ "repo": "acme/web" })); |
| 924 | + | let repository = Tool::by_name("repository").unwrap(); |
| 925 | + | assert!(repository.action("create").unwrap().summary.contains("private unless private is false")); |
| 926 | + | } |
| 927 | + | |
| 928 | + | #[test] |
| 898 | 929 | | fn every_operation_is_exactly_one_action_of_one_tool() { |
| 899 | 930 | | for op in Op::ALL { |
| 900 | 931 | | let count = TOOLS |