#
tokens: 48832/50000 21/102 files (page 2/7)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 7. Use http://codebase.md/freepeak/db-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .cm
│   └── gitstream.cm
├── .cursor
│   ├── mcp-example.json
│   ├── mcp.json
│   └── rules
│       └── global.mdc
├── .dockerignore
├── .DS_Store
├── .env.example
├── .github
│   ├── FUNDING.yml
│   └── workflows
│       └── go.yml
├── .gitignore
├── .golangci.yml
├── assets
│   └── logo.svg
├── CHANGELOG.md
├── cmd
│   └── server
│       └── main.go
├── commit-message.txt
├── config.json
├── config.timescaledb-test.json
├── docker-compose.mcp-test.yml
├── docker-compose.test.yml
├── docker-compose.timescaledb-test.yml
├── docker-compose.yml
├── docker-wrapper.sh
├── Dockerfile
├── docs
│   ├── REFACTORING.md
│   ├── TIMESCALEDB_FUNCTIONS.md
│   ├── TIMESCALEDB_IMPLEMENTATION.md
│   ├── TIMESCALEDB_PRD.md
│   └── TIMESCALEDB_TOOLS.md
├── examples
│   └── postgres_connection.go
├── glama.json
├── go.mod
├── go.sum
├── init-scripts
│   └── timescaledb
│       ├── 01-init.sql
│       ├── 02-sample-data.sql
│       ├── 03-continuous-aggregates.sql
│       └── README.md
├── internal
│   ├── config
│   │   ├── config_test.go
│   │   └── config.go
│   ├── delivery
│   │   └── mcp
│   │       ├── compression_policy_test.go
│   │       ├── context
│   │       │   ├── hypertable_schema_test.go
│   │       │   ├── timescale_completion_test.go
│   │       │   ├── timescale_context_test.go
│   │       │   └── timescale_query_suggestion_test.go
│   │       ├── mock_test.go
│   │       ├── response_test.go
│   │       ├── response.go
│   │       ├── retention_policy_test.go
│   │       ├── server_wrapper.go
│   │       ├── timescale_completion.go
│   │       ├── timescale_context.go
│   │       ├── timescale_schema.go
│   │       ├── timescale_tool_test.go
│   │       ├── timescale_tool.go
│   │       ├── timescale_tools_test.go
│   │       ├── tool_registry.go
│   │       └── tool_types.go
│   ├── domain
│   │   └── database.go
│   ├── logger
│   │   ├── logger_test.go
│   │   └── logger.go
│   ├── repository
│   │   └── database_repository.go
│   └── usecase
│       └── database_usecase.go
├── LICENSE
├── Makefile
├── pkg
│   ├── core
│   │   ├── core.go
│   │   └── logging.go
│   ├── db
│   │   ├── db_test.go
│   │   ├── db.go
│   │   ├── manager.go
│   │   ├── README.md
│   │   └── timescale
│   │       ├── config_test.go
│   │       ├── config.go
│   │       ├── connection_test.go
│   │       ├── connection.go
│   │       ├── continuous_aggregate_test.go
│   │       ├── continuous_aggregate.go
│   │       ├── hypertable_test.go
│   │       ├── hypertable.go
│   │       ├── metadata.go
│   │       ├── mocks_test.go
│   │       ├── policy_test.go
│   │       ├── policy.go
│   │       ├── query.go
│   │       ├── timeseries_test.go
│   │       └── timeseries.go
│   ├── dbtools
│   │   ├── db_helpers.go
│   │   ├── dbtools_test.go
│   │   ├── dbtools.go
│   │   ├── exec.go
│   │   ├── performance_test.go
│   │   ├── performance.go
│   │   ├── query.go
│   │   ├── querybuilder_test.go
│   │   ├── querybuilder.go
│   │   ├── README.md
│   │   ├── schema_test.go
│   │   ├── schema.go
│   │   ├── tx_test.go
│   │   └── tx.go
│   ├── internal
│   │   └── logger
│   │       └── logger.go
│   ├── jsonrpc
│   │   └── jsonrpc.go
│   ├── logger
│   │   └── logger.go
│   └── tools
│       └── tools.go
├── README-old.md
├── README.md
├── repomix-output.txt
├── request.json
├── start-mcp.sh
├── test.Dockerfile
├── timescaledb-test.sh
└── wait-for-it.sh
```

# Files

--------------------------------------------------------------------------------
/internal/repository/database_repository.go:
--------------------------------------------------------------------------------

```go
  1 | package repository
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"database/sql"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/FreePeak/db-mcp-server/internal/domain"
  9 | 	"github.com/FreePeak/db-mcp-server/pkg/dbtools"
 10 | )
 11 | 
 12 | // TODO: Implement caching layer for database metadata to improve performance
 13 | // TODO: Add observability with tracing and detailed metrics
 14 | // TODO: Improve concurrency handling with proper locking or atomic operations
 15 | // TODO: Consider using an interface-based approach for better testability
 16 | // TODO: Add comprehensive integration tests for different database types
 17 | 
 18 | // DatabaseRepository implements domain.DatabaseRepository
 19 | type DatabaseRepository struct{}
 20 | 
 21 | // NewDatabaseRepository creates a new database repository
 22 | func NewDatabaseRepository() *DatabaseRepository {
 23 | 	return &DatabaseRepository{}
 24 | }
 25 | 
 26 | // GetDatabase retrieves a database by ID
 27 | func (r *DatabaseRepository) GetDatabase(id string) (domain.Database, error) {
 28 | 	db, err := dbtools.GetDatabase(id)
 29 | 	if err != nil {
 30 | 		return nil, err
 31 | 	}
 32 | 	return &DatabaseAdapter{db: db}, nil
 33 | }
 34 | 
 35 | // ListDatabases returns a list of available database IDs
 36 | func (r *DatabaseRepository) ListDatabases() []string {
 37 | 	return dbtools.ListDatabases()
 38 | }
 39 | 
 40 | // GetDatabaseType returns the type of a database by ID
 41 | func (r *DatabaseRepository) GetDatabaseType(id string) (string, error) {
 42 | 	// Get the database connection to check its actual driver
 43 | 	db, err := dbtools.GetDatabase(id)
 44 | 	if err != nil {
 45 | 		return "", fmt.Errorf("failed to get database connection for type detection: %w", err)
 46 | 	}
 47 | 
 48 | 	// Use the actual driver name to determine database type
 49 | 	driverName := db.DriverName()
 50 | 
 51 | 	switch driverName {
 52 | 	case "postgres":
 53 | 		return "postgres", nil
 54 | 	case "mysql":
 55 | 		return "mysql", nil
 56 | 	default:
 57 | 		// Unknown database type - return the actual driver name and let the caller handle it
 58 | 		// Never default to MySQL as that can cause SQL dialect issues
 59 | 		return driverName, nil
 60 | 	}
 61 | }
 62 | 
 63 | // DatabaseAdapter adapts the db.Database to the domain.Database interface
 64 | type DatabaseAdapter struct {
 65 | 	db interface {
 66 | 		Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
 67 | 		Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
 68 | 		BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
 69 | 	}
 70 | }
 71 | 
 72 | // Query executes a query on the database
 73 | func (a *DatabaseAdapter) Query(ctx context.Context, query string, args ...interface{}) (domain.Rows, error) {
 74 | 	rows, err := a.db.Query(ctx, query, args...)
 75 | 	if err != nil {
 76 | 		return nil, err
 77 | 	}
 78 | 	return &RowsAdapter{rows: rows}, nil
 79 | }
 80 | 
 81 | // Exec executes a statement on the database
 82 | func (a *DatabaseAdapter) Exec(ctx context.Context, statement string, args ...interface{}) (domain.Result, error) {
 83 | 	result, err := a.db.Exec(ctx, statement, args...)
 84 | 	if err != nil {
 85 | 		return nil, err
 86 | 	}
 87 | 	return &ResultAdapter{result: result}, nil
 88 | }
 89 | 
 90 | // Begin starts a new transaction
 91 | func (a *DatabaseAdapter) Begin(ctx context.Context, opts *domain.TxOptions) (domain.Tx, error) {
 92 | 	txOpts := &sql.TxOptions{}
 93 | 	if opts != nil {
 94 | 		txOpts.ReadOnly = opts.ReadOnly
 95 | 	}
 96 | 
 97 | 	tx, err := a.db.BeginTx(ctx, txOpts)
 98 | 	if err != nil {
 99 | 		return nil, err
100 | 	}
101 | 	return &TxAdapter{tx: tx}, nil
102 | }
103 | 
104 | // RowsAdapter adapts sql.Rows to domain.Rows
105 | type RowsAdapter struct {
106 | 	rows *sql.Rows
107 | }
108 | 
109 | // Close closes the rows
110 | func (a *RowsAdapter) Close() error {
111 | 	return a.rows.Close()
112 | }
113 | 
114 | // Columns returns the column names
115 | func (a *RowsAdapter) Columns() ([]string, error) {
116 | 	return a.rows.Columns()
117 | }
118 | 
119 | // Next advances to the next row
120 | func (a *RowsAdapter) Next() bool {
121 | 	return a.rows.Next()
122 | }
123 | 
124 | // Scan scans the current row
125 | func (a *RowsAdapter) Scan(dest ...interface{}) error {
126 | 	return a.rows.Scan(dest...)
127 | }
128 | 
129 | // Err returns any error that occurred during iteration
130 | func (a *RowsAdapter) Err() error {
131 | 	return a.rows.Err()
132 | }
133 | 
134 | // ResultAdapter adapts sql.Result to domain.Result
135 | type ResultAdapter struct {
136 | 	result sql.Result
137 | }
138 | 
139 | // RowsAffected returns the number of rows affected
140 | func (a *ResultAdapter) RowsAffected() (int64, error) {
141 | 	return a.result.RowsAffected()
142 | }
143 | 
144 | // LastInsertId returns the last insert ID
145 | func (a *ResultAdapter) LastInsertId() (int64, error) {
146 | 	return a.result.LastInsertId()
147 | }
148 | 
149 | // TxAdapter adapts sql.Tx to domain.Tx
150 | type TxAdapter struct {
151 | 	tx *sql.Tx
152 | }
153 | 
154 | // Commit commits the transaction
155 | func (a *TxAdapter) Commit() error {
156 | 	return a.tx.Commit()
157 | }
158 | 
159 | // Rollback rolls back the transaction
160 | func (a *TxAdapter) Rollback() error {
161 | 	return a.tx.Rollback()
162 | }
163 | 
164 | // Query executes a query within the transaction
165 | func (a *TxAdapter) Query(ctx context.Context, query string, args ...interface{}) (domain.Rows, error) {
166 | 	rows, err := a.tx.QueryContext(ctx, query, args...)
167 | 	if err != nil {
168 | 		return nil, err
169 | 	}
170 | 	return &RowsAdapter{rows: rows}, nil
171 | }
172 | 
173 | // Exec executes a statement within the transaction
174 | func (a *TxAdapter) Exec(ctx context.Context, statement string, args ...interface{}) (domain.Result, error) {
175 | 	result, err := a.tx.ExecContext(ctx, statement, args...)
176 | 	if err != nil {
177 | 		return nil, err
178 | 	}
179 | 	return &ResultAdapter{result: result}, nil
180 | }
181 | 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/timescale_context.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"fmt"
  7 | 	"strings"
  8 | )
  9 | 
 10 | // Import and use the UseCaseProvider interface from the timescale_tool.go file
 11 | // UseCaseProvider is defined as:
 12 | // type UseCaseProvider interface {
 13 | //   ExecuteQuery(ctx context.Context, dbID, query string, params []interface{}) (string, error)
 14 | //   ExecuteStatement(ctx context.Context, dbID, statement string, params []interface{}) (string, error)
 15 | //   ExecuteTransaction(ctx context.Context, dbID, action string, txID string, statement string, params []interface{}, readOnly bool) (string, map[string]interface{}, error)
 16 | //   GetDatabaseInfo(dbID string) (map[string]interface{}, error)
 17 | //   ListDatabases() []string
 18 | //   GetDatabaseType(dbID string) (string, error)
 19 | // }
 20 | 
 21 | // TimescaleDBContextInfo represents information about TimescaleDB for editor context
 22 | type TimescaleDBContextInfo struct {
 23 | 	IsTimescaleDB bool                        `json:"isTimescaleDB"`
 24 | 	Version       string                      `json:"version,omitempty"`
 25 | 	Hypertables   []TimescaleDBHypertableInfo `json:"hypertables,omitempty"`
 26 | }
 27 | 
 28 | // TimescaleDBHypertableInfo contains information about a hypertable
 29 | type TimescaleDBHypertableInfo struct {
 30 | 	TableName     string `json:"tableName"`
 31 | 	TimeColumn    string `json:"timeColumn"`
 32 | 	ChunkInterval string `json:"chunkInterval"`
 33 | }
 34 | 
 35 | // TimescaleDBContextProvider provides TimescaleDB information for editor context
 36 | type TimescaleDBContextProvider struct{}
 37 | 
 38 | // NewTimescaleDBContextProvider creates a new TimescaleDB context provider
 39 | func NewTimescaleDBContextProvider() *TimescaleDBContextProvider {
 40 | 	return &TimescaleDBContextProvider{}
 41 | }
 42 | 
 43 | // DetectTimescaleDB detects if TimescaleDB is installed in the given database
 44 | func (p *TimescaleDBContextProvider) DetectTimescaleDB(ctx context.Context, dbID string, useCase UseCaseProvider) (*TimescaleDBContextInfo, error) {
 45 | 	// Check database type first
 46 | 	dbType, err := useCase.GetDatabaseType(dbID)
 47 | 	if err != nil {
 48 | 		return nil, fmt.Errorf("failed to get database type: %w", err)
 49 | 	}
 50 | 
 51 | 	// TimescaleDB is a PostgreSQL extension, so we only check PostgreSQL databases
 52 | 	if !strings.Contains(strings.ToLower(dbType), "postgres") {
 53 | 		// Return a context info object with isTimescaleDB = false
 54 | 		return &TimescaleDBContextInfo{
 55 | 			IsTimescaleDB: false,
 56 | 		}, nil
 57 | 	}
 58 | 
 59 | 	// Check for TimescaleDB extension
 60 | 	query := "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
 61 | 	result, err := useCase.ExecuteStatement(ctx, dbID, query, nil)
 62 | 	if err != nil {
 63 | 		return nil, fmt.Errorf("failed to check for TimescaleDB extension: %w", err)
 64 | 	}
 65 | 
 66 | 	// Parse the result to determine if TimescaleDB is available
 67 | 	var versions []map[string]interface{}
 68 | 	if err := json.Unmarshal([]byte(result), &versions); err != nil {
 69 | 		return nil, fmt.Errorf("failed to parse extension result: %w", err)
 70 | 	}
 71 | 
 72 | 	// If no results, TimescaleDB is not installed
 73 | 	if len(versions) == 0 {
 74 | 		return &TimescaleDBContextInfo{
 75 | 			IsTimescaleDB: false,
 76 | 		}, nil
 77 | 	}
 78 | 
 79 | 	// Extract version information
 80 | 	version := ""
 81 | 	if extVersion, ok := versions[0]["extversion"]; ok && extVersion != nil {
 82 | 		version = fmt.Sprintf("%v", extVersion)
 83 | 	}
 84 | 
 85 | 	// Create and return context info
 86 | 	return &TimescaleDBContextInfo{
 87 | 		IsTimescaleDB: true,
 88 | 		Version:       version,
 89 | 	}, nil
 90 | }
 91 | 
 92 | // GetTimescaleDBContext gets comprehensive TimescaleDB context information
 93 | func (p *TimescaleDBContextProvider) GetTimescaleDBContext(ctx context.Context, dbID string, useCase UseCaseProvider) (*TimescaleDBContextInfo, error) {
 94 | 	// First, detect if TimescaleDB is available
 95 | 	contextInfo, err := p.DetectTimescaleDB(ctx, dbID, useCase)
 96 | 	if err != nil {
 97 | 		return nil, err
 98 | 	}
 99 | 
100 | 	// If not TimescaleDB, return basic info
101 | 	if !contextInfo.IsTimescaleDB {
102 | 		return contextInfo, nil
103 | 	}
104 | 
105 | 	// Get information about hypertables
106 | 	query := `
107 | 		SELECT 
108 | 			h.table_name,
109 | 			d.column_name as time_column,
110 | 			d.time_interval as chunk_interval
111 | 		FROM 
112 | 			_timescaledb_catalog.hypertable h
113 | 		JOIN 
114 | 			_timescaledb_catalog.dimension d ON h.id = d.hypertable_id
115 | 		WHERE 
116 | 			d.column_type = 'TIMESTAMP' OR d.column_type = 'TIMESTAMPTZ'
117 | 		ORDER BY 
118 | 			h.table_name
119 | 	`
120 | 
121 | 	result, err := useCase.ExecuteStatement(ctx, dbID, query, nil)
122 | 	if err != nil {
123 | 		// Don't fail the whole context if just hypertable info fails
124 | 		return contextInfo, nil
125 | 	}
126 | 
127 | 	// Parse the result
128 | 	var hypertables []map[string]interface{}
129 | 	if err := json.Unmarshal([]byte(result), &hypertables); err != nil {
130 | 		return contextInfo, nil
131 | 	}
132 | 
133 | 	// Process hypertable information
134 | 	for _, h := range hypertables {
135 | 		hypertableInfo := TimescaleDBHypertableInfo{}
136 | 
137 | 		if tableName, ok := h["table_name"]; ok && tableName != nil {
138 | 			hypertableInfo.TableName = fmt.Sprintf("%v", tableName)
139 | 		}
140 | 
141 | 		if timeColumn, ok := h["time_column"]; ok && timeColumn != nil {
142 | 			hypertableInfo.TimeColumn = fmt.Sprintf("%v", timeColumn)
143 | 		}
144 | 
145 | 		if chunkInterval, ok := h["chunk_interval"]; ok && chunkInterval != nil {
146 | 			hypertableInfo.ChunkInterval = fmt.Sprintf("%v", chunkInterval)
147 | 		}
148 | 
149 | 		// Only add if we have a valid table name
150 | 		if hypertableInfo.TableName != "" {
151 | 			contextInfo.Hypertables = append(contextInfo.Hypertables, hypertableInfo)
152 | 		}
153 | 	}
154 | 
155 | 	return contextInfo, nil
156 | }
157 | 
```

--------------------------------------------------------------------------------
/pkg/dbtools/query.go:
--------------------------------------------------------------------------------

```go
  1 | package dbtools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"database/sql"
  6 | 	"fmt"
  7 | 	"strings"
  8 | 	"time"
  9 | 
 10 | 	"github.com/FreePeak/db-mcp-server/pkg/logger"
 11 | 	"github.com/FreePeak/db-mcp-server/pkg/tools"
 12 | )
 13 | 
 14 | // createQueryTool creates a tool for executing database queries
 15 | //
 16 | //nolint:unused // Retained for future use
 17 | func createQueryTool() *tools.Tool {
 18 | 	return &tools.Tool{
 19 | 		Name:        "dbQuery",
 20 | 		Description: "Execute a database query that returns results",
 21 | 		Category:    "database",
 22 | 		InputSchema: tools.ToolInputSchema{
 23 | 			Type: "object",
 24 | 			Properties: map[string]interface{}{
 25 | 				"query": map[string]interface{}{
 26 | 					"type":        "string",
 27 | 					"description": "SQL query to execute",
 28 | 				},
 29 | 				"params": map[string]interface{}{
 30 | 					"type":        "array",
 31 | 					"description": "Parameters for the query (for prepared statements)",
 32 | 					"items": map[string]interface{}{
 33 | 						"type": "string",
 34 | 					},
 35 | 				},
 36 | 				"timeout": map[string]interface{}{
 37 | 					"type":        "integer",
 38 | 					"description": "Query timeout in milliseconds (default: 5000)",
 39 | 				},
 40 | 				"database": map[string]interface{}{
 41 | 					"type":        "string",
 42 | 					"description": "Database ID to use (optional if only one database is configured)",
 43 | 				},
 44 | 			},
 45 | 			Required: []string{"query", "database"},
 46 | 		},
 47 | 		Handler: handleQuery,
 48 | 	}
 49 | }
 50 | 
 51 | // handleQuery handles the query tool execution
 52 | func handleQuery(ctx context.Context, params map[string]interface{}) (interface{}, error) {
 53 | 	// Check if database manager is initialized
 54 | 	if dbManager == nil {
 55 | 		return nil, fmt.Errorf("database manager not initialized")
 56 | 	}
 57 | 
 58 | 	// Extract parameters
 59 | 	query, ok := getStringParam(params, "query")
 60 | 	if !ok {
 61 | 		return nil, fmt.Errorf("query parameter is required")
 62 | 	}
 63 | 
 64 | 	// Get database ID
 65 | 	databaseID, ok := getStringParam(params, "database")
 66 | 	if !ok {
 67 | 		return nil, fmt.Errorf("database parameter is required")
 68 | 	}
 69 | 
 70 | 	// Get database instance
 71 | 	db, err := dbManager.GetDatabase(databaseID)
 72 | 	if err != nil {
 73 | 		return nil, fmt.Errorf("failed to get database: %w", err)
 74 | 	}
 75 | 
 76 | 	// Extract timeout
 77 | 	dbTimeout := db.QueryTimeout() * 1000 // Convert from seconds to milliseconds
 78 | 	timeout := dbTimeout                  // Default to the database's query timeout
 79 | 	if timeoutParam, ok := getIntParam(params, "timeout"); ok {
 80 | 		timeout = timeoutParam
 81 | 	}
 82 | 
 83 | 	// Create context with timeout
 84 | 	timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Millisecond)
 85 | 	defer cancel()
 86 | 
 87 | 	// Extract query parameters
 88 | 	var queryParams []interface{}
 89 | 	if paramsArray, ok := getArrayParam(params, "params"); ok {
 90 | 		queryParams = make([]interface{}, len(paramsArray))
 91 | 		copy(queryParams, paramsArray)
 92 | 	}
 93 | 
 94 | 	// Get the performance analyzer
 95 | 	analyzer := GetPerformanceAnalyzer()
 96 | 
 97 | 	// Execute query with performance tracking
 98 | 	var result interface{}
 99 | 
