Skip to content

Commit c3714f8

Browse files
authored
Add AST check for explicit ReadOnlyHint annotations
1 parent 8a48d07 commit c3714f8

1 file changed

Lines changed: 133 additions & 0 deletions

File tree

pkg/github/tools_validation_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
package github
22

33
import (
4+
"errors"
5+
"go/ast"
6+
"go/parser"
7+
"go/token"
8+
stdfs "io/fs"
9+
"path/filepath"
10+
"runtime"
11+
"sort"
12+
"strconv"
13+
"strings"
414
"testing"
515

616
"github.com/github/github-mcp-server/pkg/inventory"
@@ -184,3 +194,126 @@ func TestToolsetMetadataConsistency(t *testing.T) {
184194
}
185195
}
186196
}
197+
198+
// TestToolDefinitionsRequireExplicitReadOnlyHint ensures every registered tool is
199+
// defined with an explicit Annotations.ReadOnlyHint value in source.
200+
func TestToolDefinitionsRequireExplicitReadOnlyHint(t *testing.T) {
201+
tools := AllTools(stubTranslation)
202+
require.NotEmpty(t, tools, "AllTools should return at least one tool")
203+
204+
_, currentFile, _, ok := runtime.Caller(0)
205+
require.True(t, ok, "failed to determine current test file path")
206+
pkgDir := filepath.Dir(currentFile)
207+
208+
fset := token.NewFileSet()
209+
checkedCount := 0
210+
var failures []string
211+
212+
walkErr := filepath.Walk(pkgDir, func(path string, info stdfs.FileInfo, err error) error {
213+
if err != nil {
214+
return err
215+
}
216+
if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
217+
return nil
218+
}
219+
220+
file, err := parser.ParseFile(fset, path, nil, 0)
221+
if err != nil {
222+
return err
223+
}
224+
225+
ast.Inspect(file, func(n ast.Node) bool {
226+
lit, ok := n.(*ast.CompositeLit)
227+
if !ok {
228+
return true
229+
}
230+
sel, ok := lit.Type.(*ast.SelectorExpr)
231+
if !ok {
232+
return true
233+
}
234+
pkgIdent, ok := sel.X.(*ast.Ident)
235+
if !ok || pkgIdent.Name != "mcp" || sel.Sel.Name != "Tool" {
236+
return true
237+
}
238+
239+
toolName, _ := toolNameFromToolLiteral(lit)
240+
checkedCount++
241+
if err := validateReadOnlyHintInToolLiteral(lit); err != nil {
242+
pos := fset.Position(lit.Pos())
243+
if toolName == "" {
244+
toolName = "<non-literal>"
245+
}
246+
failures = append(failures, pos.String()+" tool="+toolName+": "+err.Error())
247+
}
248+
return true
249+
})
250+
251+
return nil
252+
})
253+
require.NoError(t, walkErr)
254+
require.Greater(t, checkedCount, 0, "expected to find at least one mcp.Tool literal")
255+
256+
if len(failures) > 0 {
257+
sort.Strings(failures)
258+
t.Fatalf("tools with missing explicit ReadOnlyHint:\n%s", strings.Join(failures, "\n"))
259+
}
260+
}
261+
262+
func toolNameFromToolLiteral(lit *ast.CompositeLit) (string, bool) {
263+
for _, elt := range lit.Elts {
264+
kv, ok := elt.(*ast.KeyValueExpr)
265+
if !ok {
266+
continue
267+
}
268+
key, ok := kv.Key.(*ast.Ident)
269+
if !ok || key.Name != "Name" {
270+
continue
271+
}
272+
value, ok := kv.Value.(*ast.BasicLit)
273+
if !ok || value.Kind != token.STRING {
274+
return "", false
275+
}
276+
name, err := strconv.Unquote(value.Value)
277+
if err != nil {
278+
return "", false
279+
}
280+
return name, true
281+
}
282+
return "", false
283+
}
284+
285+
func validateReadOnlyHintInToolLiteral(lit *ast.CompositeLit) error {
286+
for _, elt := range lit.Elts {
287+
kv, ok := elt.(*ast.KeyValueExpr)
288+
if !ok {
289+
continue
290+
}
291+
key, ok := kv.Key.(*ast.Ident)
292+
if !ok || key.Name != "Annotations" {
293+
continue
294+
}
295+
296+
annValue := kv.Value
297+
if unary, ok := annValue.(*ast.UnaryExpr); ok && unary.Op == token.AND {
298+
annValue = unary.X
299+
}
300+
301+
annLit, ok := annValue.(*ast.CompositeLit)
302+
if !ok {
303+
return errors.New("annotations field is not a literal")
304+
}
305+
306+
for _, annElt := range annLit.Elts {
307+
annKV, ok := annElt.(*ast.KeyValueExpr)
308+
if !ok {
309+
continue
310+
}
311+
annKey, ok := annKV.Key.(*ast.Ident)
312+
if ok && annKey.Name == "ReadOnlyHint" {
313+
return nil
314+
}
315+
}
316+
return errors.New("missing Annotations.ReadOnlyHint")
317+
}
318+
return errors.New("missing Annotations field")
319+
}

0 commit comments

Comments
 (0)