@@ -13,6 +13,12 @@ import (
1313 "github.com/stretchr/testify/require"
1414)
1515
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+
1622// TestAllToolRegistrationsExplicitlySetReadOnlyHint statically scans every
1723// non-test Go source file in this package and asserts that every mcp.Tool
1824// composite literal explicitly sets Annotations.ReadOnlyHint.
@@ -45,19 +51,22 @@ func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
4551 reason string
4652 }
4753 var violations []violation
48- literalsSeen := 0
4954
5055 for _ , pkg := range pkgs {
5156 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+ }
5262 ast .Inspect (file , func (n ast.Node ) bool {
5363 cl , ok := n .(* ast.CompositeLit )
5464 if ! ok {
5565 return true
5666 }
57- if ! isMCPToolType (cl .Type ) {
67+ if ! isQualifiedType (cl .Type , aliases , "Tool" ) {
5868 return true
5969 }
60- literalsSeen ++
6170
6271 toolName := extractToolName (cl )
6372 if toolName == "" {
@@ -69,6 +78,16 @@ func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
6978 rel = filepath .Base (filename )
7079 }
7180
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+
7291 annotations := findFieldValue (cl , "Annotations" )
7392 if annotations == nil {
7493 violations = append (violations , violation {
@@ -80,7 +99,7 @@ func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
8099 return true
81100 }
82101
83- annoLit := unwrapAnnotationsLiteral (annotations )
102+ annoLit := unwrapAnnotationsLiteral (annotations , aliases )
84103 if annoLit == nil {
85104 // Annotations is set to something we can't statically
86105 // verify (e.g. a function call). Flag it so reviewers
@@ -94,6 +113,16 @@ func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
94113 return true
95114 }
96115
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+
97126 if findFieldValue (annoLit , "ReadOnlyHint" ) == nil {
98127 violations = append (violations , violation {
99128 file : rel ,
@@ -107,8 +136,9 @@ func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
107136 }
108137 }
109138
110- require .NotZero (t , literalsSeen ,
111- "expected to discover at least one mcp.Tool literal; AST walker may be broken" )
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.
112142
113143 if len (violations ) > 0 {
114144 var msg strings.Builder
@@ -131,21 +161,32 @@ func TestAllToolRegistrationsExplicitlySetReadOnlyHint(t *testing.T) {
131161 }
132162}
133163
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
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 {}{}
143183 }
144- return ident . Name == "mcp" && sel . Sel != nil && sel . Sel . Name == "Tool"
184+ return aliases
145185}
146186
147- // isMCPToolAnnotationsType reports whether the given AST expression refers to mcp.ToolAnnotations.
148- func isMCPToolAnnotationsType (expr ast.Expr ) bool {
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 {
149190 sel , ok := expr .(* ast.SelectorExpr )
150191 if ! ok {
151192 return false
@@ -154,7 +195,23 @@ func isMCPToolAnnotationsType(expr ast.Expr) bool {
154195 if ! ok {
155196 return false
156197 }
157- return ident .Name == "mcp" && sel .Sel != nil && sel .Sel .Name == "ToolAnnotations"
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
158215}
159216
160217// findFieldValue returns the value expression for the named keyed field of a
@@ -177,24 +234,27 @@ func findFieldValue(cl *ast.CompositeLit, name string) ast.Expr {
177234}
178235
179236// 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 {
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 {
183240 if u , ok := expr .(* ast.UnaryExpr ); ok && u .Op == token .AND {
184241 expr = u .X
185242 }
186243 cl , ok := expr .(* ast.CompositeLit )
187244 if ! ok {
188245 return nil
189246 }
190- if ! isMCPToolAnnotationsType (cl .Type ) {
247+ if ! isQualifiedType (cl .Type , aliases , "ToolAnnotations" ) {
191248 return nil
192249 }
193250 return cl
194251}
195252
196253// extractToolName returns the literal value of the Name field of an mcp.Tool
197254// 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.
198258func extractToolName (cl * ast.CompositeLit ) string {
199259 v := findFieldValue (cl , "Name" )
200260 if v == nil {
@@ -204,10 +264,8 @@ func extractToolName(cl *ast.CompositeLit) string {
204264 if ! ok || bl .Kind != token .STRING {
205265 return ""
206266 }
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 ]
267+ if unq , err := strconv .Unquote (bl .Value ); err == nil {
268+ return unq
211269 }
212- return s
270+ return bl . Value
213271}
0 commit comments