|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "net/url" |
| 7 | + "os" |
| 8 | + "regexp" |
| 9 | + "sort" |
| 10 | + "strings" |
| 11 | + |
| 12 | + "github.com/github/github-mcp-server/pkg/github" |
| 13 | + "github.com/github/github-mcp-server/pkg/raw" |
| 14 | + "github.com/github/github-mcp-server/pkg/toolsets" |
| 15 | + "github.com/github/github-mcp-server/pkg/translations" |
| 16 | + gogithub "github.com/google/go-github/v72/github" |
| 17 | + "github.com/mark3labs/mcp-go/mcp" |
| 18 | + "github.com/shurcooL/githubv4" |
| 19 | + "github.com/spf13/cobra" |
| 20 | +) |
| 21 | + |
| 22 | +var generateDocsCmd = &cobra.Command{ |
| 23 | + Use: "generate-docs", |
| 24 | + Short: "Generate documentation for tools and toolsets", |
| 25 | + Long: `Generate the automated sections of README.md and docs/remote-server.md with current tool and toolset information.`, |
| 26 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 27 | + return generateAllDocs() |
| 28 | + }, |
| 29 | +} |
| 30 | + |
| 31 | +func init() { |
| 32 | + rootCmd.AddCommand(generateDocsCmd) |
| 33 | +} |
| 34 | + |
| 35 | +// mockGetClient returns a mock GitHub client for documentation generation |
| 36 | +func mockGetClient(ctx context.Context) (*gogithub.Client, error) { |
| 37 | + return gogithub.NewClient(nil), nil |
| 38 | +} |
| 39 | + |
| 40 | +// mockGetGQLClient returns a mock GraphQL client for documentation generation |
| 41 | +func mockGetGQLClient(ctx context.Context) (*githubv4.Client, error) { |
| 42 | + return githubv4.NewClient(nil), nil |
| 43 | +} |
| 44 | + |
| 45 | +// mockGetRawClient returns a mock raw client for documentation generation |
| 46 | +func mockGetRawClient(ctx context.Context) (*raw.Client, error) { |
| 47 | + return nil, nil |
| 48 | +} |
| 49 | + |
| 50 | +func generateAllDocs() error { |
| 51 | + if err := generateReadmeDocs("README.md"); err != nil { |
| 52 | + return fmt.Errorf("failed to generate README docs: %w", err) |
| 53 | + } |
| 54 | + |
| 55 | + if err := generateRemoteServerDocs("docs/remote-server.md"); err != nil { |
| 56 | + return fmt.Errorf("failed to generate remote-server docs: %w", err) |
| 57 | + } |
| 58 | + |
| 59 | + return nil |
| 60 | +} |
| 61 | + |
| 62 | +func generateReadmeDocs(readmePath string) error { |
| 63 | + // Create translation helper |
| 64 | + t, _ := translations.TranslationHelper() |
| 65 | + |
| 66 | + // Create toolset group with mock clients |
| 67 | + tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t) |
| 68 | + |
| 69 | + // Generate toolsets documentation |
| 70 | + toolsetsDoc := generateToolsetsDoc(tsg) |
| 71 | + |
| 72 | + // Generate tools documentation |
| 73 | + toolsDoc := generateToolsDoc(tsg) |
| 74 | + |
| 75 | + // Read the current README.md |
| 76 | + // #nosec G304 - readmePath is controlled by command line flag, not user input |
| 77 | + content, err := os.ReadFile(readmePath) |
| 78 | + if err != nil { |
| 79 | + return fmt.Errorf("failed to read README.md: %w", err) |
| 80 | + } |
| 81 | + |
| 82 | + // Replace toolsets section |
| 83 | + updatedContent := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc) |
| 84 | + |
| 85 | + // Replace tools section |
| 86 | + updatedContent = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc) |
| 87 | + |
| 88 | + // Write back to file |
| 89 | + err = os.WriteFile(readmePath, []byte(updatedContent), 0600) |
| 90 | + if err != nil { |
| 91 | + return fmt.Errorf("failed to write README.md: %w", err) |
| 92 | + } |
| 93 | + |
| 94 | + fmt.Println("Successfully updated README.md with automated documentation") |
| 95 | + return nil |
| 96 | +} |
| 97 | + |
| 98 | +func generateRemoteServerDocs(docsPath string) error { |
| 99 | + content, err := os.ReadFile(docsPath) //#nosec G304 |
| 100 | + if err != nil { |
| 101 | + return fmt.Errorf("failed to read docs file: %w", err) |
| 102 | + } |
| 103 | + |
| 104 | + toolsetsDoc := generateRemoteToolsetsDoc() |
| 105 | + |
| 106 | + // Replace content between markers |
| 107 | + startMarker := "<!-- START AUTOMATED TOOLSETS -->" |
| 108 | + endMarker := "<!-- END AUTOMATED TOOLSETS -->" |
| 109 | + |
| 110 | + contentStr := string(content) |
| 111 | + startIndex := strings.Index(contentStr, startMarker) |
| 112 | + endIndex := strings.Index(contentStr, endMarker) |
| 113 | + |
| 114 | + if startIndex == -1 || endIndex == -1 { |
| 115 | + return fmt.Errorf("automation markers not found in %s", docsPath) |
| 116 | + } |
| 117 | + |
| 118 | + newContent := contentStr[:startIndex] + startMarker + "\n" + toolsetsDoc + "\n" + endMarker + contentStr[endIndex+len(endMarker):] |
| 119 | + |
| 120 | + return os.WriteFile(docsPath, []byte(newContent), 0600) //#nosec G306 |
| 121 | +} |
| 122 | + |
| 123 | +func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { |
| 124 | + var lines []string |
| 125 | + |
| 126 | + // Add table header and separator |
| 127 | + lines = append(lines, "| Toolset | Description |") |
| 128 | + lines = append(lines, "| ----------------------- | ------------------------------------------------------------- |") |
| 129 | + |
| 130 | + // Add the context toolset row (handled separately in README) |
| 131 | + lines = append(lines, "| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |") |
| 132 | + |
| 133 | + // Get all toolsets except context (which is handled separately above) |
| 134 | + var toolsetNames []string |
| 135 | + for name := range tsg.Toolsets { |
| 136 | + if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately |
| 137 | + toolsetNames = append(toolsetNames, name) |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + // Sort toolset names for consistent output |
| 142 | + sort.Strings(toolsetNames) |
| 143 | + |
| 144 | + for _, name := range toolsetNames { |
| 145 | + toolset := tsg.Toolsets[name] |
| 146 | + lines = append(lines, fmt.Sprintf("| `%s` | %s |", name, toolset.Description)) |
| 147 | + } |
| 148 | + |
| 149 | + return strings.Join(lines, "\n") |
| 150 | +} |
| 151 | + |
| 152 | +func generateToolsDoc(tsg *toolsets.ToolsetGroup) string { |
| 153 | + var sections []string |
| 154 | + |
| 155 | + // Define the order of toolsets to match the existing README |
| 156 | + orderedToolsets := []string{"context", "issues", "pull_requests", "repos", "actions", "code_security", "secret_protection", "notifications", "users", "orgs", "experiments"} |
| 157 | + |
| 158 | + for _, toolsetName := range orderedToolsets { |
| 159 | + toolset, exists := tsg.Toolsets[toolsetName] |
| 160 | + if !exists { |
| 161 | + continue |
| 162 | + } |
| 163 | + |
| 164 | + tools := toolset.GetAvailableTools() |
| 165 | + if len(tools) == 0 { |
| 166 | + continue |
| 167 | + } |
| 168 | + |
| 169 | + // Sort tools by name for deterministic order |
| 170 | + sort.Slice(tools, func(i, j int) bool { |
| 171 | + return tools[i].Tool.Name < tools[j].Tool.Name |
| 172 | + }) |
| 173 | + |
| 174 | + // Generate section header - capitalize first letter and replace underscores |
| 175 | + sectionName := formatToolsetName(toolsetName) |
| 176 | + |
| 177 | + var toolDocs []string |
| 178 | + for _, serverTool := range tools { |
| 179 | + toolDoc := generateToolDoc(serverTool.Tool) |
| 180 | + toolDocs = append(toolDocs, toolDoc) |
| 181 | + } |
| 182 | + |
| 183 | + if len(toolDocs) > 0 { |
| 184 | + section := fmt.Sprintf("<details>\n\n<summary>%s</summary>\n\n%s\n\n</details>", |
| 185 | + sectionName, strings.Join(toolDocs, "\n\n")) |
| 186 | + sections = append(sections, section) |
| 187 | + } |
| 188 | + } |
| 189 | + |
| 190 | + return strings.Join(sections, "\n\n") |
| 191 | +} |
| 192 | + |
| 193 | +func formatToolsetName(name string) string { |
| 194 | + switch name { |
| 195 | + case "pull_requests": |
| 196 | + return "Pull Requests" |
| 197 | + case "repos": |
| 198 | + return "Repositories" |
| 199 | + case "code_security": |
| 200 | + return "Code Security" |
| 201 | + case "secret_protection": |
| 202 | + return "Secret Protection" |
| 203 | + case "orgs": |
| 204 | + return "Organizations" |
| 205 | + default: |
| 206 | + // Fallback: capitalize first letter and replace underscores with spaces |
| 207 | + parts := strings.Split(name, "_") |
| 208 | + for i, part := range parts { |
| 209 | + if len(part) > 0 { |
| 210 | + parts[i] = strings.ToUpper(string(part[0])) + part[1:] |
| 211 | + } |
| 212 | + } |
| 213 | + return strings.Join(parts, " ") |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +func generateToolDoc(tool mcp.Tool) string { |
| 218 | + var lines []string |
| 219 | + |
| 220 | + // Tool name only (using annotation name instead of verbose description) |
| 221 | + lines = append(lines, fmt.Sprintf("- **%s** - %s", tool.Name, tool.Annotations.Title)) |
| 222 | + |
| 223 | + // Parameters |
| 224 | + schema := tool.InputSchema |
| 225 | + if len(schema.Properties) > 0 { |
| 226 | + // Get parameter names and sort them for deterministic order |
| 227 | + var paramNames []string |
| 228 | + for propName := range schema.Properties { |
| 229 | + paramNames = append(paramNames, propName) |
| 230 | + } |
| 231 | + sort.Strings(paramNames) |
| 232 | + |
| 233 | + for _, propName := range paramNames { |
| 234 | + prop := schema.Properties[propName] |
| 235 | + required := contains(schema.Required, propName) |
| 236 | + requiredStr := "optional" |
| 237 | + if required { |
| 238 | + requiredStr = "required" |
| 239 | + } |
| 240 | + |
| 241 | + // Get the type and description |
| 242 | + typeStr := "unknown" |
| 243 | + description := "" |
| 244 | + |
| 245 | + if propMap, ok := prop.(map[string]interface{}); ok { |
| 246 | + if typeVal, ok := propMap["type"].(string); ok { |
| 247 | + if typeVal == "array" { |
| 248 | + if items, ok := propMap["items"].(map[string]interface{}); ok { |
| 249 | + if itemType, ok := items["type"].(string); ok { |
| 250 | + typeStr = itemType + "[]" |
| 251 | + } |
| 252 | + } else { |
| 253 | + typeStr = "array" |
| 254 | + } |
| 255 | + } else { |
| 256 | + typeStr = typeVal |
| 257 | + } |
| 258 | + } |
| 259 | + |
| 260 | + if desc, ok := propMap["description"].(string); ok { |
| 261 | + description = desc |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + paramLine := fmt.Sprintf(" - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) |
| 266 | + lines = append(lines, paramLine) |
| 267 | + } |
| 268 | + } else { |
| 269 | + lines = append(lines, " - No parameters required") |
| 270 | + } |
| 271 | + |
| 272 | + return strings.Join(lines, "\n") |
| 273 | +} |
| 274 | + |
| 275 | +func contains(slice []string, item string) bool { |
| 276 | + for _, s := range slice { |
| 277 | + if s == item { |
| 278 | + return true |
| 279 | + } |
| 280 | + } |
| 281 | + return false |
| 282 | +} |
| 283 | + |
| 284 | +func replaceSection(content, startMarker, endMarker, newContent string) string { |
| 285 | + startPattern := fmt.Sprintf(`<!-- %s -->`, regexp.QuoteMeta(startMarker)) |
| 286 | + endPattern := fmt.Sprintf(`<!-- %s -->`, regexp.QuoteMeta(endMarker)) |
| 287 | + |
| 288 | + re := regexp.MustCompile(fmt.Sprintf(`(?s)%s.*?%s`, startPattern, endPattern)) |
| 289 | + |
| 290 | + replacement := fmt.Sprintf("<!-- %s -->\n%s\n<!-- %s -->", startMarker, newContent, endMarker) |
| 291 | + |
| 292 | + return re.ReplaceAllString(content, replacement) |
| 293 | +} |
| 294 | + |
| 295 | +func generateRemoteToolsetsDoc() string { |
| 296 | + var buf strings.Builder |
| 297 | + |
| 298 | + // Create translation helper |
| 299 | + t, _ := translations.TranslationHelper() |
| 300 | + |
| 301 | + // Create toolset group with mock clients |
| 302 | + tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t) |
| 303 | + |
| 304 | + // Generate table header |
| 305 | + buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") |
| 306 | + buf.WriteString("|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n") |
| 307 | + |
| 308 | + // Get all toolsets |
| 309 | + toolsetNames := make([]string, 0, len(tsg.Toolsets)) |
| 310 | + for name := range tsg.Toolsets { |
| 311 | + if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately |
| 312 | + toolsetNames = append(toolsetNames, name) |
| 313 | + } |
| 314 | + } |
| 315 | + sort.Strings(toolsetNames) |
| 316 | + |
| 317 | + // Add "all" toolset first (special case) |
| 318 | + buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n") |
| 319 | + |
| 320 | + // Add individual toolsets |
| 321 | + for _, name := range toolsetNames { |
| 322 | + toolset := tsg.Toolsets[name] |
| 323 | + |
| 324 | + formattedName := formatToolsetName(name) |
| 325 | + description := toolset.Description |
| 326 | + apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", name) |
| 327 | + readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", name) |
| 328 | + |
| 329 | + // Create install config JSON (URL encoded) |
| 330 | + installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) |
| 331 | + readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL)) |
| 332 | + |
| 333 | + // Fix URL encoding to use %20 instead of + for spaces |
| 334 | + installConfig = strings.ReplaceAll(installConfig, "+", "%20") |
| 335 | + readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") |
| 336 | + |
| 337 | + installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, installConfig) |
| 338 | + readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, readonlyConfig) |
| 339 | + |
| 340 | + buf.WriteString(fmt.Sprintf("| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", |
| 341 | + formattedName, |
| 342 | + description, |
| 343 | + apiURL, |
| 344 | + installLink, |
| 345 | + fmt.Sprintf("[read-only](%s)", readonlyURL), |
| 346 | + readonlyInstallLink, |
| 347 | + )) |
| 348 | + } |
| 349 | + |
| 350 | + return buf.String() |
| 351 | +} |
0 commit comments