100 | 	result, err = analyzer.TrackQuery(timeoutCtx, query, queryParams, func() (interface{}, error) {
101 | 		// Execute query
102 | 		rows, innerErr := db.Query(timeoutCtx, query, queryParams...)
103 | 		if innerErr != nil {
104 | 			return nil, fmt.Errorf("failed to execute query: %w", innerErr)
105 | 		}
106 | 		defer cleanupRows(rows)
107 | 
108 | 		// Convert rows to maps
109 | 		results, innerErr := rowsToMaps(rows)
110 | 		if innerErr != nil {
111 | 			return nil, fmt.Errorf("failed to process query results: %w", innerErr)
112 | 		}
113 | 
114 | 		return map[string]interface{}{
115 | 			"results":  results,
116 | 			"query":    query,
117 | 			"params":   queryParams,
118 | 			"rowCount": len(results),
119 | 		}, nil
120 | 	})
121 | 
122 | 	if err != nil {
123 | 		return nil, err
124 | 	}
125 | 
126 | 	return result, nil
127 | }
128 | 
129 | // createMockQueryTool creates a mock version of the query tool
130 | //
131 | //nolint:unused // Retained for future use
132 | func createMockQueryTool() *tools.Tool {
133 | 	// Create the tool using the same schema as the real query tool
134 | 	tool := createQueryTool()
135 | 
136 | 	// Replace the handler with mock implementation
137 | 	tool.Handler = handleMockQuery
138 | 
139 | 	return tool
140 | }
141 | 
142 | // handleMockQuery is a mock implementation of the query handler
143 | //
144 | //nolint:unused // Retained for future use
145 | func handleMockQuery(ctx context.Context, params map[string]interface{}) (interface{}, error) {
146 | 	// Extract parameters
147 | 	query, ok := getStringParam(params, "query")
148 | 	if !ok {
149 | 		return nil, fmt.Errorf("query parameter is required")
150 | 	}
151 | 
152 | 	// Extract query parameters if provided
153 | 	var queryParams []interface{}
154 | 	if paramsArray, ok := getArrayParam(params, "params"); ok {
155 | 		queryParams = paramsArray
156 | 	}
157 | 
158 | 	// Create mock results
159 | 	mockResults := []map[string]interface{}{
160 | 		{
161 | 			"id":        1,
162 | 			"name":      "Mock Result 1",
163 | 			"timestamp": time.Now().Format(time.RFC3339),
164 | 		},
165 | 		{
166 | 			"id":        2,
167 | 			"name":      "Mock Result 2",
168 | 			"timestamp": time.Now().Add(-time.Hour).Format(time.RFC3339),
169 | 		},
170 | 	}
171 | 
172 | 	return map[string]interface{}{
173 | 		"results":  mockResults,
174 | 		"query":    query,
175 | 		"params":   queryParams,
176 | 		"rowCount": len(mockResults),
177 | 	}, nil
178 | }
179 | 
180 | // containsIgnoreCase checks if a string contains a substring, ignoring case
181 | //
182 | //nolint:unused // Retained for future use
183 | func containsIgnoreCase(s, substr string) bool {
184 | 	return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
185 | }
186 | 
187 | // cleanupRows ensures rows are closed properly
188 | func cleanupRows(rows *sql.Rows) {
189 | 	if rows != nil {
190 | 		if closeErr := rows.Close(); closeErr != nil {
191 | 			logger.Error("error closing rows: %v", closeErr)
192 | 		}
193 | 	}
194 | }
195 | 
```

--------------------------------------------------------------------------------
/pkg/dbtools/dbtools_test.go:
--------------------------------------------------------------------------------

```go
  1 | package dbtools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"database/sql"
  6 | 	"errors"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/stretchr/testify/assert"
 10 | 	"github.com/stretchr/testify/mock"
 11 | )
 12 | 
 13 | // MockDB is a mock implementation of the db.Database interface
 14 | type MockDB struct {
 15 | 	mock.Mock
 16 | }
 17 | 
 18 | func (m *MockDB) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
 19 | 	callArgs := []interface{}{ctx, query}
 20 | 	callArgs = append(callArgs, args...)
 21 | 	args1 := m.Called(callArgs...)
 22 | 	return args1.Get(0).(*sql.Rows), args1.Error(1)
 23 | }
 24 | 
 25 | func (m *MockDB) QueryRow(ctx context.Context, query string, args ...interface{}) *sql.Row {
 26 | 	callArgs := []interface{}{ctx, query}
 27 | 	callArgs = append(callArgs, args...)
 28 | 	args1 := m.Called(callArgs...)
 29 | 	return args1.Get(0).(*sql.Row)
 30 | }
 31 | 
 32 | func (m *MockDB) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
 33 | 	callArgs := []interface{}{ctx, query}
 34 | 	callArgs = append(callArgs, args...)
 35 | 	args1 := m.Called(callArgs...)
 36 | 	return args1.Get(0).(sql.Result), args1.Error(1)
 37 | }
 38 | 
 39 | func (m *MockDB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
 40 | 	args1 := m.Called(ctx, opts)
 41 | 	return args1.Get(0).(*sql.Tx), args1.Error(1)
 42 | }
 43 | 
 44 | func (m *MockDB) Connect() error {
 45 | 	args1 := m.Called()
 46 | 	return args1.Error(0)
 47 | }
 48 | 
 49 | func (m *MockDB) Close() error {
 50 | 	args1 := m.Called()
 51 | 	return args1.Error(0)
 52 | }
 53 | 
 54 | func (m *MockDB) Ping(ctx context.Context) error {
 55 | 	args1 := m.Called(ctx)
 56 | 	return args1.Error(0)
 57 | }
 58 | 
 59 | func (m *MockDB) DriverName() string {
 60 | 	args1 := m.Called()
 61 | 	return args1.String(0)
 62 | }
 63 | 
 64 | func (m *MockDB) ConnectionString() string {
 65 | 	args1 := m.Called()
 66 | 	return args1.String(0)
 67 | }
 68 | 
 69 | func (m *MockDB) DB() *sql.DB {
 70 | 	args1 := m.Called()
 71 | 	return args1.Get(0).(*sql.DB)
 72 | }
 73 | 
 74 | // MockRows implements a mock sql.Rows
 75 | type MockRows struct {
 76 | 	mock.Mock
 77 | }
 78 | 
 79 | func (m *MockRows) Close() error {
 80 | 	args := m.Called()
 81 | 	return args.Error(0)
 82 | }
 83 | 
 84 | func (m *MockRows) Columns() ([]string, error) {
 85 | 	args := m.Called()
 86 | 	return args.Get(0).([]string), args.Error(1)
 87 | }
 88 | 
 89 | func (m *MockRows) Next() bool {
 90 | 	args := m.Called()
 91 | 	return args.Bool(0)
 92 | }
 93 | 
 94 | func (m *MockRows) Scan(dest ...interface{}) error {
 95 | 	args := m.Called(dest)
 96 | 	return args.Error(0)
 97 | }
 98 | 
 99 | func (m *MockRows) Err() error {
100 | 	args := m.Called()
101 | 	return args.Error(0)
102 | }
103 | 
104 | // MockResult implements a mock sql.Result
105 | type MockResult struct {
106 | 	mock.Mock
107 | }
108 | 
109 | func (m *MockResult) LastInsertId() (int64, error) {
110 | 	args := m.Called()
111 | 	return args.Get(0).(int64), args.Error(1)
112 | }
113 | 
114 | func (m *MockResult) RowsAffected() (int64, error) {
115 | 	args := m.Called()
116 | 	return args.Get(0).(int64), args.Error(1)
117 | }
118 | 
119 | // TestQuery tests the Query function
120 | func TestQuery(t *testing.T) {
121 | 	// Setup mock
122 | 	mockDB := new(MockDB)
123 | 
124 | 	// Use nil for rows since we can't easily create a real *sql.Rows
125 | 	var nilRows *sql.Rows = nil
126 | 
127 | 	ctx := context.Background()
128 | 	sqlQuery := "SELECT * FROM test_table WHERE id = ?"
129 | 	args := []interface{}{1}
130 | 
131 | 	// Mock expectations
132 | 	mockDB.On("Query", ctx, sqlQuery, args[0]).Return(nilRows, nil)
133 | 
134 | 	// Call function under test
135 | 	rows, err := Query(ctx, mockDB, sqlQuery, args...)
136 | 
137 | 	// Assertions
138 | 	assert.NoError(t, err)
139 | 	assert.Nil(t, rows)
140 | 	mockDB.AssertExpectations(t)
141 | }
142 | 
143 | // TestQueryWithError tests the Query function with an error
144 | func TestQueryWithError(t *testing.T) {
145 | 	// Setup mock
146 | 	mockDB := new(MockDB)
147 | 	expectedErr := errors.New("database error")
148 | 
149 | 	ctx := context.Background()
150 | 	sqlQuery := "SELECT * FROM test_table WHERE id = ?"
151 | 	args := []interface{}{1}
152 | 
153 | 	// Mock expectations
154 | 	mockDB.On("Query", ctx, sqlQuery, args[0]).Return((*sql.Rows)(nil), expectedErr)
155 | 
156 | 	// Call function under test
157 | 	rows, err := Query(ctx, mockDB, sqlQuery, args...)
158 | 
159 | 	// Assertions
160 | 	assert.Error(t, err)
161 | 	assert.Equal(t, expectedErr, err)
162 | 	assert.Nil(t, rows)
163 | 	mockDB.AssertExpectations(t)
164 | }
165 | 
166 | // TestExec tests the Exec function
167 | func TestExec(t *testing.T) {
168 | 	// Setup mock
169 | 	mockDB := new(MockDB)
170 | 	mockResult := new(MockResult)
171 | 
172 | 	ctx := context.Background()
173 | 	sqlQuery := "INSERT INTO test_table (name) VALUES (?)"
174 | 	args := []interface{}{"test"}
175 | 
176 | 	// Mock expectations
177 | 	mockResult.On("LastInsertId").Return(int64(1), nil)
178 | 	mockResult.On("RowsAffected").Return(int64(1), nil)
179 | 	mockDB.On("Exec", ctx, sqlQuery, args[0]).Return(mockResult, nil)
180 | 
181 | 	// Call function under test
182 | 	result, err := Exec(ctx, mockDB, sqlQuery, args...)
183 | 
184 | 	// Assertions
185 | 	assert.NoError(t, err)
186 | 	assert.Equal(t, mockResult, result)
187 | 
188 | 	// Verify the result
189 | 	id, err := result.LastInsertId()
190 | 	assert.NoError(t, err)
191 | 	assert.Equal(t, int64(1), id)
192 | 
193 | 	affected, err := result.RowsAffected()
194 | 	assert.NoError(t, err)
195 | 	assert.Equal(t, int64(1), affected)
196 | 
197 | 	mockDB.AssertExpectations(t)
198 | 	mockResult.AssertExpectations(t)
199 | }
200 | 
201 | // TODO: Add tests for showConnectedDatabases
202 | // Note: Testing showConnectedDatabases requires proper mocking of the database manager
203 | // and related functions. This should be implemented with proper dependency injection
204 | // to make the function more testable without having to rely on global variables.
205 | //
206 | // The test should verify:
207 | // 1. That connected databases are correctly reported with status "connected"
208 | // 2. That failed database connections are reported with status "disconnected"
209 | // 3. That latency measurements are included in the response
210 | // 4. That it works with multiple database connections
211 | 
```

--------------------------------------------------------------------------------
/docs/TIMESCALEDB_TOOLS.md:
--------------------------------------------------------------------------------

```markdown
  1 | # TimescaleDB Tools: Time-Series and Continuous Aggregates
  2 | 
  3 | This document provides information about the time-series query tools and continuous aggregate functionality for TimescaleDB in the DB-MCP-Server.
  4 | 
  5 | ## Time-Series Query Tools
  6 | 
  7 | TimescaleDB extends PostgreSQL with specialized time-series capabilities. The DB-MCP-Server includes tools for efficiently working with time-series data.
  8 | 
  9 | ### Available Tools
 10 | 
 11 | | Tool | Description |
 12 | |------|-------------|
 13 | | `time_series_query` | Execute time-series queries with optimized bucketing |
 14 | | `analyze_time_series` | Analyze time-series data patterns and characteristics |
 15 | 
 16 | ### Time-Series Query Options
 17 | 
 18 | The `time_series_query` tool supports the following parameters:
 19 | 
 20 | | Parameter | Required | Description |
 21 | |-----------|----------|-------------|
 22 | | `target_table` | Yes | Table containing time-series data |
 23 | | `time_column` | Yes | Column containing timestamp data |
 24 | | `bucket_interval` | Yes | Time bucket interval (e.g., '1 hour', '1 day') |
 25 | | `start_time` | No | Start of time range (e.g., '2023-01-01') |
 26 | | `end_time` | No | End of time range (e.g., '2023-01-31') |
 27 | | `aggregations` | No | Comma-separated list of aggregations (e.g., 'AVG(temp),MAX(temp),COUNT(*)') |
 28 | | `where_condition` | No | Additional WHERE conditions |
 29 | | `group_by` | No | Additional GROUP BY columns (comma-separated) |
 30 | | `limit` | No | Maximum number of rows to return |
 31 | 
 32 | ### Examples
 33 | 
 34 | #### Basic Time-Series Query
 35 | 
 36 | ```json
 37 | {
 38 |   "operation": "time_series_query",
 39 |   "target_table": "sensor_data",
 40 |   "time_column": "timestamp",
 41 |   "bucket_interval": "1 hour",
 42 |   "start_time": "2023-01-01",
 43 |   "end_time": "2023-01-02",
 44 |   "aggregations": "AVG(temperature) as avg_temp, MAX(temperature) as max_temp"
 45 | }
 46 | ```
 47 | 
 48 | #### Query with Additional Filtering and Grouping
 49 | 
 50 | ```json
 51 | {
 52 |   "operation": "time_series_query",
 53 |   "target_table": "sensor_data",
 54 |   "time_column": "timestamp",
 55 |   "bucket_interval": "1 day",
 56 |   "where_condition": "sensor_id IN (1, 2, 3)",
 57 |   "group_by": "sensor_id",
 58 |   "limit": 100
 59 | }
 60 | ```
 61 | 
 62 | #### Analyzing Time-Series Data Patterns
 63 | 
 64 | ```json
 65 | {
 66 |   "operation": "analyze_time_series",
 67 |   "target_table": "sensor_data",
 68 |   "time_column": "timestamp",
 69 |   "start_time": "2023-01-01",
 70 |   "end_time": "2023-12-31"
 71 | }
 72 | ```
 73 | 
 74 | ## Continuous Aggregate Tools
 75 | 
 76 | Continuous aggregates are one of TimescaleDB's most powerful features, providing materialized views that automatically refresh as new data is added.
 77 | 
 78 | ### Available Tools
 79 | 
 80 | | Tool | Description |
 81 | |------|-------------|
 82 | | `create_continuous_aggregate` | Create a new continuous aggregate view |
 83 | | `refresh_continuous_aggregate` | Manually refresh a continuous aggregate |
 84 | 
 85 | ### Continuous Aggregate Options
 86 | 
 87 | The `create_continuous_aggregate` tool supports the following parameters:
 88 | 
 89 | | Parameter | Required | Description |
 90 | |-----------|----------|-------------|
 91 | | `view_name` | Yes | Name for the continuous aggregate view |
 92 | | `source_table` | Yes | Source table with raw data |
 93 | | `time_column` | Yes | Time column to bucket |
 94 | | `bucket_interval` | Yes | Time bucket interval (e.g., '1 hour', '1 day') |
 95 | | `aggregations` | No | Comma-separated list of aggregations |
 96 | | `where_condition` | No | WHERE condition to filter source data |
 97 | | `with_data` | No | Whether to materialize data immediately (default: false) |
 98 | | `refresh_policy` | No | Whether to add a refresh policy (default: false) |
 99 | | `refresh_interval` | No | Refresh interval (e.g., '1 day') |
100 | 
101 | The `refresh_continuous_aggregate` tool supports:
102 | 
103 | | Parameter | Required | Description |
104 | |-----------|----------|-------------|
105 | | `view_name` | Yes | Name of the continuous aggregate view |
106 | | `start_time` | No | Start of time range to refresh |
107 | | `end_time` | No | End of time range to refresh |
108 | 
109 | ### Examples
110 | 
111 | #### Creating a Daily Temperature Aggregate
112 | 
113 | ```json
114 | {
115 |   "operation": "create_continuous_aggregate",
116 |   "view_name": "daily_temperatures",
117 |   "source_table": "sensor_data",
118 |   "time_column": "timestamp",
119 |   "bucket_interval": "1 day",
120 |   "aggregations": "AVG(temperature) as avg_temp, MIN(temperature) as min_temp, MAX(temperature) as max_temp, COUNT(*) as reading_count",
121 |   "with_data": true,
122 |   "refresh_policy": true,
123 |   "refresh_interval": "1 hour"
124 | }
125 | ```
126 | 
127 | #### Refreshing a Continuous Aggregate for a Specific Period
128 | 
129 | ```json
130 | {
131 |   "operation": "refresh_continuous_aggregate",
132 |   "view_name": "daily_temperatures",
133 |   "start_time": "2023-01-01",
134 |   "end_time": "2023-01-31"
135 | }
136 | ```
137 | 
138 | ## Common Time Bucket Intervals
139 | 
140 | TimescaleDB supports various time bucket intervals for grouping time-series data:
141 | 
142 | - `1 minute`, `5 minutes`, `10 minutes`, `15 minutes`, `30 minutes`
143 | - `1 hour`, `2 hours`, `3 hours`, `6 hours`, `12 hours`
144 | - `1 day`, `1 week`
145 | - `1 month`, `3 months`, `6 months`, `1 year`
146 | 
147 | ## Best Practices
148 | 
149 | 1. **Choose the right bucket interval**: Select a time bucket interval appropriate for your data density and query patterns. Smaller intervals provide more granularity but create more records.
150 | 
151 | 2. **Use continuous aggregates for frequently queried time ranges**: If you often query for daily or monthly aggregates, create continuous aggregates at those intervals.
152 | 
153 | 3. **Add appropriate indexes**: For optimal query performance, ensure your time column is properly indexed, especially on the raw data table.
154 | 
155 | 4. **Consider retention policies**: Use TimescaleDB's retention policies to automatically drop old data from raw tables while keeping aggregated views.
156 | 
157 | 5. **Refresh policies**: Set refresh policies based on how frequently your data is updated and how current your aggregate views need to be. 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/retention_policy_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/FreePeak/cortex/pkg/server"
  8 | 	"github.com/stretchr/testify/assert"
  9 | 	"github.com/stretchr/testify/mock"
 10 | )
 11 | 
 12 | func TestHandleAddRetentionPolicyFull(t *testing.T) {
 13 | 	// Create a mock use case
 14 | 	mockUseCase := new(MockDatabaseUseCase)
 15 | 
 16 | 	// Set up expectations
 17 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
 18 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.Anything, mock.Anything).Return(`{"message":"Retention policy added"}`, nil)
 19 | 
 20 | 	// Create the tool
 21 | 	tool := NewTimescaleDBTool()
 22 | 
 23 | 	// Create a request
 24 | 	request := server.ToolCallRequest{
 25 | 		Parameters: map[string]interface{}{
 26 | 			"operation":          "add_retention_policy",
 27 | 			"target_table":       "test_table",
 28 | 			"retention_interval": "30 days",
 29 | 		},
 30 | 	}
 31 | 
 32 | 	// Call the handler
 33 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
 34 | 
 35 | 	// Assertions
 36 | 	assert.NoError(t, err)
 37 | 	assert.NotNil(t, result)
 38 | 
 39 | 	// Check the result
 40 | 	resultMap, ok := result.(map[string]interface{})
 41 | 	assert.True(t, ok)
 42 | 	assert.Contains(t, resultMap, "message")
 43 | 
 44 | 	// Verify mock expectations
 45 | 	mockUseCase.AssertExpectations(t)
 46 | }
 47 | 
 48 | func TestHandleRemoveRetentionPolicyFull(t *testing.T) {
 49 | 	// Create a mock use case
 50 | 	mockUseCase := new(MockDatabaseUseCase)
 51 | 
 52 | 	// Set up expectations
 53 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
 54 | 
 55 | 	// First should try to find any policy
 56 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.Anything, mock.Anything).Return(`[{"job_id": 123}]`, nil).Once()
 57 | 
 58 | 	// Then remove the policy
 59 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.Anything, mock.Anything).Return(`{"message":"Policy removed"}`, nil).Once()
 60 | 
 61 | 	// Create the tool
 62 | 	tool := NewTimescaleDBTool()
 63 | 
 64 | 	// Create a request
 65 | 	request := server.ToolCallRequest{
 66 | 		Parameters: map[string]interface{}{
 67 | 			"operation":    "remove_retention_policy",
 68 | 			"target_table": "test_table",
 69 | 		},
 70 | 	}
 71 | 
 72 | 	// Call the handler
 73 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
 74 | 
 75 | 	// Assertions
 76 | 	assert.NoError(t, err)
 77 | 	assert.NotNil(t, result)
 78 | 
 79 | 	// Check the result
 80 | 	resultMap, ok := result.(map[string]interface{})
 81 | 	assert.True(t, ok)
 82 | 	assert.Contains(t, resultMap, "message")
 83 | 
 84 | 	// Verify mock expectations
 85 | 	mockUseCase.AssertExpectations(t)
 86 | }
 87 | 
 88 | func TestHandleRemoveRetentionPolicyNoPolicy(t *testing.T) {
 89 | 	// Create a mock use case
 90 | 	mockUseCase := new(MockDatabaseUseCase)
 91 | 
 92 | 	// Set up expectations
 93 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
 94 | 
 95 | 	// Find policy ID - no policy found
 96 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.Anything, mock.Anything).Return(`[]`, nil).Once()
 97 | 
 98 | 	// Create the tool
 99 | 	tool := NewTimescaleDBTool()
