Skip to content

Commit 44b7a1c

Browse files
committed
feat(agent): add generic MCP management API and UI
Add generic MCP management methods to the Agent interface and implement them for all supported agents. This allows dynamic discovery and management of MCP servers beyond the hardcoded ones. The UI now shows detected MCP servers and uses the generic API for installation and removal. This provides a more flexible and extensible MCP management system that can handle both embedded and discovered MCP configurations.
1 parent dd37f6d commit 44b7a1c

11 files changed

Lines changed: 662 additions & 204 deletions

File tree

internal/agent/agent.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ type Agent interface {
2626
InstallRemixIcon() error
2727
// RemoveRemixIcon removes Remix Icon MCP from the config
2828
RemoveRemixIcon() error
29+
// HasMCP checks if a specific MCP server is configured
30+
HasMCP(name string) (bool, error)
31+
// InstallMCP adds a specific MCP server to the config
32+
InstallMCP(name string, mcpConfig map[string]interface{}) error
33+
// RemoveMCP removes a specific MCP server from the config
34+
RemoveMCP(name string) error
35+
// ListMCPs returns configured MCP servers
36+
ListMCPs() (map[string]map[string]interface{}, error)
2937
// SupportsSkills returns true if the agent supports skills
3038
SupportsSkills() bool
3139
// HasSkill checks if a skill is installed

internal/agent/claude.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package agent
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67

@@ -143,6 +144,56 @@ func (a *ClaudeAgent) RemoveRemixIcon() error {
143144
return config.WriteConfig(a.configPath, cfg)
144145
}
145146

147+
func (a *ClaudeAgent) HasMCP(name string) (bool, error) {
148+
cfg, err := config.ReadConfig(a.configPath)
149+
if err != nil {
150+
if os.IsNotExist(err) {
151+
return false, nil
152+
}
153+
return false, err
154+
}
155+
return config.HasMCP(cfg, name), nil
156+
}
157+
158+
func (a *ClaudeAgent) InstallMCP(name string, mcpConfig map[string]interface{}) error {
159+
if mcpConfig == nil {
160+
return fmt.Errorf("missing mcp config")
161+
}
162+
cfg, err := config.ReadConfig(a.configPath)
163+
if err != nil {
164+
if os.IsNotExist(err) {
165+
cfg = make(map[string]interface{})
166+
} else {
167+
return err
168+
}
169+
}
170+
config.AddMCP(cfg, name, cloneMCPConfig(mcpConfig))
171+
return config.WriteConfig(a.configPath, cfg)
172+
}
173+
174+
func (a *ClaudeAgent) RemoveMCP(name string) error {
175+
cfg, err := config.ReadConfig(a.configPath)
176+
if err != nil {
177+
if os.IsNotExist(err) {
178+
return nil
179+
}
180+
return err
181+
}
182+
config.RemoveMCP(cfg, name)
183+
return config.WriteConfig(a.configPath, cfg)
184+
}
185+
186+
func (a *ClaudeAgent) ListMCPs() (map[string]map[string]interface{}, error) {
187+
cfg, err := config.ReadConfig(a.configPath)
188+
if err != nil {
189+
if os.IsNotExist(err) {
190+
return map[string]map[string]interface{}{}, nil
191+
}
192+
return nil, err
193+
}
194+
return config.GetMCPServers(cfg), nil
195+
}
196+
146197
func (a *ClaudeAgent) SupportsSkills() bool {
147198
return true
148199
}

internal/agent/codex.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package agent
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67

@@ -164,6 +165,69 @@ func (a *CodexAgent) RemoveRemixIcon() error {
164165
return config.WriteTOMLConfig(a.configPath, cfg)
165166
}
166167

168+
func (a *CodexAgent) HasMCP(name string) (bool, error) {
169+
cfg, err := config.ReadTOMLConfig(a.configPath)
170+
if err != nil {
171+
if os.IsNotExist(err) {
172+
return false, nil
173+
}
174+
return false, err
175+
}
176+
return hasCodexMCP(cfg, name), nil
177+
}
178+
179+
func (a *CodexAgent) InstallMCP(name string, mcpConfig map[string]interface{}) error {
180+
if mcpConfig == nil {
181+
return fmt.Errorf("missing mcp config")
182+
}
183+
cfg, err := config.ReadTOMLConfig(a.configPath)
184+
if err != nil {
185+
if os.IsNotExist(err) {
186+
cfg = make(map[string]interface{})
187+
} else {
188+
return err
189+
}
190+
}
191+
addCodexMCP(cfg, name, normalizeArgsToStrings(mcpConfig))
192+
return config.WriteTOMLConfig(a.configPath, cfg)
193+
}
194+
195+
func (a *CodexAgent) RemoveMCP(name string) error {
196+
cfg, err := config.ReadTOMLConfig(a.configPath)
197+
if err != nil {
198+
if os.IsNotExist(err) {
199+
return nil
200+
}
201+
return err
202+
}
203+
removeCodexMCP(cfg, name)
204+
return config.WriteTOMLConfig(a.configPath, cfg)
205+
}
206+
207+
func (a *CodexAgent) ListMCPs() (map[string]map[string]interface{}, error) {
208+
cfg, err := config.ReadTOMLConfig(a.configPath)
209+
if err != nil {
210+
if os.IsNotExist(err) {
211+
return map[string]map[string]interface{}{}, nil
212+
}
213+
return nil, err
214+
}
215+
result := map[string]map[string]interface{}{}
216+
mcpServers, ok := cfg[codexMCPKey].(map[string]interface{})
217+
if !ok {
218+
return result, nil
219+
}
220+
for name, raw := range mcpServers {
221+
if raw == nil {
222+
continue
223+
}
224+
if serverCfg, ok := raw.(map[string]interface{}); ok {
225+
result[name] = serverCfg
226+
}
227+
}
228+
return result, nil
229+
}
230+
167231
func (a *CodexAgent) SupportsSkills() bool {
168232
return true
169233
}

internal/agent/cursor.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ func (a *CursorAgent) RemoveRemixIcon() error {
7171
return a.RemoveMCP("remix-icon")
7272
}
7373

74+
func (a *CursorAgent) ListMCPs() (map[string]map[string]interface{}, error) {
75+
cfg, err := config.ReadConfig(a.configPath)
76+
if err != nil {
77+
if os.IsNotExist(err) {
78+
return map[string]map[string]interface{}{}, nil
79+
}
80+
return nil, err
81+
}
82+
return config.GetMCPServers(cfg), nil
83+
}
84+
7485
// HasMCP checks if a specific MCP server is configured
7586
func (a *CursorAgent) HasMCP(name string) (bool, error) {
7687
cfg, err := config.ReadConfig(a.configPath)

internal/agent/gemini.go

Lines changed: 159 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package agent
22

33
import (
4+
"encoding/json"
5+
"fmt"
46
"os"
57
"path/filepath"
68

@@ -34,14 +36,7 @@ func (a *GeminiAgent) Exists() bool {
3436
}
3537

3638
func (a *GeminiAgent) HasPlaywright() (bool, error) {
37-
cfg, err := config.ReadConfig(a.configPath)
38-
if err != nil {
39-
if os.IsNotExist(err) {
40-
return false, nil
41-
}
42-
return false, err
43-
}
44-
return config.HasPlaywrightMCP(cfg), nil
39+
return a.HasMCP("playwright")
4540
}
4641

4742
func (a *GeminiAgent) InstallPlaywright() error {
@@ -74,14 +69,7 @@ func (a *GeminiAgent) RemovePlaywright() error {
7469
}
7570

7671
func (a *GeminiAgent) HasContext7() (bool, error) {
77-
cfg, err := config.ReadConfig(a.configPath)
78-
if err != nil {
79-
if os.IsNotExist(err) {
80-
return false, nil
81-
}
82-
return false, err
83-
}
84-
return config.HasContext7MCP(cfg), nil
72+
return a.HasMCP("context7")
8573
}
8674

8775
func (a *GeminiAgent) InstallContext7() error {
@@ -113,14 +101,7 @@ func (a *GeminiAgent) RemoveContext7() error {
113101
}
114102

115103
func (a *GeminiAgent) HasRemixIcon() (bool, error) {
116-
cfg, err := config.ReadConfig(a.configPath)
117-
if err != nil {
118-
if os.IsNotExist(err) {
119-
return false, nil
120-
}
121-
return false, err
122-
}
123-
return config.HasRemixIconMCP(cfg), nil
104+
return a.HasMCP("remix-icon")
124105
}
125106

126107
func (a *GeminiAgent) InstallRemixIcon() error {
@@ -151,6 +132,160 @@ func (a *GeminiAgent) RemoveRemixIcon() error {
151132
return config.WriteConfig(a.configPath, cfg)
152133
}
153134

135+
func (a *GeminiAgent) HasMCP(name string) (bool, error) {
136+
cfg, err := config.ReadConfig(a.configPath)
137+
if err != nil {
138+
if os.IsNotExist(err) {
139+
cfg = map[string]interface{}{}
140+
} else {
141+
return false, err
142+
}
143+
}
144+
if config.HasMCP(cfg, name) {
145+
return true, nil
146+
}
147+
extensions, err := a.listExtensionMCPs()
148+
if err != nil {
149+
return false, err
150+
}
151+
_, ok := extensions[name]
152+
return ok, nil
153+
}
154+
155+
func (a *GeminiAgent) InstallMCP(name string, mcpConfig map[string]interface{}) error {
156+
if mcpConfig == nil {
157+
return fmt.Errorf("missing mcp config")
158+
}
159+
cfg, err := config.ReadConfig(a.configPath)
160+
if err != nil {
161+
if os.IsNotExist(err) {
162+
cfg = make(map[string]interface{})
163+
if err := os.MkdirAll(filepath.Dir(a.configPath), 0755); err != nil {
164+
return err
165+
}
166+
} else {
167+
return err
168+
}
169+
}
170+
config.AddMCP(cfg, name, cloneMCPConfig(mcpConfig))
171+
return config.WriteConfig(a.configPath, cfg)
172+
}
173+
174+
func (a *GeminiAgent) RemoveMCP(name string) error {
175+
cfg, err := config.ReadConfig(a.configPath)
176+
if err != nil {
177+
if os.IsNotExist(err) {
178+
cfg = map[string]interface{}{}
179+
} else {
180+
return err
181+
}
182+
}
183+
if !config.HasMCP(cfg, name) {
184+
extensions, extErr := a.listExtensionMCPs()
185+
if extErr == nil {
186+
if _, ok := extensions[name]; ok {
187+
return fmt.Errorf("mcp %s is managed by a Gemini extension", name)
188+
}
189+
}
190+
return nil
191+
}
192+
config.RemoveMCP(cfg, name)
193+
return config.WriteConfig(a.configPath, cfg)
194+
}
195+
196+
func (a *GeminiAgent) ListMCPs() (map[string]map[string]interface{}, error) {
197+
cfg, err := config.ReadConfig(a.configPath)
198+
if err != nil {
199+
if os.IsNotExist(err) {
200+
cfg = map[string]interface{}{}
201+
} else {
202+
return nil, err
203+
}
204+
}
205+
result := config.GetMCPServers(cfg)
206+
extensions, err := a.listExtensionMCPs()
207+
if err != nil {
208+
return result, nil
209+
}
210+
for name, cfg := range extensions {
211+
if _, exists := result[name]; exists {
212+
continue
213+
}
214+
result[name] = cfg
215+
}
216+
return result, nil
217+
}
218+
219+
func (a *GeminiAgent) listExtensionMCPs() (map[string]map[string]interface{}, error) {
220+
extensionsDir := filepath.Join(filepath.Dir(a.configPath), "extensions")
221+
entries, err := os.ReadDir(extensionsDir)
222+
if err != nil {
223+
if os.IsNotExist(err) {
224+
return map[string]map[string]interface{}{}, nil
225+
}
226+
return nil, err
227+
}
228+
229+
enabled, _ := readGeminiExtensionEnablement(filepath.Join(extensionsDir, "extension-enablement.json"))
230+
result := make(map[string]map[string]interface{})
231+
for _, entry := range entries {
232+
if !entry.IsDir() {
233+
continue
234+
}
235+
name := entry.Name()
236+
if enabled != nil {
237+
if _, ok := enabled[name]; !ok {
238+
continue
239+
}
240+
}
241+
configPath := filepath.Join(extensionsDir, name, "gemini-extension.json")
242+
extCfg, err := readGeminiExtensionConfig(configPath)
243+
if err != nil {
244+
continue
245+
}
246+
for serverName, serverCfg := range extCfg {
247+
if serverCfg == nil {
248+
continue
249+
}
250+
result[serverName] = serverCfg
251+
}
252+
}
253+
return result, nil
254+
}
255+
256+
func readGeminiExtensionEnablement(path string) (map[string]struct{}, error) {
257+
data, err := os.ReadFile(path)
258+
if err != nil {
259+
return nil, err
260+
}
261+
var raw map[string]interface{}
262+
if err := json.Unmarshal(data, &raw); err != nil {
263+
return nil, err
264+
}
265+
result := make(map[string]struct{}, len(raw))
266+
for key := range raw {
267+
result[key] = struct{}{}
268+
}
269+
return result, nil
270+
}
271+
272+
func readGeminiExtensionConfig(path string) (map[string]map[string]interface{}, error) {
273+
data, err := os.ReadFile(path)
274+
if err != nil {
275+
return nil, err
276+
}
277+
var payload struct {
278+
MCPServers map[string]map[string]interface{} `json:"mcpServers"`
279+
}
280+
if err := json.Unmarshal(data, &payload); err != nil {
281+
return nil, err
282+
}
283+
if payload.MCPServers == nil {
284+
return map[string]map[string]interface{}{}, nil
285+
}
286+
return payload.MCPServers, nil
287+
}
288+
154289
func (a *GeminiAgent) SupportsSkills() bool {
155290
return false
156291
}

0 commit comments

Comments
 (0)