@@ -48,25 +48,11 @@ func NormalizeConfig(cfg config.MCPConfig) ([]Server, error) {
4848 }
4949 sort .Strings (names )
5050
51+ if err := ValidateUniqueNames (cfg ); err != nil {
52+ return nil , err
53+ }
54+
5155 servers := make ([]Server , 0 , len (names ))
52- // THE NORMALIZED NAME IS AN IDENTITY, so it has to be unique.
53- //
54- // Trimming means "docs" and " docs " are two config keys and one runtime
55- // server, and everything downstream keys on the runtime name: tool
56- // accounting, the skipped-server observations the panel renders, and
57- // invalidation. Two rows then share one failure, both report the same state,
58- // and Go map iteration decides which configuration survives.
59- //
60- // It is also a confidentiality problem rather than only a wrong status. Each
61- // row redacts that shared error with ITS OWN configuration, so if the server
62- // that actually failed echoed a credential, the other row does not have that
63- // value among its candidates and prints it.
64- //
65- // Rejecting is the honest answer: one of the two entries is unreachable
66- // whatever we do, and an error naming both spellings is something an
67- // operator can act on. A single padded name still works, because trimming is
68- // not the problem; two names trimming to one is.
69- claimed := make (map [string ]string , len (names ))
7056 for _ , name := range names {
7157 raw := cfg .Servers [name ]
7258 if raw .Disabled {
@@ -76,17 +62,59 @@ func NormalizeConfig(cfg config.MCPConfig) ([]Server, error) {
7662 if err != nil {
7763 return nil , err
7864 }
79- if previous , taken := claimed [server .Name ]; taken {
80- return nil , fmt .Errorf (
81- "mcp: server names %q and %q both resolve to %q; rename one so each server has its own identity" ,
82- previous , name , server .Name )
83- }
84- claimed [server .Name ] = name
8565 servers = append (servers , server )
8666 }
8767 return servers , nil
8868}
8969
70+ // ValidateUniqueNames reports two enabled configuration keys that resolve to
71+ // one runtime identity.
72+ //
73+ // THE NORMALIZED NAME IS AN IDENTITY, so it has to be unique. Trimming means
74+ // "docs" and " docs " are two configuration keys and one runtime server, and
75+ // everything downstream keys on the runtime name: tool accounting, the
76+ // skipped-server observations the panel renders, and invalidation. Two rows then
77+ // share one failure, both report the same state, and Go map iteration decides
78+ // which configuration survives.
79+ //
80+ // It is also a confidentiality problem rather than only a wrong status. Each row
81+ // redacts that shared error with ITS OWN configuration, so if the server that
82+ // actually failed echoed a credential, the other row does not have that value
83+ // among its candidates and prints it.
84+ //
85+ // Rejecting is the honest answer: one of the two entries is unreachable whatever
86+ // we do, and an error naming both spellings is something an operator can act on.
87+ // A single padded name still works, because trimming is not the problem; two
88+ // names trimming to one is. A disabled entry claims nothing, matching the rest
89+ // of startup, which skips it before it reaches any of these paths.
90+ //
91+ // Exported because startup splits the configuration before registering it, so
92+ // the two halves normalize separately and a collision that straddles them is
93+ // invisible to either call. A user-configured " exa" alongside the built-in
94+ // default "exa" is exactly that shape.
95+ func ValidateUniqueNames (cfg config.MCPConfig ) error {
96+ names := make ([]string , 0 , len (cfg .Servers ))
97+ for name := range cfg .Servers {
98+ names = append (names , name )
99+ }
100+ sort .Strings (names )
101+
102+ claimed := make (map [string ]string , len (names ))
103+ for _ , name := range names {
104+ if cfg .Servers [name ].Disabled {
105+ continue
106+ }
107+ canonical := strings .TrimSpace (name )
108+ if previous , taken := claimed [canonical ]; taken {
109+ return fmt .Errorf (
110+ "mcp: server names %q and %q both resolve to %q; rename one so each server has its own identity" ,
111+ previous , name , canonical )
112+ }
113+ claimed [canonical ] = name
114+ }
115+ return nil
116+ }
117+
90118func normalizeServer (name string , raw config.MCPServerConfig ) (Server , error ) {
91119 name = strings .TrimSpace (name )
92120 if err := ValidateServerName (name ); err != nil {
0 commit comments