diff --git a/cmd/config/config.go b/cmd/config/config.go index d82ce9c20..dda314a32 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -2,6 +2,9 @@ package config import ( + "encoding/json" + "fmt" + "io" "os" "path/filepath" @@ -66,3 +69,53 @@ func Load() *ClientConfig { return cfg } + +// ReadFile reads configuration from an external file and unmarshals it into the provided struct pointer. +// The struct should have appropriate json and yaml tags. +// Supports both JSON (.json) and YAML (.yaml, .yml) file formats. +func ReadFile[T any](filePath string, config *T) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open config file: %w", err) + } + defer f.Close() + + ext := filepath.Ext(filePath) + switch ext { + case ".yaml", ".yml": + return readFileYaml(f, config) + case ".json": + return readFileJson(f, config) + default: + fmt.Println("Unknown config file extension. Assuming json format.") + return readFileJson(f, config) + } +} + +// readFileJson reads and unmarshals JSON configuration from a reader. +func readFileJson[T any](reader io.Reader, config *T) error { + data, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + if err := json.Unmarshal(data, config); err != nil { + return fmt.Errorf("failed to parse config from config file: %w", err) + } + + return nil +} + +// readFileYaml reads and unmarshals YAML configuration from a reader. +func readFileYaml[T any](reader io.Reader, config *T) error { + data, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("failed to read config from config file: %w", err) + } + + if err := yaml.Unmarshal(data, config); err != nil { + return fmt.Errorf("failed to parse config from config file: %w", err) + } + + return nil +} diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go new file mode 100644 index 000000000..122e157d2 --- /dev/null +++ b/cmd/config/config_test.go @@ -0,0 +1,124 @@ +package config + +import ( + "strings" + "testing" + + "github.com/mcpjungle/mcpjungle/pkg/types" +) + +func TestReadFileJson(t *testing.T) { + input := `{ + "name": "test_server", + "transport": "stdio", + "description": "Test JSON server", + "command": "node", + "args": ["server.js"], + "env": {"NODE_ENV": "test"} +}` + + reader := strings.NewReader(input) + var got types.RegisterServerInput + err := readFileJson(reader, &got) + if err != nil { + t.Fatalf("readFileJson() error = %v", err) + } + + expected := types.RegisterServerInput{ + Name: "test_server", + Transport: "stdio", + Description: "Test JSON server", + Command: "node", + Args: []string{"server.js"}, + Env: map[string]string{"NODE_ENV": "test"}, + } + + if got.Name != expected.Name { + t.Errorf("Name = %q, want %q", got.Name, expected.Name) + } + if got.Transport != expected.Transport { + t.Errorf("Transport = %q, want %q", got.Transport, expected.Transport) + } + if got.Description != expected.Description { + t.Errorf("Description = %q, want %q", got.Description, expected.Description) + } + if got.Command != expected.Command { + t.Errorf("Command = %q, want %q", got.Command, expected.Command) + } + if len(got.Args) != len(expected.Args) || got.Args[0] != expected.Args[0] { + t.Errorf("Args = %v, want %v", got.Args, expected.Args) + } + if got.Env["NODE_ENV"] != expected.Env["NODE_ENV"] { + t.Errorf("Env = %v, want %v", got.Env, expected.Env) + } +} + +func TestReadFileYaml(t *testing.T) { + input := `name: test_server +transport: stdio +description: Test YAML server +command: node +args: + - server.js +env: + NODE_ENV: test` + + reader := strings.NewReader(input) + var got types.RegisterServerInput + err := readFileYaml(reader, &got) + if err != nil { + t.Fatalf("readFileYaml() error = %v", err) + } + + expected := types.RegisterServerInput{ + Name: "test_server", + Transport: "stdio", + Description: "Test YAML server", + Command: "node", + Args: []string{"server.js"}, + Env: map[string]string{"NODE_ENV": "test"}, + } + + if got.Name != expected.Name { + t.Errorf("Name = %q, want %q", got.Name, expected.Name) + } + if got.Transport != expected.Transport { + t.Errorf("Transport = %q, want %q", got.Transport, expected.Transport) + } + if got.Description != expected.Description { + t.Errorf("Description = %q, want %q", got.Description, expected.Description) + } + if got.Command != expected.Command { + t.Errorf("Command = %q, want %q", got.Command, expected.Command) + } + if len(got.Args) != len(expected.Args) || got.Args[0] != expected.Args[0] { + t.Errorf("Args = %v, want %v", got.Args, expected.Args) + } + if got.Env["NODE_ENV"] != expected.Env["NODE_ENV"] { + t.Errorf("Env = %v, want %v", got.Env, expected.Env) + } +} + +func TestReadFileGeneric(t *testing.T) { + // Test with a different struct type to verify generics work + type TestConfig struct { + Name string `json:"name" yaml:"name"` + Value int `json:"value" yaml:"value"` + } + + jsonInput := `{"name": "test", "value": 42}` + reader := strings.NewReader(jsonInput) + var config TestConfig + + err := readFileJson(reader, &config) + if err != nil { + t.Fatalf("readFileJson() with generic type error = %v", err) + } + + if config.Name != "test" { + t.Errorf("Name = %q, want %q", config.Name, "test") + } + if config.Value != 42 { + t.Errorf("Value = %d, want %d", config.Value, 42) + } +} diff --git a/cmd/register.go b/cmd/register.go index e659e41b4..c99ea3a73 100644 --- a/cmd/register.go +++ b/cmd/register.go @@ -1,10 +1,8 @@ package cmd import ( - "encoding/json" "fmt" - "os" - + "github.com/mcpjungle/mcpjungle/cmd/config" "github.com/mcpjungle/mcpjungle/pkg/types" "github.com/spf13/cobra" ) @@ -86,21 +84,6 @@ func init() { rootCmd.AddCommand(registerMCPServerCmd) } -func readMcpServerConfig(filePath string) (types.RegisterServerInput, error) { - var input types.RegisterServerInput - - data, err := os.ReadFile(filePath) - if err != nil { - return input, fmt.Errorf("failed to read config file %s: %w", filePath, err) - } - // Parse JSON config - if err := json.Unmarshal(data, &input); err != nil { - return input, fmt.Errorf("failed to parse config file: %w", err) - } - - return input, nil -} - func runRegisterMCPServer(cmd *cobra.Command, args []string) error { var input types.RegisterServerInput @@ -115,9 +98,7 @@ func runRegisterMCPServer(cmd *cobra.Command, args []string) error { } } else { // If a config file is provided, read the configuration from the file - var err error - input, err = readMcpServerConfig(registerCmdServerConfigFilePath) - if err != nil { + if err := config.ReadFile(registerCmdServerConfigFilePath, &input); err != nil { return err } } diff --git a/pkg/types/mcp_server.go b/pkg/types/mcp_server.go index ba6cd213d..c1692c2f4 100644 --- a/pkg/types/mcp_server.go +++ b/pkg/types/mcp_server.go @@ -29,34 +29,34 @@ type McpServer struct { // It is also the basis for the JSON configuration file used to register a new MCP server. type RegisterServerInput struct { // Name is the unique name of an MCP server registered in mcpjungle - Name string `json:"name"` + Name string `json:"name" yaml:"name"` // Transport is the transport protocol used by the MCP server. - // valid values are "stdio", "streamable_http", and "sse". - Transport string `json:"transport"` + // valid values are "stdio", "streamable_http"" + Transport string `json:"transport" yaml:"transport"` - Description string `json:"description"` + Description string `json:"description" yaml:"description"` // URL is the URL of the remote mcp server // It is mandatory when transport is streamable_http and must be a valid // http/https URL (e.g., https://example.com/mcp). - URL string `json:"url"` + URL string `json:"url" yaml:"url"` // BearerToken is an optional token used for authenticating requests to the remote MCP server. // It is useful when the upstream MCP server requires static tokens (e.g., API tokens) for authentication. // If the transport is "stdio", this field is ignored. - BearerToken string `json:"bearer_token"` + BearerToken string `json:"bearer_token" yaml:"bearer_token"` // Command is the command to run the mcp server. // It is mandatory when the transport is "stdio". - Command string `json:"command"` + Command string `json:"command" yaml:"command"` // Args is the list of arguments to pass to the command when the transport is "stdio". - Args []string `json:"args"` + Args []string `json:"args" yaml:"args"` // Env is the set of environment variables to pass to the mcp server when the transport is "stdio". // Both the key and value must be of type string. - Env map[string]string `json:"env"` + Env map[string]string `json:"env" yaml:"env"` } // ValidateTransport validates the input string and returns the corresponding model.McpServerTransport.