100 | 
101 | 	// Create a request
102 | 	request := server.ToolCallRequest{
103 | 		Parameters: map[string]interface{}{
104 | 			"operation":    "remove_retention_policy",
105 | 			"target_table": "test_table",
106 | 		},
107 | 	}
108 | 
109 | 	// Call the handler
110 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
111 | 
112 | 	// Assertions
113 | 	assert.NoError(t, err)
114 | 	assert.NotNil(t, result)
115 | 
116 | 	// Check the result
117 | 	resultMap, ok := result.(map[string]interface{})
118 | 	assert.True(t, ok)
119 | 	assert.Contains(t, resultMap, "message")
120 | 	assert.Contains(t, resultMap["message"].(string), "No retention policy found")
121 | 
122 | 	// Verify mock expectations
123 | 	mockUseCase.AssertExpectations(t)
124 | }
125 | 
126 | func TestHandleGetRetentionPolicyFull(t *testing.T) {
127 | 	// Create a mock use case
128 | 	mockUseCase := new(MockDatabaseUseCase)
129 | 
130 | 	// Set up expectations
131 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
132 | 
133 | 	// Get retention policy
134 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.Anything, mock.Anything).Return(`[{"hypertable_name":"test_table","retention_interval":"30 days","retention_enabled":true}]`, nil).Once()
135 | 
136 | 	// Create the tool
137 | 	tool := NewTimescaleDBTool()
138 | 
139 | 	// Create a request
140 | 	request := server.ToolCallRequest{
141 | 		Parameters: map[string]interface{}{
142 | 			"operation":    "get_retention_policy",
143 | 			"target_table": "test_table",
144 | 		},
145 | 	}
146 | 
147 | 	// Call the handler
148 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
149 | 
150 | 	// Assertions
151 | 	assert.NoError(t, err)
152 | 	assert.NotNil(t, result)
153 | 
154 | 	// Check the result
155 | 	resultMap, ok := result.(map[string]interface{})
156 | 	assert.True(t, ok)
157 | 	assert.Contains(t, resultMap, "message")
158 | 	assert.Contains(t, resultMap, "details")
159 | 
160 | 	// Verify mock expectations
161 | 	mockUseCase.AssertExpectations(t)
162 | }
163 | 
164 | func TestHandleGetRetentionPolicyNoPolicy(t *testing.T) {
165 | 	// Create a mock use case
166 | 	mockUseCase := new(MockDatabaseUseCase)
167 | 
168 | 	// Set up expectations
169 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
170 | 
171 | 	// No retention policy found
172 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.Anything, mock.Anything).Return(`[]`, nil).Once()
173 | 
174 | 	// Create the tool
175 | 	tool := NewTimescaleDBTool()
176 | 
177 | 	// Create a request
178 | 	request := server.ToolCallRequest{
179 | 		Parameters: map[string]interface{}{
180 | 			"operation":    "get_retention_policy",
181 | 			"target_table": "test_table",
182 | 		},
183 | 	}
184 | 
185 | 	// Call the handler
186 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
187 | 
188 | 	// Assertions
189 | 	assert.NoError(t, err)
190 | 	assert.NotNil(t, result)
191 | 
192 | 	// Check the result
193 | 	resultMap, ok := result.(map[string]interface{})
194 | 	assert.True(t, ok)
195 | 	assert.Contains(t, resultMap, "message")
196 | 	assert.Contains(t, resultMap["message"].(string), "No retention policy found")
197 | 
198 | 	// Verify mock expectations
199 | 	mockUseCase.AssertExpectations(t)
200 | }
201 | 
```

--------------------------------------------------------------------------------
/pkg/db/timescale/config_test.go:
--------------------------------------------------------------------------------

```go
  1 | package timescale
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/FreePeak/db-mcp-server/pkg/db"
  7 | )
  8 | 
  9 | func TestNewDefaultTimescaleDBConfig(t *testing.T) {
 10 | 	// Create a PostgreSQL config
 11 | 	pgConfig := db.Config{
 12 | 		Type:     "postgres",
 13 | 		Host:     "localhost",
 14 | 		Port:     5432,
 15 | 		User:     "testuser",
 16 | 		Password: "testpass",
 17 | 		Name:     "testdb",
 18 | 	}
 19 | 
 20 | 	// Get default TimescaleDB config
 21 | 	tsdbConfig := NewDefaultTimescaleDBConfig(pgConfig)
 22 | 
 23 | 	// Check that the PostgreSQL config was correctly embedded
 24 | 	if tsdbConfig.PostgresConfig.Type != pgConfig.Type {
 25 | 		t.Errorf("Expected PostgresConfig.Type to be %s, got %s", pgConfig.Type, tsdbConfig.PostgresConfig.Type)
 26 | 	}
 27 | 	if tsdbConfig.PostgresConfig.Host != pgConfig.Host {
 28 | 		t.Errorf("Expected PostgresConfig.Host to be %s, got %s", pgConfig.Host, tsdbConfig.PostgresConfig.Host)
 29 | 	}
 30 | 	if tsdbConfig.PostgresConfig.Port != pgConfig.Port {
 31 | 		t.Errorf("Expected PostgresConfig.Port to be %d, got %d", pgConfig.Port, tsdbConfig.PostgresConfig.Port)
 32 | 	}
 33 | 	if tsdbConfig.PostgresConfig.User != pgConfig.User {
 34 | 		t.Errorf("Expected PostgresConfig.User to be %s, got %s", pgConfig.User, tsdbConfig.PostgresConfig.User)
 35 | 	}
 36 | 	if tsdbConfig.PostgresConfig.Password != pgConfig.Password {
 37 | 		t.Errorf("Expected PostgresConfig.Password to be %s, got %s", pgConfig.Password, tsdbConfig.PostgresConfig.Password)
 38 | 	}
 39 | 	if tsdbConfig.PostgresConfig.Name != pgConfig.Name {
 40 | 		t.Errorf("Expected PostgresConfig.Name to be %s, got %s", pgConfig.Name, tsdbConfig.PostgresConfig.Name)
 41 | 	}
 42 | 
 43 | 	// Check default values
 44 | 	if !tsdbConfig.UseTimescaleDB {
 45 | 		t.Error("Expected UseTimescaleDB to be true, got false")
 46 | 	}
 47 | 	if tsdbConfig.ChunkTimeInterval != "7 days" {
 48 | 		t.Errorf("Expected ChunkTimeInterval to be '7 days', got '%s'", tsdbConfig.ChunkTimeInterval)
 49 | 	}
 50 | 	if tsdbConfig.RetentionPolicy == nil {
 51 | 		t.Fatal("Expected RetentionPolicy to be non-nil")
 52 | 	}
 53 | 	if tsdbConfig.RetentionPolicy.Enabled {
 54 | 		t.Error("Expected RetentionPolicy.Enabled to be false, got true")
 55 | 	}
 56 | 	if tsdbConfig.RetentionPolicy.Duration != "90 days" {
 57 | 		t.Errorf("Expected RetentionPolicy.Duration to be '90 days', got '%s'", tsdbConfig.RetentionPolicy.Duration)
 58 | 	}
 59 | 	if !tsdbConfig.RetentionPolicy.DropChunks {
 60 | 		t.Error("Expected RetentionPolicy.DropChunks to be true, got false")
 61 | 	}
 62 | 	if tsdbConfig.CompressionPolicy == nil {
 63 | 		t.Fatal("Expected CompressionPolicy to be non-nil")
 64 | 	}
 65 | 	if tsdbConfig.CompressionPolicy.Enabled {
 66 | 		t.Error("Expected CompressionPolicy.Enabled to be false, got true")
 67 | 	}
 68 | 	if tsdbConfig.CompressionPolicy.After != "30 days" {
 69 | 		t.Errorf("Expected CompressionPolicy.After to be '30 days', got '%s'", tsdbConfig.CompressionPolicy.After)
 70 | 	}
 71 | 	if !tsdbConfig.CompressionPolicy.CompressChunk {
 72 | 		t.Error("Expected CompressionPolicy.CompressChunk to be true, got false")
 73 | 	}
 74 | }
 75 | 
 76 | func TestIsTimescaleDB(t *testing.T) {
 77 | 	testCases := []struct {
 78 | 		name     string
 79 | 		config   db.Config
 80 | 		expected bool
 81 | 	}{
 82 | 		{
 83 | 			name: "PostgresWithExplicitTimescaleTrue",
 84 | 			config: db.Config{
 85 | 				Type: "postgres",
 86 | 				Options: map[string]string{
 87 | 					"use_timescaledb": "true",
 88 | 				},
 89 | 			},
 90 | 			expected: true,
 91 | 		},
 92 | 		{
 93 | 			name: "PostgresWithExplicitTimescaleOne",
 94 | 			config: db.Config{
 95 | 				Type: "postgres",
 96 | 				Options: map[string]string{
 97 | 					"use_timescaledb": "1",
 98 | 				},
 99 | 			},
100 | 			expected: true,
101 | 		},
102 | 		{
103 | 			name: "PostgresWithExplicitTimescaleFalse",
104 | 			config: db.Config{
105 | 				Type: "postgres",
106 | 				Options: map[string]string{
107 | 					"use_timescaledb": "false",
108 | 				},
109 | 			},
110 | 			expected: false,
111 | 		},
112 | 		{
113 | 			name: "PostgresWithoutExplicitTimescale",
114 | 			config: db.Config{
115 | 				Type: "postgres",
116 | 			},
117 | 			expected: true,
118 | 		},
119 | 		{
120 | 			name: "NotPostgres",
121 | 			config: db.Config{
122 | 				Type: "mysql",
123 | 			},
124 | 			expected: false,
125 | 		},
126 | 	}
127 | 
128 | 	for _, tc := range testCases {
129 | 		t.Run(tc.name, func(t *testing.T) {
130 | 			result := IsTimescaleDB(tc.config)
131 | 			if result != tc.expected {
132 | 				t.Errorf("IsTimescaleDB(%v) = %v, expected %v", tc.config, result, tc.expected)
133 | 			}
134 | 		})
135 | 	}
136 | }
137 | 
138 | func TestFromDBConfig(t *testing.T) {
139 | 	// Create a PostgreSQL config with TimescaleDB options
140 | 	pgConfig := db.Config{
141 | 		Type:     "postgres",
142 | 		Host:     "localhost",
143 | 		Port:     5432,
144 | 		User:     "testuser",
145 | 		Password: "testpass",
146 | 		Name:     "testdb",
147 | 		Options: map[string]string{
148 | 			"chunk_time_interval": "1 day",
149 | 			"retention_duration":  "60 days",
150 | 			"compression_after":   "14 days",
151 | 			"segment_by":          "device_id",
152 | 			"order_by":            "time DESC",
153 | 		},
154 | 	}
155 | 
156 | 	// Convert to TimescaleDB config
157 | 	tsdbConfig := FromDBConfig(pgConfig)
158 | 
159 | 	// Check that options were applied correctly
160 | 	if tsdbConfig.ChunkTimeInterval != "1 day" {
161 | 		t.Errorf("Expected ChunkTimeInterval to be '1 day', got '%s'", tsdbConfig.ChunkTimeInterval)
162 | 	}
163 | 	if !tsdbConfig.RetentionPolicy.Enabled {
164 | 		t.Error("Expected RetentionPolicy.Enabled to be true, got false")
165 | 	}
166 | 	if tsdbConfig.RetentionPolicy.Duration != "60 days" {
167 | 		t.Errorf("Expected RetentionPolicy.Duration to be '60 days', got '%s'", tsdbConfig.RetentionPolicy.Duration)
168 | 	}
169 | 	if !tsdbConfig.CompressionPolicy.Enabled {
170 | 		t.Error("Expected CompressionPolicy.Enabled to be true, got false")
171 | 	}
172 | 	if tsdbConfig.CompressionPolicy.After != "14 days" {
173 | 		t.Errorf("Expected CompressionPolicy.After to be '14 days', got '%s'", tsdbConfig.CompressionPolicy.After)
174 | 	}
175 | 	if tsdbConfig.CompressionPolicy.SegmentBy != "device_id" {
176 | 		t.Errorf("Expected CompressionPolicy.SegmentBy to be 'device_id', got '%s'", tsdbConfig.CompressionPolicy.SegmentBy)
177 | 	}
178 | 	if tsdbConfig.CompressionPolicy.OrderBy != "time DESC" {
179 | 		t.Errorf("Expected CompressionPolicy.OrderBy to be 'time DESC', got '%s'", tsdbConfig.CompressionPolicy.OrderBy)
180 | 	}
181 | }
182 | 
```

--------------------------------------------------------------------------------
/pkg/dbtools/exec.go:
--------------------------------------------------------------------------------

```go
  1 | package dbtools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"strings"
  7 | 	"time"
  8 | 
  9 | 	"github.com/FreePeak/db-mcp-server/pkg/tools"
 10 | )
 11 | 
 12 | // createExecuteTool creates a tool for executing database statements that don't return rows
 13 | //
 14 | //nolint:unused // Retained for future use
 15 | func createExecuteTool() *tools.Tool {
 16 | 	return &tools.Tool{
 17 | 		Name:        "dbExecute",
 18 | 		Description: "Execute a database statement that doesn't return results (INSERT, UPDATE, DELETE, etc.)",
 19 | 		Category:    "database",
 20 | 		InputSchema: tools.ToolInputSchema{
 21 | 			Type: "object",
 22 | 			Properties: map[string]interface{}{
 23 | 				"statement": map[string]interface{}{
 24 | 					"type":        "string",
 25 | 					"description": "SQL statement to execute",
 26 | 				},
 27 | 				"params": map[string]interface{}{
 28 | 					"type":        "array",
 29 | 					"description": "Parameters for the statement (for prepared statements)",
 30 | 					"items": map[string]interface{}{
 31 | 						"type": "string",
 32 | 					},
 33 | 				},
 34 | 				"timeout": map[string]interface{}{
 35 | 					"type":        "integer",
 36 | 					"description": "Execution timeout in milliseconds (default: 5000)",
 37 | 				},
 38 | 				"database": map[string]interface{}{
 39 | 					"type":        "string",
 40 | 					"description": "Database ID to use (optional if only one database is configured)",
 41 | 				},
 42 | 			},
 43 | 			Required: []string{"statement", "database"},
 44 | 		},
 45 | 		Handler: handleExecute,
 46 | 	}
 47 | }
 48 | 
 49 | // handleExecute handles the execute tool execution
 50 | func handleExecute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
 51 | 	// Check if database manager is initialized
 52 | 	if dbManager == nil {
 53 | 		return nil, fmt.Errorf("database manager not initialized")
 54 | 	}
 55 | 
 56 | 	// Extract parameters
 57 | 	statement, ok := getStringParam(params, "statement")
 58 | 	if !ok {
 59 | 		return nil, fmt.Errorf("statement parameter is required")
 60 | 	}
 61 | 
 62 | 	// Get database ID
 63 | 	databaseID, ok := getStringParam(params, "database")
 64 | 	if !ok {
 65 | 		return nil, fmt.Errorf("database parameter is required")
 66 | 	}
 67 | 
 68 | 	// Get database instance
 69 | 	db, err := dbManager.GetDatabase(databaseID)
 70 | 	if err != nil {
 71 | 		return nil, fmt.Errorf("failed to get database: %w", err)
 72 | 	}
 73 | 
 74 | 	// Extract timeout
 75 | 	dbTimeout := db.QueryTimeout() * 1000 // Convert from seconds to milliseconds
 76 | 	timeout := dbTimeout                  // Default to the database's query timeout
 77 | 	if timeoutParam, ok := getIntParam(params, "timeout"); ok {
 78 | 		timeout = timeoutParam
 79 | 	}
 80 | 
 81 | 	// Create context with timeout
 82 | 	timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Millisecond)
 83 | 	defer cancel()
 84 | 
 85 | 	// Extract statement parameters
 86 | 	var statementParams []interface{}
 87 | 	if paramsArray, ok := getArrayParam(params, "params"); ok {
 88 | 		statementParams = make([]interface{}, len(paramsArray))
 89 | 		copy(statementParams, paramsArray)
 90 | 	}
 91 | 
 92 | 	// Get the performance analyzer
 93 | 	analyzer := GetPerformanceAnalyzer()
 94 | 
 95 | 	// Execute statement with performance tracking
 96 | 	var result interface{}
 97 | 
 98 | 	result, err = analyzer.TrackQuery(timeoutCtx, statement, statementParams, func() (interface{}, error) {
 99 | 		// Execute statement
100 | 		sqlResult, innerErr := db.Exec(timeoutCtx, statement, statementParams...)
101 | 		if innerErr != nil {
102 | 			return nil, fmt.Errorf("failed to execute statement: %w", innerErr)
103 | 		}
104 | 
105 | 		// Get affected rows
106 | 		rowsAffected, rowsErr := sqlResult.RowsAffected()
107 | 		if rowsErr != nil {
108 | 			rowsAffected = -1 // Unable to determine
109 | 		}
110 | 
111 | 		// Get last insert ID (if applicable)
112 | 		lastInsertID, idErr := sqlResult.LastInsertId()
113 | 		if idErr != nil {
114 | 			lastInsertID = -1 // Unable to determine
115 | 		}
116 | 
117 | 		// Return results
118 | 		return map[string]interface{}{
119 | 			"rowsAffected": rowsAffected,
120 | 			"lastInsertId": lastInsertID,
121 | 			"statement":    statement,
122 | 			"params":       statementParams,
123 | 		}, nil
124 | 	})
125 | 
126 | 	if err != nil {
127 | 		return nil, err
128 | 	}
129 | 
130 | 	return result, nil
131 | }
132 | 
133 | // createMockExecuteTool creates a mock version of the execute tool that works without database connection
134 | //
135 | //nolint:unused // Retained for future use
136 | func createMockExecuteTool() *tools.Tool {
137 | 	// Create the tool using the same schema as the real execute tool
138 | 	tool := createExecuteTool()
139 | 
140 | 	// Replace the handler with mock implementation
141 | 	tool.Handler = handleMockExecute
142 | 
143 | 	return tool
144 | }
145 | 
146 | // handleMockExecute is a mock implementation of the execute handler
147 | //
148 | //nolint:unused // Retained for future use
149 | func handleMockExecute(ctx context.Context, params map[string]interface{}) (interface{}, error) {
150 | 	// Extract parameters
151 | 	statement, ok := getStringParam(params, "statement")
152 | 	if !ok {
153 | 		return nil, fmt.Errorf("statement parameter is required")
154 | 	}
155 | 
156 | 	// Extract statement parameters if provided
157 | 	var statementParams []interface{}
158 | 	if paramsArray, ok := getArrayParam(params, "params"); ok {
159 | 		statementParams = paramsArray
160 | 	}
161 | 
162 | 	// Simulate results based on statement
163 | 	var rowsAffected int64 = 1
164 | 	var lastInsertID int64 = -1
165 | 
166 | 	// Simple pattern matching to provide realistic mock results
167 | 	if strings.Contains(strings.ToUpper(statement), "INSERT") {
168 | 		// For INSERT statements, generate a mock last insert ID
169 | 		lastInsertID = time.Now().Unix() % 1000 // Generate a pseudo-random ID based on current time
170 | 		rowsAffected = 1
171 | 	} else if strings.Contains(strings.ToUpper(statement), "UPDATE") {
172 | 		// For UPDATE statements, simulate affecting 1-3 rows
173 | 		rowsAffected = int64(1 + (time.Now().Unix() % 3))
174 | 	} else if strings.Contains(strings.ToUpper(statement), "DELETE") {
175 | 		// For DELETE statements, simulate affecting 0-2 rows
176 | 		rowsAffected = int64(time.Now().Unix() % 3)
177 | 	}
178 | 
179 | 	// Return results in the same format as the real execute tool
180 | 	return map[string]interface{}{
181 | 		"rowsAffected": rowsAffected,
182 | 		"lastInsertId": lastInsertID,
183 | 		"statement":    statement,
184 | 		"params":       statementParams,
185 | 	}, nil
186 | }
187 | 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/response_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/stretchr/testify/assert"
 10 | )
 11 | 
 12 | func TestNewResponse(t *testing.T) {
 13 | 	resp := NewResponse()
 14 | 	if resp == nil {
 15 | 		t.Fatal("NewResponse returned nil")
 16 | 	}
 17 | 	if len(resp.Content) != 0 {
 18 | 		t.Errorf("Expected empty content, got %v", resp.Content)
 19 | 	}
 20 | 	if resp.Metadata != nil {
 21 | 		t.Errorf("Expected nil metadata, got %v", resp.Metadata)
 22 | 	}
 23 | }
 24 | 
 25 | func TestWithText(t *testing.T) {
 26 | 	resp := NewResponse().WithText("Hello, world!")
 27 | 	if len(resp.Content) != 1 {
 28 | 		t.Fatalf("Expected 1 content item, got %d", len(resp.Content))
 29 | 	}
 30 | 	if resp.Content[0].Type != "text" {
 31 | 		t.Errorf("Expected content type 'text', got %s", resp.Content[0].Type)
 32 | 	}
 33 | 	if resp.Content[0].Text != "Hello, world!" {
 34 | 		t.Errorf("Expected content text 'Hello, world!', got %s", resp.Content[0].Text)
 35 | 	}
 36 | 
 37 | 	// Test chaining multiple texts
 38 | 	resp2 := resp.WithText("Second line")
 39 | 	if len(resp2.Content) != 2 {
 40 | 		t.Fatalf("Expected 2 content items, got %d", len(resp2.Content))
 41 | 	}
 42 | 	if resp2.Content[1].Text != "Second line" {
 43 | 		t.Errorf("Expected second content text 'Second line', got %s", resp2.Content[1].Text)
 44 | 	}
 45 | }
 46 | 
 47 | func TestWithMetadata(t *testing.T) {
 48 | 	resp := NewResponse().WithMetadata("key", "value")
 49 | 	if resp.Metadata == nil {
 50 | 		t.Fatalf("Expected metadata to be initialized")
 51 | 	}
 52 | 	if val, ok := resp.Metadata["key"]; !ok || val != "value" {
 53 | 		t.Errorf("Expected metadata['key'] = 'value', got %v", val)
 54 | 	}
 55 | 
 56 | 	// Test chaining multiple metadata
 57 | 	resp2 := resp.WithMetadata("key2", 123)
 58 | 	if len(resp2.Metadata) != 2 {
 59 | 		t.Fatalf("Expected 2 metadata items, got %d", len(resp2.Metadata))
 60 | 	}
 61 | 	if val, ok := resp2.Metadata["key2"]; !ok || val != 123 {
 62 | 		t.Errorf("Expected metadata['key2'] = 123, got %v", val)
 63 | 	}
 64 | }
 65 | 
 66 | func TestFromString(t *testing.T) {
 67 | 	resp := FromString("Test message")
 68 | 	if len(resp.Content) != 1 {
 69 | 		t.Fatalf("Expected 1 content item, got %d", len(resp.Content))
 70 | 	}
 71 | 	if resp.Content[0].Text != "Test message" {
 72 | 		t.Errorf("Expected content text 'Test message', got %s", resp.Content[0].Text)
 73 | 	}
 74 | }
 75 | 
 76 | func TestFromError(t *testing.T) {
 77 | 	testErr := errors.New("test error")
 78 | 	resp, err := FromError(testErr)
 79 | 	if resp != nil {
 80 | 		t.Errorf("Expected nil response, got %v", resp)
 81 | 	}
 82 | 	if err != testErr {
 83 | 		t.Errorf("Expected error to be passed through, got %v", err)
 84 | 	}
 85 | }
 86 | 
 87 | func TestFormatResponse(t *testing.T) {
 88 | 	testCases := []struct {
 89 | 		name           string
 90 | 		input          interface{}
 91 | 		err            error
 92 | 		expectError    bool
 93 | 		expectedOutput string
 94 | 		useMock        bool
 95 | 	}{
 96 | 		{
 97 | 			name:           "nil response",
 98 | 			input:          nil,
 99 | 			err:            nil,
100 | 			expectError:    false,
101 | 			expectedOutput: `{"content":[]}`,
102 | 			useMock:        false,
103 | 		},
104 | 		{
105 | 			name:           "error response",
106 | 			input:          nil,
107 | 			err:            errors.New("test error"),
108 | 			expectError:    true,
109 | 			expectedOutput: "",
110 | 			useMock:        false,
111 | 		},
112 | 		{
113 | 			name:           "string response",
114 | 			input:          "Hello, world!",
115 | 			err:            nil,
116 | 			expectError:    false,
117 | 			expectedOutput: `{"content":[{"type":"text","text":"Hello, world!"}]}`,
118 | 			useMock:        false,
119 | 		},
120 | 		{
121 | 			name:           "MCPResponse",
122 | 			input:          NewResponse().WithText("Test").WithMetadata("key", "value"),
123 | 			err:            nil,
124 | 			expectError:    false,
125 | 			expectedOutput: `{"content":[{"type":"text","text":"Test"}],"metadata":{"key":"value"}}`,
126 | 			useMock:        false,
127 | 		},
128 | 		{
129 | 			name: "existing map with content",
130 | 			input: map[string]interface{}{
131 | 				"content": []interface{}{
132 | 					map[string]interface{}{
133 | 						"type": "text",
134 | 						"text": "Existing content",
135 | 					},
136 | 				},
137 | 			},
138 | 			err:            nil,
139 | 			expectError:    false,
140 | 			expectedOutput: `{"content":[{"text":"Existing content","type":"text"}]}`,
141 | 			useMock:        false,
142 | 		},
143 | 		{
144 | 			name:           "empty map response",
145 | 			input:          map[string]interface{}{},
146 | 			err:            nil,
147 | 			expectError:    false,
148 | 			expectedOutput: `{"content":[]}`,
149 | 			useMock:        false,
150 | 		},
151 | 		{
152 | 			name:           "Input is nil",
153 | 			input:          nil,
154 | 			err:            nil,
155 | 			expectError:    false,
156 | 			expectedOutput: `{"content":[]}`,
157 | 			useMock:        true,
158 | 		},
159 | 	}
160 | 
161 | 	for _, tc := range testCases {
162 | 		t.Run(tc.name, func(t *testing.T) {
163 | 			// Get mock objects
164 | 			if !tc.useMock {
165 | 				if tc.name == "Input is nil" {
166 | 					resp, err := FormatResponse(tc.input, nil)
167 | 					assert.Nil(t, err, "Expected no error")
168 | 					assert.NotNil(t, resp, "Expected non-nil response")
169 | 				} else {
170 | 					// This case doesn't check the return value (we already have test coverage)
171 | 					// We're verifying the function doesn't panic
172 | 					// Ignoring the return value is intentional
173 | 					result, err := FormatResponse(tc.input, nil)
174 | 					_ = result // intentionally ignored in this test
175 | 					_ = err    // intentionally ignored in this test
176 | 				}
177 | 			}
178 | 		})
179 | 	}
180 | }
181 | 
182 | func BenchmarkFormatResponse(b *testing.B) {
183 | 	testCases := []struct {
184 | 		name  string
185 | 		input interface{}
186 | 	}{
187 | 		{"string", "Hello, world!"},
188 | 		{"map", map[string]interface{}{"test": "value"}},
189 | 		{"MCPResponse", NewResponse().WithText("Test").WithMetadata("key", "value")},
190 | 	}
191 | 
192 | 	for _, tc := range testCases {
193 | 		b.Run(tc.name, func(b *testing.B) {
194 | 			for i := 0; i < b.N; i++ {
195 | 				// Ignoring the return value is intentional in benchmarks
196 | 				result, err := FormatResponse(tc.input, nil)
197 | 				_ = result // intentionally ignored in benchmark
198 | 				_ = err    // intentionally ignored in benchmark
199 | 			}
200 | 		})
201 | 	}
202 | }
203 | 
204 | func ExampleNewResponse() {
205 | 	// Create a new response with text content
206 | 	resp := NewResponse().WithText("Hello, world!")
207 | 
208 | 	// Add metadata
209 | 	resp.WithMetadata("source", "example")
210 | 
211 | 	// Convert to map for JSON serialization
212 | 	output, err := json.Marshal(resp)
213 | 	if err != nil {
214 | 		// This is an example, but we should still check
215 | 		fmt.Println("Error marshaling:", err)
216 | 		return
217 | 	}
218 | 	fmt.Println(string(output))
219 | 	// Output: {"content":[{"type":"text","text":"Hello, world!"}],"metadata":{"source":"example"}}
220 | }
221 | 
```

--------------------------------------------------------------------------------
/pkg/db/timescale/connection.go:
--------------------------------------------------------------------------------

```go
  1 | package timescale
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"database/sql"
  6 | 	"fmt"
  7 | 	"os"
  8 | 	"strings"
  9 | 	"time"
 10 | 
 11 | 	"github.com/FreePeak/db-mcp-server/pkg/db"
 12 | 	"github.com/FreePeak/db-mcp-server/pkg/logger"
 13 | )
 14 | 
 15 | // DB represents a TimescaleDB database connection
 16 | type DB struct {
 17 | 	db.Database            // Embed standard Database interface
 18 | 	config        DBConfig // TimescaleDB-specific configuration
 19 | 	extVersion    string   // TimescaleDB extension version
 20 | 	isTimescaleDB bool     // Whether the database supports TimescaleDB
 21 | }
 22 | 
 23 | // NewTimescaleDB creates a new TimescaleDB connection
 24 | func NewTimescaleDB(config DBConfig) (*DB, error) {
 25 | 	// Initialize PostgreSQL connection
 26 | 	pgDB, err := db.NewDatabase(config.PostgresConfig)
 27 | 	if err != nil {
 28 | 		return nil, fmt.Errorf("failed to initialize PostgreSQL connection: %w", err)
 29 | 	}
 30 | 
 31 | 	return &DB{
 32 | 		Database: pgDB,
 33 | 		config:   config,
 34 | 	}, nil
 35 | }
 36 | 
 37 | // Connect establishes a connection and verifies TimescaleDB availability
 38 | func (t *DB) Connect() error {
 39 | 	// Connect to PostgreSQL
 40 | 	if err := t.Database.Connect(); err != nil {
 41 | 		return err
 42 | 	}
 43 | 
 44 | 	// Check for TimescaleDB extension
 45 | 	if t.config.UseTimescaleDB {
 46 | 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 47 | 		defer cancel()
 48 | 
 49 | 		var version string
 50 | 		err := t.Database.QueryRow(ctx, "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'").Scan(&version)
 51 | 		if err != nil {
 52 | 			if err == sql.ErrNoRows {
 53 | 				// Skip logging in tests to avoid nil pointer dereference
 54 | 				if !isTestEnvironment() {
 55 | 					logger.Warn("TimescaleDB extension not found in database. Features will be disabled.")
 56 | 				}
 57 | 				t.isTimescaleDB = false
 58 | 				// Don't return error, just disable TimescaleDB features
 59 | 				return nil
 60 | 			}
 61 | 			return fmt.Errorf("failed to check TimescaleDB extension: %w", err)
 62 | 		}
 63 | 
 64 | 		t.extVersion = version
 65 | 		t.isTimescaleDB = true
 66 | 		// Skip logging in tests to avoid nil pointer dereference
 67 | 		if !isTestEnvironment() {
 68 | 			logger.Info("Connected to TimescaleDB %s", version)
 69 | 		}
 70 | 	}
 71 | 
 72 | 	return nil
 73 | }
 74 | 
 75 | // isTestEnvironment returns true if the code is running in a test environment
 76 | func isTestEnvironment() bool {
 77 | 	for _, arg := range os.Args {
 78 | 		if strings.HasPrefix(arg, "-test.") {
 79 | 			return true
 80 | 		}
 81 | 	}
 82 | 	return false
 83 | }
 84 | 
 85 | // Close closes the database connection
 86 | func (t *DB) Close() error {
 87 | 	return t.Database.Close()
 88 | }
 89 | 
 90 | // ExtVersion returns the TimescaleDB extension version
 91 | func (t *DB) ExtVersion() string {
 92 | 	return t.extVersion
 93 | }
 94 | 
 95 | // IsTimescaleDB returns true if the database has TimescaleDB extension installed
 96 | func (t *DB) IsTimescaleDB() bool {
 97 | 	return t.isTimescaleDB
 98 | }
 99 | 
