Skip to content

Commit 9f52ce7

Browse files
authored
Add client discovery function and endpoint (#632)
1 parent 2ad1322 commit 9f52ce7

File tree

9 files changed

+279
-31
lines changed

9 files changed

+279
-31
lines changed

docs/server/docs.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

docs/server/swagger.yaml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
components:
22
schemas:
3+
client.MCPClient:
4+
description: ClientType is the type of MCP client
5+
type: string
6+
x-enum-varnames:
7+
- RooCode
8+
- Cline
9+
- Cursor
10+
- VSCodeInsider
11+
- VSCode
12+
- ClaudeCode
13+
client.MCPClientStatus:
14+
properties:
15+
client_type:
16+
$ref: '#/components/schemas/client.MCPClient'
17+
installed:
18+
description: Installed indicates whether the client is installed on the
19+
system
20+
type: boolean
21+
registered:
22+
description: Registered indicates whether the client is registered in the
23+
ToolHive configuration
24+
type: boolean
25+
type: object
326
permissions.NetworkPermissions:
427
description: Network defines network permissions
528
properties:
@@ -259,6 +282,14 @@ components:
259282
target:
260283
type: string
261284
type: object
285+
v1.clientStatusResponse:
286+
properties:
287+
clients:
288+
items:
289+
$ref: '#/components/schemas/client.MCPClientStatus'
290+
type: array
291+
uniqueItems: false
292+
type: object
262293
v1.createRequest:
263294
description: Request to create a new server
264295
properties:
@@ -433,6 +464,19 @@ paths:
433464
summary: Get OpenAPI specification
434465
tags:
435466
- system
467+
/api/v1beta/discovery/clients:
468+
get:
469+
description: List all clients compatible with ToolHive and their status
470+
responses:
471+
"200":
472+
content:
473+
application/json:
474+
schema:
475+
$ref: '#/components/schemas/v1.clientStatusResponse'
476+
description: OK
477+
summary: List all clients status
478+
tags:
479+
- discovery
436480
/api/v1beta/registry:
437481
get:
438482
description: Get a list of the current registries

pkg/api/server.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ func Serve(
114114
}
115115

116116
routers := map[string]http.Handler{
117-
"/health": v1.HealthcheckRouter(),
118-
"/api/v1beta/version": v1.VersionRouter(),
119-
"/api/v1beta/servers": v1.ServerRouter(manager, rt, debugMode),
120-
"/api/v1beta/registry": v1.RegistryRouter(),
117+
"/health": v1.HealthcheckRouter(),
118+
"/api/v1beta/version": v1.VersionRouter(),
119+
"/api/v1beta/servers": v1.ServerRouter(manager, rt, debugMode),
120+
"/api/v1beta/registry": v1.RegistryRouter(),
121+
"/api/v1beta/discovery": v1.DiscoveryRouter(),
121122
}
122123

123124
// Only mount docs router if enabled

pkg/api/v1/discovery.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package v1
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
7+
"github.com/go-chi/chi/v5"
8+
9+
"github.com/stacklok/toolhive/pkg/client"
10+
)
11+
12+
// DiscoveryRoutes defines the routes for the client discovery API.
13+
type DiscoveryRoutes struct{}
14+
15+
// DiscoveryRouter creates a new router for the client discovery API.
16+
func DiscoveryRouter() http.Handler {
17+
routes := DiscoveryRoutes{}
18+
19+
r := chi.NewRouter()
20+
r.Get("/clients", routes.discoverClients)
21+
return r
22+
}
23+
24+
// discoverClients
25+
//
26+
// @Summary List all clients status
27+
// @Description List all clients compatible with ToolHive and their status
28+
// @Tags discovery
29+
// @Produce json
30+
// @Success 200 {object} clientStatusResponse
31+
// @Router /api/v1beta/discovery/clients [get]
32+
func (*DiscoveryRoutes) discoverClients(w http.ResponseWriter, _ *http.Request) {
33+
clients, err := client.GetClientStatus()
34+
if err != nil {
35+
http.Error(w, "Failed to get client status", http.StatusInternalServerError)
36+
}
37+
38+
err = json.NewEncoder(w).Encode(clientStatusResponse{Clients: clients})
39+
if err != nil {
40+
http.Error(w, "Failed to encode client status", http.StatusInternalServerError)
41+
return
42+
}
43+
}
44+
45+
// clientStatusResponse represents the response for the client discovery
46+
type clientStatusResponse struct {
47+
Clients []client.MCPClientStatus `json:"clients"`
48+
}

pkg/client/config.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,19 @@ type mcpClientConfig struct {
5151
ClientType MCPClient
5252
Description string
5353
RelPath []string
54+
SettingsFile string
5455
PlatformPrefix map[string][]string
5556
MCPServersPathPrefix string
5657
Extension Extension
5758
}
5859

