Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions cmd/thv/app/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ Valid clients:
- antigravity: Google Antigravity IDE
- claude-code: Claude Code CLI
- cline: Cline extension for VS Code
- codex: OpenAI Codex
- continue: Continue.dev extensions for VS Code and JetBrains
- cursor: Cursor editor
- gemini-cli: Google Gemini CLI
- goose: Goose AI agent
- kiro: Kiro AI IDE
- lm-studio: LM Studio application
- mistral-vibe: Mistral Vibe IDE
- opencode: OpenCode editor
- roo-code: Roo Code extension for VS Code
- trae: Trae IDE
Expand All @@ -90,12 +92,14 @@ Valid clients:
- antigravity: Google Antigravity IDE
- claude-code: Claude Code CLI
- cline: Cline extension for VS Code
- codex: OpenAI Codex
- continue: Continue.dev extensions for VS Code and JetBrains
- cursor: Cursor editor
- gemini-cli: Google Gemini CLI
- goose: Goose AI agent
- kiro: Kiro AI IDE
- lm-studio: LM Studio application
- mistral-vibe: Mistral Vibe IDE
- opencode: OpenCode editor
- roo-code: Roo Code extension for VS Code
- trae: Trae IDE
Expand Down Expand Up @@ -211,13 +215,13 @@ func clientRegisterCmdFunc(cmd *cobra.Command, args []string) error {
switch clientType {
case "roo-code", "cline", "cursor", "claude-code", "vscode-insider", "vscode", "windsurf", "windsurf-jetbrains",
"amp-cli", "amp-vscode", "amp-vscode-insider", "amp-cursor", "amp-windsurf", "lm-studio", "goose", "trae",
"continue", "opencode", "kiro", "antigravity", "zed", "gemini-cli":
"continue", "opencode", "kiro", "antigravity", "zed", "gemini-cli", "mistral-vibe", "codex":
// Valid client type
default:
return fmt.Errorf(
"invalid client type: %s (valid types: roo-code, cline, cursor, claude-code, vscode, vscode-insider, "+
"windsurf, windsurf-jetbrains, amp-cli, amp-vscode, amp-vscode-insider, amp-cursor, amp-windsurf, lm-studio, "+
"goose, trae, continue, opencode, kiro, antigravity, zed, gemini-cli)",
"goose, trae, continue, opencode, kiro, antigravity, zed, gemini-cli, mistral-vibe, codex)",
clientType)
}

Expand All @@ -231,13 +235,13 @@ func clientRemoveCmdFunc(cmd *cobra.Command, args []string) error {
switch clientType {
case "roo-code", "cline", "cursor", "claude-code", "vscode-insider", "vscode", "windsurf", "windsurf-jetbrains",
"amp-cli", "amp-vscode", "amp-vscode-insider", "amp-cursor", "amp-windsurf", "lm-studio", "goose", "trae",
"continue", "opencode", "kiro", "antigravity", "zed", "gemini-cli":
"continue", "opencode", "kiro", "antigravity", "zed", "gemini-cli", "mistral-vibe", "codex":
// Valid client type
default:
return fmt.Errorf(
"invalid client type: %s (valid types: roo-code, cline, cursor, claude-code, vscode, vscode-insider, "+
"windsurf, windsurf-jetbrains, amp-cli, amp-vscode, amp-vscode-insider, amp-cursor, amp-windsurf, lm-studio, "+
"goose, trae, continue, opencode, kiro, antigravity, zed, gemini-cli)",
"goose, trae, continue, opencode, kiro, antigravity, zed, gemini-cli, mistral-vibe, codex)",
clientType)
}

Expand Down
2 changes: 2 additions & 0 deletions docs/cli/thv_client_register.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions docs/cli/thv_client_remove.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 130 additions & 9 deletions pkg/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/pelletier/go-toml/v2"
"github.com/tailscale/hujson"
"gopkg.in/yaml.v3"

Expand All @@ -24,6 +26,9 @@ import (
// lockTimeout is the maximum time to wait for a file lock
const lockTimeout = 1 * time.Second

// defaultURLFieldName is the default URL field name used when no specific mapping exists
const defaultURLFieldName = "url"

// MCPClient is an enum of supported MCP clients.
type MCPClient string

Expand Down Expand Up @@ -72,6 +77,10 @@ const (
Zed MCPClient = "zed"
// GeminiCli represents the Google Gemini CLI.
GeminiCli MCPClient = "gemini-cli"
// MistralVibe represents the Mistral Vibe IDE.
MistralVibe MCPClient = "mistral-vibe"
// Codex represents the OpenAI Codex CLI.
Codex MCPClient = "codex"
)

// Extension is extension of the client config file.
Expand All @@ -82,6 +91,8 @@ const (
JSON Extension = "json"
// YAML represents a YAML extension.
YAML Extension = "yaml"
// TOML represents a TOML extension.
TOML Extension = "toml"
)

// YAMLStorageType represents how servers are stored in YAML configuration files.
Expand All @@ -94,6 +105,18 @@ const (
YAMLStorageTypeArray YAMLStorageType = "array"
)

// TOMLStorageType represents how servers are stored in TOML configuration files.
type TOMLStorageType string

const (
// TOMLStorageTypeMap represents servers stored as nested tables [section.servername].
// Example: [mcp_servers.myserver]
TOMLStorageTypeMap TOMLStorageType = "map"
// TOMLStorageTypeArray represents servers stored as array of tables [[section]].
// Example: [[mcp_servers]]
TOMLStorageTypeArray TOMLStorageType = "array"
)

// mcpClientConfig represents a configuration path for a supported MCP client.
type mcpClientConfig struct {
ClientType MCPClient
Expand All @@ -108,9 +131,32 @@ type mcpClientConfig struct {
// MCPServersUrlLabelMap maps transport type to URL field name (e.g., "url", "serverUrl", "httpUrl")
MCPServersUrlLabelMap map[types.TransportType]string
// YAML-specific configuration (only used when Extension == YAML)
YAMLStorageType YAMLStorageType // How servers are stored in YAML (map or array)
YAMLIdentifierField string // For array type: field name that identifies the server
YAMLDefaults map[string]interface{} // Default values to add to entries
YAMLStorageType YAMLStorageType // How servers are stored in YAML (map or array)
YAMLIdentifierField string // For array type: field name that identifies the server
YAMLDefaults map[string]any // Default values to add to entries
// TOML-specific configuration (only used when Extension == TOML)
TOMLStorageType TOMLStorageType // How servers are stored in TOML (map or array)
}

// extractServersKeyFromConfig extracts the servers key from MCPServersPathPrefix
// by removing the leading "/" (e.g., "/mcpServers" -> "mcpServers").
func extractServersKeyFromConfig(cfg *mcpClientConfig) string {
return strings.TrimPrefix(cfg.MCPServersPathPrefix, "/")
}

// extractURLLabelFromConfig extracts the URL field label from MCPServersUrlLabelMap.
// It checks transport types in priority order: StreamableHTTP, then Stdio.
// Returns defaultURLFieldName if no mapping is found.
func extractURLLabelFromConfig(cfg *mcpClientConfig) string {
if cfg.MCPServersUrlLabelMap != nil {
if label, ok := cfg.MCPServersUrlLabelMap[types.TransportTypeStreamableHTTP]; ok {
return label
}
if label, ok := cfg.MCPServersUrlLabelMap[types.TransportTypeStdio]; ok {
return label
}
}
return defaultURLFieldName
}

var (
Expand Down Expand Up @@ -607,6 +653,46 @@ var supportedClientIntegrations = []mcpClientConfig{
types.TransportTypeStreamableHTTP: "httpUrl",
},
},
{
ClientType: MistralVibe,
Description: "Mistral Vibe IDE",
SettingsFile: "config.toml",
MCPServersPathPrefix: "/mcp_servers",
RelPath: []string{".vibe"},
Extension: TOML,
// Mistral Vibe uses "transport" field for transport type (http or stdio)
// For HTTP-based servers (SSE and streamable HTTP), it uses "http" transport
IsTransportTypeFieldSupported: true,
SupportedTransportTypesMap: map[types.TransportType]string{
types.TransportTypeStdio: "streamable-http",
types.TransportTypeSSE: "http",
types.TransportTypeStreamableHTTP: "streamable-http",
},
MCPServersUrlLabelMap: map[types.TransportType]string{
types.TransportTypeStdio: "url",
types.TransportTypeSSE: "url",
types.TransportTypeStreamableHTTP: "url",
},
// TOML configuration - uses array-of-tables format [[mcp_servers]]
TOMLStorageType: TOMLStorageTypeArray,
},
{
ClientType: Codex,
Description: "OpenAI Codex CLI",
SettingsFile: "config.toml",
MCPServersPathPrefix: "/mcp_servers",
RelPath: []string{".codex"},
Extension: TOML,
// Codex doesn't require transport type field
IsTransportTypeFieldSupported: false,
MCPServersUrlLabelMap: map[types.TransportType]string{
types.TransportTypeStdio: "url",
types.TransportTypeSSE: "url",
types.TransportTypeStreamableHTTP: "url",
},
// TOML configuration - uses nested tables format [mcp_servers.servername]
TOMLStorageType: TOMLStorageTypeMap,
},
}

// ConfigFile represents a client configuration file
Expand Down Expand Up @@ -735,10 +821,11 @@ func (cm *ClientManager) CreateClientConfig(clientType MCPClient) (*ConfigFile,
}

var initialContent []byte
if clientCfg.Extension == YAML {
// For YAML files, create an empty file - the updater will initialize structure as needed
switch clientCfg.Extension {
case YAML, TOML:
// For YAML and TOML files, create an empty file - the updater will initialize structure as needed
initialContent = []byte("")
} else {
case JSON:
// JSON files get empty object
initialContent = []byte("{}")
}
Expand Down Expand Up @@ -787,7 +874,7 @@ func buildMCPServer(url, transportType string, clientCfg *mcpClientConfig) MCPSe
server := MCPServer{}

// Determine the URL field name from the transport type using MCPServersUrlLabelMap
urlFieldName := "url" // default fallback
urlFieldName := defaultURLFieldName // default fallback
if clientCfg.MCPServersUrlLabelMap != nil {
if mappedUrlField, ok := clientCfg.MCPServersUrlLabelMap[types.TransportType(transportType)]; ok {
urlFieldName = mappedUrlField
Expand Down Expand Up @@ -849,6 +936,27 @@ func (cm *ClientManager) retrieveConfigFileMetadata(clientType MCPClient) (*Conf
Path: path,
Converter: converter,
}
case TOML:
serversKey := extractServersKeyFromConfig(clientCfg)
urlLabel := extractURLLabelFromConfig(clientCfg)

// Choose TOML updater based on storage type
if clientCfg.TOMLStorageType == TOMLStorageTypeMap {
// Use map-based format [section.servername] (e.g., Codex)
configUpdater = &TOMLMapConfigUpdater{
Path: path,
ServersKey: serversKey,
URLField: urlLabel,
}
} else {
// Default to array-of-tables format [[section]] (e.g., Mistral Vibe)
configUpdater = &TOMLConfigUpdater{
Path: path,
ServersKey: serversKey,
IdentifierField: "name", // TOML configs use "name" as the identifier
URLField: urlLabel,
}
}
case JSON:
configUpdater = &JSONConfigUpdater{
Path: path,
Expand Down Expand Up @@ -888,17 +996,30 @@ func validateConfigFileFormat(cf *ConfigFile) error {
return fmt.Errorf("failed to read file %s: %w", cf.Path, err)
}

// For YAML and TOML files, empty content is valid
// For JSON files, default to empty object if the file is empty
if len(data) == 0 {
data = []byte("{}") // Default to an empty JSON object if the file is empty
switch cf.Extension {
case YAML, TOML:
return nil // Empty YAML/TOML files are valid
case JSON:
data = []byte("{}") // Default to an empty JSON object
}
}

switch cf.Extension {
case YAML:
var temp interface{}
var temp any
err = yaml.Unmarshal(data, &temp)
if err != nil {
return fmt.Errorf("failed to parse YAML for file %s: %w", cf.Path, err)
}
case TOML:
var temp any
err = toml.Unmarshal(data, &temp)
if err != nil {
return fmt.Errorf("failed to parse TOML for file %s: %w", cf.Path, err)
}
case JSON:
_, err = hujson.Parse(data)
if err != nil {
Expand Down
Loading
Loading