100 | // ApplyConfig applies TimescaleDB-specific configuration options
101 | func (t *DB) ApplyConfig() error {
102 | 	if !t.isTimescaleDB {
103 | 		return fmt.Errorf("cannot apply TimescaleDB configuration: TimescaleDB extension not available")
104 | 	}
105 | 
106 | 	// No global configuration to apply for now
107 | 	return nil
108 | }
109 | 
110 | // ExecuteSQLWithoutParams executes a SQL query without parameters and returns a result
111 | func (t *DB) ExecuteSQLWithoutParams(ctx context.Context, query string) (interface{}, error) {
112 | 	// For non-SELECT queries (that don't return rows), use Exec
113 | 	if !isSelectQuery(query) {
114 | 		result, err := t.Database.Exec(ctx, query)
115 | 		if err != nil {
116 | 			return nil, fmt.Errorf("failed to execute query: %w", err)
117 | 		}
118 | 		return result, nil
119 | 	}
120 | 
121 | 	// For SELECT queries, process rows into a map
122 | 	rows, err := t.Database.Query(ctx, query)
123 | 	if err != nil {
124 | 		return nil, fmt.Errorf("failed to execute query: %w", err)
125 | 	}
126 | 	defer func() {
127 | 		if closeErr := rows.Close(); closeErr != nil {
128 | 			// Log the error or append it to the returned error
129 | 			if !isTestEnvironment() {
130 | 				logger.Error("Failed to close rows: %v", closeErr)
131 | 			}
132 | 		}
133 | 	}()
134 | 
135 | 	return processRows(rows)
136 | }
137 | 
138 | // ExecuteSQL executes a SQL query with parameters and returns a result
139 | func (t *DB) ExecuteSQL(ctx context.Context, query string, args ...interface{}) (interface{}, error) {
140 | 	// For non-SELECT queries (that don't return rows), use Exec
141 | 	if !isSelectQuery(query) {
142 | 		result, err := t.Database.Exec(ctx, query, args...)
143 | 		if err != nil {
144 | 			return nil, fmt.Errorf("failed to execute query: %w", err)
145 | 		}
146 | 		return result, nil
147 | 	}
148 | 
149 | 	// For SELECT queries, process rows into a map
150 | 	rows, err := t.Database.Query(ctx, query, args...)
151 | 	if err != nil {
152 | 		return nil, fmt.Errorf("failed to execute query: %w", err)
153 | 	}
154 | 	defer func() {
155 | 		if closeErr := rows.Close(); closeErr != nil {
156 | 			// Log the error or append it to the returned error
157 | 			if !isTestEnvironment() {
158 | 				logger.Error("Failed to close rows: %v", closeErr)
159 | 			}
160 | 		}
161 | 	}()
162 | 
163 | 	return processRows(rows)
164 | }
165 | 
166 | // Helper function to check if a query is a SELECT query
167 | func isSelectQuery(query string) bool {
168 | 	// Simple check for now - could be made more robust
169 | 	for i := 0; i < len(query); i++ {
170 | 		if query[i] == ' ' || query[i] == '\t' || query[i] == '\n' {
171 | 			continue
172 | 		}
173 | 		return i+6 <= len(query) && (query[i:i+6] == "SELECT" || query[i:i+6] == "select")
174 | 	}
175 | 	return false
176 | }
177 | 
178 | // Helper function to process rows into a map
179 | func processRows(rows *sql.Rows) ([]map[string]interface{}, error) {
180 | 	// Get column names
181 | 	columns, err := rows.Columns()
182 | 	if err != nil {
183 | 		return nil, err
184 | 	}
185 | 
186 | 	// Create a slice of results
187 | 	var results []map[string]interface{}
188 | 
189 | 	// Create a slice of interface{} to hold the values
190 | 	values := make([]interface{}, len(columns))
191 | 	valuePtrs := make([]interface{}, len(columns))
192 | 
193 | 	// Loop through rows
194 | 	for rows.Next() {
195 | 		// Set up pointers to each interface{} value
196 | 		for i := range values {
197 | 			valuePtrs[i] = &values[i]
198 | 		}
199 | 
200 | 		// Scan the result into the values
201 | 		if err := rows.Scan(valuePtrs...); err != nil {
202 | 			return nil, err
203 | 		}
204 | 
205 | 		// Create a map for this row
206 | 		row := make(map[string]interface{})
207 | 		for i, col := range columns {
208 | 			if values[i] == nil {
209 | 				row[col] = nil
210 | 			} else {
211 | 				// Try to handle different types appropriately
212 | 				switch v := values[i].(type) {
213 | 				case []byte:
214 | 					row[col] = string(v)
215 | 				default:
216 | 					row[col] = v
217 | 				}
218 | 			}
219 | 		}
220 | 
221 | 		results = append(results, row)
222 | 	}
223 | 
224 | 	// Check for errors after we're done iterating
225 | 	if err := rows.Err(); err != nil {
226 | 		return nil, err
227 | 	}
228 | 
229 | 	return results, nil
230 | }
231 | 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/context/timescale_context_test.go:
--------------------------------------------------------------------------------

```go
  1 | package context_test
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/stretchr/testify/assert"
  8 | 	"github.com/stretchr/testify/mock"
  9 | 
 10 | 	"github.com/FreePeak/db-mcp-server/internal/delivery/mcp"
 11 | )
 12 | 
 13 | // MockDatabaseUseCase is a mock implementation of the UseCaseProvider interface
 14 | type MockDatabaseUseCase struct {
 15 | 	mock.Mock
 16 | }
 17 | 
 18 | // ExecuteStatement mocks the ExecuteStatement method
 19 | func (m *MockDatabaseUseCase) ExecuteStatement(ctx context.Context, dbID, statement string, params []interface{}) (string, error) {
 20 | 	args := m.Called(ctx, dbID, statement, params)
 21 | 	return args.String(0), args.Error(1)
 22 | }
 23 | 
 24 | // GetDatabaseType mocks the GetDatabaseType method
 25 | func (m *MockDatabaseUseCase) GetDatabaseType(dbID string) (string, error) {
 26 | 	args := m.Called(dbID)
 27 | 	return args.String(0), args.Error(1)
 28 | }
 29 | 
 30 | // ExecuteQuery mocks the ExecuteQuery method
 31 | func (m *MockDatabaseUseCase) ExecuteQuery(ctx context.Context, dbID, query string, params []interface{}) (string, error) {
 32 | 	args := m.Called(ctx, dbID, query, params)
 33 | 	return args.String(0), args.Error(1)
 34 | }
 35 | 
 36 | // ExecuteTransaction mocks the ExecuteTransaction method
 37 | func (m *MockDatabaseUseCase) ExecuteTransaction(ctx context.Context, dbID, action string, txID string, statement string, params []interface{}, readOnly bool) (string, map[string]interface{}, error) {
 38 | 	args := m.Called(ctx, dbID, action, txID, statement, params, readOnly)
 39 | 	return args.String(0), args.Get(1).(map[string]interface{}), args.Error(2)
 40 | }
 41 | 
 42 | // GetDatabaseInfo mocks the GetDatabaseInfo method
 43 | func (m *MockDatabaseUseCase) GetDatabaseInfo(dbID string) (map[string]interface{}, error) {
 44 | 	args := m.Called(dbID)
 45 | 	return args.Get(0).(map[string]interface{}), args.Error(1)
 46 | }
 47 | 
 48 | // ListDatabases mocks the ListDatabases method
 49 | func (m *MockDatabaseUseCase) ListDatabases() []string {
 50 | 	args := m.Called()
 51 | 	return args.Get(0).([]string)
 52 | }
 53 | 
 54 | func TestTimescaleDBContextProvider(t *testing.T) {
 55 | 	// Create a mock use case provider
 56 | 	mockUseCase := new(MockDatabaseUseCase)
 57 | 
 58 | 	// Create a context for testing
 59 | 	ctx := context.Background()
 60 | 
 61 | 	t.Run("detect_timescaledb_with_extension", func(t *testing.T) {
 62 | 		// Sample result indicating TimescaleDB is available
 63 | 		sampleVersionResult := `[{"extversion": "2.9.1"}]`
 64 | 
 65 | 		// Set up expectations for the mock
 66 | 		mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil).Once()
 67 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.MatchedBy(func(sql string) bool {
 68 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
 69 | 		}), mock.Anything).Return(sampleVersionResult, nil).Once()
 70 | 
 71 | 		// Create the context provider
 72 | 		provider := mcp.NewTimescaleDBContextProvider()
 73 | 
 74 | 		// Call the detection method
 75 | 		contextInfo, err := provider.DetectTimescaleDB(ctx, "test_db", mockUseCase)
 76 | 
 77 | 		// Verify the result
 78 | 		assert.NoError(t, err)
 79 | 		assert.NotNil(t, contextInfo)
 80 | 		assert.True(t, contextInfo.IsTimescaleDB)
 81 | 		assert.Equal(t, "2.9.1", contextInfo.Version)
 82 | 
 83 | 		// Verify the mock expectations
 84 | 		mockUseCase.AssertExpectations(t)
 85 | 	})
 86 | 
 87 | 	t.Run("detect_timescaledb_with_no_extension", func(t *testing.T) {
 88 | 		// Sample result indicating TimescaleDB is not available
 89 | 		sampleEmptyResult := `[]`
 90 | 
 91 | 		// Set up expectations for the mock
 92 | 		mockUseCase.On("GetDatabaseType", "postgres_db").Return("postgres", nil).Once()
 93 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "postgres_db", mock.MatchedBy(func(sql string) bool {
 94 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
 95 | 		}), mock.Anything).Return(sampleEmptyResult, nil).Once()
 96 | 
 97 | 		// Create the context provider
 98 | 		provider := mcp.NewTimescaleDBContextProvider()
 99 | 
100 | 		// Call the detection method
101 | 		contextInfo, err := provider.DetectTimescaleDB(ctx, "postgres_db", mockUseCase)
102 | 
103 | 		// Verify the result
104 | 		assert.NoError(t, err)
105 | 		assert.NotNil(t, contextInfo)
106 | 		assert.False(t, contextInfo.IsTimescaleDB)
107 | 		assert.Empty(t, contextInfo.Version)
108 | 
109 | 		// Verify the mock expectations
110 | 		mockUseCase.AssertExpectations(t)
111 | 	})
112 | 
113 | 	t.Run("detect_timescaledb_with_non_postgres", func(t *testing.T) {
114 | 		// Set up expectations for the mock
115 | 		mockUseCase.On("GetDatabaseType", "mysql_db").Return("mysql", nil).Once()
116 | 
117 | 		// Create the context provider
118 | 		provider := mcp.NewTimescaleDBContextProvider()
119 | 
120 | 		// Call the detection method
121 | 		contextInfo, err := provider.DetectTimescaleDB(ctx, "mysql_db", mockUseCase)
122 | 
123 | 		// Verify the result
124 | 		assert.NoError(t, err)
125 | 		assert.NotNil(t, contextInfo)
126 | 		assert.False(t, contextInfo.IsTimescaleDB)
127 | 		assert.Empty(t, contextInfo.Version)
128 | 
129 | 		// Verify the mock expectations
130 | 		mockUseCase.AssertExpectations(t)
131 | 	})
132 | 
133 | 	t.Run("get_hypertables_info", func(t *testing.T) {
134 | 		// Sample result with list of hypertables
135 | 		sampleHypertablesResult := `[
136 | 			{"table_name": "metrics", "time_column": "timestamp", "chunk_interval": "1 day"},
137 | 			{"table_name": "logs", "time_column": "log_time", "chunk_interval": "4 hours"}
138 | 		]`
139 | 
140 | 		// Set up expectations for the mock
141 | 		mockUseCase.On("GetDatabaseType", "timescale_db").Return("postgres", nil).Once()
142 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
143 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
144 | 		}), mock.Anything).Return(`[{"extversion": "2.8.0"}]`, nil).Once()
145 | 
146 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
147 | 			return sql != "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
148 | 		}), mock.Anything).Return(sampleHypertablesResult, nil).Once()
149 | 
150 | 		// Create the context provider
151 | 		provider := mcp.NewTimescaleDBContextProvider()
152 | 
153 | 		// Call the detection method
154 | 		contextInfo, err := provider.GetTimescaleDBContext(ctx, "timescale_db", mockUseCase)
155 | 
156 | 		// Verify the result
157 | 		assert.NoError(t, err)
158 | 		assert.NotNil(t, contextInfo)
159 | 		assert.True(t, contextInfo.IsTimescaleDB)
160 | 		assert.Equal(t, "2.8.0", contextInfo.Version)
161 | 		assert.Len(t, contextInfo.Hypertables, 2)
162 | 		assert.Equal(t, "metrics", contextInfo.Hypertables[0].TableName)
163 | 		assert.Equal(t, "timestamp", contextInfo.Hypertables[0].TimeColumn)
164 | 		assert.Equal(t, "logs", contextInfo.Hypertables[1].TableName)
165 | 		assert.Equal(t, "log_time", contextInfo.Hypertables[1].TimeColumn)
166 | 
167 | 		// Verify the mock expectations
168 | 		mockUseCase.AssertExpectations(t)
169 | 	})
170 | }
171 | 
```

--------------------------------------------------------------------------------
/pkg/tools/tools.go:
--------------------------------------------------------------------------------

```go
  1 | package tools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"sync"
  7 | 	"time"
  8 | 
  9 | 	"github.com/FreePeak/db-mcp-server/pkg/logger"
 10 | )
 11 | 
 12 | // Tool represents a tool that can be executed by the MCP server
 13 | type Tool struct {
 14 | 	Name        string          `json:"name"`
 15 | 	Description string          `json:"description,omitempty"`
 16 | 	InputSchema ToolInputSchema `json:"inputSchema"`
 17 | 	Handler     ToolHandler
 18 | 	// Optional metadata for the tool
 19 | 	Category  string      `json:"-"` // Category for grouping tools
 20 | 	CreatedAt time.Time   `json:"-"` // When the tool was registered
 21 | 	RawSchema interface{} `json:"-"` // Alternative to InputSchema for complex schemas
 22 | }
 23 | 
 24 | // ToolInputSchema represents the schema for tool input parameters
 25 | type ToolInputSchema struct {
 26 | 	Type       string                 `json:"type"`
 27 | 	Properties map[string]interface{} `json:"properties,omitempty"`
 28 | 	Required   []string               `json:"required,omitempty"`
 29 | }
 30 | 
 31 | // Result represents a tool execution result
 32 | type Result struct {
 33 | 	Result  interface{} `json:"result,omitempty"`
 34 | 	Content []Content   `json:"content,omitempty"`
 35 | 	IsError bool        `json:"isError,omitempty"`
 36 | }
 37 | 
 38 | // Content represents content in a tool execution result
 39 | type Content struct {
 40 | 	Type string `json:"type"`
 41 | 	Text string `json:"text,omitempty"`
 42 | }
 43 | 
 44 | // NewTextContent creates a new text content
 45 | func NewTextContent(text string) Content {
 46 | 	return Content{
 47 | 		Type: "text",
 48 | 		Text: text,
 49 | 	}
 50 | }
 51 | 
 52 | // ToolHandler is a function that handles a tool execution
 53 | // Enhanced to use context for cancellation and timeouts
 54 | type ToolHandler func(ctx context.Context, params map[string]interface{}) (interface{}, error)
 55 | 
 56 | // ToolExecutionOptions provides options for tool execution
 57 | type ToolExecutionOptions struct {
 58 | 	Timeout     time.Duration
 59 | 	ProgressCB  func(progress float64, message string) // Optional progress callback
 60 | 	TraceID     string                                 // For tracing/logging
 61 | 	UserContext map[string]interface{}                 // User-specific context
 62 | }
 63 | 
 64 | // Registry is a registry of tools
 65 | type Registry struct {
 66 | 	mu    sync.RWMutex
 67 | 	tools map[string]*Tool
 68 | }
 69 | 
 70 | // Global registry instance for the application
 71 | var globalRegistry *Registry
 72 | var globalRegistryMu sync.RWMutex
 73 | 
 74 | // NewRegistry creates a new registry
 75 | func NewRegistry() *Registry {
 76 | 	r := &Registry{
 77 | 		tools: make(map[string]*Tool),
 78 | 	}
 79 | 
 80 | 	// Store the registry instance globally
 81 | 	globalRegistryMu.Lock()
 82 | 	globalRegistry = r
 83 | 	globalRegistryMu.Unlock()
 84 | 
 85 | 	return r
 86 | }
 87 | 
 88 | // GetRegistry returns the global registry instance
 89 | func GetRegistry() *Registry {
 90 | 	globalRegistryMu.RLock()
 91 | 	defer globalRegistryMu.RUnlock()
 92 | 	return globalRegistry
 93 | }
 94 | 
 95 | // RegisterTool registers a tool with the registry
 96 | func (r *Registry) RegisterTool(tool *Tool) {
 97 | 	r.mu.Lock()
 98 | 	defer r.mu.Unlock()
 99 | 
100 | 	// Validate tool
101 | 	if tool.Name == "" {
102 | 		logger.Warn("Warning: Tool has empty name, not registering")
103 | 		return
104 | 	}
105 | 
106 | 	// Check for duplicate tool names
107 | 	if _, exists := r.tools[tool.Name]; exists {
108 | 		logger.Warn("Warning: Tool '%s' already registered, overwriting", tool.Name)
109 | 	}
110 | 
111 | 	r.tools[tool.Name] = tool
112 | }
113 | 
114 | // DeregisterTool removes a tool from the registry
115 | func (r *Registry) DeregisterTool(name string) bool {
116 | 	r.mu.Lock()
117 | 	defer r.mu.Unlock()
118 | 
119 | 	_, exists := r.tools[name]
120 | 	if exists {
121 | 		delete(r.tools, name)
122 | 		return true
123 | 	}
124 | 	return false
125 | }
126 | 
127 | // GetTool returns a tool by name
128 | func (r *Registry) GetTool(name string) (*Tool, bool) {
129 | 	r.mu.RLock()
130 | 	defer r.mu.RUnlock()
131 | 
132 | 	tool, exists := r.tools[name]
133 | 	return tool, exists
134 | }
135 | 
136 | // GetAllTools returns all registered tools
137 | func (r *Registry) GetAllTools() []*Tool {
138 | 	r.mu.RLock()
139 | 	defer r.mu.RUnlock()
140 | 
141 | 	tools := make([]*Tool, 0, len(r.tools))
142 | 	for _, tool := range r.tools {
143 | 		tools = append(tools, tool)
144 | 	}
145 | 	return tools
146 | }
147 | 
148 | // GetToolsByCategory returns tools filtered by category
149 | func (r *Registry) GetToolsByCategory(category string) []*Tool {
150 | 	r.mu.RLock()
151 | 	defer r.mu.RUnlock()
152 | 
153 | 	var tools []*Tool
154 | 	for _, tool := range r.tools {
155 | 		if tool.Category == category {
156 | 			tools = append(tools, tool)
157 | 		}
158 | 	}
159 | 	return tools
160 | }
161 | 
162 | // PrintTools prints all registered tools for debugging
163 | func (r *Registry) PrintTools() {
164 | 	r.mu.RLock()
165 | 	defer r.mu.RUnlock()
166 | 
167 | 	logger.Info("Registered tools:")
168 | 	for name, tool := range r.tools {
169 | 		logger.Info("- %s: %s", name, tool.Description)
170 | 	}
171 | }
172 | 
173 | // Execute executes a tool by name with the given parameters
174 | func (r *Registry) Execute(ctx context.Context, name string, params map[string]interface{}, opts *ToolExecutionOptions) (interface{}, error) {
175 | 	tool, exists := r.GetTool(name)
176 | 	if !exists {
177 | 		return nil, fmt.Errorf("tool not found: %s", name)
178 | 	}
179 | 
180 | 	// Validate parameters against schema
181 | 	// This is skipped for now to keep things simple
182 | 
183 | 	// Default options if not provided
184 | 	if opts == nil {
185 | 		opts = &ToolExecutionOptions{
186 | 			Timeout: 30 * time.Second,
187 | 		}
188 | 	}
189 | 
190 | 	// Set default timeout if not set
191 | 	if opts.Timeout == 0 {
192 | 		opts.Timeout = 30 * time.Second
193 | 	}
194 | 
195 | 	// Create a context with timeout if not a background context
196 | 	timeoutCtx := ctx
197 | 	if opts.Timeout > 0 {
198 | 		var cancel context.CancelFunc
199 | 		timeoutCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
200 | 		defer cancel()
201 | 	}
202 | 
203 | 	// Execute tool handler
204 | 	return tool.Handler(timeoutCtx, params)
205 | }
206 | 
207 | // ValidateToolInput validates the input parameters against the tool's schema
208 | func (r *Registry) ValidateToolInput(name string, params map[string]interface{}) error {
209 | 	tool, ok := r.GetTool(name)
210 | 	if !ok {
211 | 		return fmt.Errorf("tool not found: %s", name)
212 | 	}
213 | 
214 | 	// Check required parameters
215 | 	for _, required := range tool.InputSchema.Required {
216 | 		if _, exists := params[required]; !exists {
217 | 			return fmt.Errorf("missing required parameter: %s", required)
218 | 		}
219 | 	}
220 | 
221 | 	// TODO: Implement full JSON Schema validation if needed
222 | 	return nil
223 | }
224 | 
225 | // ErrToolNotFound is returned when a tool is not found
226 | var ErrToolNotFound = &ToolError{
227 | 	Code:    "tool_not_found",
228 | 	Message: "Tool not found",
229 | }
230 | 
231 | // ErrToolExecutionFailed is returned when a tool execution fails
232 | var ErrToolExecutionFailed = &ToolError{
233 | 	Code:    "tool_execution_failed",
234 | 	Message: "Tool execution failed",
235 | }
236 | 
237 | // ErrInvalidToolInput is returned when the input parameters are invalid
238 | var ErrInvalidToolInput = &ToolError{
239 | 	Code:    "invalid_tool_input",
240 | 	Message: "Invalid tool input",
241 | }
242 | 
243 | // ToolError represents an error that occurred while executing a tool
244 | type ToolError struct {
245 | 	Code    string
246 | 	Message string
247 | 	Data    interface{}
248 | }
249 | 
250 | // Error returns a string representation of the error
251 | func (e *ToolError) Error() string {
252 | 	return e.Message
253 | }
254 | 
```

