Skip to content

Commit 18434bd

Browse files
refactor(toolvalidation): extract ReadOnlyHint scanner into reusable package
Move the AST-based ReadOnlyHint scan introduced in #2486 out of pkg/github's test file and into a new exported package, pkg/toolvalidation, so downstream consumers (notably github/github-mcp-server-remote, which uses this repo as a library) can apply the same guardrail to their own tool registrations with a one-line test: violations, err := toolvalidation.ScanReadOnlyHint(pkgDir) Changes: - New pkg/toolvalidation/readonlyhint.go with ScanReadOnlyHint, FormatReadOnlyHintViolations, and the ReadOnlyHintViolation type. - Dedicated unit tests for the scanner using in-memory fixtures (compliant, missing-hint, missing-annotations, non-literal, aliased import, positional fields, file without mcp import). - pkg/github/tools_static_validation_test.go shrunk to a thin wrapper that calls ScanReadOnlyHint against its own package directory; the existing behavior for pkg/github is preserved. No production-code, schema, or toolsnap changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5e1c7e6 commit 18434bd

3 files changed

Lines changed: 445 additions & 248 deletions

File tree

Lines changed: 13 additions & 248 deletions
Original file line numberDiff line numberDiff line change
@@ -1,271 +1,36 @@
11
package github
22

33
import (
4-
"go/ast"
5-
"go/parser"
6-
"go/token"
74
"os"
8-
"path/filepath"
9-
"strconv"
10-
"strings"
115
"testing"
126

7+
"github.com/github/github-mcp-server/pkg/toolvalidation"
138
"github.com/stretchr/testify/require"
149
)
1510

16-
// mcpImportPath is the canonical module path of the MCP go-sdk that pkg/github
17-
// imports as `mcp` (or under an alias). Per-file alias resolution lets this
18-
// test correctly identify mcp.Tool / mcp.ToolAnnotations literals even when a
19-
// file imports the SDK under a non-default local name.
20-
const mcpImportPath = "github.com/modelcontextprotocol/go-sdk/mcp"
21-
2211
// TestAllToolRegistrationsExplicitlySetReadOnlyHint statically scans every
2312
// non-test Go source file in this package and asserts that every mcp.Tool
2413
// composite literal explicitly sets Annotations.ReadOnlyHint.
2514
//
15+
// The AST scan itself lives in pkg/toolvalidation so downstream packages
16+
// (e.g. github/github-mcp-server-remote) can apply the same guardrail to
17+
// their own tool registrations without duplicating the parser logic.
18+
//
2619
// This complements TestAllToolsHaveRequiredMetadata, which can only check
27-
// that Annotations is non-nil at runtime: Go cannot distinguish an
28-
// unset bool field from one explicitly set to false. Source-level
29-
// validation closes that gap and prevents future tool registrations
30-
// from silently defaulting ReadOnlyHint to false (which has caused
31-
// downstream agents to prompt for human approval on read-intent tools).
20+
// that Annotations is non-nil at runtime: Go cannot distinguish an unset
21+
// bool field from one explicitly set to false. Source-level validation
22+
// closes that gap and prevents future tool registrations from silently
23+
// defaulting ReadOnlyHint to false (which has caused downstream agents to
24+
// prompt for human approval on read-intent tools).
3225
//
3326
// Related issue: github/github-mcp-server#2483
3427
func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
3528
pkgDir, err := os.Getwd()
3629
require.NoError(t, err, "must be able to resolve package directory")
3730

