Skip to content

Commit dcdc124

Browse files
update documentation, add script and workflow
1 parent 798e674 commit dcdc124

File tree

11 files changed

+1109
-381
lines changed

11 files changed

+1109
-381
lines changed

.github/workflows/docs-check.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Documentation Check
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
docs-check:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- name: Checkout code
17+
uses: actions/checkout@v4
18+
19+
- name: Set up Go
20+
uses: actions/setup-go@v5
21+
with:
22+
go-version-file: 'go.mod'
23+
24+
- name: Build docs generator
25+
run: go build -o github-mcp-server ./cmd/github-mcp-server
26+
27+
- name: Generate documentation
28+
run: ./github-mcp-server generate-docs --readme-path README.md
29+
30+
- name: Check for documentation changes
31+
run: |
32+
if ! git diff --exit-code README.md; then
33+
echo "❌ Documentation is out of date!"
34+
echo ""
35+
echo "The generated documentation differs from what's committed."
36+
echo "Please run the following command to update the documentation:"
37+
echo ""
38+
echo " go run ./cmd/github-mcp-server generate-docs"
39+
echo ""
40+
echo "Then commit the changes."
41+
echo ""
42+
echo "Changes detected:"
43+
git diff README.md
44+
exit 1
45+
else
46+
echo "✅ Documentation is up to date!"
47+
fi

README.md

Lines changed: 303 additions & 373 deletions
Large diffs are not rendered by default.
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
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

Comments
 (0)