From 511c87b5a3bfe68c90590bf9201166cb85fc0571 Mon Sep 17 00:00:00 2001 From: haashim-mac Date: Sat, 18 Jul 2026 23:04:55 +0400 Subject: [PATCH 1/3] Add Qoder IDE as a supported MCP client Add Qoder to the list of supported MCP clients with platform-specific config paths: - macOS/Linux: ~/.qoder/mcp.json - Windows: ~/AppData/Roaming/Qoder/SharedClientCache/mcp.json Also fix IsClientInstalled to correctly handle clients with empty RelPath but non-empty PlatformPrefix (e.g. Qoder on all platforms). Signed-off-by: haashim-mac --- docs/cli/thv_client_register.md | 1 + docs/cli/thv_client_remove.md | 1 + docs/server/docs.go | 6 ++++-- docs/server/swagger.json | 6 ++++-- docs/server/swagger.yaml | 2 ++ pkg/client/config.go | 29 +++++++++++++++++++++++++++++ pkg/client/config_test.go | 17 +++++++++++++---- pkg/client/discovery.go | 2 +- 8 files changed, 55 insertions(+), 9 deletions(-) diff --git a/docs/cli/thv_client_register.md b/docs/cli/thv_client_register.md index 0ce5cfeb50..0cef577fba 100644 --- a/docs/cli/thv_client_register.md +++ b/docs/cli/thv_client_register.md @@ -34,6 +34,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor + - qoder: Qoder IDE - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code diff --git a/docs/cli/thv_client_remove.md b/docs/cli/thv_client_remove.md index c5bb53a5e1..52ec371f4c 100644 --- a/docs/cli/thv_client_remove.md +++ b/docs/cli/thv_client_remove.md @@ -34,6 +34,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor + - qoder: Qoder IDE - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code diff --git a/docs/server/docs.go b/docs/server/docs.go index 6b4d01e5bc..27f4835208 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -906,7 +906,8 @@ const docTemplate = `{ "codex", "kimi-cli", "factory", - "copilot-cli" + "copilot-cli", + "qoder" ], "type": "string", "x-enum-varnames": [ @@ -933,7 +934,8 @@ const docTemplate = `{ "Codex", "KimiCli", "Factory", - "CopilotCli" + "CopilotCli", + "Qoder" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 2d756d8d62..3bbf540df2 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -899,7 +899,8 @@ "codex", "kimi-cli", "factory", - "copilot-cli" + "copilot-cli", + "qoder" ], "type": "string", "x-enum-varnames": [ @@ -926,7 +927,8 @@ "Codex", "KimiCli", "Factory", - "CopilotCli" + "CopilotCli", + "Qoder" ] }, "github_com_stacklok_toolhive_pkg_client.ClientAppStatus": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index ae4b482f81..894ce96f97 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -979,6 +979,7 @@ components: - kimi-cli - factory - copilot-cli + - qoder type: string x-enum-varnames: - RooCode @@ -1005,6 +1006,7 @@ components: - KimiCli - Factory - CopilotCli + - Qoder github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: diff --git a/pkg/client/config.go b/pkg/client/config.go index 613f8405ad..3f964c34d1 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -93,6 +93,8 @@ const ( Factory ClientApp = "factory" // CopilotCli represents the GitHub Copilot CLI. CopilotCli ClientApp = "copilot-cli" + // QoderIDE represents the Qoder IDE. + Qoder ClientApp = "qoder" ) const ( @@ -1022,6 +1024,33 @@ var supportedClientIntegrations = []clientAppConfig{ {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, }, }, + { + ClientType: ClientApp(Qoder), + Description: "Qoder IDE", + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + RelPath: []string{}, + PlatformPrefix: map[Platform][]string{ + PlatformDarwin: {".qoder"}, + PlatformLinux: {".qoder"}, + PlatformWindows: {"AppData", "Roaming", "Qoder", "SharedClientCache"}, + }, + Extension: JSON, + IsTransportTypeFieldSupported: true, + SupportedTransportTypesMap: map[types.TransportType]string{ + types.TransportTypeStdio: httpTransportLabel, + types.TransportTypeSSE: "sse", + types.TransportTypeStreamableHTTP: httpTransportLabel, + }, + MCPServersUrlLabelMap: map[types.TransportType]string{ + types.TransportTypeStdio: defaultURLFieldName, + types.TransportTypeSSE: defaultURLFieldName, + types.TransportTypeStreamableHTTP: defaultURLFieldName, + }, + SupportsSkills: true, + SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsProjectPath: []string{".qoder", skillsDirName}, + }, { // Claude Desktop routes LLM traffic through the gateway via its // "third-party inference" surface. Unlike Claude Code (a single JSON diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index 0daffd239a..d298c6dda1 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -193,6 +193,14 @@ func createMockClientConfigs() []clientAppConfig { MCPServersPathPrefix: "/mcpServers", Extension: JSON, }, + { + ClientType: Qoder, + Description: "Qoder (Mock)", + RelPath: []string{"mock_qoder"}, + SettingsFile: "mcp.json", + MCPServersPathPrefix: "/mcpServers", + Extension: JSON, + }, } } @@ -376,6 +384,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { string(Codex), string(KimiCli), string(Factory), + string(Qoder), }, }, } @@ -1244,8 +1253,8 @@ func TestGetAllClients(t *testing.T) { clients := GetAllClients() - // Should return all 24 supported clients - assert.Len(t, clients, 24, "Expected 24 supported clients") + // Should return all 25 supported clients + assert.Len(t, clients, 25, "Expected 25 supported clients") // Verify the list is sorted alphabetically for i := 1; i < len(clients); i++ { @@ -1459,7 +1468,7 @@ func TestGetClientListCSV(t *testing.T) { clientNames[i-1], clientNames[i]) } - // Count the number of clients (should be 24) + // Count the number of clients (should be 25) clients := strings.Split(csv, ", ") - assert.Len(t, clients, 24, "Expected 24 clients in CSV list") + assert.Len(t, clients, 25, "Expected 25 clients in CSV list") } diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index 5173b2dd24..c0a6157d4a 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -113,7 +113,7 @@ func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool { return false } var pathToCheck string - if len(cfg.RelPath) == 0 { + if len(cfg.RelPath) == 0 && len(cfg.PlatformPrefix) == 0 { pathToCheck = filepath.Join(cm.homeDir, cfg.SettingsFile) } else { pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{cm.homeDir}) From 4f7c6e0c559eb51922186331288bd901ce3fa680 Mon Sep 17 00:00:00 2001 From: haashim-mac Date: Thu, 23 Jul 2026 12:24:29 +0400 Subject: [PATCH 2/3] Fix lint issues in Qoder client integration - Fix exported const comment: QoderIDE -> Qoder - Add missing Qoder cases in exhaustive switches in config_test - Add Qoder to the catch-all case list in switch statement Signed-off-by: haashim-mac --- pkg/client/config.go | 2 +- pkg/client/config_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index 3f964c34d1..9f7239bd2c 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -93,7 +93,7 @@ const ( Factory ClientApp = "factory" // CopilotCli represents the GitHub Copilot CLI. CopilotCli ClientApp = "copilot-cli" - // QoderIDE represents the Qoder IDE. + // Qoder represents the Qoder IDE. Qoder ClientApp = "qoder" ) diff --git a/pkg/client/config_test.go b/pkg/client/config_test.go index d298c6dda1..2672d8fd07 100644 --- a/pkg/client/config_test.go +++ b/pkg/client/config_test.go @@ -465,7 +465,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { case AmpCli: assert.Contains(t, string(content), `"mcpServers":`, "AmpCli config should contain mcpServers key") - case LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: + case Qoder, LMStudio, Trae, Kiro, Antigravity, GeminiCli, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), `"mcpServers":`, "Config should contain mcpServers key") case VSCodeServer: @@ -524,7 +524,7 @@ func TestSuccessfulClientConfigOperations(t *testing.T) { "VSCode config should contain the server URL") case Cursor, RooCode, ClaudeCode, Cline, Windsurf, WindsurfJetBrains, AmpCli, LMStudio, Goose, Trae, Continue, OpenCode, Kiro, Antigravity, Zed, GeminiCli, VSCodeServer, - MistralVibe, Codex, KimiCli, Factory, CopilotCli: + MistralVibe, Codex, Qoder, KimiCli, Factory, CopilotCli: assert.Contains(t, string(content), testURL, "Config should contain the server URL") } From 7ca7384b72e80f6574283f1c3f750d9172087cc5 Mon Sep 17 00:00:00 2001 From: haashim-mac Date: Fri, 14 Aug 2026 18:41:34 +0530 Subject: [PATCH 3/3] Address Qoder review: move entry, fix skills paths, add tests --- pkg/client/config.go | 38 ++++++++++++------------- pkg/client/discovery.go | 3 +- pkg/client/discovery_test.go | 55 ++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/pkg/client/config.go b/pkg/client/config.go index 9f7239bd2c..fc560430b8 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -1008,24 +1008,7 @@ var supportedClientIntegrations = []clientAppConfig{ }, }, { - // Xcode does not support MCP; it is an LLM-gateway-only entry. - // Cast LLMClientApp → ClientApp for internal config storage; the type - // distinction matters only for swag enum generation (see LLMClientApp). - ClientType: ClientApp(Xcode), - Description: "GitHub Copilot for Xcode", - LLMGatewayOnly: true, - LLMGatewayMode: llmgateway.ModeProxy, - // Full path is macOS-specific; on Linux/Windows this directory will not - // exist, so DetectedLLMGatewayClients() naturally returns false there. - LLMSettingsFile: "editorSettings.json", - LLMSettingsRelPath: []string{"Library", "Application Support", "GitHub Copilot for Xcode"}, - LLMGatewayKeys: []LLMGatewayKeySpec{ - {JSONPointer: "/openAIBaseURL", ValueField: "ProxyBaseURL"}, - {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, - }, - }, - { - ClientType: ClientApp(Qoder), + ClientType: Qoder, Description: "Qoder IDE", SettingsFile: "mcp.json", MCPServersPathPrefix: "/mcpServers", @@ -1048,9 +1031,26 @@ var supportedClientIntegrations = []clientAppConfig{ types.TransportTypeStreamableHTTP: defaultURLFieldName, }, SupportsSkills: true, - SkillsGlobalPath: []string{".agents", skillsDirName}, + SkillsGlobalPath: []string{".qoder", skillsDirName}, SkillsProjectPath: []string{".qoder", skillsDirName}, }, + { + // Xcode does not support MCP; it is an LLM-gateway-only entry. + // Cast LLMClientApp → ClientApp for internal config storage; the type + // distinction matters only for swag enum generation (see LLMClientApp). + ClientType: ClientApp(Xcode), + Description: "GitHub Copilot for Xcode", + LLMGatewayOnly: true, + LLMGatewayMode: llmgateway.ModeProxy, + // Full path is macOS-specific; on Linux/Windows this directory will not + // exist, so DetectedLLMGatewayClients() naturally returns false there. + LLMSettingsFile: "editorSettings.json", + LLMSettingsRelPath: []string{"Library", "Application Support", "GitHub Copilot for Xcode"}, + LLMGatewayKeys: []LLMGatewayKeySpec{ + {JSONPointer: "/openAIBaseURL", ValueField: "ProxyBaseURL"}, + {JSONPointer: "/apiKey", ValueField: "PlaceholderAPIKey"}, + }, + }, { // Claude Desktop routes LLM traffic through the gateway via its // "third-party inference" surface. Unlike Claude Code (a single JSON diff --git a/pkg/client/discovery.go b/pkg/client/discovery.go index c0a6157d4a..e554076fe2 100644 --- a/pkg/client/discovery.go +++ b/pkg/client/discovery.go @@ -106,7 +106,8 @@ type ClientAppStatus struct { // IsClientInstalled reports whether the given client appears to be installed on // the current system. Detection is based on the presence of the client's -// configuration directory (or settings file when no relative path is defined). +// configuration directory (or settings file when neither a relative path nor a +// platform prefix is defined). func (cm *ClientManager) IsClientInstalled(clientType ClientApp) bool { cfg := cm.lookupClientAppConfig(clientType) if cfg == nil || cfg.LLMGatewayOnly { diff --git a/pkg/client/discovery_test.go b/pkg/client/discovery_test.go index dc6b37dd9e..2218b94fbe 100644 --- a/pkg/client/discovery_test.go +++ b/pkg/client/discovery_test.go @@ -7,6 +7,7 @@ import ( "context" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -215,6 +216,17 @@ func TestIsClientInstalled(t *testing.T) { // VSCode path (.config/Code/User) is intentionally not created + // Qoder resolves via PlatformPrefix only (empty RelPath). Resolve its + // directory for the current OS and create it to simulate an install. + qoderPlatformPrefix := map[Platform][]string{ + PlatformDarwin: {".qoder"}, + PlatformLinux: {".qoder"}, + PlatformWindows: {"AppData", "Roaming", "Qoder", "SharedClientCache"}, + } + qoderDir := buildConfigDirectoryPath([]string{}, qoderPlatformPrefix, []string{tempHome}) + err = os.MkdirAll(qoderDir, 0700) + require.NoError(t, err) + clientIntegrations := []clientAppConfig{ { ClientType: ClaudeCode, @@ -231,6 +243,22 @@ func TestIsClientInstalled(t *testing.T) { SettingsFile: "mcp.json", RelPath: []string{".config", "Code", "User"}, // not created }, + { + // PlatformPrefix-only client whose directory is present + ClientType: Qoder, + SettingsFile: "mcp.json", + PlatformPrefix: qoderPlatformPrefix, + }, + { + // PlatformPrefix-only client whose directory is absent + ClientType: ClientApp("qoder-missing"), + SettingsFile: "mcp.json", + PlatformPrefix: map[Platform][]string{ + PlatformDarwin: {".qoder-missing"}, + PlatformLinux: {".qoder-missing"}, + PlatformWindows: {"AppData", "Roaming", "QoderMissing", "SharedClientCache"}, + }, + }, { // unknown client, no config ClientType: ClientApp("nonexistent"), @@ -249,6 +277,8 @@ func TestIsClientInstalled(t *testing.T) { {name: "ClaudeCode settings file present", clientType: ClaudeCode, want: true}, {name: "Cursor directory present", clientType: Cursor, want: true}, {name: "VSCode directory absent", clientType: VSCode, want: false}, + {name: "PlatformPrefix-only directory present", clientType: Qoder, want: true}, + {name: "PlatformPrefix-only directory absent", clientType: ClientApp("qoder-missing"), want: false}, {name: "client not in integrations", clientType: ClientApp("not-registered"), want: false}, } @@ -341,3 +371,28 @@ func TestGetClientStatus_WithGroups(t *testing.T) { assert.True(t, cursorStatus.Installed) assert.True(t, cursorStatus.Registered, "Cursor should be registered via groups") } + +func TestQoderConfigPathsResolveCorrectly(t *testing.T) { + t.Parallel() + + cfg := NewTestClientManager(t.TempDir(), nil, supportedClientIntegrations, nil).lookupClientAppConfig(Qoder) + require.NotNil(t, cfg, "Qoder config should exist") + require.Empty(t, cfg.RelPath, "Qoder uses PlatformPrefix only") + + home := t.TempDir() + + // The documented Qoder config locations (verified on a real install): + // macOS/Linux: ~/.qoder/mcp.json + // Windows: %APPDATA%\Qoder\SharedClientCache\mcp.json + expected := map[Platform]string{ + PlatformDarwin: filepath.Join(home, ".qoder"), + PlatformLinux: filepath.Join(home, ".qoder"), + PlatformWindows: filepath.Join(home, "AppData", "Roaming", "Qoder", "SharedClientCache"), + } + + want, ok := expected[Platform(runtime.GOOS)] + require.True(t, ok, "Qoder config must define a PlatformPrefix for the current platform") + + got := buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{home}) + assert.Equal(t, want, got) +}