Initial minimal mcp server for KorAP
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..361ae2b
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,158 @@
+package config
+
+import (
+ "fmt"
+ "os"
+)
+
+// Config represents the complete configuration for KorAP MCP server
+type Config struct {
+ // Server configuration
+ Server ServerConfig `yaml:"server" embed:""`
+
+ // OAuth2 authentication configuration
+ OAuth OAuthConfig `yaml:"oauth"`
+
+ // KorAP API configuration
+ KorAP KorAPConfig `yaml:"korap"`
+
+ // Logging configuration
+ Logging LoggingConfig `yaml:"logging"`
+}
+
+// ServerConfig represents server-specific configuration
+type ServerConfig struct {
+ // Name is the server name (constant, not configurable)
+ Name string `yaml:"-"`
+
+ // Version is the server version (constant, not configurable)
+ Version string `yaml:"-"`
+
+ // ConfigFile is the path to the configuration file (handled by CLI layer)
+ ConfigFile string `yaml:"-"`
+}
+
+// KorAPConfig represents KorAP API configuration
+type KorAPConfig struct {
+ // BaseURL is the KorAP server base URL
+ BaseURL string `yaml:"base_url" default:"https://korap.ids-mannheim.de" help:"KorAP server base URL"`
+
+ // APIVersion is the API version to use
+ APIVersion string `yaml:"api_version" default:"v1.0" help:"KorAP API version"`
+
+ // Timeout is the HTTP request timeout in seconds
+ Timeout int `yaml:"timeout" default:"30" help:"HTTP request timeout in seconds"`
+
+ // MaxRetries is the maximum number of retry attempts
+ MaxRetries int `yaml:"max_retries" default:"3" help:"Maximum number of retry attempts"`
+}
+
+// LoggingConfig represents logging configuration
+type LoggingConfig struct {
+ // Level is the logging level (trace, debug, info, warn, error)
+ Level string `yaml:"level" default:"info" enum:"trace,debug,info,warn,error" help:"Logging level"`
+
+ // Format is the log format (json, text)
+ Format string `yaml:"format" default:"text" enum:"json,text" help:"Log output format"`
+
+ // File is the log file path (empty for stdout)
+ File string `yaml:"file" help:"Log file path (empty for stdout)"`
+}
+
+// DefaultConfig returns a default configuration
+func DefaultConfig() *Config {
+ return &Config{
+ Server: ServerConfig{
+ // Name and Version are set by the CLI layer as constants
+ },
+ OAuth: *DefaultOAuthConfig(),
+ KorAP: KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ Timeout: 30,
+ MaxRetries: 3,
+ },
+ Logging: LoggingConfig{
+ Level: "info",
+ Format: "text",
+ },
+ }
+}
+
+// Validate validates the complete configuration
+func (c *Config) Validate() error {
+ if err := c.OAuth.Validate(); err != nil {
+ return fmt.Errorf("oauth config validation failed: %w", err)
+ }
+
+ if err := c.KorAP.Validate(); err != nil {
+ return fmt.Errorf("korap config validation failed: %w", err)
+ }
+
+ if err := c.Logging.Validate(); err != nil {
+ return fmt.Errorf("logging config validation failed: %w", err)
+ }
+
+ return nil
+}
+
+// Validate validates KorAP configuration
+func (k *KorAPConfig) Validate() error {
+ if k.BaseURL == "" {
+ return fmt.Errorf("base_url cannot be empty")
+ }
+
+ if k.APIVersion == "" {
+ return fmt.Errorf("api_version cannot be empty")
+ }
+
+ if k.Timeout <= 0 {
+ return fmt.Errorf("timeout must be positive")
+ }
+
+ if k.MaxRetries < 0 {
+ return fmt.Errorf("max_retries cannot be negative")
+ }
+
+ return nil
+}
+
+// Validate validates logging configuration
+func (l *LoggingConfig) Validate() error {
+ validLevels := map[string]bool{
+ "trace": true,
+ "debug": true,
+ "info": true,
+ "warn": true,
+ "error": true,
+ }
+
+ if !validLevels[l.Level] {
+ return fmt.Errorf("invalid log level: %s", l.Level)
+ }
+
+ validFormats := map[string]bool{
+ "json": true,
+ "text": true,
+ }
+
+ if !validFormats[l.Format] {
+ return fmt.Errorf("invalid log format: %s", l.Format)
+ }
+
+ // Check if log file is writable if specified
+ if l.File != "" {
+ file, err := os.OpenFile(l.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
+ if err != nil {
+ return fmt.Errorf("cannot write to log file %s: %w", l.File, err)
+ }
+ file.Close()
+ }
+
+ return nil
+}
+
+// GetKorAPEndpoint returns the full KorAP API endpoint URL
+func (k *KorAPConfig) GetKorAPEndpoint() string {
+ return fmt.Sprintf("%s/api/%s", k.BaseURL, k.APIVersion)
+}
diff --git a/config/config_test.go b/config/config_test.go
new file mode 100644
index 0000000..5482d0c
--- /dev/null
+++ b/config/config_test.go
@@ -0,0 +1,273 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDefaultConfig(t *testing.T) {
+ cfg := DefaultConfig()
+
+ assert.NotNil(t, cfg)
+
+ // Note: Server name and version are now set by the CLI layer as constants
+ // so they will be empty in the default config
+
+ // Test KorAP defaults
+ assert.Equal(t, "https://korap.ids-mannheim.de", cfg.KorAP.BaseURL)
+ assert.Equal(t, "v1.0", cfg.KorAP.APIVersion)
+ assert.Equal(t, 30, cfg.KorAP.Timeout)
+ assert.Equal(t, 3, cfg.KorAP.MaxRetries)
+
+ // Test OAuth defaults
+ assert.False(t, cfg.OAuth.Enabled, "Expected OAuth to be disabled by default")
+
+ // Test logging defaults
+ assert.Equal(t, "info", cfg.Logging.Level)
+ assert.Equal(t, "text", cfg.Logging.Format)
+}
+
+func TestConfigValidate(t *testing.T) {
+ // Test valid configuration
+ cfg := DefaultConfig()
+ err := cfg.Validate()
+ assert.NoError(t, err)
+
+ // Test invalid OAuth configuration
+ cfg.OAuth.Enabled = true
+ cfg.OAuth.ClientID = ""
+ err = cfg.Validate()
+ assert.Error(t, err, "Expected validation error for empty OAuth client ID")
+
+ // Test invalid KorAP configuration
+ cfg = DefaultConfig()
+ cfg.KorAP.BaseURL = ""
+ err = cfg.Validate()
+ assert.Error(t, err, "Expected validation error for empty KorAP base URL")
+
+ // Test invalid logging configuration
+ cfg = DefaultConfig()
+ cfg.Logging.Level = "invalid"
+ err = cfg.Validate()
+ assert.Error(t, err, "Expected validation error for invalid log level")
+}
+
+func TestKorAPConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config KorAPConfig
+ expectErr bool
+ }{
+ {
+ name: "valid config",
+ config: KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ Timeout: 30,
+ MaxRetries: 3,
+ },
+ expectErr: false,
+ },
+ {
+ name: "empty base URL",
+ config: KorAPConfig{
+ BaseURL: "",
+ APIVersion: "v1.0",
+ Timeout: 30,
+ MaxRetries: 3,
+ },
+ expectErr: true,
+ },
+ {
+ name: "empty API version",
+ config: KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "",
+ Timeout: 30,
+ MaxRetries: 3,
+ },
+ expectErr: true,
+ },
+ {
+ name: "zero timeout",
+ config: KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ Timeout: 0,
+ MaxRetries: 3,
+ },
+ expectErr: true,
+ },
+ {
+ name: "negative timeout",
+ config: KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ Timeout: -1,
+ MaxRetries: 3,
+ },
+ expectErr: true,
+ },
+ {
+ name: "negative max retries",
+ config: KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ Timeout: 30,
+ MaxRetries: -1,
+ },
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+ if tt.expectErr {
+ assert.Error(t, err, "Expected validation error but got none")
+ } else {
+ assert.NoError(t, err, "Unexpected validation error")
+ }
+ })
+ }
+}
+
+func TestKorAPConfigGetEndpoint(t *testing.T) {
+ config := KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ }
+
+ expected := "https://korap.ids-mannheim.de/api/v1.0"
+ actual := config.GetKorAPEndpoint()
+
+ assert.Equal(t, expected, actual)
+}
+
+func TestLoggingConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config LoggingConfig
+ expectErr bool
+ }{
+ {
+ name: "valid text format",
+ config: LoggingConfig{
+ Level: "info",
+ Format: "text",
+ },
+ expectErr: false,
+ },
+ {
+ name: "valid json format",
+ config: LoggingConfig{
+ Level: "debug",
+ Format: "json",
+ },
+ expectErr: false,
+ },
+ {
+ name: "invalid level",
+ config: LoggingConfig{
+ Level: "invalid",
+ Format: "text",
+ },
+ expectErr: true,
+ },
+ {
+ name: "invalid format",
+ config: LoggingConfig{
+ Level: "info",
+ Format: "invalid",
+ },
+ expectErr: true,
+ },
+ {
+ name: "all log levels",
+ config: LoggingConfig{
+ Level: "trace",
+ Format: "json",
+ },
+ expectErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+ if tt.expectErr {
+ assert.Error(t, err, "Expected validation error but got none")
+ } else {
+ assert.NoError(t, err, "Unexpected validation error")
+ }
+ })
+ }
+}
+
+func TestLoggingConfigValidateWithFile(t *testing.T) {
+ // Create a temporary directory for testing
+ tempDir, err := os.MkdirTemp("", "korap-mcp-test")
+ assert.NoError(t, err, "Failed to create temp dir")
+ defer os.RemoveAll(tempDir)
+
+ // Create a writable log file
+ logFile := filepath.Join(tempDir, "test.log")
+ file, err := os.Create(logFile)
+ assert.NoError(t, err)
+ file.Close()
+
+ config := LoggingConfig{
+ Level: "info",
+ Format: "json",
+ File: logFile,
+ }
+
+ err = config.Validate()
+ assert.NoError(t, err, "Validation failed for writable log file")
+
+ // Test with invalid log file path
+ config.File = "/root/invalid.log"
+ err = config.Validate()
+ assert.Error(t, err, "Expected validation error for invalid log file path")
+}
+
+func TestServerConfigDefaults(t *testing.T) {
+ cfg := DefaultConfig()
+
+ assert.Empty(t, cfg.Server.ConfigFile, "Expected empty config file by default")
+
+ // Name and version are now constants set by CLI layer, so they will be empty here
+ assert.Empty(t, cfg.Server.Name, "Server name should be empty in default config (set by CLI constants)")
+ assert.Empty(t, cfg.Server.Version, "Server version should be empty in default config (set by CLI constants)")
+}
+
+// Benchmark tests
+func BenchmarkDefaultConfig(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _ = DefaultConfig()
+ }
+}
+
+func BenchmarkConfigValidate(b *testing.B) {
+ cfg := DefaultConfig()
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ _ = cfg.Validate()
+ }
+}
+
+func BenchmarkKorAPGetEndpoint(b *testing.B) {
+ config := KorAPConfig{
+ BaseURL: "https://korap.ids-mannheim.de",
+ APIVersion: "v1.0",
+ }
+ b.ResetTimer()
+
+ for i := 0; i < b.N; i++ {
+ _ = config.GetKorAPEndpoint()
+ }
+}
diff --git a/config/oauth.go b/config/oauth.go
new file mode 100644
index 0000000..b015f16
--- /dev/null
+++ b/config/oauth.go
@@ -0,0 +1,83 @@
+package config
+
+import (
+ "fmt"
+
+ "golang.org/x/oauth2"
+)
+
+// OAuthConfig represents OAuth2 configuration for KorAP authentication
+type OAuthConfig struct {
+ // ClientID is the OAuth2 client identifier
+ ClientID string `yaml:"client_id"`
+
+ // ClientSecret is the OAuth2 client secret
+ ClientSecret string `yaml:"client_secret"`
+
+ // AuthURL is the authorization endpoint URL
+ AuthURL string `yaml:"auth_url"`
+
+ // TokenURL is the token endpoint URL
+ TokenURL string `yaml:"token_url"`
+
+ // RedirectURL is the callback URL for authorization code flow
+ RedirectURL string `yaml:"redirect_url"`
+
+ // Scopes are the requested OAuth2 scopes
+ Scopes []string `yaml:"scopes"`
+
+ // Enabled indicates whether OAuth2 authentication is enabled
+ Enabled bool `yaml:"enabled"`
+}
+
+// DefaultOAuthConfig returns a default OAuth2 configuration
+func DefaultOAuthConfig() *OAuthConfig {
+ return &OAuthConfig{
+ AuthURL: "https://korap.ids-mannheim.de/api/v1.0/oauth2/authorize",
+ TokenURL: "https://korap.ids-mannheim.de/api/v1.0/oauth2/token",
+ RedirectURL: "urn:ietf:wg:oauth:2.0:oob",
+ Scopes: []string{"read"},
+ Enabled: false,
+ }
+}
+
+// ToOAuth2Config converts the config to golang.org/x/oauth2.Config
+func (c *OAuthConfig) ToOAuth2Config() *oauth2.Config {
+ if !c.Enabled {
+ return nil
+ }
+
+ return &oauth2.Config{
+ ClientID: c.ClientID,
+ ClientSecret: c.ClientSecret,
+ Endpoint: oauth2.Endpoint{
+ AuthURL: c.AuthURL,
+ TokenURL: c.TokenURL,
+ },
+ RedirectURL: c.RedirectURL,
+ Scopes: c.Scopes,
+ }
+}
+
+// Validate checks if the OAuth2 configuration is valid
+func (c *OAuthConfig) Validate() error {
+ if !c.Enabled {
+ return nil
+ }
+
+ if c.ClientID == "" {
+ return fmt.Errorf("oauth2 client_id is required when authentication is enabled")
+ }
+
+ if c.ClientSecret == "" {
+ return fmt.Errorf("oauth2 client_secret is required when authentication is enabled")
+ }
+
+ if c.TokenURL == "" {
+ return fmt.Errorf("oauth2 token_url is required when authentication is enabled")
+ }
+
+ // AuthURL is only required for authorization code flow, not client credentials
+
+ return nil
+}
diff --git a/config/oauth_test.go b/config/oauth_test.go
new file mode 100644
index 0000000..e658910
--- /dev/null
+++ b/config/oauth_test.go
@@ -0,0 +1,259 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDefaultOAuthConfig(t *testing.T) {
+ config := DefaultOAuthConfig()
+
+ assert.NotNil(t, config)
+
+ // Test default values
+ assert.Equal(t, "https://korap.ids-mannheim.de/api/v1.0/oauth2/authorize", config.AuthURL)
+ assert.Equal(t, "https://korap.ids-mannheim.de/api/v1.0/oauth2/token", config.TokenURL)
+ assert.Equal(t, "urn:ietf:wg:oauth:2.0:oob", config.RedirectURL)
+ assert.Equal(t, []string{"read"}, config.Scopes)
+ assert.False(t, config.Enabled)
+
+ // Test that required fields are empty by default
+ assert.Empty(t, config.ClientID)
+ assert.Empty(t, config.ClientSecret)
+
+ // Test ToOAuth2Config with disabled config
+ oauth2Config := config.ToOAuth2Config()
+ assert.Nil(t, oauth2Config, "ToOAuth2Config should return nil when disabled")
+
+ // Test validation of default (disabled) config
+ err := config.Validate()
+ assert.NoError(t, err, "Default config should be valid (disabled)")
+}
+
+func TestOAuthConfigValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ config *OAuthConfig
+ wantError bool
+ errorMsg string
+ }{
+ {
+ name: "valid enabled config",
+ config: &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ RedirectURL: "https://example.com/callback",
+ Scopes: []string{"read"},
+ Enabled: true,
+ },
+ wantError: false,
+ },
+ {
+ name: "valid disabled config",
+ config: &OAuthConfig{
+ Enabled: false,
+ },
+ wantError: false,
+ },
+ {
+ name: "missing client ID",
+ config: &OAuthConfig{
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ Enabled: true,
+ },
+ wantError: true,
+ errorMsg: "client_id is required",
+ },
+ {
+ name: "missing client secret",
+ config: &OAuthConfig{
+ ClientID: "test-client",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ Enabled: true,
+ },
+ wantError: true,
+ errorMsg: "client_secret is required",
+ },
+ {
+ name: "missing token URL",
+ config: &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ Enabled: true,
+ },
+ wantError: true,
+ errorMsg: "token_url is required",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+
+ if tt.wantError {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errorMsg)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestOAuthConfigToOAuth2Config(t *testing.T) {
+ tests := []struct {
+ name string
+ config *OAuthConfig
+ wantNil bool
+ testFunc func(*testing.T, *OAuthConfig)
+ }{
+ {
+ name: "disabled config returns nil",
+ config: &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ Enabled: false,
+ },
+ wantNil: true,
+ },
+ {
+ name: "enabled config returns valid oauth2.Config",
+ config: &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ RedirectURL: "https://example.com/callback",
+ Scopes: []string{"read", "write"},
+ Enabled: true,
+ },
+ wantNil: false,
+ testFunc: func(t *testing.T, config *OAuthConfig) {
+ oauth2Config := config.ToOAuth2Config()
+ assert.NotNil(t, oauth2Config)
+ assert.Equal(t, config.ClientID, oauth2Config.ClientID)
+ assert.Equal(t, config.ClientSecret, oauth2Config.ClientSecret)
+ assert.Equal(t, config.AuthURL, oauth2Config.Endpoint.AuthURL)
+ assert.Equal(t, config.TokenURL, oauth2Config.Endpoint.TokenURL)
+ assert.Equal(t, config.RedirectURL, oauth2Config.RedirectURL)
+ assert.Equal(t, config.Scopes, oauth2Config.Scopes)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ oauth2Config := tt.config.ToOAuth2Config()
+
+ if tt.wantNil {
+ assert.Nil(t, oauth2Config)
+ } else {
+ assert.NotNil(t, oauth2Config)
+ if tt.testFunc != nil {
+ tt.testFunc(t, tt.config)
+ }
+ }
+ })
+ }
+}
+
+func TestOAuthConfigEdgeCases(t *testing.T) {
+ t.Run("empty scopes", func(t *testing.T) {
+ config := &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ Scopes: []string{},
+ Enabled: true,
+ }
+
+ err := config.Validate()
+ assert.NoError(t, err, "Empty scopes should be valid")
+
+ oauth2Config := config.ToOAuth2Config()
+ assert.NotNil(t, oauth2Config)
+ assert.Empty(t, oauth2Config.Scopes)
+ })
+
+ t.Run("nil scopes", func(t *testing.T) {
+ config := &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ Scopes: nil,
+ Enabled: true,
+ }
+
+ err := config.Validate()
+ assert.NoError(t, err, "Nil scopes should be valid")
+
+ oauth2Config := config.ToOAuth2Config()
+ assert.NotNil(t, oauth2Config)
+ assert.Nil(t, oauth2Config.Scopes)
+ })
+}
+
+func TestOAuthConfigScopesHandling(t *testing.T) {
+ config := &OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ RedirectURL: "https://example.com/callback",
+ Scopes: []string{"read", "write", "admin"},
+ Enabled: true,
+ }
+
+ oauth2Config := config.ToOAuth2Config()
+ assert.NotNil(t, oauth2Config)
+ assert.Len(t, oauth2Config.Scopes, 3)
+ assert.Contains(t, oauth2Config.Scopes, "read")
+ assert.Contains(t, oauth2Config.Scopes, "write")
+ assert.Contains(t, oauth2Config.Scopes, "admin")
+}
+
+// Benchmark tests
+func BenchmarkDefaultOAuthConfig(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ _ = DefaultOAuthConfig()
+ }
+}
+
+func BenchmarkOAuthConfigValidate(b *testing.B) {
+ cfg := DefaultOAuthConfig()
+ cfg.Enabled = true
+ cfg.ClientID = "test-client"
+ cfg.ClientSecret = "test-secret"
+ cfg.TokenURL = "https://example.com/token"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = cfg.Validate()
+ }
+}
+
+func BenchmarkOAuthConfigToOAuth2Config(b *testing.B) {
+ cfg := &OAuthConfig{
+ Enabled: true,
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ RedirectURL: "https://example.com/callback",
+ Scopes: []string{"read", "write"},
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = cfg.ToOAuth2Config()
+ }
+}