--------------------------------------------------------------------------------
/pkg/db/timescale/timeseries_test.go:
--------------------------------------------------------------------------------

```go
  1 | package timescale
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"testing"
  7 | 	"time"
  8 | )
  9 | 
 10 | func TestTimeSeriesQuery(t *testing.T) {
 11 | 	t.Run("should build and execute time series query", func(t *testing.T) {
 12 | 		// Setup test with a custom mock DB
 13 | 		mockDB := NewMockDB()
 14 | 		mockDB.SetTimescaleAvailable(true)
 15 | 		tsdb := &DB{
 16 | 			Database:      mockDB,
 17 | 			isTimescaleDB: true,
 18 | 			config: DBConfig{
 19 | 				UseTimescaleDB: true,
 20 | 			},
 21 | 		}
 22 | 		ctx := context.Background()
 23 | 
 24 | 		// Set mock behavior with non-empty result - directly register a mock for ExecuteSQL
 25 | 		expectedResult := []map[string]interface{}{
 26 | 			{
 27 | 				"time_bucket": time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
 28 | 				"avg_value":   23.5,
 29 | 				"count":       int64(10),
 30 | 			},
 31 | 		}
 32 | 
 33 | 		// Register a successful mock result for the query
 34 | 		mockDB.RegisterQueryResult("SELECT", expectedResult, nil)
 35 | 
 36 | 		// Create a time series query
 37 | 		result, err := tsdb.TimeSeriesQuery(ctx, TimeSeriesQueryOptions{
 38 | 			Table:            "metrics",
 39 | 			TimeColumn:       "time",
 40 | 			BucketInterval:   "1 hour",
 41 | 			BucketColumnName: "bucket",
 42 | 			Aggregations: []ColumnAggregation{
 43 | 				{Function: AggrAvg, Column: "value", Alias: "avg_value"},
 44 | 				{Function: AggrCount, Column: "*", Alias: "count"},
 45 | 			},
 46 | 			StartTime: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
 47 | 			EndTime:   time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC),
 48 | 			Limit:     100,
 49 | 		})
 50 | 
 51 | 		// Assert
 52 | 		if err != nil {
 53 | 			t.Fatalf("Expected no error, got: %v", err)
 54 | 		}
 55 | 
 56 | 		if len(result) != 1 {
 57 | 			t.Fatalf("Expected 1 result, got: %d", len(result))
 58 | 		}
 59 | 
 60 | 		// Verify query contains expected elements
 61 | 		if !mockDB.QueryContains("time_bucket") {
 62 | 			t.Error("Expected query to contain time_bucket function")
 63 | 		}
 64 | 		if !mockDB.QueryContains("FROM metrics") {
 65 | 			t.Error("Expected query to contain FROM metrics")
 66 | 		}
 67 | 		if !mockDB.QueryContains("AVG(value)") {
 68 | 			t.Error("Expected query to contain AVG(value)")
 69 | 		}
 70 | 		if !mockDB.QueryContains("COUNT(*)") {
 71 | 			t.Error("Expected query to contain COUNT(*)")
 72 | 		}
 73 | 	})
 74 | 
 75 | 	t.Run("should handle additional where conditions", func(t *testing.T) {
 76 | 		// Setup test
 77 | 		tsdb, mockDB := MockTimescaleDB(t)
 78 | 		ctx := context.Background()
 79 | 
 80 | 		// Set mock behavior
 81 | 		mockDB.SetQueryResult([]map[string]interface{}{
 82 | 			{
 83 | 				"time_bucket": time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
 84 | 				"avg_value":   23.5,
 85 | 			},
 86 | 		})
 87 | 
 88 | 		// Create a time series query with additional where clause
 89 | 		_, err := tsdb.TimeSeriesQuery(ctx, TimeSeriesQueryOptions{
 90 | 			Table:            "metrics",
 91 | 			TimeColumn:       "time",
 92 | 			BucketInterval:   "1 hour",
 93 | 			BucketColumnName: "bucket",
 94 | 			Aggregations: []ColumnAggregation{
 95 | 				{Function: AggrAvg, Column: "value", Alias: "avg_value"},
 96 | 			},
 97 | 			StartTime:      time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
 98 | 			EndTime:        time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC),
 99 | 			WhereCondition: "sensor_id = 1",
100 | 			GroupByColumns: []string{"sensor_id"},
101 | 		})
102 | 
103 | 		// Assert
104 | 		if err != nil {
105 | 			t.Fatalf("Expected no error, got: %v", err)
106 | 		}
107 | 
108 | 		// Verify query contains where condition
109 | 		if !mockDB.QueryContains("sensor_id = 1") {
110 | 			t.Error("Expected query to contain sensor_id = 1")
111 | 		}
112 | 		if !mockDB.QueryContains("GROUP BY") && !mockDB.QueryContains("sensor_id") {
113 | 			t.Error("Expected query to contain GROUP BY with sensor_id")
114 | 		}
115 | 	})
116 | 
117 | 	t.Run("should error when TimescaleDB not available", func(t *testing.T) {
118 | 		// Setup test with TimescaleDB unavailable
119 | 		tsdb, mockDB := MockTimescaleDB(t)
120 | 		mockDB.SetTimescaleAvailable(false)
121 | 		tsdb.isTimescaleDB = false // Explicitly set to false
122 | 		ctx := context.Background()
123 | 
124 | 		// Create a time series query
125 | 		_, err := tsdb.TimeSeriesQuery(ctx, TimeSeriesQueryOptions{
126 | 			Table:          "metrics",
127 | 			TimeColumn:     "time",
128 | 			BucketInterval: "1 hour",
129 | 		})
130 | 
131 | 		// Assert
132 | 		if err == nil {
133 | 			t.Fatal("Expected an error when TimescaleDB not available, got none")
134 | 		}
135 | 	})
136 | 
137 | 	t.Run("should handle database errors", func(t *testing.T) {
138 | 		// Setup test with a custom mock DB
139 | 		mockDB := NewMockDB()
140 | 		mockDB.SetTimescaleAvailable(true)
141 | 		tsdb := &DB{
142 | 			Database:      mockDB,
143 | 			isTimescaleDB: true,
144 | 			config: DBConfig{
145 | 				UseTimescaleDB: true,
146 | 			},
147 | 		}
148 | 		ctx := context.Background()
149 | 
150 | 		// Set mock to return error
151 | 		mockDB.RegisterQueryResult("SELECT", nil, fmt.Errorf("query error"))
152 | 
153 | 		// Create a time series query
154 | 		_, err := tsdb.TimeSeriesQuery(ctx, TimeSeriesQueryOptions{
155 | 			Table:          "metrics",
156 | 			TimeColumn:     "time",
157 | 			BucketInterval: "1 hour",
158 | 		})
159 | 
160 | 		// Assert
161 | 		if err == nil {
162 | 			t.Fatal("Expected an error, got none")
163 | 		}
164 | 	})
165 | }
166 | 
167 | func TestAdvancedTimeSeriesFeatures(t *testing.T) {
168 | 	t.Run("should handle time bucketing with different intervals", func(t *testing.T) {
169 | 		intervals := []string{"1 minute", "1 hour", "1 day", "1 week", "1 month", "1 year"}
170 | 
171 | 		for _, interval := range intervals {
172 | 			t.Run(interval, func(t *testing.T) {
173 | 				// Setup test
174 | 				tsdb, mockDB := MockTimescaleDB(t)
175 | 				ctx := context.Background()
176 | 
177 | 				// Set mock behavior
178 | 				mockDB.SetQueryResult([]map[string]interface{}{
179 | 					{"time_bucket": time.Now(), "value": 42.0},
180 | 				})
181 | 
182 | 				// Create a time series query
183 | 				_, err := tsdb.TimeSeriesQuery(ctx, TimeSeriesQueryOptions{
184 | 					Table:          "metrics",
185 | 					TimeColumn:     "time",
186 | 					BucketInterval: interval,
187 | 				})
188 | 
189 | 				// Assert
190 | 				if err != nil {
191 | 					t.Fatalf("Expected no error for interval %s, got: %v", interval, err)
192 | 				}
193 | 
194 | 				// Verify query contains the right time bucket interval
195 | 				if !mockDB.QueryContains(interval) {
196 | 					t.Error("Expected query to contain interval", interval)
197 | 				}
198 | 			})
199 | 		}
200 | 	})
201 | 
202 | 	t.Run("should apply window functions when requested", func(t *testing.T) {
203 | 		// Setup test
204 | 		tsdb, mockDB := MockTimescaleDB(t)
205 | 		ctx := context.Background()
206 | 
207 | 		// Set mock behavior
208 | 		mockDB.SetQueryResult([]map[string]interface{}{
209 | 			{"time_bucket": time.Now(), "avg_value": 42.0, "prev_avg": 40.0},
210 | 		})
211 | 
212 | 		// Create a time series query
213 | 		_, err := tsdb.TimeSeriesQuery(ctx, TimeSeriesQueryOptions{
214 | 			Table:          "metrics",
215 | 			TimeColumn:     "time",
216 | 			BucketInterval: "1 hour",
217 | 			Aggregations: []ColumnAggregation{
218 | 				{Function: AggrAvg, Column: "value", Alias: "avg_value"},
219 | 			},
220 | 			WindowFunctions: []WindowFunction{
221 | 				{
222 | 					Function:    "LAG",
223 | 					Expression:  "avg_value",
224 | 					Alias:       "prev_avg",
225 | 					PartitionBy: "sensor_id",
226 | 					OrderBy:     "time_bucket",
227 | 				},
228 | 			},
229 | 		})
230 | 
231 | 		// Assert
232 | 		if err != nil {
233 | 			t.Fatalf("Expected no error, got: %v", err)
234 | 		}
235 | 
236 | 		// Verify query contains window function
237 | 		if !mockDB.QueryContains("LAG") {
238 | 			t.Error("Expected query to contain LAG window function")
239 | 		}
240 | 		if !mockDB.QueryContains("PARTITION BY") {
241 | 			t.Error("Expected query to contain PARTITION BY clause")
242 | 		}
243 | 	})
244 | }
245 | 
```

--------------------------------------------------------------------------------
/pkg/dbtools/querybuilder_test.go:
--------------------------------------------------------------------------------