5960
var supportedClientIntegrations = []mcpClientConfig{
6061
{
61-
ClientType: RooCode,
62-
Description: "VS Code Roo Code extension",
62+
ClientType: RooCode,
63+
Description: "VS Code Roo Code extension",
64+
SettingsFile: "mcp_settings.json",
6365
RelPath: []string{
64-
"Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json",
66+
"Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings",
6567
},
6668
PlatformPrefix: map[string][]string{
6769
"linux": {".config"},
@@ -72,10 +74,11 @@ var supportedClientIntegrations = []mcpClientConfig{
7274
Extension: JSON,
7375
},
7476
{
75-
ClientType: Cline,
76-
Description: "VS Code Cline extension",
77+
ClientType: Cline,
78+
Description: "VS Code Cline extension",
79+
SettingsFile: "cline_mcp_settings.json",
7780
RelPath: []string{
78-
"Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json",
81+
"Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings",
7982
},
8083
PlatformPrefix: map[string][]string{
8184
"linux": {".config"},
@@ -86,10 +89,11 @@ var supportedClientIntegrations = []mcpClientConfig{
8689
Extension: JSON,
8790
},
8891
{
89-
ClientType: VSCodeInsider,
90-
Description: "Visual Studio Code Insiders",
92+
ClientType: VSCodeInsider,
93+
Description: "Visual Studio Code Insiders",
94+
SettingsFile: "settings.json",
9195
RelPath: []string{
92-
"Code - Insiders", "User", "settings.json",
96+
"Code - Insiders", "User",
9397
},
9498
PlatformPrefix: map[string][]string{
9599
"linux": {".config"},
@@ -100,10 +104,11 @@ var supportedClientIntegrations = []mcpClientConfig{
100104
Extension: JSON,
101105
},
102106
{
103-
ClientType: VSCode,
104-
Description: "Visual Studio Code",
107+
ClientType: VSCode,
108+
Description: "Visual Studio Code",
109+
SettingsFile: "settings.json",
105110
RelPath: []string{
106-
"Code", "User", "settings.json",
111+
"Code", "User",
107112
},
108113
MCPServersPathPrefix: "/mcp/servers",
109114
PlatformPrefix: map[string][]string{
@@ -116,15 +121,17 @@ var supportedClientIntegrations = []mcpClientConfig{
116121
{
117122
ClientType: Cursor,
118123
Description: "Cursor editor",
124+
SettingsFile: "mcp.json",
119125
MCPServersPathPrefix: "/mcpServers",
120-
RelPath: []string{".cursor", "mcp.json"},
126+
RelPath: []string{".cursor"},
121127
Extension: JSON,
122128
},
123129
{
124130
ClientType: ClaudeCode,
125131
Description: "Claude Code CLI",
132+
SettingsFile: ".claude.json",
126133
MCPServersPathPrefix: "/mcpServers",
127-
RelPath: []string{".claude.json"},
134+
RelPath: []string{},
128135
Extension: JSON,
129136
},
130137
}
@@ -216,7 +223,7 @@ func retrieveConfigFilesMetadata(filters []MCPClient) ([]ConfigFile, error) {
216223
continue
217224
}
218225

219-
path := buildConfigFilePath(cfg.RelPath, cfg.PlatformPrefix, []string{home})
226+
path := buildConfigFilePath(cfg.SettingsFile, cfg.RelPath, cfg.PlatformPrefix, []string{home})
220227

221228
err := validateConfigFileExists(path)
222229
if err != nil {
@@ -239,11 +246,12 @@ func retrieveConfigFilesMetadata(filters []MCPClient) ([]ConfigFile, error) {
239246
return configFiles, nil
240247
}
241248

242-
func buildConfigFilePath(relPath []string, platformPrefix map[string][]string, path []string) string {
249+
func buildConfigFilePath(settingsFile string, relPath []string, platformPrefix map[string][]string, path []string) string {
243250
if prefix, ok := platformPrefix[runtime.GOOS]; ok {
244251
path = append(path, prefix...)
245252
}
246253
path = append(path, relPath...)
254+
path = append(path, settingsFile)
247255
return filepath.Clean(filepath.Join(path...))
248256
}
249257

pkg/client/config_test.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,32 @@ func createMockClientConfigs() []mcpClientConfig {
2121
{
2222
ClientType: VSCode,
2323
Description: "Visual Studio Code (Mock)",
24-
RelPath: []string{"mock_vscode", "settings.json"},
24+
RelPath: []string{"mock_vscode"},
25+
SettingsFile: "settings.json",
2526
MCPServersPathPrefix: "/mcp/servers",
2627
Extension: JSON,
2728
},
2829
{
2930
ClientType: Cursor,
3031
Description: "Cursor editor (Mock)",
31-
RelPath: []string{"mock_cursor", "mcp.json"},
32+
RelPath: []string{"mock_cursor"},
33+
SettingsFile: "mcp.json",
3234
MCPServersPathPrefix: "/mcpServers",
3335
Extension: JSON,
3436
},
3537
{
3638
ClientType: RooCode,
3739
Description: "VS Code Roo Code extension (Mock)",
38-
RelPath: []string{"mock_roo", "mcp_settings.json"},
40+
RelPath: []string{"mock_roo"},
41+
SettingsFile: "mcp_settings.json",
3942
MCPServersPathPrefix: "/mcpServers",
4043
Extension: JSON,
4144
},
4245
{
4346
ClientType: ClaudeCode,
4447
Description: "Claude Code CLI (Mock)",
45-
RelPath: []string{"mock_claude", ".claude.json"},
48+
RelPath: []string{"mock_claude"},
49+
SettingsFile: ".claude.json",
4650
MCPServersPathPrefix: "/mcpServers",
4751
Extension: JSON,
4852
},
@@ -208,7 +212,8 @@ func TestFindClientConfigs(t *testing.T) {
208212
invalidClient := mcpClientConfig{
209213
ClientType: "invalid",
210214
Description: "Invalid client",
211-
RelPath: []string{".cursor", "invalid.json"},
215+
RelPath: []string{".cursor"},
216+
SettingsFile: "invalid.json",
212217
MCPServersPathPrefix: "/mcpServers",
213218
Extension: JSON,
214219
}
@@ -404,10 +409,10 @@ func createTestConfigFiles(t *testing.T, homeDir string) {
404409
// Create test config files for each mock client configuration
405410
for _, cfg := range supportedClientIntegrations {
406411
// Build the full path for the config file
407-
configDir := filepath.Join(homeDir, filepath.Join(cfg.RelPath[:len(cfg.RelPath)-1]...))
412+
configDir := filepath.Join(homeDir, filepath.Join(cfg.RelPath...))
408413
err := os.MkdirAll(configDir, 0755)
409414
if err == nil {
410-
configPath := filepath.Join(configDir, cfg.RelPath[len(cfg.RelPath)-1])
415+
configPath := filepath.Join(configDir, cfg.SettingsFile)
411416
validJSON := `{"mcpServers": {}, "mcp": {"servers": {}}}`
412417
err = os.WriteFile(configPath, []byte(validJSON), 0644)
413418
require.NoError(t, err)

pkg/client/discovery.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package client
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
9+
"github.com/stacklok/toolhive/pkg/config"
10+
)
11+
12+
// MCPClientStatus represents the status of a supported MCP client
13+
type MCPClientStatus struct {
14+
// ClientType is the type of MCP client
15+
ClientType MCPClient `json:"client_type"`
16+
17+
// Installed indicates whether the client is installed on the system
18+
Installed bool `json:"installed"`
19+
20+
// Registered indicates whether the client is registered in the ToolHive configuration
21+
Registered bool `json:"registered"`
22+
}
23+
24+
// GetClientStatus returns the installation status of all supported MCP clients
25+
func GetClientStatus() ([]MCPClientStatus, error) {
26+
var statuses []MCPClientStatus
27+
28+
// Get home directory
29+
home, err := os.UserHomeDir()
30+
if err != nil {
31+
return nil, fmt.Errorf("failed to get home directory: %w", err)
32+
}
33+
34+
// Get app configuration to check for registered clients
35+
appConfig := config.GetConfig()
36+
registeredClients := make(map[string]bool)
37+
38+
// Create a map of registered clients for quick lookup
39+
for _, client := range appConfig.Clients.RegisteredClients {
40+
registeredClients[client] = true
41+
}
42+
43+
for _, cfg := range supportedClientIntegrations {
44+
status := MCPClientStatus{
45+
ClientType: cfg.ClientType,
46+
Installed: false, // start with assuming client is not installed
47+
Registered: registeredClients[string(cfg.ClientType)],
48+
}
49+
50+
// Determine path to check based on configuration
51+
var pathToCheck string
52+
if len(cfg.RelPath) == 0 {
53+
// If RelPath is empty, look at just the settings file
54+
pathToCheck = filepath.Join(home, cfg.SettingsFile)
55+
} else {
56+
// Otherwise build the directory path using RelPath
57+
pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{home})
58+
}
59+
60+
// Check if the path exists
61+
if _, err := os.Stat(pathToCheck); err == nil {
62+
status.Installed = true
63+
}
64+
65+
statuses = append(statuses, status)
66+
}
67+
68+
return statuses, nil
69+
}
70+
71+
func buildConfigDirectoryPath(relPath []string, platformPrefix map[string][]string, path []string) string {
72+
if prefix, ok := platformPrefix[runtime.GOOS]; ok {
73+
path = append(path, prefix...)
74+
}
75+
path = append(path, relPath...)
76+
return filepath.Clean(filepath.Join(path...))
77+
}

0 commit comments

Comments
 (0)