38-
fset := token.NewFileSet()
39-
pkgs, err := parser.ParseDir(fset, pkgDir, func(info os.FileInfo) bool {
40-
// Skip test files: they are allowed to construct mcp.Tool literals
41-
// for fixtures or mocks where ReadOnlyHint is not meaningful.
42-
return !strings.HasSuffix(info.Name(), "_test.go")
43-
}, parser.ParseComments)
44-
require.NoError(t, err, "parser.ParseDir on package directory")
45-
require.NotEmpty(t, pkgs, "expected at least one package parsed")
46-
47-
type violation struct {
48-
file string
49-
line int
50-
toolName string
51-
reason string
52-
}
53-
var violations []violation
54-
55-
for _, pkg := range pkgs {
56-
for filename, file := range pkg.Files {
57-
aliases := mcpAliasesFor(file)
58-
if len(aliases) == 0 {
59-
// File does not import the MCP go-sdk; no tool literals possible.
60-
continue
61-
}
62-
ast.Inspect(file, func(n ast.Node) bool {
63-
cl, ok := n.(*ast.CompositeLit)
64-
if !ok {
65-
return true
66-
}
67-
if !isQualifiedType(cl.Type, aliases, "Tool") {
68-
return true
69-
}
70-
71-
toolName := extractToolName(cl)
72-
if toolName == "" {
73-
toolName = "<unknown>"
74-
}
75-
pos := fset.Position(cl.Pos())
76-
rel, _ := filepath.Rel(pkgDir, filename)
77-
if rel == "" {
78-
rel = filepath.Base(filename)
79-
}
80-
81-
if hasUnkeyedFields(cl) {
82-
violations = append(violations, violation{
83-
file: rel,
84-
line: pos.Line,
85-
toolName: toolName,
86-
reason: "mcp.Tool literal uses positional (unkeyed) fields; this check requires keyed fields so Annotations.ReadOnlyHint can be verified",
87-
})
88-
return true
89-
}
90-
91-
annotations := findFieldValue(cl, "Annotations")
92-
if annotations == nil {
93-
violations = append(violations, violation{
94-
file: rel,
95-
line: pos.Line,
96-
toolName: toolName,
97-
reason: "mcp.Tool literal is missing an Annotations field",
98-
})
99-
return true
100-
}
101-
102-
annoLit := unwrapAnnotationsLiteral(annotations, aliases)
103-
if annoLit == nil {
104-
// Annotations is set to something we can't statically
105-
// verify (e.g. a function call). Flag it so reviewers
106-
// can confirm ReadOnlyHint is honored.
107-
violations = append(violations, violation{
108-
file: rel,
109-
line: pos.Line,
110-
toolName: toolName,
111-
reason: "Annotations is not an &mcp.ToolAnnotations{...} literal; ReadOnlyHint cannot be statically verified",
112-
})
113-
return true
114-
}
115-
116-
if hasUnkeyedFields(annoLit) {
117-
violations = append(violations, violation{
118-
file: rel,
119-
line: pos.Line,
120-
toolName: toolName,
121-
reason: "mcp.ToolAnnotations literal uses positional (unkeyed) fields; use keyed fields so ReadOnlyHint can be verified",
122-
})
123-
return true
124-
}
125-
126-
if findFieldValue(annoLit, "ReadOnlyHint") == nil {
127-
violations = append(violations, violation{
128-
file: rel,
129-
line: pos.Line,
130-
toolName: toolName,
131-
reason: "ToolAnnotations literal does not explicitly set ReadOnlyHint",
132-
})
133-
}
134-
return true
135-
})
136-
}
137-
}
138-
139-
// Intentionally do not assert that any literals were observed: if tool
140-
// registrations move behind constructors/factories there may be nothing
141-
// for this check to validate, and that is a legitimate state.
142-
31+
violations, err := toolvalidation.ScanReadOnlyHint(pkgDir)
32+
require.NoError(t, err)
14333
if len(violations) > 0 {
144-
var msg strings.Builder
145-
msg.WriteString("Found tool registrations that do not explicitly set ReadOnlyHint:\n")
146-
for _, v := range violations {
147-
msg.WriteString(" - ")
148-
msg.WriteString(v.file)
149-
msg.WriteString(":")
150-
msg.WriteString(strconv.Itoa(v.line))
151-
msg.WriteString(" tool=")
152-
msg.WriteString(v.toolName)
153-
msg.WriteString(": ")
154-
msg.WriteString(v.reason)
155-
msg.WriteString("\n")
156-
}
157-
msg.WriteString("\nEvery mcp.Tool registration must declare Annotations.ReadOnlyHint explicitly ")
158-
msg.WriteString("(true for read-only tools, false for tools with side effects). ")
159-
msg.WriteString("See pkg/github/tools_static_validation_test.go.")
160-
t.Fatal(msg.String())
161-
}
162-
}
163-
164-
// mcpAliasesFor returns the set of local identifiers under which the given
165-
// file imports the MCP go-sdk (mcpImportPath). The default unaliased import
166-
// resolves to the package name "mcp". Blank (`_`) and dot (`.`) imports are
167-
// skipped because tool literals cannot meaningfully be qualified through them.
168-
func mcpAliasesFor(file *ast.File) map[string]struct{} {
169-
aliases := map[string]struct{}{}
170-
for _, imp := range file.Imports {
171-
path, err := strconv.Unquote(imp.Path.Value)
172-
if err != nil || path != mcpImportPath {
173-
continue
174-
}
175-
if imp.Name != nil {
176-
if imp.Name.Name == "_" || imp.Name.Name == "." {
177-
continue
178-
}
179-
aliases[imp.Name.Name] = struct{}{}
180-
continue
181-
}
182-
aliases["mcp"] = struct{}{}
183-
}
184-
return aliases
185-
}
186-
187-
// isQualifiedType reports whether expr is a SelectorExpr of the form
188-
// <alias>.<typeName> where alias is in the provided alias set.
189-
func isQualifiedType(expr ast.Expr, aliases map[string]struct{}, typeName string) bool {
190-
sel, ok := expr.(*ast.SelectorExpr)
191-
if !ok {
192-
return false
193-
}
194-
ident, ok := sel.X.(*ast.Ident)
195-
if !ok {
196-
return false
197-
}
198-
if _, ok := aliases[ident.Name]; !ok {
199-
return false
200-
}
201-
return sel.Sel != nil && sel.Sel.Name == typeName
202-
}
203-
204-
// hasUnkeyedFields reports whether the composite literal has any positional
205-
// (non-key/value) elements. The static check cannot reliably map positional
206-
// fields without full type information, so such literals are rejected with a
207-
// dedicated diagnostic rather than producing false "missing field" violations.
208-
func hasUnkeyedFields(cl *ast.CompositeLit) bool {
209-
for _, elt := range cl.Elts {
210-
if _, ok := elt.(*ast.KeyValueExpr); !ok {
211-
return true
212-
}
213-
}
214-
return false
215-
}
216-
217-
// findFieldValue returns the value expression for the named keyed field of a
218-
// composite literal, or nil if the field is absent.
219-
func findFieldValue(cl *ast.CompositeLit, name string) ast.Expr {
220-
for _, elt := range cl.Elts {
221-
kv, ok := elt.(*ast.KeyValueExpr)
222-
if !ok {
223-
continue
224-
}
225-
key, ok := kv.Key.(*ast.Ident)
226-
if !ok {
227-
continue
228-
}
229-
if key.Name == name {
230-
return kv.Value
231-
}
232-
}
233-
return nil
234-
}
235-
236-
// unwrapAnnotationsLiteral attempts to extract the *ast.CompositeLit for
237-
// &mcp.ToolAnnotations{...} or mcp.ToolAnnotations{...} from an expression,
238-
// resolving the MCP package's local alias per file.
239-
func unwrapAnnotationsLiteral(expr ast.Expr, aliases map[string]struct{}) *ast.CompositeLit {
240-
if u, ok := expr.(*ast.UnaryExpr); ok && u.Op == token.AND {
241-
expr = u.X
242-
}
243-
cl, ok := expr.(*ast.CompositeLit)
244-
if !ok {
245-
return nil
246-
}
247-
if !isQualifiedType(cl.Type, aliases, "ToolAnnotations") {
248-
return nil
249-
}
250-
return cl
251-
}
252-
253-
// extractToolName returns the literal value of the Name field of an mcp.Tool
254-
// composite literal, or empty string if the value is not a basic string literal.
255-
// Interpreted ("...") and raw (`...`) string literals are handled via
256-
// strconv.Unquote so embedded escapes are decoded correctly; the raw
257-
// literal value is returned as a best-effort fallback if unquoting fails.
258-
func extractToolName(cl *ast.CompositeLit) string {
259-
v := findFieldValue(cl, "Name")
260-
if v == nil {
261-
return ""
262-
}
263-
bl, ok := v.(*ast.BasicLit)
264-
if !ok || bl.Kind != token.STRING {
265-
return ""
266-
}
267-
if unq, err := strconv.Unquote(bl.Value); err == nil {
268-
return unq
34+
t.Fatal(toolvalidation.FormatReadOnlyHintViolations(violations))
26935
}
270-
return bl.Value
27136
}

0 commit comments

Comments
 (0)