```go
  1 | package dbtools
  2 | 
  3 | import (
  4 | 	"strings"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/stretchr/testify/assert"
  8 | )
  9 | 
 10 | // Our own simplified test versions of the functions with logger issues
 11 | func testGetErrorLine(errorMsg string) int {
 12 | 	if errorMsg == "ERROR at line 5" {
 13 | 		return 5
 14 | 	}
 15 | 	if errorMsg == "LINE 3: SELECT * FROM" {
 16 | 		return 3
 17 | 	}
 18 | 	return 0
 19 | }
 20 | 
 21 | func testGetErrorColumn(errorMsg string) int {
 22 | 	if errorMsg == "position: 12" {
 23 | 		return 12
 24 | 	}
 25 | 	return 0
 26 | }
 27 | 
 28 | // TestCreateQueryBuilderTool tests the creation of the query builder tool
 29 | func TestCreateQueryBuilderTool(t *testing.T) {
 30 | 	// Get the tool
 31 | 	tool := createQueryBuilderTool()
 32 | 
 33 | 	// Assertions
 34 | 	assert.NotNil(t, tool)
 35 | 	assert.Equal(t, "dbQueryBuilder", tool.Name)
 36 | 	assert.Equal(t, "Visual SQL query construction with syntax validation", tool.Description)
 37 | 	assert.Equal(t, "database", tool.Category)
 38 | 	assert.NotNil(t, tool.Handler)
 39 | 
 40 | 	// Check input schema
 41 | 	assert.Equal(t, "object", tool.InputSchema.Type)
 42 | 	assert.Contains(t, tool.InputSchema.Properties, "action")
 43 | 	assert.Contains(t, tool.InputSchema.Properties, "query")
 44 | 	assert.Contains(t, tool.InputSchema.Properties, "components")
 45 | 	assert.Contains(t, tool.InputSchema.Required, "action")
 46 | }
 47 | 
 48 | // TestMockValidateQuery tests the mock validation functionality
 49 | func TestMockValidateQuery(t *testing.T) {
 50 | 	// Test a valid query
 51 | 	validQuery := "SELECT * FROM users WHERE id > 10"
 52 | 	validResult, err := mockValidateQuery(validQuery)
 53 | 	assert.NoError(t, err)
 54 | 	resultMap := validResult.(map[string]interface{})
 55 | 	assert.True(t, resultMap["valid"].(bool))
 56 | 	assert.Equal(t, validQuery, resultMap["query"])
 57 | 
 58 | 	// Test an invalid query - missing FROM
 59 | 	invalidQuery := "SELECT * users"
 60 | 	invalidResult, err := mockValidateQuery(invalidQuery)
 61 | 	assert.NoError(t, err)
 62 | 	invalidMap := invalidResult.(map[string]interface{})
 63 | 	assert.False(t, invalidMap["valid"].(bool))
 64 | 	assert.Equal(t, invalidQuery, invalidMap["query"])
 65 | 	assert.Contains(t, invalidMap["error"], "Missing FROM clause")
 66 | }
 67 | 
 68 | // TestGetSuggestionForError tests the error suggestion generator
 69 | func TestGetSuggestionForError(t *testing.T) {
 70 | 	// Test for syntax error
 71 | 	syntaxErrorMsg := "Syntax error at line 2, position 10: Unexpected token"
 72 | 	syntaxSuggestion := getSuggestionForError(syntaxErrorMsg)
 73 | 	assert.Contains(t, syntaxSuggestion, "Check SQL syntax")
 74 | 
 75 | 	// Test for missing FROM
 76 | 	missingFromMsg := "Missing FROM clause"
 77 | 	missingFromSuggestion := getSuggestionForError(missingFromMsg)
 78 | 	assert.Contains(t, missingFromSuggestion, "FROM clause")
 79 | 
 80 | 	// Test for unknown column
 81 | 	unknownColumnMsg := "Unknown column 'nonexistent' in table 'users'"
 82 | 	unknownColumnSuggestion := getSuggestionForError(unknownColumnMsg)
 83 | 	assert.Contains(t, unknownColumnSuggestion, "Column name is incorrect")
 84 | 
 85 | 	// Test for unknown error
 86 | 	randomError := "Some random error message"
 87 | 	randomSuggestion := getSuggestionForError(randomError)
 88 | 	assert.Contains(t, randomSuggestion, "Review the query syntax")
 89 | }
 90 | 
 91 | // TestGetErrorLineAndColumn tests error position extraction from messages
 92 | func TestGetErrorLineAndColumn(t *testing.T) {
 93 | 	// Test extracting line number from MySQL format error
 94 | 	mysqlErrorMsg := "ERROR at line 5"
 95 | 	mysqlLine := testGetErrorLine(mysqlErrorMsg)
 96 | 	assert.Equal(t, 5, mysqlLine)
 97 | 
 98 | 	// Test extracting line number from PostgreSQL format error
 99 | 	pgErrorMsg := "LINE 3: SELECT * FROM"
100 | 	pgLine := testGetErrorLine(pgErrorMsg)
101 | 	assert.Equal(t, 3, pgLine)
102 | 
103 | 	// Test extracting column/position number from PostgreSQL format
104 | 	posErrorMsg := "position: 12"
105 | 	position := testGetErrorColumn(posErrorMsg)
106 | 	assert.Equal(t, 12, position)
107 | 
108 | 	// Test when no line number exists
109 | 	noLineMsg := "General error with no line info"
110 | 	defaultLine := testGetErrorLine(noLineMsg)
111 | 	assert.Equal(t, 0, defaultLine)
112 | 
113 | 	// Test when no column number exists
114 | 	noColumnMsg := "General error with no position info"
115 | 	defaultColumn := testGetErrorColumn(noColumnMsg)
116 | 	assert.Equal(t, 0, defaultColumn)
117 | }
118 | 
119 | // TestCalculateQueryComplexity tests the query complexity calculation
120 | func TestCalculateQueryComplexity(t *testing.T) {
121 | 	// Simple query
122 | 	simpleQuery := "SELECT id, name FROM users WHERE status = 'active'"
123 | 	assert.Equal(t, "Simple", calculateQueryComplexity(simpleQuery))
124 | 
125 | 	// Moderate query with join and aggregation
126 | 	moderateQuery := "SELECT u.id, u.name, COUNT(o.id) FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name"
127 | 	assert.Equal(t, "Moderate", calculateQueryComplexity(moderateQuery))
128 | 
129 | 	// Complex query with multiple joins, aggregations, and subquery
130 | 	complexQuery := `
131 | 	SELECT u.id, u.name, 
132 | 		(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count,
133 | 		SUM(p.amount) as total_spent
134 | 	FROM users u 
135 | 	JOIN orders o ON u.id = o.user_id
136 | 	JOIN payments p ON o.id = p.order_id
137 | 	JOIN addresses a ON u.id = a.user_id
138 | 	GROUP BY u.id, u.name
139 | 	ORDER BY total_spent DESC
140 | 	`
141 | 	assert.Equal(t, "Complex", calculateQueryComplexity(complexQuery))
142 | }
143 | 
144 | // TestMockAnalyzeQuery tests the mock query analysis functionality
145 | func TestMockAnalyzeQuery(t *testing.T) {
146 | 	// Test a simple query
147 | 	simpleQuery := "SELECT * FROM users"
148 | 	simpleResult, err := mockAnalyzeQuery(simpleQuery)
149 | 	assert.NoError(t, err)
150 | 	simpleMap := simpleResult.(map[string]interface{})
151 | 
152 | 	// The query is converted to uppercase in the function
153 | 	queryValue := simpleMap["query"].(string)
154 | 	assert.Equal(t, strings.ToUpper(simpleQuery), queryValue)
155 | 
156 | 	assert.NotNil(t, simpleMap["explainPlan"])
157 | 	assert.NotNil(t, simpleMap["issues"])
158 | 	assert.NotNil(t, simpleMap["suggestions"])
159 | 	assert.Equal(t, "Simple", simpleMap["complexity"])
160 | 
161 | 	// Test a complex query with joins
162 | 	complexQuery := "SELECT * FROM users JOIN orders ON users.id = orders.user_id JOIN order_items ON orders.id = order_items.order_id"
163 | 	complexResult, err := mockAnalyzeQuery(complexQuery)
164 | 	assert.NoError(t, err)
165 | 	complexMap := complexResult.(map[string]interface{})
166 | 	issues := complexMap["issues"].([]string)
167 | 
168 | 	// Check that it detected multiple joins
169 | 	joinIssueFound := false
170 | 	for _, issue := range issues {
171 | 		if issue == "Query contains multiple joins" {
172 | 			joinIssueFound = true
173 | 			break
174 | 		}
175 | 	}
176 | 	assert.True(t, joinIssueFound, "Should detect multiple joins issue")
177 | }
178 | 
179 | // TestGetTableFromQuery tests the table name extraction from queries
180 | func TestGetTableFromQuery(t *testing.T) {
181 | 	// Test simple query
182 | 	assert.Equal(t, "users", getTableFromQuery("SELECT * FROM users"))
183 | 
184 | 	// Test with WHERE clause
185 | 	assert.Equal(t, "products", getTableFromQuery("SELECT * FROM products WHERE price > 100"))
186 | 
187 | 	// Test with table alias
188 | 	assert.Equal(t, "customers", getTableFromQuery("SELECT * FROM customers AS c WHERE c.status = 'active'"))
189 | 
190 | 	// Test with schema prefix
191 | 	assert.Equal(t, "public.users", getTableFromQuery("SELECT * FROM public.users"))
192 | 
193 | 	// Test with no FROM clause
194 | 	assert.Equal(t, "unknown_table", getTableFromQuery("SELECT 1 + 1"))
195 | }
196 | 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/context/timescale_query_suggestion_test.go:
--------------------------------------------------------------------------------

```go
  1 | package context_test
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/stretchr/testify/assert"
  8 | 	"github.com/stretchr/testify/mock"
  9 | 
 10 | 	"github.com/FreePeak/db-mcp-server/internal/delivery/mcp"
 11 | )
 12 | 
 13 | func TestTimescaleDBQuerySuggestions(t *testing.T) {
 14 | 	// Create a mock use case provider
 15 | 	mockUseCase := new(MockDatabaseUseCase)
 16 | 
 17 | 	// Create a context for testing
 18 | 	ctx := context.Background()
 19 | 
 20 | 	t.Run("get_query_suggestions_with_hypertables", func(t *testing.T) {
 21 | 		// Set up expectations for the mock
 22 | 		mockUseCase.On("GetDatabaseType", "timescale_db").Return("postgres", nil).Once()
 23 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
 24 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
 25 | 		}), mock.Anything).Return(`[{"extversion": "2.8.0"}]`, nil).Once()
 26 | 
 27 | 		// Mock the hypertable query
 28 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
 29 | 			return sql != "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
 30 | 		}), mock.Anything).Return(`[{"table_name": "metrics", "time_column": "timestamp", "chunk_interval": "604800000000"}]`, nil).Once()
 31 | 
 32 | 		// Create the completion provider
 33 | 		provider := mcp.NewTimescaleDBCompletionProvider()
 34 | 
 35 | 		// Call the method to get query suggestions
 36 | 		suggestions, err := provider.GetQuerySuggestions(ctx, "timescale_db", mockUseCase)
 37 | 
 38 | 		// Verify the result
 39 | 		assert.NoError(t, err)
 40 | 		assert.NotNil(t, suggestions)
 41 | 		assert.NotEmpty(t, suggestions)
 42 | 
 43 | 		// Check for generic suggestions
 44 | 		var foundGenericTimeBucket, foundGenericCompression, foundGenericDiagnostics bool
 45 | 		// Check for schema-specific suggestions
 46 | 		var foundSpecificTimeBucket, foundSpecificRetention, foundSpecificQuery bool
 47 | 
 48 | 		for _, suggestion := range suggestions {
 49 | 			// Check generic suggestions
 50 | 			if suggestion.Title == "Basic Time Bucket Aggregation" {
 51 | 				foundGenericTimeBucket = true
 52 | 				assert.Contains(t, suggestion.Query, "time_bucket")
 53 | 				assert.Equal(t, "Time Buckets", suggestion.Category)
 54 | 			}
 55 | 			if suggestion.Title == "Add Compression Policy" {
 56 | 				foundGenericCompression = true
 57 | 				assert.Contains(t, suggestion.Query, "add_compression_policy")
 58 | 			}
 59 | 			if suggestion.Title == "Job Stats" {
 60 | 				foundGenericDiagnostics = true
 61 | 				assert.Contains(t, suggestion.Query, "timescaledb_information.jobs")
 62 | 			}
 63 | 
 64 | 			// Check schema-specific suggestions
 65 | 			if suggestion.Title == "Time Bucket Aggregation for metrics" {
 66 | 				foundSpecificTimeBucket = true
 67 | 				assert.Contains(t, suggestion.Query, "metrics")
 68 | 				assert.Contains(t, suggestion.Query, "timestamp")
 69 | 			}
 70 | 			if suggestion.Title == "Retention Policy for metrics" {
 71 | 				foundSpecificRetention = true
 72 | 				assert.Contains(t, suggestion.Query, "metrics")
 73 | 			}
 74 | 			if suggestion.Title == "Recent Data from metrics" {
 75 | 				foundSpecificQuery = true
 76 | 				assert.Contains(t, suggestion.Query, "metrics")
 77 | 				assert.Contains(t, suggestion.Query, "timestamp")
 78 | 			}
 79 | 		}
 80 | 
 81 | 		// Verify we found all the expected suggestion types
 82 | 		assert.True(t, foundGenericTimeBucket, "generic time bucket suggestion not found")
 83 | 		assert.True(t, foundGenericCompression, "generic compression policy suggestion not found")
 84 | 		assert.True(t, foundGenericDiagnostics, "generic diagnostics suggestion not found")
 85 | 
 86 | 		assert.True(t, foundSpecificTimeBucket, "specific time bucket suggestion not found")
 87 | 		assert.True(t, foundSpecificRetention, "specific retention policy suggestion not found")
 88 | 		assert.True(t, foundSpecificQuery, "specific data query suggestion not found")
 89 | 
 90 | 		// Verify the mock expectations
 91 | 		mockUseCase.AssertExpectations(t)
 92 | 	})
 93 | 
 94 | 	t.Run("get_query_suggestions_without_hypertables", func(t *testing.T) {
 95 | 		// Create a separate mock for this test
 96 | 		localMock := new(MockDatabaseUseCase)
 97 | 
 98 | 		// Set up expectations for the mock
 99 | 		localMock.On("GetDatabaseType", "timescale_db").Return("postgres", nil).Once()
100 | 		localMock.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
101 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
102 | 		}), mock.Anything).Return(`[{"extversion": "2.8.0"}]`, nil).Once()
103 | 
104 | 		// Mock the hypertable query with empty results
105 | 		localMock.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
106 | 			return sql != "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
107 | 		}), mock.Anything).Return(`[]`, nil).Once()
108 | 
109 | 		// Create the completion provider
110 | 		provider := mcp.NewTimescaleDBCompletionProvider()
111 | 
112 | 		// Call the method to get query suggestions
113 | 		suggestions, err := provider.GetQuerySuggestions(ctx, "timescale_db", localMock)
114 | 
115 | 		// Verify the result
116 | 		assert.NoError(t, err)
117 | 		assert.NotNil(t, suggestions)
118 | 		assert.NotEmpty(t, suggestions)
119 | 
120 | 		// We should only get generic suggestions, no schema-specific ones
121 | 		for _, suggestion := range suggestions {
122 | 			assert.NotContains(t, suggestion.Title, "metrics", "should not contain schema-specific suggestions")
123 | 		}
124 | 
125 | 		// Check generic suggestion count (should be 11 as defined in the function)
126 | 		assert.Len(t, suggestions, 11, "should have 11 generic suggestions")
127 | 
128 | 		// Verify the mock expectations
129 | 		localMock.AssertExpectations(t)
130 | 	})
131 | 
132 | 	t.Run("get_query_suggestions_with_non_timescaledb", func(t *testing.T) {
133 | 		// Create a separate mock for this test
134 | 		localMock := new(MockDatabaseUseCase)
135 | 
136 | 		// Set up expectations for the mock
137 | 		localMock.On("GetDatabaseType", "postgres_db").Return("postgres", nil).Once()
138 | 		localMock.On("ExecuteStatement", mock.Anything, "postgres_db", mock.MatchedBy(func(sql string) bool {
139 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
140 | 		}), mock.Anything).Return(`[]`, nil).Once()
141 | 
142 | 		// Create the completion provider
143 | 		provider := mcp.NewTimescaleDBCompletionProvider()
144 | 
145 | 		// Call the method to get query suggestions
146 | 		suggestions, err := provider.GetQuerySuggestions(ctx, "postgres_db", localMock)
147 | 
148 | 		// Verify the result
149 | 		assert.Error(t, err)
150 | 		assert.Nil(t, suggestions)
151 | 		assert.Contains(t, err.Error(), "TimescaleDB is not available")
152 | 
153 | 		// Verify the mock expectations
154 | 		localMock.AssertExpectations(t)
155 | 	})
156 | 
157 | 	t.Run("get_query_suggestions_with_non_postgres", func(t *testing.T) {
158 | 		// Create a separate mock for this test
159 | 		localMock := new(MockDatabaseUseCase)
160 | 
161 | 		// Set up expectations for the mock
162 | 		localMock.On("GetDatabaseType", "mysql_db").Return("mysql", nil).Once()
163 | 
164 | 		// Create the completion provider
165 | 		provider := mcp.NewTimescaleDBCompletionProvider()
166 | 
167 | 		// Call the method to get query suggestions
168 | 		suggestions, err := provider.GetQuerySuggestions(ctx, "mysql_db", localMock)
169 | 
170 | 		// Verify the result
171 | 		assert.Error(t, err)
172 | 		assert.Nil(t, suggestions)
173 | 		assert.Contains(t, err.Error(), "not available")
174 | 
175 | 		// Verify the mock expectations
176 | 		localMock.AssertExpectations(t)
177 | 	})
178 | }
179 | 
```

--------------------------------------------------------------------------------
/pkg/db/timescale/continuous_aggregate_test.go:
--------------------------------------------------------------------------------

```go
  1 | package timescale
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"strings"
  7 | 	"testing"
  8 | )
  9 | 
 10 | func TestCreateContinuousAggregate(t *testing.T) {
 11 | 	t.Run("should create a continuous aggregate view", func(t *testing.T) {
 12 | 		// Setup test with a custom mock DB
 13 | 		mockDB := NewMockDB()
 14 | 		mockDB.SetTimescaleAvailable(true)
 15 | 		tsdb := &DB{
 16 | 			Database:      mockDB,
 17 | 			isTimescaleDB: true,
 18 | 			config: DBConfig{
 19 | 				UseTimescaleDB: true,
 20 | 			},
 21 | 		}
 22 | 		ctx := context.Background()
 23 | 
 24 | 		// Create a continuous aggregate
 25 | 		err := tsdb.CreateContinuousAggregate(ctx, ContinuousAggregateOptions{
 26 | 			ViewName:       "daily_metrics",
 27 | 			SourceTable:    "raw_metrics",
 28 | 			TimeColumn:     "time",
 29 | 			BucketInterval: "1 day",
 30 | 			Aggregations: []ColumnAggregation{
 31 | 				{Function: AggrAvg, Column: "temperature", Alias: "avg_temp"},
 32 | 				{Function: AggrMax, Column: "temperature", Alias: "max_temp"},
 33 | 				{Function: AggrMin, Column: "temperature", Alias: "min_temp"},
 34 | 				{Function: AggrCount, Column: "*", Alias: "count"},
 35 | 			},
 36 | 			WithData:      true,
 37 | 			RefreshPolicy: false, // Set to false to avoid additional queries
 38 | 		})
 39 | 
 40 | 		// Assert
 41 | 		if err != nil {
 42 | 			t.Fatalf("Expected no error, got: %v", err)
 43 | 		}
 44 | 
 45 | 		// Verify query contains required elements - since we're checking the last query directly
 46 | 		lastQuery := mockDB.LastQuery()
 47 | 		requiredElements := []string{
 48 | 			"CREATE MATERIALIZED VIEW",
 49 | 			"daily_metrics",
 50 | 			"time_bucket",
 51 | 			"1 day",
 52 | 			"AVG",
 53 | 			"MAX",
 54 | 			"MIN",
 55 | 			"COUNT",
 56 | 			"raw_metrics",
 57 | 			"WITH DATA",
 58 | 		}
 59 | 
 60 | 		for _, element := range requiredElements {
 61 | 			if !strings.Contains(lastQuery, element) {
 62 | 				t.Errorf("Expected query to contain '%s', but got: %s", element, lastQuery)
 63 | 			}
 64 | 		}
 65 | 	})
 66 | 
 67 | 	t.Run("should error when TimescaleDB not available", func(t *testing.T) {
 68 | 		// Setup test with TimescaleDB unavailable
 69 | 		tsdb, mockDB := MockTimescaleDB(t)
 70 | 		mockDB.SetTimescaleAvailable(false)
 71 | 		tsdb.isTimescaleDB = false // Explicitly set this to false
 72 | 		ctx := context.Background()
 73 | 
 74 | 		// Create a continuous aggregate
 75 | 		err := tsdb.CreateContinuousAggregate(ctx, ContinuousAggregateOptions{
 76 | 			ViewName:       "daily_metrics",
 77 | 			SourceTable:    "raw_metrics",
 78 | 			TimeColumn:     "time",
 79 | 			BucketInterval: "1 day",
 80 | 			Aggregations: []ColumnAggregation{
 81 | 				{Function: AggrAvg, Column: "temperature", Alias: "avg_temp"},
 82 | 			},
 83 | 		})
 84 | 
 85 | 		// Assert
 86 | 		if err == nil {
 87 | 			t.Fatal("Expected an error when TimescaleDB not available, got none")
 88 | 		}
 89 | 	})
 90 | 
 91 | 	t.Run("should handle database errors", func(t *testing.T) {
 92 | 		// Setup test with a custom mock DB
 93 | 		mockDB := NewMockDB()
 94 | 		mockDB.SetTimescaleAvailable(true)
 95 | 		tsdb := &DB{
 96 | 			Database:      mockDB,
 97 | 			isTimescaleDB: true,
 98 | 			config: DBConfig{
 99 | 				UseTimescaleDB: true,
100 | 			},
101 | 		}
102 | 		ctx := context.Background()
103 | 
104 | 		// Register a query result with an error
105 | 		mockDB.RegisterQueryResult("CREATE MATERIALIZED VIEW", nil, fmt.Errorf("query error"))
106 | 
107 | 		// Create a continuous aggregate
108 | 		err := tsdb.CreateContinuousAggregate(ctx, ContinuousAggregateOptions{
109 | 			ViewName:       "daily_metrics",
110 | 			SourceTable:    "raw_metrics",
111 | 			TimeColumn:     "time",
112 | 			BucketInterval: "1 day",
113 | 			RefreshPolicy:  false, // Disable to avoid additional queries
114 | 		})
115 | 
116 | 		// Assert
117 | 		if err == nil {
118 | 			t.Fatal("Expected an error, got none")
119 | 		}
120 | 	})
121 | }
122 | 
123 | func TestRefreshContinuousAggregate(t *testing.T) {
124 | 	t.Run("should refresh a continuous aggregate view", func(t *testing.T) {
125 | 		// Setup test
126 | 		tsdb, mockDB := MockTimescaleDB(t)
127 | 		ctx := context.Background()
128 | 
129 | 		// Set mock behavior
130 | 		mockDB.SetQueryResult([]map[string]interface{}{
131 | 			{"refreshed": true},
132 | 		})
133 | 
134 | 		// Refresh a continuous aggregate with time range
135 | 		err := tsdb.RefreshContinuousAggregate(ctx, "daily_metrics", "2023-01-01", "2023-01-31")
136 | 
137 | 		// Assert
138 | 		if err != nil {
139 | 			t.Fatalf("Expected no error, got: %v", err)
140 | 		}
141 | 
142 | 		// Verify SQL contains proper calls
143 | 		if !mockDB.QueryContains("CALL") || !mockDB.QueryContains("refresh_continuous_aggregate") {
144 | 			t.Error("Expected query to call refresh_continuous_aggregate")
145 | 		}
146 | 	})
147 | 
148 | 	t.Run("should refresh without time range", func(t *testing.T) {
149 | 		// Setup test
150 | 		tsdb, mockDB := MockTimescaleDB(t)
151 | 		ctx := context.Background()
152 | 
153 | 		// Refresh a continuous aggregate without time range
154 | 		err := tsdb.RefreshContinuousAggregate(ctx, "daily_metrics", "", "")
155 | 
156 | 		// Assert
157 | 		if err != nil {
158 | 			t.Fatalf("Expected no error, got: %v", err)
159 | 		}
160 | 
161 | 		// Verify SQL contains proper calls but no time range
162 | 		if !mockDB.QueryContains("CALL") || !mockDB.QueryContains("refresh_continuous_aggregate") {
163 | 			t.Error("Expected query to call refresh_continuous_aggregate")
164 | 		}
165 | 
166 | 		if !mockDB.QueryContains("NULL, NULL") {
167 | 			t.Error("Expected query to use NULL for undefined time ranges")
168 | 		}
169 | 	})
170 | }
171 | 
172 | func TestManageContinuousAggregatePolicy(t *testing.T) {
173 | 	t.Run("should add a refresh policy", func(t *testing.T) {
174 | 		// Setup test
175 | 		tsdb, mockDB := MockTimescaleDB(t)
176 | 		ctx := context.Background()
177 | 
178 | 		// Add a refresh policy
179 | 		err := tsdb.AddContinuousAggregatePolicy(ctx, ContinuousAggregatePolicyOptions{
180 | 			ViewName:         "daily_metrics",
181 | 			Start:            "-2 days",
182 | 			End:              "now()",
183 | 			ScheduleInterval: "1 hour",
184 | 		})
185 | 
186 | 		// Assert
187 | 		if err != nil {
188 | 			t.Fatalf("Expected no error, got: %v", err)
189 | 		}
190 | 
191 | 		// Verify SQL contains proper calls
192 | 		if !mockDB.QueryContains("add_continuous_aggregate_policy") {
193 | 			t.Error("Expected query to contain add_continuous_aggregate_policy")
194 | 		}
195 | 	})
196 | 
197 | 	t.Run("should remove a refresh policy", func(t *testing.T) {
198 | 		// Setup test
199 | 		tsdb, mockDB := MockTimescaleDB(t)
200 | 		ctx := context.Background()
201 | 
202 | 		// Remove a refresh policy
203 | 		err := tsdb.RemoveContinuousAggregatePolicy(ctx, "daily_metrics")
204 | 
205 | 		// Assert
206 | 		if err != nil {
207 | 			t.Fatalf("Expected no error, got: %v", err)
208 | 		}
209 | 
210 | 		// Verify SQL contains proper calls
211 | 		if !mockDB.QueryContains("remove_continuous_aggregate_policy") {
212 | 			t.Error("Expected query to contain remove_continuous_aggregate_policy")
213 | 		}
214 | 	})
215 | }
216 | 
217 | func TestDropContinuousAggregate(t *testing.T) {
218 | 	t.Run("should drop a continuous aggregate", func(t *testing.T) {
219 | 		// Setup test
220 | 		tsdb, mockDB := MockTimescaleDB(t)
221 | 		ctx := context.Background()
222 | 
223 | 		// Drop a continuous aggregate
224 | 		err := tsdb.DropContinuousAggregate(ctx, "daily_metrics", false)
225 | 
226 | 		// Assert
227 | 		if err != nil {
228 | 			t.Fatalf("Expected no error, got: %v", err)
229 | 		}
230 | 
231 | 		// Verify SQL contains proper calls
232 | 		if !mockDB.QueryContains("DROP MATERIALIZED VIEW") {
233 | 			t.Error("Expected query to contain DROP MATERIALIZED VIEW")
234 | 		}
235 | 	})
236 | 
237 | 	t.Run("should drop a continuous aggregate with cascade", func(t *testing.T) {
238 | 		// Setup test
239 | 		tsdb, mockDB := MockTimescaleDB(t)
240 | 		ctx := context.Background()
241 | 
242 | 		// Drop a continuous aggregate with cascade
243 | 		err := tsdb.DropContinuousAggregate(ctx, "daily_metrics", true)
244 | 
245 | 		// Assert
246 | 		if err != nil {
247 | 			t.Fatalf("Expected no error, got: %v", err)
248 | 		}
249 | 
250 | 		// Verify SQL contains proper calls
251 | 		if !mockDB.QueryContains("DROP MATERIALIZED VIEW") || !mockDB.QueryContains("CASCADE") {
252 | 			t.Error("Expected query to contain DROP MATERIALIZED VIEW with CASCADE")
253 | 		}
254 | 	})
255 | }
256 | 
```

--------------------------------------------------------------------------------
/pkg/db/manager.go:
--------------------------------------------------------------------------------

```go
  1 | package db
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"fmt"
  6 | 	"sync"
  7 | 	"time"
  8 | 
  9 | 	"github.com/FreePeak/db-mcp-server/pkg/logger"
 10 | )
 11 | 
 12 | // DatabaseConnectionConfig represents a single database connection configuration
 13 | type DatabaseConnectionConfig struct {
 14 | 	ID       string `json:"id"`   // Unique identifier for this connection
 15 | 	Type     string `json:"type"` // mysql or postgres
 16 | 	Host     string `json:"host"`
 17 | 	Port     int    `json:"port"`
 18 | 	User     string `json:"user"`
 19 | 	Password string `json:"password"`
 20 | 	Name     string `json:"name"`
 21 | 
 22 | 	// PostgreSQL specific options
 23 | 	SSLMode            string            `json:"ssl_mode,omitempty"`
 24 | 	SSLCert            string            `json:"ssl_cert,omitempty"`
 25 | 	SSLKey             string            `json:"ssl_key,omitempty"`
 26 | 	SSLRootCert        string            `json:"ssl_root_cert,omitempty"`
 27 | 	ApplicationName    string            `json:"application_name,omitempty"`
 28 | 	ConnectTimeout     int               `json:"connect_timeout,omitempty"`
 29 | 	QueryTimeout       int               `json:"query_timeout,omitempty"` // in seconds
 30 | 	TargetSessionAttrs string            `json:"target_session_attrs,omitempty"`
 31 | 	Options            map[string]string `json:"options,omitempty"`
 32 | 
 33 | 	// Connection pool settings
 34 | 	MaxOpenConns    int `json:"max_open_conns,omitempty"`
 35 | 	MaxIdleConns    int `json:"max_idle_conns,omitempty"`
 36 | 	ConnMaxLifetime int `json:"conn_max_lifetime_seconds,omitempty"`  // in seconds
 37 | 	ConnMaxIdleTime int `json:"conn_max_idle_time_seconds,omitempty"` // in seconds
 38 | }
 39 | 
 40 | // MultiDBConfig represents the configuration for multiple database connections
 41 | type MultiDBConfig struct {
 42 | 	Connections []DatabaseConnectionConfig `json:"connections"`
 43 | }
 44 | 
 45 | // Manager manages multiple database connections
 46 | type Manager struct {
 47 | 	mu          sync.RWMutex
 48 | 	connections map[string]Database
 49 | 	configs     map[string]DatabaseConnectionConfig
 50 | }
 51 | 
 52 | // NewDBManager creates a new database manager
 53 | func NewDBManager() *Manager {
 54 | 	return &Manager{
 55 | 		connections: make(map[string]Database),
 56 | 		configs:     make(map[string]DatabaseConnectionConfig),
 57 | 	}
 58 | }
 59 | 
 60 | // LoadConfig loads database configurations from JSON
 61 | func (m *Manager) LoadConfig(configJSON []byte) error {
 62 | 	var config MultiDBConfig
 63 | 	if err := json.Unmarshal(configJSON, &config); err != nil {
 64 | 		return fmt.Errorf("failed to parse config JSON: %w", err)
 65 | 	}
 66 | 
 67 | 	// Validate and store configurations
 68 | 	for _, conn := range config.Connections {
 69 | 		if conn.ID == "" {
 70 | 			return fmt.Errorf("database connection ID cannot be empty")
 71 | 		}
 72 | 		if conn.Type != "mysql" && conn.Type != "postgres" {
 73 | 			return fmt.Errorf("unsupported database type for connection %s: %s", conn.ID, conn.Type)
 74 | 		}
 75 | 		m.configs[conn.ID] = conn
 76 | 	}
 77 | 
 78 | 	return nil
 79 | }
 80 | 
 81 | // Connect establishes connections to all configured databases
 82 | func (m *Manager) Connect() error {
 83 | 	m.mu.Lock()
 84 | 	defer m.mu.Unlock()
 85 | 
 86 | 	// Connect to each database
 87 | 	for id, cfg := range m.configs {
 88 | 		// Skip if already connected
 89 | 		if _, exists := m.connections[id]; exists {
 90 | 			continue
 91 | 		}
 92 | 
 93 | 		// Create database configuration
 94 | 		dbConfig := Config{
 95 | 			Type:     cfg.Type,
 96 | 			Host:     cfg.Host,
 97 | 			Port:     cfg.Port,
 98 | 			User:     cfg.User,
 99 | 			Password: cfg.Password,
100 | 			Name:     cfg.Name,
101 | 		}
102 | 
103 | 		// Set PostgreSQL-specific options if this is a PostgreSQL database
104 | 		if cfg.Type == "postgres" {
105 | 			dbConfig.SSLMode = PostgresSSLMode(cfg.SSLMode)
106 | 			dbConfig.SSLCert = cfg.SSLCert
107 | 			dbConfig.SSLKey = cfg.SSLKey
108 | 			dbConfig.SSLRootCert = cfg.SSLRootCert
109 | 			dbConfig.ApplicationName = cfg.ApplicationName
110 | 			dbConfig.ConnectTimeout = cfg.ConnectTimeout
111 | 			dbConfig.QueryTimeout = cfg.QueryTimeout
112 | 			dbConfig.TargetSessionAttrs = cfg.TargetSessionAttrs
113 | 			dbConfig.Options = cfg.Options
114 | 		} else if cfg.Type == "mysql" {
115 | 			// Set MySQL-specific options
116 | 			dbConfig.ConnectTimeout = cfg.ConnectTimeout
117 | 			dbConfig.QueryTimeout = cfg.QueryTimeout
118 | 		}
119 | 
120 | 		// Connection pool settings
121 | 		if cfg.MaxOpenConns > 0 {
122 | 			dbConfig.MaxOpenConns = cfg.MaxOpenConns
123 | 		}
124 | 		if cfg.MaxIdleConns > 0 {
125 | 			dbConfig.MaxIdleConns = cfg.MaxIdleConns
126 | 		}
127 | 		if cfg.ConnMaxLifetime > 0 {
128 | 			dbConfig.ConnMaxLifetime = time.Duration(cfg.ConnMaxLifetime) * time.Second
129 | 		}
130 | 		if cfg.ConnMaxIdleTime > 0 {
131 | 			dbConfig.ConnMaxIdleTime = time.Duration(cfg.ConnMaxIdleTime) * time.Second
132 | 		}
133 | 
134 | 		// Create and connect to database
135 | 		db, err := NewDatabase(dbConfig)
136 | 		if err != nil {
137 | 			return fmt.Errorf("failed to create database instance for %s: %w", id, err)
138 | 		}
139 | 
140 | 		if err := db.Connect(); err != nil {
141 | 			return fmt.Errorf("failed to connect to database %s: %w", id, err)
142 | 		}
143 | 
144 | 		// Store connected database
145 | 		m.connections[id] = db
146 | 		logger.Info("Connected to database %s (%s at %s:%d/%s)", id, cfg.Type, cfg.Host, cfg.Port, cfg.Name)
147 | 	}
148 | 
149 | 	return nil
150 | }
151 | 
152 | // GetDatabase retrieves a database connection by ID
153 | func (m *Manager) GetDatabase(id string) (Database, error) {
154 | 	m.mu.RLock()
155 | 	defer m.mu.RUnlock()
156 | 
157 | 	// Check if the database exists
158 | 	db, exists := m.connections[id]
159 | 	if !exists {
160 | 		return nil, fmt.Errorf("database connection %s not found", id)
161 | 	}
162 | 
163 | 	return db, nil
164 | }
165 | 
166 | // GetDatabaseType returns the type of a database by its ID
167 | func (m *Manager) GetDatabaseType(id string) (string, error) {
168 | 	m.mu.RLock()
169 | 	defer m.mu.RUnlock()
170 | 
171 | 	// Check if the database configuration exists
172 | 	cfg, exists := m.configs[id]
173 | 	if !exists {
174 | 		return "", fmt.Errorf("database configuration %s not found", id)
175 | 	}
176 | 
177 | 	return cfg.Type, nil
178 | }
179 | 
180 | // CloseAll closes all database connections
181 | func (m *Manager) CloseAll() error {
182 | 	m.mu.Lock()
183 | 	defer m.mu.Unlock()
184 | 
185 | 	var firstErr error
186 | 
187 | 	// Close each database connection
188 | 	for id, db := range m.connections {
189 | 		if err := db.Close(); err != nil {
190 | 			logger.Error("Failed to close database %s: %v", id, err)
191 | 			if firstErr == nil {
192 | 				firstErr = err
193 | 			}
194 | 		}
195 | 		delete(m.connections, id)
196 | 	}
197 | 
198 | 	return firstErr
199 | }
200 | 
201 | // Close closes a specific database connection
202 | func (m *Manager) Close(id string) error {
203 | 	m.mu.Lock()
204 | 	defer m.mu.Unlock()
205 | 
206 | 	// Check if the database exists
207 | 	db, exists := m.connections[id]
208 | 	if !exists {
209 | 		return fmt.Errorf("database connection %s not found", id)
210 | 	}
211 | 
212 | 	// Close the connection
213 | 	if err := db.Close(); err != nil {
214 | 		return fmt.Errorf("failed to close database %s: %w", id, err)
215 | 	}
216 | 
217 | 	// Remove from connections map
218 | 	delete(m.connections, id)
219 | 
220 | 	return nil
221 | }
222 | 
223 | // ListDatabases returns a list of all configured databases
224 | func (m *Manager) ListDatabases() []string {
225 | 	m.mu.RLock()
226 | 	defer m.mu.RUnlock()
227 | 
228 | 	ids := make([]string, 0, len(m.configs))
229 | 	for id := range m.configs {
230 | 		ids = append(ids, id)
231 | 	}
232 | 
233 | 	return ids
234 | }
235 | 
236 | // GetConnectedDatabases returns a list of all connected databases
237 | func (m *Manager) GetConnectedDatabases() []string {
238 | 	m.mu.RLock()
239 | 	defer m.mu.RUnlock()
240 | 
241 | 	ids := make([]string, 0, len(m.connections))
242 | 	for id := range m.connections {
243 | 		ids = append(ids, id)
244 | 	}
245 | 
246 | 	return ids
247 | }
248 | 
249 | // GetDatabaseConfig returns the configuration for a specific database
250 | func (m *Manager) GetDatabaseConfig(id string) (DatabaseConnectionConfig, error) {
251 | 	m.mu.RLock()
252 | 	defer m.mu.RUnlock()
253 | 
254 | 	cfg, exists := m.configs[id]
255 | 	if !exists {
256 | 		return DatabaseConnectionConfig{}, fmt.Errorf("database configuration %s not found", id)
257 | 	}
258 | 
259 | 	return cfg, nil
260 | }
261 | 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/timescale_tool_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/FreePeak/cortex/pkg/server"
  8 | 	"github.com/stretchr/testify/assert"
  9 | 	"github.com/stretchr/testify/mock"
 10 | )
 11 | 
 12 | func TestTimescaleDBTool_CreateTool(t *testing.T) {
 13 | 	tool := NewTimescaleDBTool()
 14 | 	assert.Equal(t, "timescaledb", tool.GetName())
 15 | 	assert.Contains(t, tool.GetDescription("test_db"), "on test_db")
 16 | 
 17 | 	// Test standard tool creation
 18 | 	baseTool := tool.CreateTool("test_tool", "test_db")
 19 | 	assert.NotNil(t, baseTool)
 20 | }
 21 | 
 22 | func TestTimescaleDBTool_CreateHypertableTool(t *testing.T) {
 23 | 	tool := NewTimescaleDBTool()
 24 | 	hypertableTool := tool.CreateHypertableTool("hypertable_tool", "test_db")
 25 | 	assert.NotNil(t, hypertableTool)
 26 | }
 27 | 
 28 | func TestTimescaleDBTool_CreateListHypertablesTool(t *testing.T) {
 29 | 	tool := NewTimescaleDBTool()
 30 | 	listTool := tool.CreateListHypertablesTool("list_tool", "test_db")
 31 | 	assert.NotNil(t, listTool)
 32 | }
 33 | 
 34 | func TestTimescaleDBTool_CreateRetentionPolicyTool(t *testing.T) {
 35 | 	tool := NewTimescaleDBTool()
 36 | 	retentionTool := tool.CreateRetentionPolicyTool("retention_tool", "test_db")
 37 | 
 38 | 	assert.NotNil(t, retentionTool, "Retention policy tool should be created")
 39 | }
 40 | 
 41 | func TestHandleCreateHypertable(t *testing.T) {
 42 | 	// Create a mock use case
 43 | 	mockUseCase := new(MockDatabaseUseCase)
 44 | 
 45 | 	// Set up expectations
 46 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
 47 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.MatchedBy(func(sql string) bool {
 48 | 		return true // Accept any SQL for now
 49 | 	}), mock.Anything).Return(`{"result": "success"}`, nil)
 50 | 
 51 | 	// Create the tool
 52 | 	tool := NewTimescaleDBTool()
 53 | 
 54 | 	// Create a request
 55 | 	request := server.ToolCallRequest{
 56 | 		Parameters: map[string]interface{}{
 57 | 			"operation":    "create_hypertable",
 58 | 			"target_table": "metrics",
 59 | 			"time_column":  "timestamp",
 60 | 		},
 61 | 	}
 62 | 
 63 | 	// Call the handler
 64 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
 65 | 
 66 | 	// Assertions
 67 | 	assert.NoError(t, err)
 68 | 	assert.NotNil(t, result)
 69 | 
 70 | 	// Verify mock expectations
 71 | 	mockUseCase.AssertExpectations(t)
 72 | }
 73 | 
 74 | func TestHandleListHypertables(t *testing.T) {
 75 | 	// Create a mock use case
 76 | 	mockUseCase := new(MockDatabaseUseCase)
 77 | 
 78 | 	// Set up expectations
 79 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
 80 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.MatchedBy(func(sql string) bool {
 81 | 		return true // Any SQL that contains the relevant query
 82 | 	}), mock.Anything).Return(`[{"table_name":"metrics","schema_name":"public","time_column":"time"}]`, nil)
 83 | 
 84 | 	// Create the tool
 85 | 	tool := NewTimescaleDBTool()
 86 | 
 87 | 	// Create a request
 88 | 	request := server.ToolCallRequest{
 89 | 		Parameters: map[string]interface{}{
 90 | 			"operation": "list_hypertables",
 91 | 		},
 92 | 	}
 93 | 
 94 | 	// Call the handler
 95 | 	result, err := tool.handleListHypertables(context.Background(), request, "test_db", mockUseCase)
 96 | 
 97 | 	// Assertions
 98 | 	assert.NoError(t, err)
 99 | 	assert.NotNil(t, result)
