Initial minimal mcp server for KorAP
diff --git a/service/client.go b/service/client.go
new file mode 100644
index 0000000..bdc9d8b
--- /dev/null
+++ b/service/client.go
@@ -0,0 +1,238 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/korap/korap-mcp/auth"
+ "github.com/korap/korap-mcp/config"
+)
+
+// Client represents a KorAP API client
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+ oauthClient *auth.OAuthClient
+}
+
+// ClientOptions configures the KorAP client
+type ClientOptions struct {
+ BaseURL string
+ Timeout time.Duration
+ OAuthConfig *config.OAuthConfig
+}
+
+// NewClient creates a new KorAP API client
+func NewClient(opts ClientOptions) (*Client, error) {
+ if opts.BaseURL == "" {
+ return nil, fmt.Errorf("base URL is required")
+ }
+
+ // Ensure base URL ends with /
+ baseURL := strings.TrimSuffix(opts.BaseURL, "/") + "/"
+
+ // Set default timeout if not specified
+ timeout := opts.Timeout
+ if timeout == 0 {
+ timeout = 30 * time.Second
+ }
+
+ client := &Client{
+ baseURL: baseURL,
+ httpClient: &http.Client{
+ Timeout: timeout,
+ },
+ }
+
+ // Initialize OAuth client if configuration is provided
+ if opts.OAuthConfig != nil && opts.OAuthConfig.Enabled {
+ oauthClient, err := auth.NewOAuthClient(opts.OAuthConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create OAuth client: %w", err)
+ }
+ client.oauthClient = oauthClient
+ }
+
+ return client, nil
+}
+
+// SetOAuthClient sets the OAuth client for authentication
+func (c *Client) SetOAuthClient(oauthClient *auth.OAuthClient) {
+ c.oauthClient = oauthClient
+}
+
+// IsAuthenticated returns true if the client has valid authentication
+func (c *Client) IsAuthenticated() bool {
+ return c.oauthClient != nil && c.oauthClient.IsAuthenticated()
+}
+
+// AuthenticateWithClientCredentials performs client credentials OAuth flow
+func (c *Client) AuthenticateWithClientCredentials(ctx context.Context) error {
+ if c.oauthClient == nil {
+ return fmt.Errorf("OAuth client not configured")
+ }
+
+ return c.oauthClient.ClientCredentialsFlow(ctx)
+}
+
+// buildURL constructs a full URL from the base URL and endpoint
+func (c *Client) buildURL(endpoint string) (string, error) {
+ endpoint = strings.TrimPrefix(endpoint, "/")
+ if endpoint == "" {
+ return c.baseURL, nil
+ }
+ fullURL, err := url.JoinPath(c.baseURL, endpoint)
+ if err != nil {
+ return "", fmt.Errorf("failed to build URL: %w", err)
+ }
+ return fullURL, nil
+}
+
+// doRequest performs an HTTP request with optional authentication
+func (c *Client) doRequest(ctx context.Context, method, endpoint string, body io.Reader) (*http.Response, error) {
+ fullURL, err := c.buildURL(endpoint)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, fullURL, body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Set common headers
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ // Add authentication if available
+ if c.oauthClient != nil && c.oauthClient.IsAuthenticated() {
+ if err := c.oauthClient.AddAuthHeader(req); err != nil {
+ return nil, fmt.Errorf("failed to add auth header: %w", err)
+ }
+ }
+
+ // Use OAuth HTTP client if available, otherwise use default client
+ httpClient := c.httpClient
+ if c.oauthClient != nil {
+ httpClient = c.oauthClient.GetHTTPClient()
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+
+ return resp, nil
+}
+
+// Get performs a GET request to the specified endpoint
+func (c *Client) Get(ctx context.Context, endpoint string) (*http.Response, error) {
+ return c.doRequest(ctx, http.MethodGet, endpoint, nil)
+}
+
+// Post performs a POST request to the specified endpoint with JSON body
+func (c *Client) Post(ctx context.Context, endpoint string, body interface{}) (*http.Response, error) {
+ var bodyReader io.Reader
+ if body != nil {
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+ bodyReader = strings.NewReader(string(jsonBody))
+ }
+
+ return c.doRequest(ctx, http.MethodPost, endpoint, bodyReader)
+}
+
+// GetJSON performs a GET request and unmarshals the JSON response
+func (c *Client) GetJSON(ctx context.Context, endpoint string, target interface{}) error {
+ resp, err := c.Get(ctx, endpoint)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return c.handleErrorResponse(resp)
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
+ return fmt.Errorf("failed to decode JSON response: %w", err)
+ }
+
+ return nil
+}
+
+// PostJSON performs a POST request and unmarshals the JSON response
+func (c *Client) PostJSON(ctx context.Context, endpoint string, body interface{}, target interface{}) error {
+ resp, err := c.Post(ctx, endpoint, body)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return c.handleErrorResponse(resp)
+ }
+
+ if target != nil {
+ if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
+ return fmt.Errorf("failed to decode JSON response: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// handleErrorResponse processes error responses from the KorAP API
+func (c *Client) handleErrorResponse(resp *http.Response) error {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("HTTP %d: failed to read error response", resp.StatusCode)
+ }
+
+ // Try to parse as KorAP error response
+ var errorResp ErrorResponse
+ if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
+ return &APIError{
+ StatusCode: resp.StatusCode,
+ Message: errorResp.Error,
+ Details: errorResp.ErrorDescription,
+ }
+ }
+
+ // Fallback to generic error
+ return &APIError{
+ StatusCode: resp.StatusCode,
+ Message: fmt.Sprintf("HTTP %d", resp.StatusCode),
+ Details: string(body),
+ }
+}
+
+// GetBaseURL returns the base URL of the KorAP instance
+func (c *Client) GetBaseURL() string {
+ return c.baseURL
+}
+
+// Ping checks if the KorAP server is reachable
+func (c *Client) Ping(ctx context.Context) error {
+ resp, err := c.Get(ctx, "")
+ if err != nil {
+ return fmt.Errorf("failed to ping KorAP server: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 500 {
+ return fmt.Errorf("KorAP server error: HTTP %d", resp.StatusCode)
+ }
+
+ return nil
+}
diff --git a/service/client_test.go b/service/client_test.go
new file mode 100644
index 0000000..3827b09
--- /dev/null
+++ b/service/client_test.go
@@ -0,0 +1,320 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/korap/korap-mcp/auth"
+ "github.com/korap/korap-mcp/config"
+ "github.com/stretchr/testify/assert"
+ "golang.org/x/oauth2"
+)
+
+// TestNewClient tests client creation
+func TestNewClient(t *testing.T) {
+ tests := []struct {
+ name string
+ opts ClientOptions
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid client without oauth",
+ opts: ClientOptions{
+ BaseURL: "https://example.com",
+ Timeout: 10 * time.Second,
+ },
+ wantErr: false,
+ },
+ {
+ name: "valid client with oauth",
+ opts: ClientOptions{
+ BaseURL: "https://example.com",
+ OAuthConfig: &config.OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ AuthURL: "https://example.com/auth",
+ TokenURL: "https://example.com/token",
+ Enabled: true,
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid client - no base URL",
+ opts: ClientOptions{
+ Timeout: 10 * time.Second,
+ },
+ wantErr: true,
+ errMsg: "base URL is required",
+ },
+ {
+ name: "invalid oauth config",
+ opts: ClientOptions{
+ BaseURL: "https://example.com",
+ OAuthConfig: &config.OAuthConfig{
+ Enabled: true, // Missing required fields
+ },
+ },
+ wantErr: true,
+ errMsg: "failed to create OAuth client",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := NewClient(tt.opts)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errMsg)
+ return
+ }
+
+ assert.NoError(t, err)
+ assert.NotNil(t, client)
+
+ // Check base URL normalization
+ assert.True(t, strings.HasSuffix(client.baseURL, "/"), "Base URL should end with /")
+
+ // Check default timeout
+ if tt.opts.Timeout == 0 {
+ assert.Equal(t, 30*time.Second, client.httpClient.Timeout, "Default timeout should be 30 seconds")
+ }
+ })
+ }
+}
+
+// TestClientMethods tests basic client HTTP methods
+func TestClientMethods(t *testing.T) {
+ // Create a mock server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/test":
+ if r.Method == http.MethodGet {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"message": "success"})
+ } else if r.Method == http.MethodPost {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]string{"created": "true"})
+ }
+ case "/error":
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(ErrorResponse{
+ Error: "bad_request",
+ ErrorDescription: "Invalid request parameter",
+ })
+ case "/":
+ // Root endpoint for ping test
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ client, err := NewClient(ClientOptions{
+ BaseURL: server.URL,
+ Timeout: 5 * time.Second,
+ })
+ assert.NoError(t, err)
+
+ ctx := context.Background()
+
+ t.Run("GET request", func(t *testing.T) {
+ resp, err := client.Get(ctx, "/test")
+ assert.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ })
+
+ t.Run("POST request", func(t *testing.T) {
+ body := map[string]string{"key": "value"}
+ resp, err := client.Post(ctx, "/test", body)
+ assert.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusCreated, resp.StatusCode)
+ })
+
+ t.Run("GetJSON", func(t *testing.T) {
+ var result map[string]string
+ err := client.GetJSON(ctx, "/test", &result)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "success", result["message"])
+ })
+
+ t.Run("PostJSON", func(t *testing.T) {
+ body := map[string]string{"key": "value"}
+ var result map[string]string
+ err := client.PostJSON(ctx, "/test", body, &result)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "true", result["created"])
+ })
+
+ t.Run("Error handling", func(t *testing.T) {
+ var result map[string]interface{}
+ err := client.GetJSON(ctx, "/error", &result)
+
+ assert.Error(t, err)
+
+ apiErr, ok := err.(*APIError)
+ assert.True(t, ok, "Expected APIError")
+
+ assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
+ assert.Equal(t, "bad_request", apiErr.Message)
+ })
+
+ t.Run("Ping", func(t *testing.T) {
+ err := client.Ping(ctx)
+ assert.NoError(t, err)
+ })
+}
+
+// TestClientAuthentication tests OAuth2 integration
+func TestClientAuthentication(t *testing.T) {
+ // Create a mock OAuth2 server
+ authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ // Mock token endpoint
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "access_token": "test-access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ })
+ }
+ }))
+ defer authServer.Close()
+
+ // Create a mock API server that checks authentication
+ apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ if auth != "Bearer test-access-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(ErrorResponse{
+ Error: "unauthorized",
+ ErrorDescription: "Invalid or missing access token",
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"authenticated": "true"})
+ }))
+ defer apiServer.Close()
+
+ // Create client with OAuth2 configuration
+ oauthConfig := &config.OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenURL: authServer.URL + "/token",
+ Enabled: true,
+ }
+
+ client, err := NewClient(ClientOptions{
+ BaseURL: apiServer.URL,
+ OAuthConfig: oauthConfig,
+ })
+ assert.NoError(t, err)
+
+ ctx := context.Background()
+
+ t.Run("Unauthenticated request", func(t *testing.T) {
+ var result map[string]interface{}
+ err := client.GetJSON(ctx, "/", &result)
+
+ assert.Error(t, err)
+
+ apiErr, ok := err.(*APIError)
+ assert.True(t, ok, "Expected APIError")
+
+ assert.Equal(t, http.StatusUnauthorized, apiErr.StatusCode)
+ })
+
+ t.Run("Client credentials authentication", func(t *testing.T) {
+ err := client.AuthenticateWithClientCredentials(ctx)
+ assert.NoError(t, err)
+
+ assert.True(t, client.IsAuthenticated(), "Client should be authenticated")
+ })
+
+ t.Run("Authenticated request", func(t *testing.T) {
+ var result map[string]interface{}
+ err := client.GetJSON(ctx, "/", &result)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "true", result["authenticated"])
+ })
+}
+
+// TestURLBuilding tests URL construction logic
+func TestURLBuilding(t *testing.T) {
+ client, err := NewClient(ClientOptions{
+ BaseURL: "https://example.com/api/v1",
+ })
+ assert.NoError(t, err)
+
+ tests := []struct {
+ endpoint string
+ expected string
+ }{
+ {"/search", "https://example.com/api/v1/search"},
+ {"search", "https://example.com/api/v1/search"},
+ {"/corpus/info", "https://example.com/api/v1/corpus/info"},
+ {"", "https://example.com/api/v1/"},
+ }
+
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("endpoint_%s", tt.endpoint), func(t *testing.T) {
+ url, err := client.buildURL(tt.endpoint)
+ assert.NoError(t, err)
+
+ assert.Equal(t, tt.expected, url)
+ })
+ }
+}
+
+// TestOAuthClientOperations tests OAuth client operations
+func TestOAuthClientOperations(t *testing.T) {
+ client, err := NewClient(ClientOptions{
+ BaseURL: "https://example.com",
+ })
+ assert.NoError(t, err)
+
+ // Test setting OAuth client
+ oauthConfig := &config.OAuthConfig{
+ ClientID: "test-client",
+ ClientSecret: "test-secret",
+ TokenURL: "https://example.com/token",
+ Enabled: true,
+ }
+
+ oauthClient, err := auth.NewOAuthClient(oauthConfig)
+ assert.NoError(t, err)
+
+ // Set a mock token
+ token := &oauth2.Token{
+ AccessToken: "test-token",
+ TokenType: "Bearer",
+ Expiry: time.Now().Add(time.Hour),
+ }
+ oauthClient.SetToken(token)
+
+ client.SetOAuthClient(oauthClient)
+
+ assert.True(t, client.IsAuthenticated(), "Client should be authenticated after setting valid token")
+
+ assert.Equal(t, "https://example.com/", client.GetBaseURL())
+}
diff --git a/service/types.go b/service/types.go
new file mode 100644
index 0000000..d5d4036
--- /dev/null
+++ b/service/types.go
@@ -0,0 +1,123 @@
+package service
+
+import "fmt"
+
+// ErrorResponse represents a KorAP API error response
+type ErrorResponse struct {
+ Error string `json:"error"`
+ ErrorDescription string `json:"error_description,omitempty"`
+}
+
+// APIError represents an error from the KorAP API
+type APIError struct {
+ StatusCode int
+ Message string
+ Details string
+}
+
+// Error implements the error interface
+func (e *APIError) Error() string {
+ if e.Details != "" {
+ return fmt.Sprintf("KorAP API error (HTTP %d): %s - %s", e.StatusCode, e.Message, e.Details)
+ }
+ return fmt.Sprintf("KorAP API error (HTTP %d): %s", e.StatusCode, e.Message)
+}
+
+// SearchRequest represents a KorAP search request
+type SearchRequest struct {
+ Query string `json:"q"`
+ QueryLang string `json:"ql,omitempty"` // Query language (e.g., "poliqarp", "cosmas2", "annis")
+ Collection string `json:"collection,omitempty"` // Collection filter
+ Count int `json:"count,omitempty"` // Number of results to return
+ Offset int `json:"offset,omitempty"` // Offset for pagination
+ Context string `json:"context,omitempty"` // Context specification
+}
+
+// SearchResponse represents a KorAP search response
+type SearchResponse struct {
+ Meta SearchMeta `json:"meta"`
+ Query SearchQuery `json:"query"`
+ Matches []SearchMatch `json:"matches"`
+}
+
+// SearchMeta contains metadata about the search results
+type SearchMeta struct {
+ Count int `json:"count"`
+ StartIndex int `json:"startIndex"`
+ ItemsPerPage int `json:"itemsPerPage"`
+ TotalResults int `json:"totalResults"`
+ SearchTime float64 `json:"searchTime,omitempty"`
+ Benchmark string `json:"benchmark,omitempty"`
+ Context string `json:"context,omitempty"`
+ CorpusStatistics map[string]interface{} `json:"corpusStatistics,omitempty"`
+}
+
+// SearchQuery contains information about the executed query
+type SearchQuery struct {
+ Query string `json:"q"`
+ QueryLang string `json:"ql,omitempty"`
+ Collection string `json:"collection,omitempty"`
+ CutOff bool `json:"cutOff,omitempty"`
+ TimeExceeded bool `json:"timeExceeded,omitempty"`
+}
+
+// SearchMatch represents a single search result match
+type SearchMatch struct {
+ Field string `json:"field"`
+ PubPlace string `json:"pubPlace,omitempty"`
+ TextSigle string `json:"textSigle"`
+ UID int `json:"UID"`
+ MatchID string `json:"matchID"`
+ Snippet string `json:"snippet"`
+ LeftContext []ContextToken `json:"leftContext,omitempty"`
+ RightContext []ContextToken `json:"rightContext,omitempty"`
+ Position int `json:"position"`
+ StartMore bool `json:"startMore,omitempty"`
+ EndMore bool `json:"endMore,omitempty"`
+ Match []MatchToken `json:"match,omitempty"`
+ Fields map[string]interface{} `json:"fields,omitempty"`
+}
+
+// ContextToken represents a token in the context
+type ContextToken struct {
+ Type string `json:"@type,omitempty"`
+ Text string `json:"text"`
+ Layer string `json:"layer,omitempty"`
+ Key string `json:"key,omitempty"`
+ Value string `json:"value,omitempty"`
+}
+
+// MatchToken represents a token in the match
+type MatchToken struct {
+ Type string `json:"@type,omitempty"`
+ Text string `json:"text"`
+ Layer string `json:"layer,omitempty"`
+ Key string `json:"key,omitempty"`
+ Value string `json:"value,omitempty"`
+}
+
+// CorpusInfo represents information about a corpus
+type CorpusInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Documents int `json:"documents,omitempty"`
+ Tokens int `json:"tokens,omitempty"`
+ Sentences int `json:"sentences,omitempty"`
+ Paragraphs int `json:"paragraphs,omitempty"`
+ Fields map[string]interface{} `json:"fields,omitempty"`
+}
+
+// CorpusListResponse represents the response for corpus listing
+type CorpusListResponse struct {
+ Corpora []CorpusInfo `json:"corpora"`
+}
+
+// StatisticsResponse represents corpus statistics
+type StatisticsResponse struct {
+ Documents int `json:"documents"`
+ Tokens int `json:"tokens"`
+ Sentences int `json:"sentences,omitempty"`
+ Paragraphs int `json:"paragraphs,omitempty"`
+ Fields map[string]interface{} `json:"fields,omitempty"`
+}