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"`
+}