Skip to content

Commit 69ee9bb

Browse files
committed
test(github): enforce explicit ReadOnlyHint on every mcp.Tool literal
Adds a source-level (AST) validation test that walks every non-test Go file in pkg/github and fails if any mcp.Tool composite literal omits Annotations.ReadOnlyHint. The existing TestAllToolsHaveRequiredMetadata can only assert that Annotations is non-nil at runtime: Go cannot distinguish an unset bool field from one explicitly set to false. The new test closes that gap so future read-intent tools cannot silently default to ReadOnlyHint=false, which has caused downstream agents to prompt for human approval on safe read operations. All 97 current mcp.Tool registrations pass. Fault-injected by removing ReadOnlyHint from issue_read and confirmed the test reports the exact file, line, tool name, and reason. Refs #2483
1 parent 8a48d07 commit 69ee9bb

1 file changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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

Comments
 (0)