|
| 1 | +package github |
| 2 | + |
| 3 | +import ( |
| 4 | + "go/ast" |
| 5 | + "go/parser" |
| 6 | + "go/token" |
| 7 | + "os" |
| 8 | + "path/filepath" |
| 9 | + "strconv" |
| 10 | + "strings" |
| 11 | + "testing" |
| 12 | + |
| 13 | + "github.com/stretchr/testify/require" |
| 14 | +) |
| 15 | + |
| 16 | +// TestAllToolRegistrationsExplicitlySetReadOnlyHint statically scans every |
| 17 | +// non-test Go source file in this package and asserts that every mcp.Tool |
| 18 | +// composite literal explicitly sets Annotations.ReadOnlyHint. |
| 19 | +// |
| 20 | +// This complements TestAllToolsHaveRequiredMetadata, which can only check |
| 21 | +// that Annotations is non-nil at runtime: Go cannot distinguish an |
| 22 | +// unset bool field from one explicitly set to false. Source-level |
| 23 | +// validation closes that gap and prevents future tool registrations |
| 24 | +// from silently defaulting ReadOnlyHint to false (which has caused |
| 25 | +// downstream agents to prompt for human approval on read-intent tools). |
| 26 | +// |
| 27 | +// Related issue: github/github-mcp-server#2483 |
| 28 | +func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) { |
| 29 | + pkgDir, err := os.Getwd() |
| 30 | + require.NoError(t, err, "must be able to resolve package directory") |
| 31 | + |
| 32 | + fset := token.NewFileSet() |
| 33 | + pkgs, err := parser.ParseDir(fset, pkgDir, func(info os.FileInfo) bool { |
| 34 | + // Skip test files: they are allowed to construct mcp.Tool literals |
| 35 | + // for fixtures or mocks where ReadOnlyHint is not meaningful. |
| 36 | + return !strings.HasSuffix(info.Name(), "_test.go") |
| 37 | + }, parser.ParseComments) |
| 38 | + require.NoError(t, err, "parser.ParseDir on package directory") |
| 39 | + require.NotEmpty(t, pkgs, "expected at least one package parsed") |
| 40 | + |
| 41 | + type violation struct { |
| 42 | + file string |
| 43 | + line int |
| 44 | + toolName string |
| 45 | + reason string |
| 46 | + } |
| 47 | + var violations []violation |
| 48 | + literalsSeen := 0 |
| 49 | + |
| 50 | + for _, pkg := range pkgs { |
| 51 | + for filename, file := range pkg.Files { |
| 52 | + ast.Inspect(file, func(n ast.Node) bool { |
| 53 | + cl, ok := n.(*ast.CompositeLit) |
| 54 | + if !ok { |
| 55 | + return true |
| 56 | + } |
| 57 | + if !isMCPToolType(cl.Type) { |
| 58 | + return true |
| 59 | + } |
| 60 | + literalsSeen++ |
| 61 | + |
| 62 | + toolName := extractToolName(cl) |
| 63 | + if toolName == "" { |
| 64 | + toolName = "<unknown>" |
| 65 | + } |
| 66 | + pos := fset.Position(cl.Pos()) |
| 67 | + rel, _ := filepath.Rel(pkgDir, filename) |
| 68 | + if rel == "" { |
| 69 | + rel = filepath.Base(filename) |
| 70 | + } |
| 71 | + |
| 72 | + annotations := findFieldValue(cl, "Annotations") |
| 73 | + if annotations == nil { |
| 74 | + violations = append(violations, violation{ |
| 75 | + file: rel, |
| 76 | + line: pos.Line, |
| 77 | + toolName: toolName, |
| 78 | + reason: "mcp.Tool literal is missing an Annotations field", |
| 79 | + }) |
| 80 | + return true |
| 81 | + } |
| 82 | + |
| 83 | + annoLit := unwrapAnnotationsLiteral(annotations) |
| 84 | + if annoLit == nil { |
| 85 | + // Annotations is set to something we can't statically |
| 86 | + // verify (e.g. a function call). Flag it so reviewers |
| 87 | + // can confirm ReadOnlyHint is honored. |
| 88 | + violations = append(violations, violation{ |
| 89 | + file: rel, |
| 90 | + line: pos.Line, |
| 91 | + toolName: toolName, |
| 92 | + reason: "Annotations is not an &mcp.ToolAnnotations{...} literal; ReadOnlyHint cannot be statically verified", |
| 93 | + }) |
| 94 | + return true |
| 95 | + } |
| 96 | + |
| 97 | + if findFieldValue(annoLit, "ReadOnlyHint") == nil { |
| 98 | + violations = append(violations, violation{ |
| 99 | + file: rel, |
| 100 | + line: pos.Line, |
| 101 | + toolName: toolName, |
| 102 | + reason: "ToolAnnotations literal does not explicitly set ReadOnlyHint", |
| 103 | + }) |
| 104 | + } |
| 105 | + return true |
| 106 | + }) |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + require.NotZero(t, literalsSeen, |
| 111 | + "expected to discover at least one mcp.Tool literal; AST walker may be broken") |
| 112 | + |
| 113 | + if len(violations) > 0 { |
| 114 | + var msg strings.Builder |
| 115 | + msg.WriteString("Found tool registrations that do not explicitly set ReadOnlyHint:\n") |
| 116 | + for _, v := range violations { |
| 117 | + msg.WriteString(" - ") |
| 118 | + msg.WriteString(v.file) |
| 119 | + msg.WriteString(":") |
| 120 | + msg.WriteString(strconv.Itoa(v.line)) |
| 121 | + msg.WriteString(" tool=") |
| 122 | + msg.WriteString(v.toolName) |
| 123 | + msg.WriteString(": ") |
| 124 | + msg.WriteString(v.reason) |
| 125 | + msg.WriteString("\n") |
| 126 | + } |
| 127 | + msg.WriteString("\nEvery mcp.Tool registration must declare Annotations.ReadOnlyHint explicitly ") |
| 128 | + msg.WriteString("(true for read-only tools, false for tools with side effects). ") |
| 129 | + msg.WriteString("See pkg/github/tools_static_validation_test.go.") |
| 130 | + t.Fatal(msg.String()) |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +// isMCPToolType reports whether the given AST expression refers to mcp.Tool. |
| 135 | +func isMCPToolType(expr ast.Expr) bool { |
| 136 | + sel, ok := expr.(*ast.SelectorExpr) |
| 137 | + if !ok { |
| 138 | + return false |
| 139 | + } |
| 140 | + ident, ok := sel.X.(*ast.Ident) |
| 141 | + if !ok { |
| 142 | + return false |
| 143 | + } |
| 144 | + return ident.Name == "mcp" && sel.Sel != nil && sel.Sel.Name == "Tool" |
| 145 | +} |
| 146 | + |
| 147 | +// isMCPToolAnnotationsType reports whether the given AST expression refers to mcp.ToolAnnotations. |
| 148 | +func isMCPToolAnnotationsType(expr ast.Expr) bool { |
| 149 | + sel, ok := expr.(*ast.SelectorExpr) |
| 150 | + if !ok { |
| 151 | + return false |
| 152 | + } |
| 153 | + ident, ok := sel.X.(*ast.Ident) |
| 154 | + if !ok { |
| 155 | + return false |
| 156 | + } |
| 157 | + return ident.Name == "mcp" && sel.Sel != nil && sel.Sel.Name == "ToolAnnotations" |
| 158 | +} |
| 159 | + |
| 160 | +// findFieldValue returns the value expression for the named keyed field of a |
| 161 | +// composite literal, or nil if the field is absent. |
| 162 | +func findFieldValue(cl *ast.CompositeLit, name string) ast.Expr { |
| 163 | + for _, elt := range cl.Elts { |
| 164 | + kv, ok := elt.(*ast.KeyValueExpr) |
| 165 | + if !ok { |
| 166 | + continue |
| 167 | + } |
| 168 | + key, ok := kv.Key.(*ast.Ident) |
| 169 | + if !ok { |
| 170 | + continue |
| 171 | + } |
| 172 | + if key.Name == name { |
| 173 | + return kv.Value |
| 174 | + } |
| 175 | + } |
| 176 | + return nil |
| 177 | +} |
| 178 | + |
| 179 | +// unwrapAnnotationsLiteral attempts to extract the *ast.CompositeLit for |
| 180 | +// &mcp.ToolAnnotations{...} or mcp.ToolAnnotations{...} from an expression. |
| 181 | +// Returns nil if the expression is not a statically inspectable literal. |
| 182 | +func unwrapAnnotationsLiteral(expr ast.Expr) *ast.CompositeLit { |
| 183 | + if u, ok := expr.(*ast.UnaryExpr); ok && u.Op == token.AND { |
| 184 | + expr = u.X |
| 185 | + } |
| 186 | + cl, ok := expr.(*ast.CompositeLit) |
| 187 | + if !ok { |
| 188 | + return nil |
| 189 | + } |
| 190 | + if !isMCPToolAnnotationsType(cl.Type) { |
| 191 | + return nil |
| 192 | + } |
| 193 | + return cl |
| 194 | +} |
| 195 | + |
| 196 | +// extractToolName returns the literal value of the Name field of an mcp.Tool |
| 197 | +// composite literal, or empty string if the value is not a basic string literal. |
| 198 | +func extractToolName(cl *ast.CompositeLit) string { |
| 199 | + v := findFieldValue(cl, "Name") |
| 200 | + if v == nil { |
| 201 | + return "" |
| 202 | + } |
| 203 | + bl, ok := v.(*ast.BasicLit) |
| 204 | + if !ok || bl.Kind != token.STRING { |
| 205 | + return "" |
| 206 | + } |
| 207 | + // Strip surrounding quotes; tolerate raw strings too. |
| 208 | + s := bl.Value |
| 209 | + if len(s) >= 2 && (s[0] == '"' || s[0] == '`') { |
| 210 | + s = s[1 : len(s)-1] |
| 211 | + } |
| 212 | + return s |
| 213 | +} |
0 commit comments