Skip to content

Commit dc79cf2

Browse files
authored
Add support for Goose client (#1718)
Fix #1524
1 parent 685b247 commit dc79cf2

File tree

12 files changed

+883
-19
lines changed

12 files changed

+883
-19
lines changed

cmd/thv/app/client.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Valid clients:
5454
- claude-code: Claude Code CLI
5555
- cline: Cline extension for VS Code
5656
- cursor: Cursor editor
57+
- goose: Goose AI agent
5758
- lm-studio: LM Studio application
5859
- roo-code: Roo Code extension for VS Code
5960
- vscode: Visual Studio Code
@@ -78,6 +79,7 @@ Valid clients:
7879
- claude-code: Claude Code CLI
7980
- cline: Cline extension for VS Code
8081
- cursor: Cursor editor
82+
- goose: Goose AI agent
8183
- lm-studio: LM Studio application
8284
- roo-code: Roo Code extension for VS Code
8385
- vscode: Visual Studio Code
@@ -185,12 +187,12 @@ func clientRegisterCmdFunc(cmd *cobra.Command, args []string) error {
185187
// Validate the client type
186188
switch clientType {
187189
case "roo-code", "cline", "cursor", "claude-code", "vscode-insider", "vscode", "windsurf", "windsurf-jetbrains",
188-
"amp-cli", "amp-vscode", "amp-vscode-insider", "amp-cursor", "amp-windsurf", "lm-studio":
190+
"amp-cli", "amp-vscode", "amp-vscode-insider", "amp-cursor", "amp-windsurf", "lm-studio", "goose":
189191
// Valid client type
190192
default:
191193
return fmt.Errorf(
192194
"invalid client type: %s (valid types: roo-code, cline, cursor, claude-code, vscode, vscode-insider, "+
193-
"windsurf, windsurf-jetbrains, amp-cli, amp-vscode, amp-vscode-insider, amp-cursor, amp-windsurf, lm-studio)",
195+
"windsurf, windsurf-jetbrains, amp-cli, amp-vscode, amp-vscode-insider, amp-cursor, amp-windsurf, lm-studio, goose)",
194196
clientType)
195197
}
196198

@@ -203,12 +205,12 @@ func clientRemoveCmdFunc(cmd *cobra.Command, args []string) error {
203205
// Validate the client type
204206
switch clientType {
205207
case "roo-code", "cline", "cursor", "claude-code", "vscode-insider", "vscode", "windsurf", "windsurf-jetbrains",
206-
"amp-cli", "amp-vscode", "amp-vscode-insider", "amp-cursor", "amp-windsurf", "lm-studio":
208+
"amp-cli", "amp-vscode", "amp-vscode-insider", "amp-cursor", "amp-windsurf", "lm-studio", "goose":
207209
// Valid client type
208210
default:
209211
return fmt.Errorf(
210212
"invalid client type: %s (valid types: roo-code, cline, cursor, claude-code, vscode, vscode-insider, "+
211-
"windsurf, windsurf-jetbrains, amp-cli, amp-vscode, amp-vscode-insider, amp-cursor, amp-windsurf, lm-studio)",
213+
"windsurf, windsurf-jetbrains, amp-cli, amp-vscode, amp-vscode-insider, amp-cursor, amp-windsurf, lm-studio, goose)",
212214
clientType)
213215
}
214216

docs/cli/thv_client_register.md

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

docs/cli/thv_client_remove.md

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

docs/server/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

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

pkg/client/config.go

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/tailscale/hujson"
15+
"gopkg.in/yaml.v3"
1516

1617
"github.com/stacklok/toolhive/pkg/logger"
1718
"github.com/stacklok/toolhive/pkg/transport/types"
@@ -52,6 +53,8 @@ const (
5253
AmpWindsurf MCPClient = "amp-windsurf"
5354
// LMStudio represents the LM Studio application.
5455
LMStudio MCPClient = "lm-studio"
56+
// Goose represents the Goose AI agent.
57+
Goose MCPClient = "goose"
5558
)
5659

5760
// Extension is extension of the client config file.
@@ -60,6 +63,8 @@ type Extension string
6063
const (
6164
// JSON represents a JSON extension.
6265
JSON Extension = "json"
66+
// YAML represents a YAML extension.
67+
YAML Extension = "yaml"
6368
)
6469

6570
// mcpClientConfig represents a configuration path for a supported MCP client.
@@ -342,6 +347,26 @@ var supportedClientIntegrations = []mcpClientConfig{
342347
IsTransportTypeFieldSupported: true,
343348
MCPServersUrlLabel: "url",
344349
},
350+
{
351+
ClientType: Goose,
352+
Description: "Goose AI agent",
353+
SettingsFile: "config.yaml",
354+
MCPServersPathPrefix: "/extensions",
355+
RelPath: []string{"goose"},
356+
PlatformPrefix: map[string][]string{
357+
"linux": {".config"},
358+
"darwin": {".config"},
359+
"windows": {"AppData", "Block"},
360+
},
361+
Extension: YAML,
362+
SupportedTransportTypesMap: map[types.TransportType]string{
363+
types.TransportTypeStdio: "sse",
364+
types.TransportTypeSSE: "sse",
365+
types.TransportTypeStreamableHTTP: "streamable_http",
366+
},
367+
IsTransportTypeFieldSupported: true,
368+
MCPServersUrlLabel: "uri",
369+
},
345370
}
346371

347372
// ConfigFile represents a client configuration file
@@ -530,10 +555,19 @@ func (cm *ClientManager) retrieveConfigFileMetadata(clientType MCPClient) (*Conf
530555
return nil, err
531556
}
532557

533-
// Create a config updater for this file
534-
configUpdater := &JSONConfigUpdater{
535-
Path: path,
536-
MCPServersPathPrefix: clientCfg.MCPServersPathPrefix,
558+
// Create a config updater for this file based on the extension
559+
var configUpdater ConfigUpdater
560+
switch clientCfg.Extension {
561+
case YAML:
562+
configUpdater = &YAMLConfigUpdater{
563+
Path: path,
564+
Converter: &GooseYAMLConverter{},
565+
}
566+
case JSON:
567+
configUpdater = &JSONConfigUpdater{
568+
Path: path,
569+
MCPServersPathPrefix: clientCfg.MCPServersPathPrefix,
570+
}
537571
}
538572

539573
// Return the configuration file metadata
@@ -572,11 +606,18 @@ func validateConfigFileFormat(cf *ConfigFile) error {
572606
data = []byte("{}") // Default to an empty JSON object if the file is empty
573607
}
574608

575-
// Default to JSON
576-
// we don't care about the contents of the file, we just want to validate that it's valid JSON
577-
_, err = hujson.Parse(data)
578-
if err != nil {
579-
return fmt.Errorf("failed to parse JSON for file %s: %w", cf.Path, err)
609+
switch cf.Extension {
610+
case YAML:
611+
var temp interface{}
612+
err = yaml.Unmarshal(data, &temp)
613+
if err != nil {
614+
return fmt.Errorf("failed to parse YAML for file %s: %w", cf.Path, err)
615+
}
616+
case JSON:
617+
_, err = hujson.Parse(data)
618+
if err != nil {
619+
return fmt.Errorf("failed to parse JSON for file %s: %w", cf.Path, err)
620+
}
580621
}
581622
return nil
582623
}

pkg/client/config_editor.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/gofrs/flock"
1212
"github.com/tailscale/hujson"
1313
"github.com/tidwall/gjson"
14+
"gopkg.in/yaml.v3"
1415

1516
"github.com/stacklok/toolhive/pkg/logger"
1617
)
@@ -151,6 +152,137 @@ func (jcu *JSONConfigUpdater) Remove(serverName string) error {
151152
return nil
152153
}
153154

155+
// YAMLConfigUpdater is a ConfigUpdater that is responsible for updating
156+
// YAML config files using a converter interface for flexibility.
157+
type YAMLConfigUpdater struct {
158+
Path string
159+
Converter YAMLConverter
160+
}
161+
162+
// Upsert inserts or updates an MCP server in the config.yaml file using the converter
163+
func (ycu *YAMLConfigUpdater) Upsert(serverName string, data MCPServer) error {
164+
// Create a lock file
165+
fileLock := flock.New(ycu.Path + ".lock")
166+
167+
// Create a context with timeout
168+
ctx, cancel := context.WithTimeout(context.Background(), lockTimeout)
169+
defer cancel()
170+
171+
// Try to acquire the lock with a timeout
172+
locked, err := fileLock.TryLockContext(ctx, 100*time.Millisecond)
173+
if err != nil {
174+
return fmt.Errorf("failed to acquire lock: %w", err)
175+
}
176+
if !locked {
177+
return fmt.Errorf("failed to acquire lock: timeout after %v", lockTimeout)
178+
}
179+
defer fileLock.Unlock()
180+
181+
content, err := os.ReadFile(ycu.Path)
182+
if err != nil && !os.IsNotExist(err) {
183+
return fmt.Errorf("failed to read file: %w", err)
184+
}
185+
186+
// Use a generic map to preserve all existing fields, not just extensions
187+
var config map[string]interface{}
188+
189+
// If file exists and is not empty, unmarshal existing config into generic map
190+
if len(content) > 0 {
191+
err = yaml.Unmarshal(content, &config)
192+
if err != nil {
193+
return fmt.Errorf("failed to parse existing YAML config: %w", err)
194+
}
195+
} else {
196+
// Initialize empty map if file doesn't exist or is empty
197+
config = make(map[string]interface{})
198+
}
199+
200+
// Convert MCPServer using the converter
201+
entry, err := ycu.Converter.ConvertFromMCPServer(serverName, data)
202+
if err != nil {
203+
return fmt.Errorf("failed to convert MCPServer: %w", err)
204+
}
205+
206+
// Upsert the entry using the converter
207+
err = ycu.Converter.UpsertEntry(config, serverName, entry)
208+
if err != nil {
209+
return fmt.Errorf("failed to upsert entry: %w", err)
210+
}
211+
212+
// Marshal back to YAML
213+
updatedContent, err := yaml.Marshal(config)
214+
if err != nil {
215+
return fmt.Errorf("failed to marshal YAML: %w", err)
216+
}
217+
218+
// Write back to file
219+
if err := os.WriteFile(ycu.Path, updatedContent, 0600); err != nil {
220+
return fmt.Errorf("failed to write file: %w", err)
221+
}
222+
223+
logger.Infof("Successfully updated YAML client config file for server %s", serverName)
224+
return nil
225+
}
226+
227+
// Remove removes an entry from the config.yaml file using the converter
228+
func (ycu *YAMLConfigUpdater) Remove(serverName string) error {
229+
// Create a lock file
230+
fileLock := flock.New(ycu.Path + ".lock")
231+
232+
ctx, cancel := context.WithTimeout(context.Background(), lockTimeout)
233+
defer cancel()
234+
235+
// Try to acquire the lock with a timeout
236+
locked, err := fileLock.TryLockContext(ctx, 100*time.Millisecond)
237+
if err != nil {
238+
return fmt.Errorf("failed to acquire lock: %w", err)
239+
}
240+
if !locked {
241+
return fmt.Errorf("failed to acquire lock: timeout after %v", lockTimeout)
242+
}
243+
defer fileLock.Unlock()
244+
245+
// Read existing config
246+
content, err := os.ReadFile(ycu.Path)
247+
if err != nil {
248+
if os.IsNotExist(err) {
249+
// File doesn't exist, nothing to remove
250+
return nil
251+
}
252+
return fmt.Errorf("failed to read file: %w", err)
253+
}
254+
255+
if len(content) == 0 {
256+
// File is empty, nothing to remove
257+
return nil
258+
}
259+
260+
// Use a generic map to preserve all existing fields, not just extensions
261+
var config map[string]interface{}
262+
err = yaml.Unmarshal(content, &config)
263+
if err != nil {
264+
return fmt.Errorf("failed to parse YAML: %w", err)
265+
}
266+
267+
err = ycu.Converter.RemoveEntry(config, serverName)
268+
if err != nil {
269+
return fmt.Errorf("failed to remove entry: %w", err)
270+
}
271+
272+
updatedContent, err := yaml.Marshal(config)
273+
if err != nil {
274+
return fmt.Errorf("failed to marshal YAML: %w", err)
275+
}
276+
277+
// Write back to file
278+
if err := os.WriteFile(ycu.Path, updatedContent, 0600); err != nil {
279+
return fmt.Errorf("failed to write file: %w", err)
280+
}
281+
282+
logger.Infof("Successfully removed server %s from YAML config file", serverName)
283+
return nil
284+
}
285+
154286
// ensurePathExists ensures that the path exists in the JSON content
155287
// and returns the updated content.
156288
// For example:

0 commit comments

Comments
 (0)