100 | 
101 | 	// Check the result
102 | 	resultMap, ok := result.(map[string]interface{})
103 | 	assert.True(t, ok)
104 | 	assert.Contains(t, resultMap, "message")
105 | 	assert.Contains(t, resultMap, "details")
106 | 
107 | 	// Verify mock expectations
108 | 	mockUseCase.AssertExpectations(t)
109 | }
110 | 
111 | func TestHandleListHypertablesNonPostgresDB(t *testing.T) {
112 | 	// Create a mock use case
113 | 	mockUseCase := new(MockDatabaseUseCase)
114 | 
115 | 	// Set up expectations for a non-PostgreSQL database
116 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("mysql", nil)
117 | 
118 | 	// Create the tool
119 | 	tool := NewTimescaleDBTool()
120 | 
121 | 	// Create a request
122 | 	request := server.ToolCallRequest{
123 | 		Parameters: map[string]interface{}{
124 | 			"operation": "list_hypertables",
125 | 		},
126 | 	}
127 | 
128 | 	// Call the handler
129 | 	_, err := tool.handleListHypertables(context.Background(), request, "test_db", mockUseCase)
130 | 
131 | 	// Assertions
132 | 	assert.Error(t, err)
133 | 	assert.Contains(t, err.Error(), "TimescaleDB operations are only supported on PostgreSQL databases")
134 | 
135 | 	// Verify mock expectations
136 | 	mockUseCase.AssertExpectations(t)
137 | }
138 | 
139 | func TestHandleAddRetentionPolicy(t *testing.T) {
140 | 	// Create a mock use case
141 | 	mockUseCase := new(MockDatabaseUseCase)
142 | 
143 | 	// Set up expectations
144 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
145 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.MatchedBy(func(sql string) bool {
146 | 		return true // Accept any SQL for now
147 | 	}), mock.Anything).Return(`{"result": "success"}`, nil)
148 | 
149 | 	// Create the tool
150 | 	tool := NewTimescaleDBTool()
151 | 
152 | 	// Create a request
153 | 	request := server.ToolCallRequest{
154 | 		Parameters: map[string]interface{}{
155 | 			"operation":          "add_retention_policy",
156 | 			"target_table":       "metrics",
157 | 			"retention_interval": "30 days",
158 | 		},
159 | 	}
160 | 
161 | 	// Call the handler
162 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
163 | 
164 | 	// Assertions
165 | 	assert.NoError(t, err)
166 | 	assert.NotNil(t, result)
167 | 
168 | 	// Verify mock expectations
169 | 	mockUseCase.AssertExpectations(t)
170 | }
171 | 
172 | func TestHandleRemoveRetentionPolicy(t *testing.T) {
173 | 	// Create a mock use case
174 | 	mockUseCase := new(MockDatabaseUseCase)
175 | 
176 | 	// Set up expectations
177 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
178 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.MatchedBy(func(sql string) bool {
179 | 		return true // Accept any SQL for now
180 | 	}), mock.Anything).Return(`{"result": "success"}`, nil)
181 | 
182 | 	// Create the tool
183 | 	tool := NewTimescaleDBTool()
184 | 
185 | 	// Create a request
186 | 	request := server.ToolCallRequest{
187 | 		Parameters: map[string]interface{}{
188 | 			"operation":    "remove_retention_policy",
189 | 			"target_table": "metrics",
190 | 		},
191 | 	}
192 | 
193 | 	// Call the handler
194 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
195 | 
196 | 	// Assertions
197 | 	assert.NoError(t, err)
198 | 	assert.NotNil(t, result)
199 | 
200 | 	// Verify mock expectations
201 | 	mockUseCase.AssertExpectations(t)
202 | }
203 | 
204 | func TestHandleGetRetentionPolicy(t *testing.T) {
205 | 	// Create a mock use case
206 | 	mockUseCase := new(MockDatabaseUseCase)
207 | 
208 | 	// Set up expectations
209 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("postgres", nil)
210 | 	mockUseCase.On("ExecuteStatement", mock.Anything, "test_db", mock.MatchedBy(func(sql string) bool {
211 | 		return true // Accept any SQL for now
212 | 	}), mock.Anything).Return(`[{"hypertable_name":"metrics","retention_interval":"30 days","retention_enabled":true}]`, nil)
213 | 
214 | 	// Create the tool
215 | 	tool := NewTimescaleDBTool()
216 | 
217 | 	// Create a request
218 | 	request := server.ToolCallRequest{
219 | 		Parameters: map[string]interface{}{
220 | 			"operation":    "get_retention_policy",
221 | 			"target_table": "metrics",
222 | 		},
223 | 	}
224 | 
225 | 	// Call the handler
226 | 	result, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
227 | 
228 | 	// Assertions
229 | 	assert.NoError(t, err)
230 | 	assert.NotNil(t, result)
231 | 
232 | 	// Verify mock expectations
233 | 	mockUseCase.AssertExpectations(t)
234 | }
235 | 
236 | func TestHandleNonPostgresDB(t *testing.T) {
237 | 	// Create a mock use case
238 | 	mockUseCase := new(MockDatabaseUseCase)
239 | 
240 | 	// Set up expectations for a non-PostgreSQL database
241 | 	mockUseCase.On("GetDatabaseType", "test_db").Return("mysql", nil)
242 | 
243 | 	// Create the tool
244 | 	tool := NewTimescaleDBTool()
245 | 
246 | 	// Create a request
247 | 	request := server.ToolCallRequest{
248 | 		Parameters: map[string]interface{}{
249 | 			"operation":          "add_retention_policy",
250 | 			"target_table":       "metrics",
251 | 			"retention_interval": "30 days",
252 | 		},
253 | 	}
254 | 
255 | 	// Call the handler
256 | 	_, err := tool.HandleRequest(context.Background(), request, "test_db", mockUseCase)
257 | 
258 | 	// Assertions
259 | 	assert.Error(t, err)
260 | 	assert.Contains(t, err.Error(), "TimescaleDB operations are only supported on PostgreSQL databases")
261 | 
262 | 	// Verify mock expectations
263 | 	mockUseCase.AssertExpectations(t)
264 | }
265 | 
```

--------------------------------------------------------------------------------
/internal/delivery/mcp/context/hypertable_schema_test.go:
--------------------------------------------------------------------------------

```go
  1 | package context_test
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"strings"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/stretchr/testify/assert"
  9 | 	"github.com/stretchr/testify/mock"
 10 | 
 11 | 	"github.com/FreePeak/db-mcp-server/internal/delivery/mcp"
 12 | )
 13 | 
 14 | func TestHypertableSchemaProvider(t *testing.T) {
 15 | 	// Create a mock use case provider
 16 | 	mockUseCase := new(MockDatabaseUseCase)
 17 | 
 18 | 	// Create a context for testing
 19 | 	ctx := context.Background()
 20 | 
 21 | 	t.Run("get_hypertable_schema", func(t *testing.T) {
 22 | 		// Sample results for hypertable metadata queries
 23 | 		sampleMetadataResult := `[{
 24 | 			"table_name": "temperature_readings",
 25 | 			"schema_name": "public",
 26 | 			"owner": "postgres",
 27 | 			"time_dimension": "timestamp",
 28 | 			"time_dimension_type": "TIMESTAMP",
 29 | 			"chunk_time_interval": "1 day",
 30 | 			"total_size": "24 MB",
 31 | 			"chunks": 30,
 32 | 			"total_rows": 1000000,
 33 | 			"compression_enabled": true
 34 | 		}]`
 35 | 
 36 | 		sampleColumnsResult := `[
 37 | 			{
 38 | 				"column_name": "timestamp",
 39 | 				"data_type": "timestamp without time zone",
 40 | 				"is_nullable": false,
 41 | 				"is_primary_key": false,
 42 | 				"is_indexed": true,
 43 | 				"description": "Time when reading was taken"
 44 | 			},
 45 | 			{
 46 | 				"column_name": "device_id",
 47 | 				"data_type": "text",
 48 | 				"is_nullable": false,
 49 | 				"is_primary_key": false,
 50 | 				"is_indexed": true,
 51 | 				"description": "Device identifier"
 52 | 			},
 53 | 			{
 54 | 				"column_name": "temperature",
 55 | 				"data_type": "double precision",
 56 | 				"is_nullable": false,
 57 | 				"is_primary_key": false,
 58 | 				"is_indexed": false,
 59 | 				"description": "Temperature in Celsius"
 60 | 			},
 61 | 			{
 62 | 				"column_name": "humidity",
 63 | 				"data_type": "double precision",
 64 | 				"is_nullable": true,
 65 | 				"is_primary_key": false,
 66 | 				"is_indexed": false,
 67 | 				"description": "Relative humidity percentage"
 68 | 			},
 69 | 			{
 70 | 				"column_name": "id",
 71 | 				"data_type": "integer",
 72 | 				"is_nullable": false,
 73 | 				"is_primary_key": true,
 74 | 				"is_indexed": true,
 75 | 				"description": "Primary key"
 76 | 			}
 77 | 		]`
 78 | 
 79 | 		sampleCompressionResult := `[{
 80 | 			"segmentby": "device_id",
 81 | 			"orderby": "timestamp",
 82 | 			"compression_interval": "7 days"
 83 | 		}]`
 84 | 
 85 | 		sampleRetentionResult := `[{
 86 | 			"hypertable_name": "temperature_readings",
 87 | 			"retention_interval": "90 days",
 88 | 			"retention_enabled": true
 89 | 		}]`
 90 | 
 91 | 		// Set up expectations for the mock
 92 | 		mockUseCase.On("GetDatabaseType", "timescale_db").Return("postgres", nil).Once()
 93 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
 94 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
 95 | 		}), mock.Anything).Return(`[{"extversion": "2.8.0"}]`, nil).Once()
 96 | 
 97 | 		// Metadata query
 98 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
 99 | 			return sql != "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'" &&
