0503f2f2c5
* fix(ui): render success view when an MCP App tool executed up-front The create_pull_request / issue_write / update_pull_request Views decided form-vs-success from in-app submit state only, ignoring the tool-result the host delivers on render. Per the MCP Apps 2026-01-26 spec the host renders a View whenever the tool carries _meta.ui.resourceUri — independent of whether the server deferred or executed. So when the server executed up-front (e.g. show_ui=false, or parameters the form can't represent) the View still showed its "Create pull request" input form over an already-created PR, which reads as a bug (it even shows a PR number). Drive the Views off the result instead: a new shared completedToolResult() helper returns parsed data only for a genuine completed success, and returns null for the awaiting_user_submission deferral sentinel, errors, or no result. Each write View now shows its success card when that completed result is present, so the form is only ever shown while the action is genuinely deferred. Reconciles the show/defer state machine at the View (decision layer that the host result feeds). See github/copilot-mcp-core#1864. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ui): scope tool-result to the current invocation Address review feedback: the write Views derive their success card from `toolResult`, but it wasn't cleared when a new invocation arrived (only the in-app `successPR`/`successIssue` was reset on `toolInput` change). A completed result from a previous invocation could briefly render a stale success card over the next, still-deferred form. Clear `toolResult` whenever a new `tool-input` notification arrives. The spec guarantees `tool-input` precedes that invocation's `tool-result`, so this scopes the result to the current invocation centrally in the hook — fixing all three Views without per-app invocation keys. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(mcp-apps): remove show_ui — it can't suppress app rendering show_ui promised "skip the form and execute directly", but it can't deliver: the host renders an MCP App for any tool that carries _meta.ui.resourceUri, and the 2026-01-26 MCP Apps spec has no per-call/per-result way to opt out of rendering. show_ui only flipped the server's defer decision, so show_ui=false created the PR/issue up-front yet the host still rendered the app — exactly the contradiction this work set out to fix. And show_ui is only ever exposed to clients that support UI, i.e. precisely the clients that always render the app. Remove it entirely: - Drop the show_ui schema property, the form-param allowlist entry, and the showUI term from the defer predicate in create_pull_request and issue_write. The gate is now FF && clientSupportsUI && !_ui_submitted && !hasNonFormParams. - Delete the now-unused UI-only schema-property strip machinery in pkg/inventory (uiOnlySchemaProperties, stripUIOnlySchemaProperties, stripSchemaProperties) and the exported ConditionalSchemaPropertyDescriptions, which existed solely to surface show_ui to UI-capable clients. _meta.ui stripping is untouched. - Drop the conditional-property annotation from the docs generator. - Update toolsnaps, generated docs, and tests. With the up-front-execution Views now rendering the result (success card), the remaining contract is simple: when MCP Apps are enabled the form is the path, and the form is only shown while the action is genuinely deferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(mcp-apps): centralize the show/defer decision (single source of truth) The defer-to-form predicate was triplicated across create_pull_request, update_pull_request, and issue_write, each with its own near-identical *HasNonFormParams function. As more MCP App tools are added this duplication would grow and the copies could silently drift. Extract one shared gate in ui_capability.go: - shouldDeferToForm(ctx, deps, req, args, formParams) — the single show/defer decision (MCP Apps enabled, client supports UI, not a form submission, and no non-form params). - hasNonFormParams(args, formParams) — one generic helper replacing the three per-tool functions. - uiSubmitted(args) — small shared predicate. Each handler is now a one-line `if shouldDeferToForm(...) { return awaiting }`. The per-tool form-parameter allowlists and the user-facing messages stay per-tool (that is the genuine per-tool config). Pure refactor — behavior unchanged; existing tests now exercise the generic helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
510 lines
17 KiB
Go
510 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/github/github-mcp-server/pkg/github"
|
|
"github.com/github/github-mcp-server/pkg/inventory"
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var generateDocsCmd = &cobra.Command{
|
|
Use: "generate-docs",
|
|
Short: "Generate documentation for tools and toolsets",
|
|
Long: `Generate the automated sections of README.md and docs/remote-server.md with current tool and toolset information.`,
|
|
RunE: func(_ *cobra.Command, _ []string) error {
|
|
return generateAllDocs()
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(generateDocsCmd)
|
|
}
|
|
|
|
// noFeatureFlagsChecker reports every feature flag as disabled. It models the
|
|
// default user experience used by the generated documentation.
|
|
func noFeatureFlagsChecker(_ context.Context, _ string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
func generateAllDocs() error {
|
|
for _, doc := range []struct {
|
|
path string
|
|
fn func(string) error
|
|
}{
|
|
// File to edit, function to generate its docs
|
|
{"README.md", generateReadmeDocs},
|
|
{"docs/remote-server.md", generateRemoteServerDocs},
|
|
{"docs/insiders-features.md", generateInsidersFeaturesDocs},
|
|
{"docs/feature-flags.md", generateFeatureFlagsDocs},
|
|
{"docs/tool-renaming.md", generateDeprecatedAliasesDocs},
|
|
} {
|
|
if err := doc.fn(doc.path); err != nil {
|
|
return fmt.Errorf("failed to generate docs for %s: %w", doc.path, err)
|
|
}
|
|
fmt.Printf("Successfully updated %s with automated documentation\n", doc.path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func generateReadmeDocs(readmePath string) error {
|
|
// Create translation helper
|
|
t, _ := translations.TranslationHelper()
|
|
|
|
// The README documents the default user experience: tools that are
|
|
// enabled with no special flags set. Installing a checker that reports
|
|
// every flag as disabled excludes tools gated by FeatureFlagEnable and
|
|
// keeps the legacy variants of tools gated by FeatureFlagDisable, so
|
|
// flag-gated duplicates don't appear twice.
|
|
// Build() can only fail if WithTools specifies invalid tools - not used here
|
|
r, _ := github.NewInventory(t).
|
|
WithToolsets([]string{"all"}).
|
|
WithFeatureChecker(noFeatureFlagsChecker).
|
|
Build()
|
|
|
|
// Generate toolsets documentation
|
|
toolsetsDoc := generateToolsetsDoc(r)
|
|
|
|
// Generate tools documentation
|
|
toolsDoc := generateToolsDoc(r)
|
|
|
|
// Read the current README.md
|
|
// #nosec G304 - readmePath is controlled by command line flag, not user input
|
|
content, err := os.ReadFile(readmePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read README.md: %w", err)
|
|
}
|
|
|
|
// Replace toolsets section
|
|
updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Replace tools section
|
|
updatedContent, err = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write back to file
|
|
err = os.WriteFile(readmePath, []byte(updatedContent), 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write README.md: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func generateRemoteServerDocs(docsPath string) error {
|
|
content, err := os.ReadFile(docsPath) //#nosec G304
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read docs file: %w", err)
|
|
}
|
|
|
|
toolsetsDoc := generateRemoteToolsetsDoc()
|
|
|
|
// Replace content between markers
|
|
updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Also generate remote-only toolsets section
|
|
remoteOnlyDoc := generateRemoteOnlyToolsetsDoc()
|
|
updatedContent, err = replaceSection(updatedContent, "START AUTOMATED REMOTE TOOLSETS", "END AUTOMATED REMOTE TOOLSETS", remoteOnlyDoc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(docsPath, []byte(updatedContent), 0600) //#nosec G306
|
|
}
|
|
|
|
// octiconImg returns an img tag for an Octicon that works with GitHub's light/dark theme.
|
|
// Uses picture element with prefers-color-scheme for automatic theme switching.
|
|
// References icons from the repo's pkg/octicons/icons directory.
|
|
// Optional pathPrefix for files in subdirectories (e.g., "../" for docs/).
|
|
func octiconImg(name string, pathPrefix ...string) string {
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
prefix := ""
|
|
if len(pathPrefix) > 0 {
|
|
prefix = pathPrefix[0]
|
|
}
|
|
// Use picture element with media queries for light/dark mode support
|
|
// GitHub renders these correctly in markdown
|
|
lightIcon := fmt.Sprintf("%spkg/octicons/icons/%s-light.png", prefix, name)
|
|
darkIcon := fmt.Sprintf("%spkg/octicons/icons/%s-dark.png", prefix, name)
|
|
return fmt.Sprintf(`<picture><source media="(prefers-color-scheme: dark)" srcset="%s"><source media="(prefers-color-scheme: light)" srcset="%s"><img src="%s" width="20" height="20" alt="%s"></picture>`, darkIcon, lightIcon, lightIcon, name)
|
|
}
|
|
|
|
func generateToolsetsDoc(i *inventory.Inventory) string {
|
|
var buf strings.Builder
|
|
|
|
// Add table header and separator (with icon column)
|
|
buf.WriteString("| | Toolset | Description |\n")
|
|
buf.WriteString("| --- | ----------------------- | ------------------------------------------------------------- |\n")
|
|
|
|
// Add the context toolset row with custom description (strongly recommended)
|
|
// Get context toolset for its icon
|
|
contextIcon := octiconImg("person")
|
|
fmt.Fprintf(&buf, "| %s | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n", contextIcon)
|
|
|
|
// AvailableToolsets() returns toolsets that have tools, sorted by ID
|
|
// Exclude context (custom description above)
|
|
for _, ts := range i.AvailableToolsets("context") {
|
|
icon := octiconImg(ts.Icon)
|
|
fmt.Fprintf(&buf, "| %s | `%s` | %s |\n", icon, ts.ID, ts.Description)
|
|
}
|
|
|
|
return strings.TrimSuffix(buf.String(), "\n")
|
|
}
|
|
|
|
func generateToolsDoc(r *inventory.Inventory) string {
|
|
tools := r.ToolsForRegistration(context.Background())
|
|
if len(tools) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var buf strings.Builder
|
|
var toolBuf strings.Builder
|
|
var currentToolsetID inventory.ToolsetID
|
|
var currentToolsetIcon string
|
|
firstSection := true
|
|
|
|
writeSection := func() {
|
|
if toolBuf.Len() == 0 {
|
|
return
|
|
}
|
|
if !firstSection {
|
|
buf.WriteString("\n\n")
|
|
}
|
|
firstSection = false
|
|
sectionName := formatToolsetName(string(currentToolsetID))
|
|
icon := octiconImg(currentToolsetIcon)
|
|
if icon != "" {
|
|
icon += " "
|
|
}
|
|
fmt.Fprintf(&buf, "<details>\n\n<summary>%s%s</summary>\n\n%s\n\n</details>", icon, sectionName, strings.TrimSuffix(toolBuf.String(), "\n\n"))
|
|
toolBuf.Reset()
|
|
}
|
|
|
|
for _, tool := range tools {
|
|
// When toolset changes, emit the previous section
|
|
if tool.Toolset.ID != currentToolsetID {
|
|
writeSection()
|
|
currentToolsetID = tool.Toolset.ID
|
|
currentToolsetIcon = tool.Toolset.Icon
|
|
}
|
|
writeToolDoc(&toolBuf, tool)
|
|
toolBuf.WriteString("\n\n")
|
|
}
|
|
|
|
// Emit the last section
|
|
writeSection()
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
|
|
// Tool name (no icon - section header already has the toolset icon)
|
|
fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title)
|
|
|
|
// OAuth scopes if present
|
|
if len(tool.RequiredScopes) > 0 {
|
|
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
|
|
// so when multiple required scopes are listed, render them as alternatives
|
|
// rather than implying all are required.
|
|
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
|
|
if len(tool.RequiredScopes) > 1 {
|
|
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
|
|
} else {
|
|
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
|
|
}
|
|
|
|
// Only show accepted scopes if they differ from required scopes
|
|
if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) {
|
|
fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `"))
|
|
}
|
|
}
|
|
|
|
// MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag
|
|
// applied to the inventory; for the no-flags README this section is
|
|
// stripped by inventory.ToolsForRegistration before rendering).
|
|
if ui, ok := tool.Tool.Meta["ui"].(map[string]any); ok {
|
|
if uri, ok := ui["resourceUri"].(string); ok && uri != "" {
|
|
fmt.Fprintf(buf, " - **MCP App UI**: `%s`\n", uri)
|
|
}
|
|
}
|
|
|
|
// Parameters
|
|
if tool.Tool.InputSchema == nil {
|
|
buf.WriteString(" - No parameters required")
|
|
return
|
|
}
|
|
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
|
|
if !ok || schema == nil {
|
|
buf.WriteString(" - No parameters required")
|
|
return
|
|
}
|
|
|
|
if len(schema.Properties) > 0 {
|
|
// Get parameter names and sort them for deterministic order
|
|
var paramNames []string
|
|
for propName := range schema.Properties {
|
|
paramNames = append(paramNames, propName)
|
|
}
|
|
sort.Strings(paramNames)
|
|
|
|
for i, propName := range paramNames {
|
|
prop := schema.Properties[propName]
|
|
required := slices.Contains(schema.Required, propName)
|
|
requiredStr := "optional"
|
|
if required {
|
|
requiredStr = "required"
|
|
}
|
|
|
|
var typeStr string
|
|
|
|
// Get the type and description
|
|
switch prop.Type {
|
|
case "array":
|
|
if prop.Items != nil {
|
|
typeStr = prop.Items.Type + "[]"
|
|
} else {
|
|
typeStr = "array"
|
|
}
|
|
default:
|
|
typeStr = prop.Type
|
|
}
|
|
|
|
// Indent any continuation lines in the description to maintain markdown formatting
|
|
description := indentMultilineDescription(prop.Description, " ")
|
|
|
|
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
|
|
if i < len(paramNames)-1 {
|
|
buf.WriteString("\n")
|
|
}
|
|
}
|
|
} else {
|
|
buf.WriteString(" - No parameters required")
|
|
}
|
|
}
|
|
|
|
// scopesEqual checks if two scope slices contain the same elements (order-independent)
|
|
func scopesEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
|
|
// Create a map for quick lookup
|
|
aMap := make(map[string]bool, len(a))
|
|
for _, scope := range a {
|
|
aMap[scope] = true
|
|
}
|
|
|
|
// Check if all elements in b are in a
|
|
for _, scope := range b {
|
|
if !aMap[scope] {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// indentMultilineDescription adds the specified indent to all lines after the first line.
|
|
// This ensures that multi-line descriptions maintain proper markdown list formatting.
|
|
func indentMultilineDescription(description, indent string) string {
|
|
if !strings.Contains(description, "\n") {
|
|
return description
|
|
}
|
|
var buf strings.Builder
|
|
lines := strings.Split(description, "\n")
|
|
buf.WriteString(lines[0])
|
|
for i := 1; i < len(lines); i++ {
|
|
buf.WriteString("\n")
|
|
buf.WriteString(indent)
|
|
buf.WriteString(lines[i])
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
func replaceSection(content, startMarker, endMarker, newContent string) (string, error) {
|
|
start := fmt.Sprintf("<!-- %s -->", startMarker)
|
|
end := fmt.Sprintf("<!-- %s -->", endMarker)
|
|
|
|
before, _, ok := strings.Cut(content, start)
|
|
endIdx := strings.Index(content, end)
|
|
if !ok || endIdx == -1 {
|
|
return "", fmt.Errorf("markers not found: %s / %s", start, end)
|
|
}
|
|
|
|
var buf strings.Builder
|
|
buf.WriteString(before)
|
|
buf.WriteString(start)
|
|
buf.WriteString("\n")
|
|
buf.WriteString(newContent)
|
|
buf.WriteString("\n")
|
|
buf.WriteString(content[endIdx:])
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func generateRemoteToolsetsDoc() string {
|
|
var buf strings.Builder
|
|
|
|
// Create translation helper
|
|
t, _ := translations.TranslationHelper()
|
|
|
|
// Build inventory - stateless
|
|
// Build() can only fail if WithTools specifies invalid tools - not used here
|
|
r, _ := github.NewInventory(t).Build()
|
|
|
|
// Generate table header (icon is combined with Name column)
|
|
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
|
|
buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n")
|
|
|
|
// Add "default" and "all" meta toolsets first (special cases). The base
|
|
// URL serves the default toolset; /x/all enables every toolset at once.
|
|
metaIcon := octiconImg("apps", "../")
|
|
fmt.Fprintf(&buf, "| %s<br>`default` | Default toolset | 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", metaIcon)
|
|
fmt.Fprintf(&buf, "| %s<br>`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%2Freadonly%%22%%7D) |\n", metaIcon)
|
|
|
|
// AvailableToolsets() returns toolsets that have tools, sorted by ID
|
|
// Exclude context (handled separately)
|
|
for _, ts := range r.AvailableToolsets("context") {
|
|
idStr := string(ts.ID)
|
|
|
|
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr)
|
|
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr)
|
|
|
|
// Create install config JSON (URL encoded)
|
|
installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL))
|
|
readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
|
|
|
|
// Fix URL encoding to use %20 instead of + for spaces
|
|
installConfig = strings.ReplaceAll(installConfig, "+", "%20")
|
|
readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20")
|
|
|
|
installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig)
|
|
readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig)
|
|
|
|
icon := octiconImg(ts.Icon, "../")
|
|
fmt.Fprintf(&buf, "| %s<br>`%s` | %s | %s | %s | [read-only](%s) | %s |\n",
|
|
icon,
|
|
idStr,
|
|
ts.Description,
|
|
apiURL,
|
|
installLink,
|
|
readonlyURL,
|
|
readonlyInstallLink,
|
|
)
|
|
}
|
|
|
|
return strings.TrimSuffix(buf.String(), "\n")
|
|
}
|
|
|
|
func generateRemoteOnlyToolsetsDoc() string {
|
|
var buf strings.Builder
|
|
|
|
// Generate table header (icon is combined with Name column)
|
|
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
|
|
buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n")
|
|
|
|
// Use RemoteOnlyToolsets from github package
|
|
for _, ts := range github.RemoteOnlyToolsets() {
|
|
idStr := string(ts.ID)
|
|
|
|
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr)
|
|
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr)
|
|
|
|
// Create install config JSON (URL encoded)
|
|
installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL))
|
|
readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
|
|
|
|
// Fix URL encoding to use %20 instead of + for spaces
|
|
installConfig = strings.ReplaceAll(installConfig, "+", "%20")
|
|
readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20")
|
|
|
|
installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig)
|
|
readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig)
|
|
|
|
icon := octiconImg(ts.Icon, "../")
|
|
fmt.Fprintf(&buf, "| %s<br>`%s` | %s | %s | %s | [read-only](%s) | %s |\n",
|
|
icon,
|
|
idStr,
|
|
ts.Description,
|
|
apiURL,
|
|
installLink,
|
|
readonlyURL,
|
|
readonlyInstallLink,
|
|
)
|
|
}
|
|
|
|
return strings.TrimSuffix(buf.String(), "\n")
|
|
}
|
|
|
|
func generateDeprecatedAliasesDocs(docsPath string) error {
|
|
// Read the current file
|
|
content, err := os.ReadFile(docsPath) //#nosec G304
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read docs file: %w", err)
|
|
}
|
|
|
|
// Generate the table
|
|
aliasesDoc := generateDeprecatedAliasesTable()
|
|
|
|
// Replace content between markers
|
|
updatedContent, err := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write back to file
|
|
err = os.WriteFile(docsPath, []byte(updatedContent), 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write deprecated aliases docs: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func generateDeprecatedAliasesTable() string {
|
|
var buf strings.Builder
|
|
|
|
// Add table header
|
|
buf.WriteString("| Old Name | New Name |\n")
|
|
buf.WriteString("|----------|----------|\n")
|
|
|
|
aliases := github.DeprecatedToolAliases
|
|
if len(aliases) == 0 {
|
|
buf.WriteString("| *(none currently)* | |")
|
|
} else {
|
|
// Sort keys for deterministic output
|
|
var oldNames []string
|
|
for oldName := range aliases {
|
|
oldNames = append(oldNames, oldName)
|
|
}
|
|
sort.Strings(oldNames)
|
|
|
|
for i, oldName := range oldNames {
|
|
newName := aliases[oldName]
|
|
fmt.Fprintf(&buf, "| `%s` | `%s` |", oldName, newName)
|
|
if i < len(oldNames)-1 {
|
|
buf.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|