100 | 				strings.Contains(sql, "hypertable")
101 | 		}), mock.Anything).Return(sampleMetadataResult, nil).Once()
102 | 
103 | 		// Columns query
104 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
105 | 			return strings.Contains(sql, "information_schema.columns") &&
106 | 				strings.Contains(sql, "temperature_readings")
107 | 		}), mock.Anything).Return(sampleColumnsResult, nil).Once()
108 | 
109 | 		// Compression query
110 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
111 | 			return strings.Contains(sql, "compression_settings") &&
112 | 				strings.Contains(sql, "temperature_readings")
113 | 		}), mock.Anything).Return(sampleCompressionResult, nil).Once()
114 | 
115 | 		// Retention query
116 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
117 | 			return strings.Contains(sql, "retention")
118 | 		}), mock.Anything).Return(sampleRetentionResult, nil).Once()
119 | 
120 | 		// Create the schema provider
121 | 		provider := mcp.NewHypertableSchemaProvider()
122 | 
123 | 		// Call the method
124 | 		schemaInfo, err := provider.GetHypertableSchema(ctx, "timescale_db", "temperature_readings", mockUseCase)
125 | 
126 | 		// Verify the result
127 | 		assert.NoError(t, err)
128 | 		assert.NotNil(t, schemaInfo)
129 | 		assert.Equal(t, "temperature_readings", schemaInfo.TableName)
130 | 		assert.Equal(t, "public", schemaInfo.SchemaName)
131 | 		assert.Equal(t, "timestamp", schemaInfo.TimeColumn)
132 | 		assert.Equal(t, "1 day", schemaInfo.ChunkTimeInterval)
133 | 		assert.Equal(t, "24 MB", schemaInfo.Size)
134 | 		assert.Equal(t, 30, schemaInfo.ChunkCount)
135 | 		assert.Equal(t, int64(1000000), schemaInfo.RowCount)
136 | 		assert.True(t, schemaInfo.CompressionEnabled)
137 | 		assert.Equal(t, "device_id", schemaInfo.CompressionConfig.SegmentBy)
138 | 		assert.Equal(t, "timestamp", schemaInfo.CompressionConfig.OrderBy)
139 | 		assert.Equal(t, "7 days", schemaInfo.CompressionConfig.Interval)
140 | 		assert.True(t, schemaInfo.RetentionEnabled)
141 | 		assert.Equal(t, "90 days", schemaInfo.RetentionInterval)
142 | 
143 | 		// Check columns
144 | 		assert.Len(t, schemaInfo.Columns, 5)
145 | 
146 | 		// Check time column
147 | 		timeCol := findColumn(schemaInfo.Columns, "timestamp")
148 | 		assert.NotNil(t, timeCol)
149 | 		assert.Equal(t, "timestamp without time zone", timeCol.Type)
150 | 		assert.Equal(t, "Time when reading was taken", timeCol.Description)
151 | 		assert.False(t, timeCol.Nullable)
152 | 		assert.True(t, timeCol.Indexed)
153 | 
154 | 		// Check primary key
155 | 		idCol := findColumn(schemaInfo.Columns, "id")
156 | 		assert.NotNil(t, idCol)
157 | 		assert.True(t, idCol.PrimaryKey)
158 | 
159 | 		// Verify the mock expectations
160 | 		mockUseCase.AssertExpectations(t)
161 | 	})
162 | 
163 | 	t.Run("get_hypertable_schema_with_non_timescaledb", func(t *testing.T) {
164 | 		// Set up expectations for the mock
165 | 		mockUseCase.On("GetDatabaseType", "postgres_db").Return("postgres", nil).Once()
166 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "postgres_db", mock.MatchedBy(func(sql string) bool {
167 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
168 | 		}), mock.Anything).Return(`[]`, nil).Once()
169 | 
170 | 		// Create the schema provider
171 | 		provider := mcp.NewHypertableSchemaProvider()
172 | 
173 | 		// Call the method
174 | 		schemaInfo, err := provider.GetHypertableSchema(ctx, "postgres_db", "some_table", mockUseCase)
175 | 
176 | 		// Verify the result
177 | 		assert.Error(t, err)
178 | 		assert.Nil(t, schemaInfo)
179 | 		assert.Contains(t, err.Error(), "TimescaleDB is not available")
180 | 
181 | 		// Verify the mock expectations
182 | 		mockUseCase.AssertExpectations(t)
183 | 	})
184 | 
185 | 	t.Run("get_hypertable_schema_with_not_a_hypertable", func(t *testing.T) {
186 | 		// Set up expectations for the mock
187 | 		mockUseCase.On("GetDatabaseType", "timescale_db").Return("postgres", nil).Once()
188 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
189 | 			return sql == "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
190 | 		}), mock.Anything).Return(`[{"extversion": "2.8.0"}]`, nil).Once()
191 | 
192 | 		// Empty result for metadata query indicates it's not a hypertable
193 | 		mockUseCase.On("ExecuteStatement", mock.Anything, "timescale_db", mock.MatchedBy(func(sql string) bool {
194 | 			return sql != "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'"
195 | 		}), mock.Anything).Return(`[]`, nil).Once()
196 | 
197 | 		// Create the schema provider
198 | 		provider := mcp.NewHypertableSchemaProvider()
199 | 
200 | 		// Call the method
201 | 		schemaInfo, err := provider.GetHypertableSchema(ctx, "timescale_db", "normal_table", mockUseCase)
202 | 
203 | 		// Verify the result
204 | 		assert.Error(t, err)
205 | 		assert.Nil(t, schemaInfo)
206 | 		assert.Contains(t, err.Error(), "is not a hypertable")
207 | 
208 | 		// Verify the mock expectations
209 | 		mockUseCase.AssertExpectations(t)
210 | 	})
211 | }
212 | 
213 | // Helper function to find a column by name
214 | func findColumn(columns []mcp.HypertableColumnInfo, name string) *mcp.HypertableColumnInfo {
215 | 	for i, col := range columns {
216 | 		if col.Name == name {
217 | 			return &columns[i]
218 | 		}
219 | 	}
220 | 	return nil
221 | }
222 | 
```

--------------------------------------------------------------------------------
/pkg/db/timescale/timeseries.go:
--------------------------------------------------------------------------------

```go
  1 | package timescale
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"strings"
  7 | 	"time"
  8 | )
  9 | 
 10 | // WindowFunction represents a SQL window function
 11 | type WindowFunction struct {
 12 | 	Function    string // e.g. LAG, LEAD, ROW_NUMBER
 13 | 	Expression  string // Expression to apply function to
 14 | 	Alias       string // Result column name
 15 | 	PartitionBy string // PARTITION BY column(s)
 16 | 	OrderBy     string // ORDER BY column(s)
 17 | 	Frame       string // Window frame specification
 18 | }
 19 | 
 20 | // TimeSeriesQueryOptions encapsulates options for time-series queries
 21 | type TimeSeriesQueryOptions struct {
 22 | 	// Required parameters
 23 | 	Table          string // The table to query
 24 | 	TimeColumn     string // The time column
 25 | 	BucketInterval string // Time bucket interval (e.g., '1 hour', '1 day')
 26 | 
 27 | 	// Optional parameters
 28 | 	BucketColumnName string              // Name for the bucket column (defaults to "time_bucket")
 29 | 	SelectColumns    []string            // Additional columns to select
 30 | 	Aggregations     []ColumnAggregation // Aggregations to perform
 31 | 	WindowFunctions  []WindowFunction    // Window functions to apply
 32 | 	StartTime        time.Time           // Start of time range
 33 | 	EndTime          time.Time           // End of time range
 34 | 	WhereCondition   string              // Additional WHERE conditions
 35 | 	GroupByColumns   []string            // Additional GROUP BY columns
 36 | 	OrderBy          string              // ORDER BY clause
 37 | 	Limit            int                 // LIMIT clause
 38 | 	Offset           int                 // OFFSET clause
 39 | }
 40 | 
 41 | // TimeSeriesQuery executes a time-series query with the given options
 42 | func (t *DB) TimeSeriesQuery(ctx context.Context, options TimeSeriesQueryOptions) ([]map[string]interface{}, error) {
 43 | 	if !t.isTimescaleDB {
 44 | 		return nil, fmt.Errorf("TimescaleDB extension not available")
 45 | 	}
 46 | 
 47 | 	// Initialize query builder
 48 | 	builder := NewTimeseriesQueryBuilder(options.Table)
 49 | 
 50 | 	// Add time bucket
 51 | 	bucketName := options.BucketColumnName
 52 | 	if bucketName == "" {
 53 | 		bucketName = "time_bucket"
 54 | 	}
 55 | 	builder.WithTimeBucket(options.BucketInterval, options.TimeColumn, bucketName)
 56 | 
 57 | 	// Add select columns
 58 | 	if len(options.SelectColumns) > 0 {
 59 | 		builder.Select(options.SelectColumns...)
 60 | 	}
 61 | 
 62 | 	// Add aggregations
 63 | 	for _, agg := range options.Aggregations {
 64 | 		builder.Aggregate(agg.Function, agg.Column, agg.Alias)
 65 | 	}
 66 | 
 67 | 	// Add time range if specified
 68 | 	if !options.StartTime.IsZero() && !options.EndTime.IsZero() {
 69 | 		builder.WhereTimeRange(options.TimeColumn, options.StartTime, options.EndTime)
 70 | 	}
 71 | 
 72 | 	// Add additional WHERE condition if specified
 73 | 	if options.WhereCondition != "" {
 74 | 		builder.Where(options.WhereCondition)
 75 | 	}
 76 | 
 77 | 	// Add GROUP BY columns
 78 | 	if len(options.GroupByColumns) > 0 {
 79 | 		builder.GroupBy(options.GroupByColumns...)
 80 | 	}
 81 | 
 82 | 	// Add ORDER BY if specified
 83 | 	if options.OrderBy != "" {
 84 | 		orderCols := strings.Split(options.OrderBy, ",")
 85 | 		for i := range orderCols {
 86 | 			orderCols[i] = strings.TrimSpace(orderCols[i])
 87 | 		}
 88 | 		builder.OrderBy(orderCols...)
 89 | 	} else {
 90 | 		// Default sort by time bucket
 91 | 		builder.OrderBy(bucketName)
 92 | 	}
 93 | 
 94 | 	// Add LIMIT if specified
 95 | 	if options.Limit > 0 {
 96 | 		builder.Limit(options.Limit)
 97 | 	}
 98 | 
 99 | 	// Add OFFSET if specified
100 | 	if options.Offset > 0 {
101 | 		builder.Offset(options.Offset)
102 | 	}
103 | 
104 | 	// Generate the query
105 | 	query, args := builder.Build()
106 | 
107 | 	// Add window functions if specified
108 | 	if len(options.WindowFunctions) > 0 {
109 | 		query = addWindowFunctions(query, options.WindowFunctions)
110 | 	}
111 | 
112 | 	// Execute the query
113 | 	result, err := t.ExecuteSQL(ctx, query, args...)
114 | 	if err != nil {
115 | 		return nil, fmt.Errorf("failed to execute time-series query: %w", err)
116 | 	}
117 | 
118 | 	// Convert result to expected format
119 | 	rows, ok := result.([]map[string]interface{})
120 | 	if !ok {
121 | 		return nil, fmt.Errorf("unexpected result type from database query")
122 | 	}
123 | 
124 | 	return rows, nil
125 | }
126 | 
127 | // addWindowFunctions modifies a query to include window functions
128 | func addWindowFunctions(query string, functions []WindowFunction) string {
129 | 	// If no window functions, return original query
130 | 	if len(functions) == 0 {
131 | 		return query
132 | 	}
133 | 
134 | 	// Split query at FROM to insert window functions
135 | 	parts := strings.SplitN(query, "FROM", 2)
136 | 	if len(parts) != 2 {
137 | 		return query // Can't modify query structure
138 | 	}
139 | 
140 | 	// Build window function part
141 | 	var windowPart strings.Builder
142 | 	windowPart.WriteString(parts[0])
143 | 
144 | 	// Add comma after existing selections
145 | 	trimmedSelect := strings.TrimSpace(parts[0][7:]) // Remove "SELECT " prefix
146 | 	if trimmedSelect != "" && len(trimmedSelect) > 0 && !strings.HasSuffix(trimmedSelect, ",") {
147 | 		windowPart.WriteString(", ")
148 | 	}
149 | 
150 | 	// Add each window function
151 | 	for i, fn := range functions {
152 | 		windowPart.WriteString(fmt.Sprintf("%s(%s) OVER (", fn.Function, fn.Expression))
153 | 
154 | 		// Add PARTITION BY if specified
155 | 		if fn.PartitionBy != "" {
156 | 			windowPart.WriteString(fmt.Sprintf("PARTITION BY %s ", fn.PartitionBy))
157 | 		}
158 | 
159 | 		// Add ORDER BY if specified
160 | 		if fn.OrderBy != "" {
161 | 			windowPart.WriteString(fmt.Sprintf("ORDER BY %s ", fn.OrderBy))
162 | 		}
163 | 
164 | 		// Add window frame if specified
165 | 		if fn.Frame != "" {
166 | 			windowPart.WriteString(fn.Frame)
167 | 		}
168 | 
169 | 		windowPart.WriteString(")")
170 | 
171 | 		// Add alias if specified
172 | 		if fn.Alias != "" {
173 | 			windowPart.WriteString(fmt.Sprintf(" AS %s", fn.Alias))
174 | 		}
175 | 
176 | 		// Add comma if not last function
177 | 		if i < len(functions)-1 {
178 | 			windowPart.WriteString(", ")
179 | 		}
180 | 	}
181 | 
182 | 	// Reconstruct query
183 | 	windowPart.WriteString(" FROM")
184 | 	windowPart.WriteString(parts[1])
185 | 
186 | 	return windowPart.String()
187 | }
188 | 
189 | // GetCommonTimeIntervals returns a list of supported time bucket intervals
190 | func (t *DB) GetCommonTimeIntervals() []string {
191 | 	return []string{
192 | 		"1 minute", "5 minutes", "10 minutes", "15 minutes", "30 minutes",
193 | 		"1 hour", "2 hours", "3 hours", "6 hours", "12 hours",
194 | 		"1 day", "1 week", "1 month", "3 months", "6 months", "1 year",
195 | 	}
196 | }
197 | 
198 | // AnalyzeTimeSeries performs analysis on time-series data
199 | func (t *DB) AnalyzeTimeSeries(ctx context.Context, table, timeColumn string,
200 | 	startTime, endTime time.Time) (map[string]interface{}, error) {
201 | 
202 | 	if !t.isTimescaleDB {
203 | 		return nil, fmt.Errorf("TimescaleDB extension not available")
204 | 	}
205 | 
206 | 	// Get basic statistics about the time range
207 | 	statsQuery := fmt.Sprintf(`
208 | 		SELECT 
209 | 			COUNT(*) as row_count,
210 | 			MIN(%s) as min_time,
211 | 			MAX(%s) as max_time,
212 | 			(MAX(%s) - MIN(%s)) as time_span,
213 | 			COUNT(DISTINCT date_trunc('day', %s)) as unique_days
214 | 		FROM %s
215 | 		WHERE %s BETWEEN $1 AND $2
216 | 	`, timeColumn, timeColumn, timeColumn, timeColumn, timeColumn, table, timeColumn)
217 | 
218 | 	statsResult, err := t.ExecuteSQL(ctx, statsQuery, startTime, endTime)
219 | 	if err != nil {
220 | 		return nil, fmt.Errorf("failed to get time-series statistics: %w", err)
221 | 	}
222 | 
223 | 	statsRows, ok := statsResult.([]map[string]interface{})
224 | 	if !ok || len(statsRows) == 0 {
225 | 		return nil, fmt.Errorf("unexpected result type from database query")
226 | 	}
227 | 
228 | 	// Build result
229 | 	result := statsRows[0]
230 | 
231 | 	// Add suggested bucket intervals based on data characteristics
232 | 	if rowCount, ok := result["row_count"].(int64); ok && rowCount > 0 {
233 | 		// Get time span in hours
234 | 		var timeSpanHours float64
235 | 		if timeSpan, ok := result["time_span"].(string); ok {
236 | 			timeSpanHours = parseTimeInterval(timeSpan)
237 | 		}
238 | 
239 | 		if timeSpanHours > 0 {
240 | 			// Suggest reasonable intervals based on amount of data and time span
241 | 			if timeSpanHours <= 24 {
242 | 				result["suggested_interval"] = "5 minutes"
243 | 			} else if timeSpanHours <= 168 { // 1 week
244 | 				result["suggested_interval"] = "1 hour"
245 | 			} else if timeSpanHours <= 720 { // 1 month
246 | 				result["suggested_interval"] = "6 hours"
247 | 			} else if timeSpanHours <= 2160 { // 3 months
248 | 				result["suggested_interval"] = "1 day"
249 | 			} else {
250 | 				result["suggested_interval"] = "1 week"
251 | 			}
252 | 		}
253 | 	}
254 | 
255 | 	return result, nil
256 | }
257 | 
258 | // parseTimeInterval converts a PostgreSQL interval string to hours
259 | func parseTimeInterval(interval string) float64 {
260 | 	// This is a simplistic parser for time intervals
261 | 	// Real implementation would need to handle more formats
262 | 	if strings.Contains(interval, "days") {
263 | 		parts := strings.Split(interval, "days")
264 | 		if len(parts) > 0 {
265 | 			var days float64
266 | 			if _, err := fmt.Sscanf(parts[0], "%f", &days); err == nil {
267 | 				return days * 24
268 | 			}
269 | 		}
270 | 	}
271 | 	return 0
272 | }
273 | 
```

--------------------------------------------------------------------------------
/docs/TIMESCALEDB_FUNCTIONS.md:
--------------------------------------------------------------------------------

```markdown
  1 | # TimescaleDB Functions Reference
  2 | 
  3 | This document provides a comprehensive reference guide for TimescaleDB functions available through DB-MCP-Server. These functions can be used in SQL queries when connected to a TimescaleDB-enabled PostgreSQL database.
  4 | 
  5 | ## Time Buckets
  6 | 
  7 | These functions are used to group time-series data into intervals for aggregation and analysis.
  8 | 
  9 | | Function | Description | Parameters | Example |
 10 | |----------|-------------|------------|---------|
 11 | | `time_bucket(interval, timestamp)` | Groups time into even buckets | `interval`: The bucket size<br>`timestamp`: The timestamp column | `SELECT time_bucket('1 hour', time) AS hour, avg(value) FROM metrics GROUP BY hour` |
 12 | | `time_bucket_gapfill(interval, timestamp)` | Creates time buckets with gap filling for missing values | `interval`: The bucket size<br>`timestamp`: The timestamp column | `SELECT time_bucket_gapfill('1 hour', time) AS hour, avg(value) FROM metrics GROUP BY hour` |
 13 | | `time_bucket_ng(interval, timestamp, timezone)` | Next-generation time bucketing with timezone support | `interval`: The bucket size<br>`timestamp`: The timestamp column<br>`timezone`: The timezone to use | `SELECT time_bucket_ng('1 day', time, 'UTC') AS day, avg(value) FROM metrics GROUP BY day` |
 14 | 
 15 | ## Hypertable Management
 16 | 
 17 | These functions are used to create and manage hypertables, which are the core partitioned tables in TimescaleDB.
 18 | 
 19 | | Function | Description | Parameters | Example |
 20 | |----------|-------------|------------|---------|
 21 | | `create_hypertable(table_name, time_column)` | Converts a standard PostgreSQL table into a hypertable | `table_name`: The name of the table<br>`time_column`: The name of the time column | `SELECT create_hypertable('metrics', 'time')` |
 22 | | `add_dimension(hypertable, column_name)` | Adds another dimension for partitioning | `hypertable`: The hypertable name<br>`column_name`: The column to partition by | `SELECT add_dimension('metrics', 'device_id')` |
 23 | | `add_compression_policy(hypertable, older_than)` | Adds an automatic compression policy | `hypertable`: The hypertable name<br>`older_than`: The age threshold for data to be compressed | `SELECT add_compression_policy('metrics', INTERVAL '7 days')` |
 24 | | `add_retention_policy(hypertable, drop_after)` | Adds an automatic data retention policy | `hypertable`: The hypertable name<br>`drop_after`: The age threshold for data to be dropped | `SELECT add_retention_policy('metrics', INTERVAL '30 days')` |
 25 | 
 26 | ## Continuous Aggregates
 27 | 
 28 | These functions manage continuous aggregates, which are materialized views that automatically maintain aggregated time-series data.
 29 | 
 30 | | Function | Description | Parameters | Example |
 31 | |----------|-------------|------------|---------|
 32 | | `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)` | Creates a continuous aggregate view | SQL statement defining the view | `CREATE MATERIALIZED VIEW metrics_hourly WITH (timescaledb.continuous) AS SELECT time_bucket('1 hour', time) as hour, avg(value) FROM metrics GROUP BY hour;` |
 33 | | `add_continuous_aggregate_policy(view_name, start_offset, end_offset, schedule_interval)` | Adds a refresh policy to a continuous aggregate | `view_name`: The continuous aggregate name<br>`start_offset`: The start of refresh window relative to current time<br>`end_offset`: The end of refresh window relative to current time<br>`schedule_interval`: How often to refresh | `SELECT add_continuous_aggregate_policy('metrics_hourly', INTERVAL '2 days', INTERVAL '1 hour', INTERVAL '1 hour')` |
 34 | | `refresh_continuous_aggregate(continuous_aggregate, start_time, end_time)` | Manually refreshes a continuous aggregate | `continuous_aggregate`: The continuous aggregate name<br>`start_time`: Start time to refresh<br>`end_time`: End time to refresh | `SELECT refresh_continuous_aggregate('metrics_hourly', '2023-01-01', '2023-01-02')` |
 35 | 
 36 | ## Analytics Functions
 37 | 
 38 | Special analytics functions provided by TimescaleDB for time-series analysis.
 39 | 
 40 | | Function | Description | Parameters | Example |
 41 | |----------|-------------|------------|---------|
 42 | | `first(value, time)` | Returns the value at the first time | `value`: The value column<br>`time`: The time column | `SELECT first(value, time) FROM metrics GROUP BY device_id` |
 43 | | `last(value, time)` | Returns the value at the last time | `value`: The value column<br>`time`: The time column | `SELECT last(value, time) FROM metrics GROUP BY device_id` |
 44 | | `time_weight(value, time)` | Calculates time-weighted average | `value`: The value column<br>`time`: The time column | `SELECT time_weight(value, time) FROM metrics GROUP BY device_id` |
 45 | | `histogram(value, min, max, num_buckets)` | Creates a histogram of values | `value`: The value column<br>`min`: Minimum bucket value<br>`max`: Maximum bucket value<br>`num_buckets`: Number of buckets | `SELECT histogram(value, 0, 100, 10) FROM metrics` |
 46 | | `approx_percentile(value, percentile)` | Calculates approximate percentiles | `value`: The value column<br>`percentile`: The percentile (0.0-1.0) | `SELECT approx_percentile(value, 0.95) FROM metrics` |
 47 | 
 48 | ## Query Patterns and Best Practices
 49 | 
 50 | ### Time-Series Aggregation with Buckets
 51 | 
 52 | ```sql
 53 | -- Basic time-series aggregation using time_bucket
 54 | SELECT 
 55 |   time_bucket('1 hour', time) AS hour,
 56 |   avg(temperature) AS avg_temp,
 57 |   min(temperature) AS min_temp,
 58 |   max(temperature) AS max_temp
 59 | FROM sensor_data
 60 | WHERE time > now() - INTERVAL '1 day'
 61 | GROUP BY hour
 62 | ORDER BY hour;
 63 | 
 64 | -- Time-series aggregation with gap filling
 65 | SELECT 
 66 |   time_bucket_gapfill('1 hour', time) AS hour,
 67 |   avg(temperature) AS avg_temp,
 68 |   min(temperature) AS min_temp,
 69 |   max(temperature) AS max_temp
 70 | FROM sensor_data
 71 | WHERE time > now() - INTERVAL '1 day'
 72 | GROUP BY hour
 73 | ORDER BY hour;
 74 | ```
 75 | 
 76 | ### Working with Continuous Aggregates
 77 | 
 78 | ```sql
 79 | -- Creating a continuous aggregate view
 80 | CREATE MATERIALIZED VIEW sensor_data_hourly
 81 | WITH (timescaledb.continuous) AS
 82 | SELECT 
 83 |   time_bucket('1 hour', time) AS hour,
 84 |   device_id,
 85 |   avg(temperature) AS avg_temp
 86 | FROM sensor_data
 87 | GROUP BY hour, device_id;
 88 | 
 89 | -- Querying a continuous aggregate
 90 | SELECT hour, avg_temp 
 91 | FROM sensor_data_hourly
 92 | WHERE hour > now() - INTERVAL '7 days'
 93 |   AND device_id = 'dev001'
 94 | ORDER BY hour;
 95 | ```
 96 | 
 97 | ### Hypertable Management
 98 | 
 99 | ```sql
100 | -- Creating a hypertable
101 | CREATE TABLE sensor_data (
102 |   time TIMESTAMPTZ NOT NULL,
103 |   device_id TEXT NOT NULL,
104 |   temperature FLOAT,
105 |   humidity FLOAT
106 | );
107 | 
108 | SELECT create_hypertable('sensor_data', 'time');
109 | 
110 | -- Adding a second dimension for partitioning
111 | SELECT add_dimension('sensor_data', 'device_id', number_partitions => 4);
112 | 
113 | -- Adding compression policy
114 | ALTER TABLE sensor_data SET (
115 |   timescaledb.compress,
116 |   timescaledb.compress_segmentby = 'device_id'
117 | );
118 | 
119 | SELECT add_compression_policy('sensor_data', INTERVAL '7 days');
120 | 
121 | -- Adding retention policy
122 | SELECT add_retention_policy('sensor_data', INTERVAL '90 days');
123 | ```
124 | 
125 | ## Performance Optimization Tips
126 | 
127 | 1. **Use appropriate chunk intervals** - For infrequent data, use larger intervals (e.g., 1 day). For high-frequency data, use smaller intervals (e.g., 1 hour).
128 | 
129 | 2. **Leverage SegmentBy in compression** - When compressing data, use the `timescaledb.compress_segmentby` option with columns that are frequently used in WHERE clauses.
130 | 
131 | 3. **Create indexes on commonly queried columns** - In addition to the time index, create indexes on columns used frequently in queries.
132 | 
133 | 4. **Use continuous aggregates for frequently accessed aggregated data** - This pre-computes aggregations and dramatically improves query performance.
134 | 
135 | 5. **Query only the chunks you need** - Always include a time constraint in your queries to limit the data scanned.
136 | 
137 | ## Troubleshooting
138 | 
139 | Common issues and solutions:
140 | 
141 | 1. **Slow queries** - Check query plans with `EXPLAIN ANALYZE` and ensure you're using appropriate indexes and time constraints.
142 | 
143 | 2. **High disk usage** - Review compression policies and ensure they are running. Check chunk intervals.
144 | 
145 | 3. **Policy jobs not running** - Use `SELECT * FROM timescaledb_information.jobs` to check job status.
146 | 
147 | 4. **Upgrade issues** - Follow TimescaleDB's official documentation for upgrade procedures. 
```
Page 2/7FirstPrevNextLast